Compare commits

..

151 Commits
main ... dev

Author SHA1 Message Date
null 196389ddf3 fix(brand): update veteran-owned badge to certified mark logo (batch 0.9.2) 2026-06-14 16:17:42 -05:00
null d074e597b2 feat(zoho): WebToLead forwarding mode, veteran-owned certified badge, Docker/env/CI updates (batch 0.9.1) 2026-06-14 16:08:29 -05:00
null 05b27d216a feat: Dockerfile/docker-compose updates, server improvements, contact form with recaptcha, API integration (batch 0.9.0) 2026-06-14 15:37:26 -05:00
null 76cb558e8b chore: bump version to v0.8.3 2026-05-28 01:10:38 -05:00
null a8d9492a80 added 404 2026-05-28 00:41:24 -05:00
null 0f272fcf19 error and injection 2026-05-28 00:18:08 -05:00
null 3a61000c12 fix scroll 2026-05-27 23:40:09 -05:00
null e625a24b6e redirect to contact form 2026-05-27 22:33:54 -05:00
null f35de43952 fix footer 2026-05-27 22:19:04 -05:00
null c43d3bc955 company name footer one line 2026-05-27 22:08:46 -05:00
null 1dcfbfc7a7 chore: bump version to v0.8.2 2026-05-27 21:56:47 -05:00
null ec14701795 center footer 2026-05-27 21:54:44 -05:00
null 78967ff56f phone 2026-05-27 21:49:21 -05:00
null cc1970fd1d added phone numebr hamburger 2026-05-27 21:44:06 -05:00
null 8c1e0f4c3d ui 2026-05-27 21:35:12 -05:00
null fb12d8cf3c 1 2026-05-27 21:23:17 -05:00
null a3ba03b7e1 cisco about 2026-05-27 21:15:54 -05:00
null f59d053afd mobile navbar text per owner 2026-05-27 21:04:30 -05:00
null 033bdf6625 Form now POSTs to Zoho 2026-05-27 20:57:55 -05:00
null 548e20e6f0 . 2026-05-27 14:43:50 -05:00
null 225c4e5485 chore: bump version to v0.8.1 2026-05-27 14:33:13 -05:00
null 8f20670292 breadcrumbs 2026-05-27 14:27:30 -05:00
null 0cfa048d0d injection security 2026-05-27 14:14:24 -05:00
null 4410f01d48 Missing `Description` or `aria-describedby={undefined}` for {DialogContent}. 2026-05-27 14:11:48 -05:00
null 6bab4b5c89 corrections 2026-05-27 14:10:28 -05:00
null f9b36c198b badges size 2026-05-27 13:58:31 -05:00
null 4b17e9f109 badges 2026-05-27 13:28:13 -05:00
null 6de9490764 badge center 2026-05-27 12:51:08 -05:00
null a81e97fb31 mobile view 2026-05-27 12:42:25 -05:00
null 362a7e0059 heatmap 2026-05-26 17:08:55 -05:00
null 431d1157b3 chore: bump version to 0.8.0 2026-05-26 13:44:27 -05:00
null 510edace07 removed duplicate card 2026-05-26 13:41:46 -05:00
null 8731903463 updated badges homepage 2026-05-26 13:36:16 -05:00
null a2a32687ff branding 2026-05-26 13:29:37 -05:00
null f0f0cfd599 #181 2026-05-26 13:04:57 -05:00
null 335601b00e remove submit ticket 2026-05-26 12:55:23 -05:00
null 85d7ae4bb1 logo enlarge 2026-05-26 12:44:10 -05:00
null bfcf7f114c #176 2026-05-26 12:31:26 -05:00
null f2a5e2c1bc Hero name widen 2026-05-26 12:26:13 -05:00
null d626c34ba2 parternership branding 2026-05-26 12:22:11 -05:00
null fd1e2b6f50 update 2026-05-25 20:26:50 -05:00
null afec6547c1 RECAPTCHA 2026-05-25 20:20:15 -05:00
null 7f48847049 CTA 2026-05-25 20:08:04 -05:00
null 07a43b9b7c visual update services 2026-05-25 20:03:56 -05:00
null 9b5f9f885c text 2026-05-25 20:02:36 -05:00
null fca9336656 close 184 2026-05-25 19:54:39 -05:00
null e489245104 service military 2026-05-25 19:51:03 -05:00
null 80b1747ffe branding 2026-05-25 19:44:10 -05:00
null bc6bc9a787 logo 2026-05-25 19:40:18 -05:00
null 1d687c5fa1 branch footer 2026-05-25 19:23:37 -05:00
null 52d9a16462 branch footer 2026-05-25 18:35:40 -05:00
null c17ac83b52 services 2026-05-25 18:33:19 -05:00
null 09926fed6d Contact.jsx (line 23): added ZIP to validation/error state, rendered it as required, and shows ZIP code is required.
server/index.js (line 253): backend Zod schema now rejects missing or blank ZIP.
api.js (line 3): preserves backend field errors for display.
2026-05-25 18:17:16 -05:00
null 529cce7ec0 branding 2026-05-25 18:11:51 -05:00
null 5f5c085fbe contact update 2026-05-25 17:56:32 -05:00
null ca8972b54d chore: bump version to 0.7.6 2026-05-18 14:26:06 -05:00
null 046d5b1d4a fix: ui industries 2026-05-18 14:25:45 -05:00
null 3f8eafb76a chore: bump version to 0.7.5 2026-05-18 14:02:50 -05:00
null a79f42123a fix: seo fb 2026-05-18 14:02:12 -05:00
null a293d0fa9a feat(seo): add canonical URLs, close SEO audit (#175) 2026-05-18 13:58:35 -05:00
null f378233328 fix: seo 2026-05-18 13:55:06 -05:00
null a8765990ef mobile ui fixes 2026-05-18 13:45:39 -05:00
null de61d5e625 fix(ui): UI fixes across header, contact, home, about, support, services, industries (#132 #133 #134 #154 #165 #172 #173) 2026-05-18 13:12:18 -05:00
null fdc6eaeab5 chore: bump version to 0.7.2 2026-05-18 12:33:06 -05:00
null 928527b908 fix: remove all email addresses from site, redesign contact page, update footer, about, header, support (#165 #172 #134 #173) 2026-05-18 12:11:56 -05:00
null b04e5bbb04 spacing 2026-05-18 10:57:06 -05:00
null fff92a40df close 134 2026-05-18 09:46:56 -05:00
null 990139b77f fix(ui): remove duplicate trust section (#173), enlarge logo and add home link (#154) 2026-05-18 09:44:44 -05:00
null d8a975431f fix(ui): dropdown hover gap fix (#132) and remove contact form button (#133) 2026-05-18 09:35:29 -05:00
null aec33165b9 chore: bump version to 0.7.1, fix Home.jsx aria-label text fragment (batch 10.3) 2026-05-17 22:48:57 -05:00
null e1604ee28f fix: add aria-labels for accessibility, fix JSX template literal syntax (#101) (batch 10.2) 2026-05-17 22:47:03 -05:00
null 9c1b6e4753 fix: optimize images to WebP (95% reduction), add form loading spinners (#97 #130) (batch 10.1) 2026-05-17 22:35:55 -05:00
null 2c002c2f82 fix: remove React Query, add HTTPS redirect, document CSP Zoho note (#128 #127 #129) (batch 10.0) 2026-05-17 22:33:11 -05:00
null 95917bc699 chore: bump version to 0.7.0 (batch 9.10) 2026-05-17 22:11:40 -05:00
null 5c17019931 fix: remove unused assets (24MB), Dialog component, CardDescription dup, zustand dep (#95 #113 #114 #115 #116) (batch 9.9) 2026-05-17 22:08:21 -05:00
null 829362fb79 fix: Cisco/Veteran icons, sidebar slug, JointLogo placeholder, Footer copy (#98 #100 #103 #107) (batch 9.8) 2026-05-17 22:05:08 -05:00
null 6e975b869b fix: favicon/manifest, og:image PNG, logo optimized to 44KB PNG (#99 #117 #118) (batch 9.7) 2026-05-17 22:01:27 -05:00
null f8d380ebab fix: disable prod sourcemaps, secure CORS default, allow HMR websocket (#122 #124 #131) (batch 9.6) 2026-05-17 21:53:39 -05:00
null 53e2873fd4 fix: honeypot spam protection, 409 conflict handling (#119 #126) (batch 9.5) 2026-05-17 21:51:53 -05:00
null 00f5356db4 fix: Support CTA bg color, clickable phone/email, Footer tel: +1 (#112 #106 #90) (batch 9.4) 2026-05-17 21:48:27 -05:00
null 8adb9cdb76 fix: desktop dropdown nav, Button→Link, Contact scroll-to-form (#109 #102 #105) (batch 9.3) 2026-05-17 21:44:48 -05:00
null a5d9d142d5 fix: 8x8 logo visibility, industry icons, service cards, icon fallback (#91 #94 #125 #92 #93) (batch 9.2) 2026-05-17 21:41:01 -05:00
null bdef2684bb fix: header CTA visibility, scroll-to-top, 404 page (#104 #89 #88) (batch 9.1) 2026-05-17 21:37:42 -05:00
null 4e57efdc53 fix: DB schema UNIQUE constraint, Docker healthcheck, DB permissions (#120 #121 #123) (batch 9.0) 2026-05-17 21:34:39 -05:00
null 4235ed7a50 fix: tighten service and industry copy to match original site tone (batch 8.8, issue #85) 2026-05-17 20:56:42 -05:00
null 770941752f feat: add ZohoDesk signup/signin CTA to Support page (batch 8.7, issue #84) 2026-05-17 20:55:03 -05:00
null 2f58e93c43 feat: add 8x8 certification details to homepage trust card (batch 8.6, issue #83) 2026-05-17 20:54:16 -05:00
null 123329b03e feat: add free migration offer CTA section to homepage (batch 8.5, issue #82) 2026-05-17 20:51:16 -05:00
null 6ca8585f89 fix: rename asset files to kebab-case (batch 8.4, issue #81) 2026-05-17 20:49:11 -05:00
null 4fe31ed9b6 fix: correct contact info and remove unverified location claims (batch 8.9)
- Phone: (906) 482-6616 → (321) 730-8020 direct, (888) 656-2850 toll-free
- Add toll-free number to Footer and Contact page
- Add LinkedIn link to footer
- Add 8x8 trademark disclaimer to footer legal section
- Fix JSON-LD phone numbers in Home.jsx
- Add support phone to Support page
- Strip all Houghton, MI and Upper Peninsula location references
  from meta tags, JSON-LD, and fallback descriptions
  (not present on original site, unverified)
- Change JSON-LD areaServed from Houghton/UP to United States
- Update Support.jsx Zoho Desk link placeholder
2026-05-17 20:44:18 -05:00
null 1b0d5adc36 feat(seo): add react-helmet-async, per-page meta/OG tags, JSON-LD, sitemap, robots.txt, heading fixes (#71)
- Added react-helmet-async + HelmetProvider to main.jsx
- Per-page Helmet components on all 8 pages (title, description, OG tags)
- JSON-LD structured data (Organization, LocalBusiness, Service)
- Created public/sitemap.xml with all 17 routes
- Created public/robots.txt
- Fixed heading hierarchy (no h1->h3 skips)
- Improved image alt text throughout
- Fixed docs/zoho-setup.md env defaults clarification
2026-05-17 20:03:42 -05:00
null 2a9eef0e71 docs: update FUTURE.md + HISTORY.md, bump v0.6.6 (Phase 7 complete) 2026-05-17 19:27:57 -05:00
null 2923ef0d50 feat(zoho): add Cases forwarding + setup docs (closes #76, #78)
- Add forwardSupportToZoho() for Zoho Cases (fire-and-forget)
- Map support fields: issue→Subject, priority→Priority, Case_Origin=Website
- ZOHO_CASES_ENABLED env var (independent from ZOHO_ENABLED)
- Add docs/zoho-setup.md with step-by-step setup guide
- Batch 7.2 and 7.4
2026-05-17 19:27:04 -05:00
null debde23ab7 fix(zoho): fix OAuth token endpoint, improve lead field mapping, add upsert
- Fix critical bug: token refresh now uses ZOHO_ACCOUNTS_DOMAIN
  (accounts.zoho.com) instead of API domain (www.zohoapis.com).
  The OAuth token endpoint lives on a different domain.
- Remove unnecessary redirect_uri from refresh token request
- Add ZOHO_ACCOUNTS_DOMAIN env var (separate from API domain)
- Split contact name into First_Name/Last_Name for Zoho schema
- Replace Service_Interest (non-standard field) with Description
  + Lead_Source: Website (standard picklist value)
- Switch from Insert to Upsert API with duplicate_check_fields:
  [Email] so duplicate submissions update instead of error
- Add trigger: ['workflow'] for explicit workflow control
- Add token refresh retry (1 retry on transient failure)
- Add ZOHO_CASES_ENABLED env var for future Cases forwarding
- Update .env.example with full Zoho config documentation
- Update FUTURE.md with detailed Phase 7 Zoho integration plan
- Remove obsolete ZOHO_REDIRECT_URI from Dockerfile
2026-05-17 18:37:10 -05:00
null f1823bcc4b fix: rename asset files to remove spaces (closes #67) 2026-05-17 18:08:25 -05:00
null 1437b2af07 fix: 10 bug fixes from code review (batch 0.6.5)
- #63: Fix industry.href undefined → use industry.id for navigation
- #50: Fix sanitized scope error in catch block (let before try)
- #58: Footer.jsx: convert all internal <a href> to <Link to>
- #61: Textarea.jsx: fix className interpolation (quotes → backticks)
- #59: About.jsx: convert CTA <a href> to <Link to>
- #60: Support.jsx: convert Contact button <a href> to <Link to>
- #62: Badge.jsx: text-foreground → text-text
- #64: Support.jsx: hover:bg-navy-darker → hover:bg-primary-navy-dark
- #65: Server: move timeoutMiddleware before catch-all routes
- #66: Contact.jsx: convert self-referencing <a href> to <Link to>
2026-05-17 18:03:55 -05:00
null 4f3e20b7a0 fix: dead code cleanup, timeout middleware, Zoho error handling (closes #53, #54, #55, #56, #57)
- Delete broken barrel exports ui/index.jsx and ui/all.jsx (#53)
- Remove duplicate QueryClient instance and dead queryClient.js (#55)
- Remove unused queryClient import/export from api.js (#55)
- Move timeoutMiddleware before catch-all routes so it actually fires (#54)
- Fix async error handling in forwardToZoho - add .catch() (#56)
- Add ZOHO_CLIENT_ID to credential guard, normalize defaults to null (#57)
(batch 0.6.4)
2026-05-17 17:46:54 -05:00
null 9cdc299ade fix: undefined Tailwind classes, SPA navigation, phone/email links (closes #51, #52)
- Replace all undefined shadcn/ui Tailwind classes with correct config values
  (text-muted-foreground→text-muted, text-card-foreground→text-text,
   bg-secondary→bg-section-alt, ring-ring→ring-primary-navy, etc.)
- Replace text-cyan/bg-cyan with text-primary-cyan/bg-primary-cyan
- Convert all internal <a href> to React Router <Link to> for SPA navigation
- Remove self-referencing /contact button on Contact page
- Add tel: links for phone numbers, mailto: links for emails in
  Contact.jsx and Support.jsx hero badges and info sections
- Fix Header.jsx mobile menu button hover:text-cyan→hover:text-primary-cyan (batch 0.6.3)
2026-05-17 17:42:03 -05:00
null b5170caf9d refactor: full-bleed section backgrounds with centered content (closes #48) 2026-05-17 17:11:29 -05:00
null 5d67149a51 feat: add JointLogoWhite trust bar + selective service detail images (closes #48) 2026-05-17 17:01:08 -05:00
null 32a2b905a1 fix: add missing CardDescription component and import (batch 0.6.2) 2026-05-17 16:39:54 -05:00
null 762c9d899d fix(docker): slim runner stage — native deps in separate build stage (567MB → 248MB) 2026-05-17 16:34:36 -05:00
null b428348a4c docs: close issues #45 #46 #47 — already implemented in earlier batches (batch 0.6.2) 2026-05-17 16:17:54 -05:00
null 56bdf07216 fix: close issues #12 #15 #17 #18 — CSP nonce, API retry, input debounce, caching verified (batch 0.6.1) 2026-05-17 16:10:10 -05:00
null ca67974c5f fix(docker): add python3/make/g++ for better-sqlite3 native build (batch 0.6.8) 2026-05-17 15:48:43 -05:00
null e11aefd184 fix: audit issues #10 #14 #16 #19 — CORS errors, JSON middleware, Zoho fields, noValidate (batch 0.6.8) 2026-05-17 15:46:59 -05:00
null 80969e5aee feat: merge 8x8 page into UCaaS/CCaaS service pages, remove standalone route (batch 0.6.6) 2026-05-17 15:44:27 -05:00
null 4845c7ab5f feat(ui): industry icons bigger with color and contrast (batch 0.6.5) 2026-05-17 15:41:22 -05:00
null 5807582df1 feat: hero left-alignment + about section content fixes (batches 0.6.3, 0.6.4)
- Hero text left-aligned on all screen sizes (Issue #33)
- Hero image always visible, grid breakpoint md instead of lg
- Spacing refactored from margin to gap utils
- About: veteran-founded framing, mission paragraph (Issue #34)
- About: vendor-neutral consulting in expertise (Issue #36)
- About: image sizing max-h-96 object-cover (Issue #37)
- Version bump 0.5.6 → 0.5.7
2026-05-17 15:33:30 -05:00
null eb76e1fadc feat(cisco): add Cisco partnership signals across site (batch 0.6.2) 2026-05-17 15:28:12 -05:00
null 25ab4c7986 fix(server): Zoho token endpoint hardening + version bump to 0.5.4 (batch 0.6.0) 2026-05-17 15:18:24 -05:00
null c4d40c39ba feat(ui): nav active-state styling for header and mobile nav (batch 0.6.0) 2026-05-17 15:15:00 -05:00
null 0e89bc2c0a fix: remove FUTURE.md and HISTORY.md from git tracking — agent-only docs, not for repo 2026-05-17 15:07:50 -05:00
null 71c8129046 feat(ui): footer + contact improvements, CTAs everywhere, gitignore update (batch 0.5.6)
- Footer: email, phone, Request Consultation CTA
- Home: CTA links added to Trust Signals, Services, Why Queue North
- Contact: hero CTA, prominent phone/email
- Support page updates
- Version bumped to 0.5.3
- FUTURE.md and HISTORY.md now tracked in git
- .gitignore updated to allow FUTURE.md and HISTORY.md
2026-05-17 15:07:28 -05:00
null 5b0a509e70 fix(zoho): P0/P1 criticals — credential check, response validation, timeout, null normalization (Neo N1) 2026-05-17 15:01:04 -05:00
null a963dc4dcc feat(ui): why queue north section refinement (batch 0.5.5) 2026-05-17 14:56:10 -05:00
null 940cd94ba3 feat(services): business outcomes rewrite (batch 0.5.4)
- Section title: Our Services → What We Handle
- Subtitle: outcome-focused language
- Added homeDesc field to all 7 services in data/services.js
- Service cards now show icon + homeDesc + shortDesc
- lucide-react icons per service (MessageCircle, Users, etc)
- B2B professional card layout with icon containers
- Service detail pages unchanged
2026-05-17 14:49:01 -05:00
null 7c145bc8ca fix(security): Hudson remediation + batch 0.5.3 trust signals
- Clean up docker-entrypoint per Hudson review (issue #4):
  - Remove chmod 777 → chown nodejs:nodejs
  - Remove hardcoded su-exec, add root-detection logic
  - Entry point unused but now safe if re-enabled
- Batch 0.5.3: Trust signals section (Scarlett)
  - 8x8 Certified Partner card (cert #25432)
  - Veteran Owned card (VCERT #12847)
  - 25+ Years Experience metric
  - 99.99% uptime, <15m response, 24/7 support, 100% satisfaction
  - Mobile-first, B2B professional tone
2026-05-17 14:45:55 -05:00
null 7d476f36e8 fix(security): audit fixes #4 #6 #10 + hero rewrite (batch 0.5.2)
- #4: Replace su-exec with USER nodejs in Dockerfile (P0)
- #6: Add UNIQUE constraint on leads.email with migration (P1)
- #10: Consistent NULL handling for optional fields (P1)
- Hero section rewrite: B2B value proposition, prominent 8x8 badge
- Clean up .bak file left by agent
2026-05-17 14:44:34 -05:00
null 851759ae5e fix: P0 owner feedback — contact info, phone, support redirect (#25, #26, #27)
- Remove fake info@queuenorth.com, replace with contact form callout (fixes #25)
- Add phone (906) 482-6616 to Contact, Support, and Footer (fixes #26)
- Add Zoho support center link to Support page (fixes #27)
2026-05-17 14:35:29 -05:00
null 54dc893ec5 chore: bump version to 0.5.1 2026-05-17 14:33:54 -05:00
null c48cf89428 fix: P0 owner feedback — header & nav fixes (#24, #28, #29, #42)
- Remove stray 'Primary' heading from mobile menu (fixes #24)
- Logo links to homepage, increased size h-10/h-11 (fixes #28, #42)
- Nav links visible with text-white/70 + active underline state (fixes #29)
- Mobile logo and text size increased for readability (fixes #42)
2026-05-17 14:33:41 -05:00
null 796d372e79 chore: add docker-push.sh, docker-test.sh, npm scripts, bump v0.5.0
- docker-push.sh: build + tag + push dev image to Forgejo registry
- docker-test.sh: rebuild and run container for local testing
- npm scripts: docker:push and docker:test
- Version bump to 0.5.0 (Phase 5)
2026-05-14 01:18:44 -05:00
null c4985e37bc feat: Phase 5 SPA fixes, mobile menu, assets, and redesign planning
- Fix BrowserRouter → RouterProvider (routes were disconnected)
- Strip TS generics from .jsx files (Card, Badge, Dialog, Input, Textarea)
- Fix useToast import from sonner (Contact, Support)
- Merge mobile Sheet into Header (DialogTrigger outside Dialog)
- Add SPA catch-all route for client-side navigation
- Add CSP style-src for Google Fonts
- Copy all image assets to public/ (were 404)
- Replace placeholder logo with real Queue North logo
- Fix SheetContent positional CSS + install tailwindcss-animate
- Add visually hidden SheetTitle for accessibility
- Update README and FUTURE.md with Phase 5 redesign batches
- Add review.md (redesign assessment, exempt from git)
2026-05-13 22:07:35 -05:00
null c2d5873f08 feat: error handling hardening, 404 catch-all, health check DB test, request timeout, global error handlers (v0.4.8) 2026-05-13 19:59:19 -05:00
null 7257633d94 feat: rate limiting, helmet security headers, CORS, trust proxy, Docker env vars (v0.4.7) 2026-05-13 18:37:32 -05:00
null 39ee1fe537 feat: structured logging with timestamps, request logging, and submission details (v0.4.6) 2026-05-13 18:31:52 -05:00
null 6bfd804313 feat: Zoho CRM forwarding layer with OAuth2 token management (v0.4.6) 2026-05-13 18:28:56 -05:00
null 4ac0fa250d feat: server-side validation + input sanitization (v0.4.5) 2026-05-13 18:18:07 -05:00
null ee5af44b58 docs: update README phase 4 checkmark for validation 2026-05-13 18:10:16 -05:00
null 931c9a9095 feat: client-side form validation + Sonner feedback (v0.4.4) 2026-05-13 18:10:04 -05:00
null 21b5418461 docs: update README phases with checkmarks for completed work 2026-05-13 18:03:03 -05:00
null 71347d070b chore: bump version to 0.4.3 (SQLite persistence verified) 2026-05-13 04:14:07 -05:00
null 87203bcded fix: consolidate legacy CSS, fix dynamic routes, convert anchors to Link components
- Remove duplicate App.css, consolidate into index.css as single Tailwind entry point
- Move maxWidth.container to tailwind.config.js theme extension
- Update App.jsx import from ./App.css to ./index.css
- Fix router.jsx to use dynamic :slug routes for services and industries
- Fix ServiceDetail.jsx and IndustryDetail.jsx to use useParams()
- Convert Header.jsx and MobileNav.jsx <a> tags to React Router <Link> components
- Add scripts/docker-test.sh for persistence verification
- Add project-requirements.md
2026-05-13 00:29:45 -05:00
null a7fa18ec63 chore: add .learnings/ to gitignore 2026-05-12 02:50:04 -05:00
null f03229dd50 feat: Phase 3 Batch 4 — inner pages layout system with consistent hero/card/CTA pattern (v0.3.4) 2026-05-12 02:45:25 -05:00
null 35aaa639ec feat: Phase 3 Batch 3 — header/footer/mobilenav polish + fix Button.jsx TS generics (v0.3.3)
- Sticky dark navy header with clean nav and CTA
- Reorganized MobileNav with Primary/Services/Industries sections
- Dark navy footer with cyan accent headers
- Added navy-light color token
- Fixed Button.jsx: removed TypeScript generic syntax that broke esbuild
- Replaced asChild Button usage with styled anchor tags
2026-05-12 02:39:35 -05:00
null 76aa71691f feat: Phase 3 Batch 2 — home page redesign with hero, trust bar, services, CTA (v0.3.2) 2026-05-12 02:31:23 -05:00
null 287e2b79f6 feat: Phase 3 Batch 1 — theme tokens, spacing scale, container width (v0.3.1) 2026-05-12 02:26:18 -05:00
null 0b7da4d237 chore: bump to v0.3.0 — Phase 3 Visual Overhaul baseline 2026-05-12 02:15:36 -05:00
null ba0d039cdc fix: reduce Docker image from 331MB to 215MB — remove duplicate node_modules layer
v0.2.2: Removed COPY --from=builder node_modules from runner stage.
The full dev+prod modules (116MB) were being copied as a permanent
Docker layer, then npm ci --omit=dev installed a separate prod-only
set on top. Now only the prod install runs, cutting 116MB.
2026-05-12 02:04:52 -05:00
null 1f3e3864f9 feat: Docker batch 0.2.1 — production-ready containerization
- Multi-stage Dockerfile with non-root nodejs user
- Healthcheck using Node 20 built-in fetch (no wget)
- docker-entrypoint.sh: root permission fix, then exec to nodejs
- server/db.js: deferred SQLite init for Docker volume permissions
- docker-compose.yml with named volumes for persistence
- .dockerignore and .env.example added
- README updated with Docker usage section

Security reviewed by Private Hudson. All blockers resolved.
2026-05-12 01:57:55 -05:00
null c83dc08660 feat: complete phase 2 layout rebuild 2026-05-12 01:18:57 -05:00
null d2bb91fd72 docs: track overhaul plan 2026-05-12 01:10:34 -05:00
null 8352558240 chore: keep project docs private 2026-05-12 01:09:21 -05:00
null bd17e964b3 chore: bump phase 1 checkpoint to 0.1.1 2026-05-12 01:05:44 -05:00
null b7f7765a72 feat: complete phase 1 foundation 2026-05-12 01:04:17 -05:00
null c8307e61d6 docs: add Queue North website objective 2026-05-12 00:34:46 -05:00
null b7dfee3023 update OVERHAUL_PLAN 2026-05-12 00:27:51 -05:00
null 7598dd4573 gitignore: exclude project management docs from repo 2026-05-12 00:01:39 -05:00
null 836ccd9856 project setup: PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md 2026-05-11 23:58:17 -05:00
89 changed files with 15220 additions and 3465 deletions

76
.dockerignore Normal file
View File

@ -0,0 +1,76 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build output
dist
build
*.tsbuildinfo
# Database runtime files
db
*.db
*.sqlite
*.sqlite3
# Git
.git
.gitignore
.gitattributes
# Logs
logs
*.log
# Private docs (ignored per requirements)
DEVELOPMENT_LOG.md
FUTURE.md
HISTORY.md
BUILD_SUMMARY.md
PROJECT.md
SCRIPTS.md
STRUCTURE.md
OVERHAUL_PLAN.md
MEMORY.md
AGENTS.md
SOUL.md
IDENTITY.md
USER.md
TOOLS.md
# IDE
.idea
.vscode
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker files (not needed in image)
Dockerfile
docker-compose.yml
.dockerignore
# Runtime data
*.pid
*.seed
coverage/
# Environment files (don't include in image)
.env
.env.local
.env.production
# Docker socket mount (not needed in image)
/var/run/docker.sock
# Host volume permissions
# Ensure ./db and ./logs are writable by UID 1001 before running
# Run: sudo chown -R 1001:1001 ./db ./logs

32
.env.example Normal file
View File

@ -0,0 +1,32 @@
# Environment configuration
# Copy this file to .env and customize as needed
NODE_ENV=production
SERVER_PORT=3001
# Zoho CRM Integration
# Preferred current setup: webtolead for contact leads using the legacy Zoho form tokens.
ZOHO_FORWARDING_MODE=webtolead
ZOHO_WEBTOLEAD_ENABLED=false
ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ZOHO_WEBTOLEAD_XNQSJSDP=
ZOHO_WEBTOLEAD_XMIWTLD=
ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ZOHO_WEBTOLEAD_RETURN_URL=null
ZOHO_WEBTOLEAD_ZC_GAD=
# Standby REST API/OAuth setup. Set ZOHO_FORWARDING_MODE=api and ZOHO_ENABLED=true to use it.
ZOHO_ENABLED=false
ZOHO_API_DOMAIN=https://www.zohoapis.com
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
ZOHO_CLIENT_ID=
ZOHO_CLIENT_SECRET=
ZOHO_REFRESH_TOKEN=
ZOHO_CASES_ENABLED=false
# Google reCAPTCHA
# Leave disabled until a real Google reCAPTCHA site key and secret key are configured.
RECAPTCHA_ENABLED=false
RECAPTCHA_SECRET_KEY=
RECAPTCHA_MIN_SCORE=0.5
VITE_RECAPTCHA_SITE_KEY=

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Private project/agent docs — never commit
DEVELOPMENT_LOG.md
PROJECT.md
STRUCTURE.md
FUTURE.md
HISTORY.md
BUILD_SUMMARY.md
SCRIPTS.md
.drop/
zoho.md
# Dependencies
node_modules/
# Build output
dist/
# Runtime/database artifacts
db/*.db
db/*.db-*
# Environment/local files
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# OS/editor
.DS_Store
.vscode/
.idea/
.learnings/
Levi.md
Queue-North-Website.code-workspace
Working Site.zip

View File

@ -1,8 +0,0 @@
# Queue-North-Website — Development Log
## v0.0.1 — 2026-05-11
**Ripley** — Project initialized
- Created project directory at `/home/kaspa/.openclaw/Projects/Queue-North-Website/`
- Set up PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md
- Git repo not yet initialized (awaiting first code)

94
Dockerfile Normal file
View File

@ -0,0 +1,94 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first for layer caching
COPY package.json package-lock.json* ./
# Install build tools for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# Install all dependencies for build
RUN npm ci
# Copy source files
COPY . .
# Public Vite values are compiled into the frontend bundle at build time.
ARG VITE_RECAPTCHA_SITE_KEY=
ENV VITE_RECAPTCHA_SITE_KEY=$VITE_RECAPTCHA_SITE_KEY
# Build the frontend
RUN npm run build
# Native modules stage — compile better-sqlite3 in a dedicated stage
FROM node:20-alpine AS native-deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN apk add --no-cache python3 make g++
RUN npm ci --omit=dev
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user for security (consistent UID/GID 1001)
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nodejs
# Set environment
ENV NODE_ENV=production
ENV SERVER_PORT=3001
ENV RATE_LIMIT_PER_MINUTE=5
ENV CORS_ORIGIN=*
ENV LOG_LEVEL=info
ENV ZOHO_FORWARDING_MODE=webtolead
ENV ZOHO_WEBTOLEAD_ENABLED=false
ENV ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ENV ZOHO_WEBTOLEAD_XNQSJSDP=
ENV ZOHO_WEBTOLEAD_XMIWTLD=
ENV ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ENV ZOHO_WEBTOLEAD_RETURN_URL=null
ENV ZOHO_WEBTOLEAD_ZC_GAD=
ENV ZOHO_ENABLED=false
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
ENV ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
ENV ZOHO_CLIENT_ID=
ENV ZOHO_CLIENT_SECRET=
ENV ZOHO_REFRESH_TOKEN=
ENV ZOHO_CASES_ENABLED=false
ENV RECAPTCHA_ENABLED=false
ENV RECAPTCHA_SECRET_KEY=
ENV RECAPTCHA_MIN_SCORE=0.5
# Create app directory structure
RUN mkdir -p /app/db /app/logs
# Set permissions for db directory (before USER switch)
RUN chown -R nodejs:nodejs /app/db /app/logs
# Copy from builder - built artifacts and package manifests
COPY --from=builder /app/package.json /app/package-lock.json* ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
# Copy compiled native modules from native-deps stage (no build tools in final image)
COPY --from=native-deps /app/node_modules ./node_modules
# Expose backend port
EXPOSE 3001
# Switch to non-root user (standard approach, no su-exec needed)
USER nodejs
# Health check using Node 20 built-in fetch (no wget required)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3001/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
# Run the Express server
CMD ["node", "server/index.js"]

View File

@ -1,8 +0,0 @@
# Queue-North-Website — Planning
## Next Items
*Awaiting project requirements from _null.*
---
*Add items here as they are defined. Priority levels: CRITICAL, HIGH, MEDIUM, LOW*

View File

@ -1,6 +0,0 @@
# Queue-North-Website — Changelog
## v0.0.1
### Added
- Project initialized with PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md

989
OVERHAUL_PLAN.md Normal file
View File

@ -0,0 +1,989 @@
# Queue North Website — 2026 Overhaul Plan
## TL;DR
Rebuild the current static HTML/CSS/JS website into a modern full-stack app using:
- **Vite** — build tool, not Next.js
- **React** — SPA with client-side routing via React Router
- **Tailwind CSS** — utility-first styling
- **shadcn/ui** — component primitives
- **Sonner** — toast notifications
- **TanStack Query** — server state management
- **Express** — backend API
- **better-sqlite3** — local SQLite database
The current website has good business content, but the structure and visual design feel dated. The rebuild should make Queue North Technologies feel like a modern, trustworthy, premium communications and infrastructure partner.
---
## Current Site Assessment
The current project is a static site with:
- `index.html` — all pages live in one large file
- `styles.css` — large hand-written stylesheet with many page-specific overrides
- `main.js` — manual hash-based routing and interactions
- Inline Zoho webform scripts
- Static assets in `assets/`
### Current Problems
1. **Single giant HTML file**
- All pages live inside `index.html`.
- Routing is manually handled through hash-based JavaScript.
- The structure is hard to maintain and scale.
2. **CSS is doing too much**
- `styles.css` is very large.
- Many page-specific rules and overrides exist.
- Layout feels patched together instead of systemized.
3. **Old visual language**
- Current colors lean heavily on dark teal and beige.
- Header/logo behavior feels awkward and dated.
- Repeated cards and sections lack a strong modern design system.
- The site communicates the right business, but not with enough polish.
4. **Fragile contact form flow**
- Inline Zoho scripts.
- Hidden iframe submission.
- Difficult to validate, style, and control in a modern app.
5. **No real application architecture**
- No component system.
- No API layer.
- No database.
- No modern routing.
- No server-state management.
---
## Target Architecture
## Frontend Stack
Use:
- **Vite**
- **React**
- **React Router**
- **Tailwind CSS**
- **shadcn/ui**
- **Sonner**
- **TanStack Query**
### Suggested Frontend Structure
```txt
client/
src/
app/
App.jsx
router.jsx
components/
layout/
Header.jsx
Footer.jsx
MobileNav.jsx
sections/
Hero.jsx
TrustBar.jsx
ServicesGrid.jsx
IndustriesGrid.jsx
PartnerSection.jsx
ContactCTA.jsx
ui/
shadcn components
pages/
Home.jsx
About.jsx
Services.jsx
ServiceDetail.jsx
Industries.jsx
IndustryDetail.jsx
EightXEight.jsx
Contact.jsx
Support.jsx
lib/
api.js
queryClient.js
data/
services.js
industries.js
```
---
## Backend Stack
Use:
- **Express**
- **better-sqlite3**
### Suggested Backend Structure
```txt
server/
index.js
db/
connection.js
schema.sql
routes/
leads.js
support.js
health.js
services/
leadService.js
supportService.js
```
### Initial API Routes
```txt
GET /api/health
POST /api/leads
POST /api/support
GET /api/services
GET /api/industries
```
---
## Database Plan
Use SQLite through `better-sqlite3`.
### Initial Tables
```txt
leads
- id
- company
- name
- email
- phone
- zip
- message
- service_interest
- created_at
support_requests
- id
- name
- company
- email
- phone
- issue
- priority
- created_at
```
### Zoho Integration
Zoho should not drive the frontend anymore.
Recommended path:
1. Frontend submits to Express.
2. Express validates request.
3. Express stores submission in SQLite.
4. Optional later: backend forwards lead data to Zoho.
This keeps the website clean, testable, and controllable.
---
## New Visual Direction
## Brand Feel
Target vibe:
**Bright enterprise communications partner. Clean, trustworthy, modern, approachable, and premium.**
The site should feel less like a local IT website from the early 2000s and more like a serious communications, contact center, and infrastructure partner for modern businesses.
Because this is a business services website, the visual direction should be **light-first**, not fully dark/cyber. A mostly dark interface can look sleek, but it may also feel too niche, too technical, or too startup/gaming-adjacent for SMB and enterprise buyers. The better direction is a bright, polished base with strong navy sections and crisp blue/cyan accents.
## Recommended Color Scheme
### Primary Light-First Palette
```txt
Background: #F8FAFC
Section Alt: #EEF6FB
Card: #FFFFFF
Border: #D8E3EA
Text: #0F172A
Muted: #475569
Soft Text: #64748B
Primary Navy: #0B2A3C
Deep Navy: #071A2A
Trust Blue: #0EA5E9
Accent Cyan: #22D3EE
```
### Optional Warm Accent
```txt
Signal Gold: #F59E0B
```
Use gold sparingly for badges, stats, certification highlights, or key trust moments.
### Dark Usage Guidance
Use dark navy intentionally, not everywhere:
- Header or top navigation
- Hero section
- Footer
- CTA bands
- Certification/trust moments
Keep most body sections bright and readable:
- White cards
- Light blue-gray section backgrounds
- Dark readable text
- Blue/cyan action accents
This gives the site a modern 2026 look without making it feel like a cybersecurity, crypto, or gaming landing page.
---
## Layout Direction
This section is the primary design brief for Scarlett.
The layout should feel like a modern B2B technology services website: bright, structured, confident, and conversion-focused. Avoid the early-2000s pattern of giant logos, crowded nav, heavy gradients everywhere, oversized generic images, and repeated boxed sections without rhythm.
### Global Layout Principles
- Use a **light-first page body** with strategic dark navy moments.
- Use a consistent max-width container: approximately `1200px` to `1280px`.
- Use generous vertical spacing:
- Mobile sections: `64px` to `80px`
- Desktop sections: `96px` to `128px`
- Use a clear rhythm:
- Dark hero
- Light trust section
- White/soft-blue service sections
- Dark CTA band
- Light detail sections
- Dark footer
- Keep the site visually calm. Do not use too many competing gradients, glows, borders, and shadows at once.
- Cards should feel premium and clean, not like old Bootstrap panels.
- Prefer fewer stronger sections over many cramped sections.
- Every page should have one obvious primary action: usually `Request Consultation` or `Contact Us`.
### Header / Navigation Layout
Desktop header:
- Sticky top header.
- Dark navy or white header is acceptable, but it must be compact and polished.
- Logo should be normal-sized and aligned, not oversized or bleeding into content.
- Navigation should be single-line and calm:
```txt
Home
Services
Industries
8x8
About
Contact
Support
```
- Primary CTA button on the right:
- `Request Consultation`
- Use shadcn/ui `NavigationMenu` if dropdowns are retained.
- Avoid wrapping nav links.
- Avoid huge dropdown panels unless they add real clarity.
Mobile header:
- Use shadcn/ui `Sheet` for navigation.
- Menu should slide in cleanly.
- Primary routes appear first.
- Service and industry subroutes can appear under simple section labels.
- CTA button should be visible in the sheet.
### Home Page Layout
Recommended home page structure:
1. **Hero**
- Use a dark navy hero section with bright text.
- Large modern headline.
- Suggested headline:
`Modern Communications Infrastructure Without the Vendor Noise`
- Subheading focused on UCaaS, Contact Center, networking, deployment, and managed lifecycle support.
- Two CTAs:
- Primary: `Request Consultation`
- Secondary: `Explore Services`
- Right side visual:
- Use a refined communications/network image, abstract interface panel, or polished existing asset.
- Do not use a raw stock-image block that feels pasted in.
- Trust chips below copy:
- `8x8 Certified Partner`
- `Veteran Owned`
- `25+ Years Experience`
- `SMB to Enterprise`
2. **Trust / Certification Bar**
- Light section directly below hero.
- Include 8x8 partner logo or certification language.
- Purpose: quickly establish credibility before services.
- Keep it compact and horizontal on desktop, stacked on mobile.
3. **Services Preview**
- Bright section with white cards.
- 6-7 concise service cards.
- Each card should include:
- Icon or small visual marker
- Service name
- One-sentence value statement
- Subtle `Learn more` affordance
- Avoid large image tiles unless imagery is carefully cropped and consistent.
4. **Why Queue North**
- Three-pillar layout:
- `Architecture`
- `Deployment`
- `Lifecycle Support`
- This section should explain how Queue North works, not just what it sells.
- Recommended style: alternating light background with strong typography and small supporting cards.
5. **Industries**
- Healthcare
- Retail
- Manufacturing
- Education & Finance
- These should feel like solution pathways.
- Do not present them as generic stock categories.
- Each industry card should connect pain point → Queue North solution.
6. **Final CTA**
- Dark navy CTA band near bottom of page.
- Suggested copy:
`Tell us what you're trying to fix. We'll help map the path.`
- CTA button:
`Request Consultation`
### Inner Page Layouts
Every inner page should use a consistent pattern:
1. **Page Hero**
- Short headline.
- One paragraph explaining the page value.
- Optional badge, e.g. `Service`, `Industry`, or `8x8 Partner`.
2. **Main Content Section**
- Use a two-column layout on desktop when useful:
- Left: core explanation
- Right: card with key benefits, use cases, or CTA
- Stack cleanly on mobile.
3. **Supporting Cards**
- Use 3-card or 4-card grids for benefits, capabilities, or outcomes.
- Keep card heights balanced.
4. **CTA Footer Band**
- Each major page should end with a CTA leading to contact.
### Services Page Layout
The `/services` page should act as a service hub.
- Top page hero explains the whole service model.
- Follow with a card grid for all services.
- Each service card links to its detail route.
- Recommended service card structure:
```txt
[small icon]
Service Name
Short outcome-driven description
Learn more →
```
Service detail pages should include:
- Hero with service name and value proposition.
- Section: `What this solves`
- Section: `How Queue North helps`
- Section: `Ideal for`
- CTA: `Talk to us about this service`
### Industries Page Layout
The `/industries` page should explain that Queue North adapts communications and support to operational realities.
Industry detail pages should include:
- Hero with industry-specific value proposition.
- Pain points.
- Queue North solution approach.
- Relevant services.
- CTA.
### Contact / Support Layout
Contact and support pages must feel trustworthy and simple.
- Use shadcn/ui form primitives.
- Keep forms visually clean with clear labels.
- Use Sonner toast for success/error states.
- Avoid giant intimidating forms.
- Use a two-column desktop layout:
- Left: reassurance, contact expectations, phone/email if available
- Right: form card
- On mobile, form comes after the intro copy.
### Design Quality Bar
Scarlett should treat the design as unacceptable if it has any of these issues:
- Nav wraps on desktop.
- Logo is oversized or visually collides with content.
- Cards look like default boxes with no hierarchy.
- Text contrast is weak.
- Sections feel cramped.
- Mobile layout feels like a collapsed desktop site.
- Images are inconsistent in crop, tone, or quality.
- There is no clear CTA above the fold.
- The site feels like cybersecurity/gaming/crypto instead of business communications.
---
## Navigation Plan
Current dropdown navigation should be simplified.
### Desktop Navigation
```txt
Home
Services
Industries
8x8
About
Contact
Support
```
### Mobile Navigation
Use shadcn/ui `Sheet`.
Mobile nav should:
- Open cleanly from a menu button.
- Avoid wrapping links.
- Show primary routes first.
- Include service and industry routes in grouped sections if needed.
---
## Route Plan
Replace hash routing with React Router paths.
```txt
/
/about
/services
/services/unified-communications
/services/contact-center
/services/managed-support
/services/consulting-training
/services/infrastructure-cabling
/services/wireless-access
/services/local-networking
/industries
/industries/healthcare
/industries/retail
/industries/manufacturing
/industries/education-finance
/8x8
/contact
/support
```
---
## Component System
Use shadcn/ui primitives wherever available.
Required primitives:
- `Button`
- `Card`
- `Sheet`
- `Dialog`
- `Input`
- `Textarea`
- `Select`
- `Badge`
- `Accordion`
- `NavigationMenu`
- `Toaster` / Sonner integration
This will modernize interactions and keep the UI consistent.
---
## Content Model
Move repeated content into data files.
Suggested files:
```txt
client/src/data/services.js
client/src/data/industries.js
client/src/data/certifications.js
client/src/data/stats.js
```
Use these files to render:
- Service overview cards
- Service detail pages
- Industry cards
- Industry detail pages
- Footer links
- Navigation links
- Trust badges
This prevents duplication and keeps future edits simple.
---
## Scarlett Design Implementation Brief
### Tailwind Theme Tokens
Using the **light-first business palette** defined in OVERHAUL_PLAN.md:
```js
// tailwind.config.js theme extension
theme: {
extend: {
colors: {
background: '#F8FAFC',
'section-alt': '#EEF6FB',
card: '#FFFFFF',
border: '#D8E3EA',
text: '#0F172A',
muted: '#475569',
'soft-text': '#64748B',
primary: {
navy: '#0B2A3C',
'navy-dark': '#071A2A',
blue: '#0EA5E9',
cyan: '#22D3EE',
},
accent: {
gold: '#F59E0B',
},
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
},
}
```
### Numeric Typography Rule
All numbers in the visual design should use **Georgia**. This applies to stats, counters, years, version displays, numeric badges, metrics, and prominent numeric callouts.
Implementation guidance:
- Add a reusable Tailwind font family such as `font-numeric` mapped to `Georgia, serif`.
- Use the numeric font only on numeric content.
- Do not switch body text, headings, nav, or general copy to Georgia.
### Typography Scale
```css
/* Base: 16px */
.text-xs: 12px / 16px
.text-sm: 14px / 20px
.text-base: 16px / 24px
.text-lg: 18px / 28px
.text-xl: 20px / 30px
.text-2xl: 24px / 36px
.text-3xl: 30px / 44px
.text-4xl: 36px / 52px
.text-5xl: 48px / 64px
```
### Section Spacing / Container Sizes
| Breakpoint | Container Max | Section Vertical Spacing |
|------------|---------------|--------------------------|
| Mobile | `100%` | `64px` / `80px` (bottom) |
| Desktop | `1200px` | `96px` / `128px` (bottom) |
Use `container mx-auto px-4 md:px-6 lg:px-8` pattern.
### Radius / Shadow / Border Rules
```css
/* Borders */
border: 1px solid border-color
rounded-lg: 8px
rounded-xl: 12px
rounded-2xl: 16px
/* Shadows */
shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05)
shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)
shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)
shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)
/* Cards */
- White background
- subtle border radius (12px)
- light shadow
- no heavy borders
```
### shadcn/ui Components to Use
Required primitives from `@/components/ui/`:
- `Button` — primary/secondary/callouts
- `Card` — service cards, industry cards
- `Sheet` — mobile navigation
- `Input`, `Textarea`, `Select` — forms
- `Badge` — certifications, status indicators
- `Accordion` — FAQ or details sections
- `NavigationMenu` — desktop nav (optional if simple)
- `Toast` / `Toaster` — success/error feedback
- `Dialog` — optional modal content
### Home Page Layout Blueprint
**1. Hero (Dark Navy)**
- Full-width, dark background (`bg-primary-navy`)
- Max-width container (`1200px`)
- Two-column desktop: copy left, visual right
- Mobile: stacked, copy first
- Headline: `Modern Communications Infrastructure Without the Vendor Noise`
- Subhead: UCaaS, Contact Center, Deployment, Lifecycle
- Two CTAs: Primary (`Request Consultation`), Secondary (`Explore Services`)
- Trust chips below: 8x8 Partner, Veteran Owned, 25+ Years, SMB to Enterprise
**2. Trust Bar (Light Section)**
- Background: `bg-section-alt`
- Horizontal center-aligned icons/logos
- Mobile: stacked, centered
**3. Services Grid (White Cards)**
- Background: `bg-background`
- Grid: 2 cols mobile, 3 cols tablet, 4 cols desktop
- Each card: icon, name, 1-sentence value, subtle `Learn more`
- Shadow: `shadow-sm`
**4. Why Queue North (Three Pillars)**
- Alternating light/dark sections optional
- Card grid: `Architecture | Deployment | Lifecycle Support`
- Light background with navy accent text
**5. Industries Preview (Solution Pathways)**
- Background: `bg-section-alt`
- Grid: Healthcare, Retail, Manufacturing, Education & Finance
- Each card: pain point → Queue North solution
- 2 cols mobile, 4 cols desktop
**6. Final CTA (Dark Band)**
- Dark navy background
- Centered copy: `Tell us what you're trying to fix. We'll help map the path.`
- Single CTA: `Request Consultation`
### Service / Industry Detail Layout Blueprint
**Page Hero**
- Section background: `bg-background`
- Left-aligned headline + short intro paragraph
- Optional badge (Service / Industry / 8x8 Partner)
**Main Content (Two-Column Desktop)**
- Desktop: Left = core explanation, Right = benefits/CTA card
- Mobile: stacked
- Left column: `prose` or clean `p + ul` text
- Right column: Card with checklist, use cases, or brief differentiators
**Supporting Cards Grid**
- 3 or 4 cards
- Icons, short titles, 1-sentence descriptions
- Grid: 1 col mobile, 2 col tablet, 3 col desktop
**CTA Footer Band**
- Each major page ends with CTA
- Dark navy section
- Centered copy + primary button
### Contact / Support Form Layout
**Desktop: Two-Column**
- Left: reassurance copy, phone/email if available, trust badges
- Right: Form Card
- Background: `bg-background`
**Mobile: Stacked**
- Copy first
- Form second
- Single-column input stacking
**Form Components**
- `Input` — name, company, email, phone, zip (if needed)
- `Textarea` — message or issue description
- `Select` — service interest or priority (if applicable)
- `Button` — Submit with loading state
- `Toast` — success/error via Sonner
### Responsive / Mobile Behavior
**Mobile First Rule**
- Sections stack vertically
- Grids: 1 col → 2 col → 3+ col
- Nav: `Sheet` menu slides in from right
- Buttons full-width on mobile, constrained width on desktop
- Images: full-width or max `max-w-full`, object-cover
**Typography Scaling**
- Headlines shrink for mobile (e.g., `text-4xl md:text-5xl`)
- Body text: `text-base` minimum
- Ensure contrast ratio ≥ 4.5:1
**Spacing Rhythm**
- Mobile section padding: `py-16` (64px)
- Desktop section padding: `py-24` (96px)
- Card padding: `p-4 md:p-6`
### Asset / Image Treatment
**Guidelines**
- Images: consistent aspect ratio (16:9 or 4:3 for hero)
- Avoid inconsistent stock-photo quality
- Use `object-cover` for hero banners
- For card visuals, use consistent sizing and cropping
- Replace generic stock with custom illustrations or refined photos
**Current Assets Check**
- Review `assets/` folder
- Confirm logo PNG/SVG at 2x size for retina
- Favicon: 32px, 48px, 180px (iPhone), 192px (Android)
- Replace placeholder images with clean B2B visuals
### Explicit Anti-Patterns to Avoid
❌ Nav wraps on desktop (single line only)
❌ Oversized logo bleeding into content
❌ Cards with default Bootstrap-style borders
❌ Weak text contrast (dark gray on white, light gray on white)
❌ Sections feeling cramped or inconsistent spacing
❌ Mobile layout feeling like collapsed desktop (no mobile-first grid)
❌ Images inconsistent in crop, tone, or quality
❌ No clear primary CTA above the fold on home
❌ Dark navy used everywhere (feels crypto/cyber/gaming instead of business)
❌ Gradient overlays on every section
❌ Multiple competing typefaces
❌ Large font sizes without line-height spacing
---
## Phase-Based Versioning
Version numbers must correlate directly to the active overhaul phase.
- **Phase 1** uses `0.1.x`
- First Phase 1 release: `0.1.0`
- Every completed agent pass/checkpoint within Phase 1: `0.1.1`, `0.1.2`, etc.
- **Phase 2** uses `0.2.x`
- First Phase 2 release: `0.2.0`
- Iterations/fixes within Phase 2: `0.2.1`, `0.2.2`, etc.
- **Phase 3** uses `0.3.x`
- **Phase 4** uses `0.4.x`
- **Phase 5** uses `0.5.x`
Rule: the minor version maps to the phase number; the patch version maps to each completed task batch after the full pipeline finishes. Dispatch a task batch, run it through the required agents, then push that completed batch once. Example: Docker task batch goes through Neo → Private Hudson → Bishop → Ripley, then pushes as `0.2.1`. Notes/tags should use the version number only, e.g. `0.2.1`.
---
## Migration Phases
## Phase 1: Scaffold the Stack
Goals:
- Create Vite React app.
- Add Tailwind CSS.
- Add shadcn/ui.
- Add React Router.
- Add Sonner.
- Add TanStack Query.
- Create Express server.
- Add better-sqlite3.
- Set up development scripts.
Result:
- Frontend boots.
- Backend boots.
- API health check works.
- Frontend can call backend.
---
## Phase 2: Rebuild Layout
Goals:
- Build application shell:
- Header
- Footer
- Layout wrapper
- Mobile navigation
- Build route pages.
- Port existing business content into React components.
- Replace hash routing with React Router.
- Move repeated content into data files.
- Remove legacy `styles.css` file.
Result:
- Site content exists in the new React app.
- Routes are clean and shareable.
- Structure is maintainable.
- Old global stylesheet removed.
---
## Phase 3: Visual Overhaul
Scarlett owns this phase.
Goals:
- Define Tailwind theme tokens.
- Apply new color system.
- Improve typography.
- Improve spacing scale.
- Build responsive-first layouts.
- Replace dated cards/sections with modern component patterns.
- Improve logo/header placement.
- Remove layout jank.
- Ensure mobile experience feels intentional.
Result:
- Website feels modern, premium, and trustworthy.
- Visual system is reusable across pages.
---
## Phase 4: Forms + Backend
Goals:
- Replace inline Zoho iframe form with React form.
- Submit contact requests to Express API.
- Submit support requests to Express API.
- Save submissions in SQLite.
- Use Sonner for success/error messages.
- Add validation.
- Optional later: forward leads to Zoho from backend.
Result:
- Forms are owned by the application.
- Leads/support requests are stored locally.
- UX is cleaner and easier to test.
---
## Phase 5: Verification
Bishop owns verification.
Goals:
- Confirm frontend build works.
- Confirm backend starts.
- Confirm all routes load.
- Confirm mobile navigation works.
- Confirm contact form submits.
- Confirm support form submits.
- Confirm SQLite writes succeed.
- Confirm no obvious console errors.
- Confirm basic accessibility expectations.
- Update project documentation.
Result:
- Rebuild is tested and documented.
---
## Agent Plan
Recommended pipeline:
1. **Scarlett**
- Create design direction.
- Define Tailwind theme.
- Define layout system and component expectations.
2. **Neo**
- Scaffold Vite + React + Express + SQLite.
- Build API/database foundation.
- Convert static site into structured app.
3. **Scarlett**
- Polish UI implementation.
- Enforce shadcn/ui and Tailwind standards.
- Review responsive behavior and visual quality.
4. **Bishop**
- Verify build/runtime/routes/forms.
- Update docs.
5. **Ripley**
- Final test.
- Commit and push.
---
## Recommendation
```txt
Design system first → scaffold/build → polish → verify
```
---
## Success Criteria
The overhaul is successful when:
- The site no longer looks or feels early-2000s.
- The app runs on the requested stack.
- Routing uses React Router, not hash switching.
- Styling uses Tailwind, not a giant global stylesheet.
- shadcn/ui primitives are used for common UI elements.
- Contact/support forms submit through Express.
- Form submissions are stored in SQLite.
- The site is responsive and polished on mobile.
- The content still clearly communicates Queue North's services, 8x8 partnership, and operational expertise.

View File

@ -1,23 +0,0 @@
# Queue North Website
## Overview
Project: Queue-North-Website
Created: 2026-05-11
Status: Active
## Description
Website project for Queue North. Details TBD.
## Directory Structure
- `/home/kaspa/.openclaw/Projects/Queue-North-Website/` — Project root
- All project files live here
## Git
- **Branch:** `dev` (working), `main` (stable)
- **Remote:** `ssh://forgejo/null/Queue-North-Website.git`
## Conventions
- Follow AGENTS.md for agent dispatch protocol
- Ripley coordinates, Neo codes, Scarlett styles, Bishop verifies, Hudson secures
- All agents read STRUCTURE.md before starting tasks
- Ripley owns git — no agent touches git directly

293
README.md Normal file
View File

@ -0,0 +1,293 @@
# Queue North Website
## Objective
Queue North Website is the modern rebuild of the Queue North Technologies business website.
The goal is to replace the current static early-2000s-style HTML/CSS/JS site with a polished 2026 business website that clearly presents Queue North as a trustworthy communications, contact center, networking, and managed support partner.
The site should feel:
- Bright and professional
- Modern but not flashy
- Business-first, not cyber/gaming/crypto
- Easy to navigate
- Clear about Queue North's 8x8 partnership and service expertise
- Optimized for consultation and support request conversion
## Target Stack
- **Vite** — build tool, not Next.js
- **React** — SPA frontend
- **React Router** — client-side routing
- **Tailwind CSS** — utility-first styling
- **shadcn/ui** — component primitives
- **Sonner** — toast notifications
- **TanStack Query** — server state management
- **Express** — backend API
- **better-sqlite3** — SQLite database
## Layout Direction
The design direction is a light-first B2B technology layout with strategic dark navy sections.
Primary structure:
1. **Hero**
- Dark navy section
- Clear headline and value proposition
- Primary CTA: `Request Consultation`
- Secondary CTA: `Explore Services`
- Trust chips: 8x8 Certified Partner, Veteran Owned, 25+ Years Experience, SMB to Enterprise
2. **Trust / Certification Bar**
- Light section
- Reinforces 8x8 partner credibility
3. **Services Preview**
- White cards on a bright background
- Concise service explanations
- Links to service detail pages
4. **Why Queue North**
- Three-pillar section:
- Architecture
- Deployment
- Lifecycle Support
5. **Industries**
- Healthcare
- Retail
- Manufacturing
- Education & Finance
6. **Final CTA**
- Dark navy conversion band
- Consultation-focused message
## Planned Routes
```txt
/
/about
/services
/services/unified-communications
/services/contact-center
/services/managed-support
/services/consulting-training
/services/infrastructure-cabling
/services/wireless-access
/services/local-networking
/industries
/industries/healthcare
/industries/retail
/industries/manufacturing
/industries/education-finance
/8x8
/contact
/support
```
## Overhaul Phases
Version numbers correlate directly to the active phase:
- **Phase 1 — Stack Scaffold**: `0.1.x` ✅ Complete
- ~~Vite + React app foundation~~
- ~~Tailwind CSS setup~~
- ~~shadcn/ui-style primitives~~
- ~~React Router~~
- ~~Express backend~~
- ~~better-sqlite3 database~~
- ~~Initial API health/contact/support paths~~
- **Phase 2 — Layout Rebuild**: `0.2.x` ✅ Complete
- ~~App shell: Header, Footer, layout wrapper, mobile nav~~
- ~~Route pages fully built and navigable~~
- ~~Existing business content ported into React~~
- ~~Repeated service/industry content moved into data files~~
- ~~Static hash routing fully replaced by React Router~~
- **Phase 3 — Visual Overhaul**: `0.3.x` ✅ Complete
- ~~Modern light-first business design~~
- ~~Tailwind theme polish~~
- ~~Typography, spacing, radius, shadows, and responsive rhythm~~
- ~~Refined service/industry cards and CTA sections~~
- ~~Mobile-first layout polish~~
- **Phase 4 — Forms + Backend Hardening**: `0.4.x` ✅ Complete
- ~~Contact and support forms fully wired to Express~~
- ~~SQLite persistence verified~~
- ~~Client-side validation + Sonner feedback~~
- ~~Server-side validation + input sanitization~~
- ~~Optional Zoho forwarding layer~~
- ~~Rate limiting + security headers + CORS~~
- ~~Backend/API hardening as needed~~
- **Phase 5 — Verification + Redesign**: `0.5.x` 🔄 In Progress
- ~~SPA router fix (BrowserRouter → RouterProvider)~~
- ~~TS generics stripped from .jsx files~~
- ~~Mobile menu Sheet/Dialog fix~~
- ~~DialogTitle accessibility fix~~
- ~~SPA catch-all route for client-side navigation~~
- ~~Image assets copied to public/ (were 404)~~
- ~~Real Queue North logo replacing placeholder~~
- ~~CSP updated for Google Fonts~~
- ~~Hamburger menu + SheetContent CSS fix~~
- ~~tailwindcss-animate installed and configured~~
- Hero section rewrite — B2B clarity, 8x8 partnership prominence
- Trust signals section — metrics, badges, certifications
- Services rewrite — business outcomes over technical jargon
- Why Queue North refinement — concrete differentiators
- Footer + CTA pass — contact paths everywhere
- Remaining P0/P1 audit fixes (Zoho, su-exec, email constraint)
- Accessibility checks
- Final push to `dev` for the completed phase
Patch versions increment for completed task batches after the full pipeline finishes. Dispatch a task batch, run it through the required agents, then push that completed batch once. Example: Docker task batch goes through Neo → Private Hudson → Bishop → Ripley, then pushes as `0.2.1`. Notes/tags should use the version number only.
## Backend Goals
Initial API endpoints:
```txt
GET /api/health
POST /api/leads
POST /api/support
```
Initial SQLite tables:
- `leads`
- `support_requests`
Contact and support forms should submit through Express, save to SQLite, and show user feedback with Sonner.
## Agent Plan
The overhaul is handled through the agent pipeline below:
1. **Scarlett** — design system, Tailwind/shadcn layout direction, responsive polish, accessibility review
2. **Neo** — Vite/React implementation, Express API, SQLite/database work, build-system changes
3. **Private Hudson** — security review for API routes, form handling, validation, data exposure, dependency risks, and backend hardening
4. **Scarlett** — UI polish pass after implementation changes
5. **Bishop** — build/runtime verification, route checks, documentation verification, version consistency
6. **Ripley** — final local checks, commit, tag, and push to `dev`
Agents do not touch git. Ripley owns all commits, tags, and pushes.
## Batch Pipeline Rule
Work is dispatched as task batches. A batch runs through the required agents, then Ripley pushes that completed batch once.
Example Docker batch:
```txt
Neo → Private Hudson → Bishop → Ripley
```
The whole Docker batch is one checkpoint: `0.2.1`.
Do not increment the patch version for each individual agent inside the same batch. Increment only after the full task batch finishes and is ready to push.
Notes, tags, and checkpoint labels should use only the version number, such as `0.2.1`.
## Design Direction
Based on the redesign review (see `review.md`), the site should feel:
- **Modern, clean, stable** — not experimental, not hacker aesthetic
- **Business-first** — B2B UCaaS/IT partner, not a dev portfolio
- **Trust-forward** — 8x8 partnership, certifications, uptime SLAs front and center
- **Human but competent** — less corporate fluff, more concrete outcomes
Color palette evolution (not rip-and-replace):
- Keep navy dark base, add teal/cyan accents for depth and hierarchy
- Improve contrast and spacing
- Mobile-first — SMB decision-makers browse on phones
Reference brands: RingCentral, Cloudflare, Dialpad — modern but enterprise-trustworthy.
See [review.md](./review.md) for the full redesign assessment.
## Docker Deployment
The application can be containerized using Docker for consistent deployment across environments.
### Prerequisites
- Docker (v20+)
- Docker Compose (v2+)
### Quick Start
#### Using Docker Compose (Recommended)
```bash
# Build and start the container
npm run docker:compose:up
# View logs
npm run docker:compose:logs
# Stop the container
npm run docker:compose:down
```
#### Manual Docker Build
```bash
# Build the image
npm run docker:build
# Run the container
npm run docker:run
```
### Environment Variables
Set the following in the `.env` file (not included in image by default):
```env
NODE_ENV=production
SERVER_PORT=3001
```
### Data Persistence
SQLite database is persisted in the `./db` directory. Data will survive container restarts.
**Note on data persistence:**
The application uses Docker named volumes (`queuenorth-db` and `queuenorth-logs`) to persist data. Docker manages the ownership and permissions of these volumes automatically, so no manual setup is required.
If you prefer to use host bind mounts instead, ensure your host `./db` and `./logs` directories are owned by UID 1001:
```bash
mkdir -p ./db ./logs
sudo chown -R 1001:1001 ./db ./logs
```
If you encounter "unable to open database file" errors, verify the host directory is writable by the container's UID (1001) or use named volumes as shown above.
### Health Check
The container includes a health check at `/api/health`. A healthy container returns:
```json
{"status":"ok","timestamp":"2026-05-12T..."}
```
### Ports
- Backend API: `3001` (host) → `3001` (container)
### Build Optimization
The `.dockerignore` excludes:
- `node_modules` (reinstalled in container)
- `dist` (built in container)
- `db/` (mounted as volume)
- `.git`, logs, private docs
This ensures minimal image size and reproducible builds.

View File

@ -1,25 +0,0 @@
# Queue-North-Website — Project Structure
## Agent Roles
| Agent | Role | Focus Area |
|-------|------|------------|
| Neo | Backend Coder | Server code, APIs, database, build system |
| Scarlett | UI/Design | Frontend components, Tailwind CSS, layout, visuals |
| Bishop | Verification | Build, runtime tests, documentation, version bumps |
| Private_Hudson | Security | Auth, data exposure, input validation, dependency audit |
| Ripley | Coordinator | Git, deploy, pipeline, task dispatch |
## Code Ownership
TBD — will be defined as the project takes shape.
## Key Files
- `PROJECT.md` — Project overview and conventions
- `STRUCTURE.md` — This file. Agent roles, code ownership, critical paths
- `FUTURE.md` — Planning doc (what to build next)
- `HISTORY.md` — Version changelog
- `DEVELOPMENT_LOG.md` — Agent activity log
## Cross-Cutting Concerns
- All agents must read this file before starting any task
- All agents report back to Ripley — no agent-to-agent handoffs

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

48
docker-compose.yml Normal file
View File

@ -0,0 +1,48 @@
version: "3.8"
services:
queuenorth:
build:
context: .
dockerfile: Dockerfile
args:
- VITE_RECAPTCHA_SITE_KEY=${VITE_RECAPTCHA_SITE_KEY:-}
container_name: queuenorth-website
ports:
- "3001:3001"
volumes:
# Persist SQLite database between runs using named volume
# This avoids host permission issues - Docker manages ownership automatically
- queuenorth-db:/app/db:rw
# Persist logs using named volume
- queuenorth-logs:/app/logs:rw
environment:
- NODE_ENV=production
- SERVER_PORT=3001
- RATE_LIMIT_PER_MINUTE=5
- CORS_ORIGIN=https://queuenorth.com
- LOG_LEVEL=info
- ZOHO_FORWARDING_MODE=${ZOHO_FORWARDING_MODE:-webtolead}
- ZOHO_WEBTOLEAD_ENABLED=${ZOHO_WEBTOLEAD_ENABLED:-false}
- ZOHO_WEBTOLEAD_URL=${ZOHO_WEBTOLEAD_URL:-https://crm.zoho.com/crm/WebToLeadForm}
- ZOHO_WEBTOLEAD_XNQSJSDP=${ZOHO_WEBTOLEAD_XNQSJSDP:-}
- ZOHO_WEBTOLEAD_XMIWTLD=${ZOHO_WEBTOLEAD_XMIWTLD:-}
- ZOHO_WEBTOLEAD_ACTION_TYPE=${ZOHO_WEBTOLEAD_ACTION_TYPE:-TGVhZHM=}
- ZOHO_WEBTOLEAD_RETURN_URL=${ZOHO_WEBTOLEAD_RETURN_URL:-null}
- ZOHO_WEBTOLEAD_ZC_GAD=${ZOHO_WEBTOLEAD_ZC_GAD:-}
- ZOHO_ENABLED=${ZOHO_ENABLED:-false}
- ZOHO_API_DOMAIN=${ZOHO_API_DOMAIN:-https://www.zohoapis.com}
- ZOHO_ACCOUNTS_DOMAIN=${ZOHO_ACCOUNTS_DOMAIN:-https://accounts.zoho.com}
- ZOHO_CLIENT_ID=${ZOHO_CLIENT_ID:-}
- ZOHO_CLIENT_SECRET=${ZOHO_CLIENT_SECRET:-}
- ZOHO_REFRESH_TOKEN=${ZOHO_REFRESH_TOKEN:-}
- ZOHO_CASES_ENABLED=${ZOHO_CASES_ENABLED:-false}
- RECAPTCHA_ENABLED=${RECAPTCHA_ENABLED:-false}
- RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY:-}
- RECAPTCHA_MIN_SCORE=${RECAPTCHA_MIN_SCORE:-0.5}
restart: unless-stopped
# Container runs as non-root user (UID 1001) for security
volumes:
queuenorth-db:
queuenorth-logs:

22
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,22 @@
#!/bin/sh
# Docker entrypoint for Queue North Website
# The Dockerfile uses USER nodejs + CMD directly, so this script
# is only used if explicitly set as ENTRYPOINT.
# It ensures db/logs directories exist before starting the server.
set -e
# Create directories if they don't exist
mkdir -p /app/db
mkdir -p /app/logs
# Ensure proper ownership (runs as root before su-exec)
chown -R nodejs:nodejs /app/db /app/logs 2>/dev/null || true
# Run as nodejs user if currently root
if [ "$(id -u)" = "0" ]; then
exec su-exec nodejs node server/index.js
else
exec node server/index.js
fi

214
docs/zoho-setup.md Normal file
View File

@ -0,0 +1,214 @@
# Zoho CRM Setup Guide for Queue North Admins
This guide walks you through the current Zoho CRM integration. Contact leads use the legacy Zoho WebToLead form tokens, while the OAuth/API integration remains available as a standby option for future lead upserts or support cases.
---
## Prerequisites
Before you begin, ensure you have:
- A Zoho CRM account (admin access required)
- The WebToLead hidden field values from the old Zoho form
---
## Step 1: Gather WebToLead Values
The current integration needs the old Zoho form's hidden fields:
```text
xnQsjsdp
xmIwtLD
actionType
returnURL
zc_gad
```
These values are stored locally in `zoho.md`, which is ignored by git.
---
## Optional Standby: Create a Zoho Self-Client App
Only use these OAuth steps if switching `ZOHO_FORWARDING_MODE=api`.
1. Go to **https://api-console.zoho.com**
2. Click **"Create Self Client"**
3. Fill in:
- **Client Name**: Queue-North-Zoho-Integration
- **Description**: Auto-capture leads and cases from Queue North website
- **Redirect URI**: `https://www.zoho.com` (required for Self-Client, not used)
4. Click **Create**
5. **Copy and save**:
- **Client ID**
- **Client Secret**
> ⚠️ Store these securely — they're like a username and password.
---
## Optional Standby: Generate an Authorization Code
1. In the Self Client tab, click **"Generate Code"**
2. Set the **Scope** to:
```
ZohoCRM.modules.leads.CREATE,ZohoCRM.modules.leads.READ,ZohoCRM.modules.cases.CREATE,ZohoCRM.modules.cases.READ
```
3. Set **Expiry** to **10 minutes** (use it quickly)
4. Click **Generate**
5. **Copy the authorization code** — it expires in 10 minutes
---
## Optional Standby: Exchange Auth Code for Tokens
Run this `curl` command (replace placeholders):
```bash
curl -X POST https://accounts.zoho.com/oauth/v2/token \
-d "code=<YOUR_AUTH_CODE>" \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "client_secret=<YOUR_CLIENT_SECRET>" \
-d "grant_type=authorization_code" \
-d "redirect_uri=https://www.zoho.com"
```
**Response will include:**
```json
{
"access_token": "1000.xxxxx.xxxxx",
"refresh_token": "1000.yyyyy.yyyyy",
"expires_in": 3600,
"token_type": "bearer"
}
```
**Save the `refresh_token`** — this never expires and must be kept secret.
---
## Step 2: Configure Environment Variables
The current production-friendly setup uses the legacy Zoho WebToLead form tokens for contact leads while keeping the OAuth API integration available as a standby option.
Add these to your `.env` file for WebToLead lead forwarding:
```env
ZOHO_FORWARDING_MODE=webtolead
ZOHO_WEBTOLEAD_ENABLED=true
ZOHO_WEBTOLEAD_URL=https://crm.zoho.com/crm/WebToLeadForm
ZOHO_WEBTOLEAD_XNQSJSDP=<from Zoho WebToLead hidden field>
ZOHO_WEBTOLEAD_XMIWTLD=<from Zoho WebToLead hidden field>
ZOHO_WEBTOLEAD_ACTION_TYPE=TGVhZHM=
ZOHO_WEBTOLEAD_RETURN_URL=null
ZOHO_WEBTOLEAD_ZC_GAD=
```
Use these only if switching back to the Zoho CRM REST API/OAuth integration:
```env
ZOHO_FORWARDING_MODE=api
ZOHO_ENABLED=false
ZOHO_API_DOMAIN=https://www.zohoapis.com
ZOHO_ACCOUNTS_DOMAIN=https://accounts.zoho.com
ZOHO_CLIENT_ID=<from Step 1>
ZOHO_CLIENT_SECRET=<from Step 1>
ZOHO_REFRESH_TOKEN=<from Step 3>
# Cases forwarding is also OFF by default
ZOHO_CASES_ENABLED=false
```
> **Note:** `ZOHO_CASES_ENABLED` only applies to the OAuth/API path. The WebToLead values found in the old site are for lead capture only.
### Datacenter Variants
If your Zoho datacenter is **outside the US**, adjust the domains:
| Region | API Domain | Accounts Domain |
|--------|-----------|-----------------|
| US | `www.zohoapis.com` | `accounts.zoho.com` |
| EU | `www.zohoapis.eu` | `accounts.zoho.eu` |
| IN | `www.zohoapis.in` | `accounts.zoho.in` |
| AU | `www.zohoapis.com.au` | `accounts.zoho.com.au` |
---
## Step 3: Test the Integration
### Test Lead Capture
1. Submit a lead on the contact form (name, email, phone, message)
2. Wait ~510 seconds
3. Log in to Zoho CRM → Leads tab
4. Verify the new lead appears with correct data
### Test Case Capture
1. Submit a support request (e.g., booking inquiry, technical question)
2. Wait ~510 seconds
3. Log in to Zoho CRM → Cases tab
4. Verify the new case appears with correct data
---
## Troubleshooting
### Token Errors
- **"invalid_grant"**: Your authorization code expired. Generate a new one in Step 2 and repeat Step 3.
- **"invalid_client"**: Double-check Client ID and Secret — no extra spaces.
- **"invalid_scope"**: Re-run Step 2 with the exact scopes listed above.
### Field Mismatches
- If leads/cases don't appear, check if Zoho requires custom fields like `Service_Interest`
- Edit the field mapping in `server/zoho/` to match your Zoho CRM field API names
### Cases Not Appearing
- Ensure `ZOHO_CASES_ENABLED=true` is set
- Verify the Cases tab is enabled in your Zoho CRM plan
- Check that your Zoho CRM user has **Cases CREATE** permissions
### Lead Upsert Behavior
- Leads are **upserted by email**: duplicate email = update existing lead
- Cases are **always inserted** (new ticket each time)
- If you see duplicate leads, check for slight email variations (e.g., `test@` vs `test+1@`)
---
## Architecture Notes
### Flow Overview
```
Website Contact Form → SQLite (always saved)
Zoho CRM (best-effort)
Fire-and-forget (no failure blocking)
```
### OAuth2 Refresh Token Flow
1. Use `refresh_token` to get a new `access_token` when expired
2. `access_token` expires in 1 hour
3. `refresh_token` never expires — store it securely
### Upsert Logic
- **Leads**: WebToLead creates leads through the legacy Zoho form endpoint. API mode uses email-based upsert.
- **Cases**: Always insert (new case per submission)
### Fire-and-Forget Design
- Zoho failures **do not block** form submissions
- All data is saved to SQLite first
- Zoho attempts happen in the background
- No retry logic needed — users won't wait for Zoho
---
## What Happens Next?
After configuration:
1. Deploy the environment variables to production
2. Set `ZOHO_WEBTOLEAD_ENABLED=true` in production `.env`
3. Restart the application
4. Submit a test lead and support case to verify data flows to Zoho CRM
5. Check Zoho CRM Leads and Cases tabs to confirm both appear
---
**Need help?** Contact your site administrator.

View File

@ -1,768 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8" />
<title>Queue North Technologies</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Queue North Technologies is an official 8x8 Certified Partner delivering UCaaS, Contact Center, deployment, and managed lifecycle support for SMB and enterprise organizations."
/>
<link rel="stylesheet" href="styles.css">
<meta name="theme-color" content="#0B1B3F" />
<title>Queue North Technologies | Business Communications & IT Partner</title>
<meta name="description" content="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." />
<!-- Open Graph fallback for crawlers that don't execute JavaScript -->
<meta property="og:title" content="Queue North Technologies | Business Communications & IT Partner" />
<meta property="og:description" content="Veteran-owned 8x8 Certified Partner. Business phone, UCaaS, contact center, IT support, and networking solutions. 25+ years of proven reliability." />
<meta property="og:url" content="https://queuenorth.com" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Queue North Technologies" />
<meta property="og:locale" content="en_US" />
<meta property="og:image" content="https://queuenorth.com/assets/og-image.png" />
<meta property="og:image:secure_url" content="https://queuenorth.com/assets/og-image.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Queue North Technologies — Business Communications & IT Partner" />
<!-- Twitter / X Card fallback -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Queue North Technologies | Business Communications & IT Partner" />
<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:alt" content="Queue North Technologies — Business Communications & IT Partner" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="16x16" href="assets/icons/logo16.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icons/logo32.png">
<link rel="icon" type="image/png" sizes="48x48" href="assets/icons/logo48.png">
<link rel="icon" type="image/png" sizes="96x96" href="assets/icons/logo96.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/logo180.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/icons/logo192.png">
<link rel="icon" type="image/png" sizes="512x512" href="assets/icons/logo512.png">
</head>
<body>
<header class="site-header">
<div class="logo">
<a href="#home" data-route="home" class="logo-link" aria-label="Queue North Technologies Home">
<img src="assets/logo2.png" alt="Queue North Technologies Logo" />
</a>
</div>
<nav id="primary-navigation" class="site-nav" aria-label="Primary">
<a href="#home" data-route="home">Home</a>
<a href="#about" data-route="about">About</a>
<div class="nav-dropdown">
<button class="nav-dropdown-toggle" type="button" data-route="services" aria-haspopup="true" aria-expanded="false">Services</button>
<div class="nav-dropdown-menu" role="menu" aria-label="Services menu">
<a href="#services" data-route="services" role="menuitem">All Services</a>
<a href="#unified-communications" data-route="unified-communications" role="menuitem">Unified Communications</a>
<a href="#contact-center" data-route="contact-center" role="menuitem">Contact Center</a>
<a href="#managed-support" data-route="managed-support" role="menuitem">Managed Services &amp; Support</a>
<a href="#consulting-training" data-route="consulting-training" role="menuitem">Consulting &amp; Training</a>
<a href="#infrastructure-cabling" data-route="infrastructure-cabling" role="menuitem">Infrastructure Cabling</a>
<a href="#wireless-access" data-route="wireless-access" role="menuitem">Wireless Access</a>
<a href="#local-networking" data-route="local-networking" role="menuitem">Local Networking</a>
</div>
</div>
<a href="#8x8" data-route="eightx8">8x8</a>
<div class="nav-dropdown">
<button class="nav-dropdown-toggle" type="button" data-route="industries" aria-haspopup="true" aria-expanded="false">Industries</button>
<div class="nav-dropdown-menu" role="menu" aria-label="Industries menu">
<a href="#industries" data-route="industries" role="menuitem">All Industries</a>
<a href="#healthcare" data-route="healthcare" role="menuitem">Healthcare</a>
<a href="#retail" data-route="retail" role="menuitem">Retail</a>
<a href="#manufacturing" data-route="manufacturing" role="menuitem">Manufacturing</a>
<a href="#education-finance" data-route="education-finance" role="menuitem">Education &amp; Finance</a>
</div>
</div>
<a href="#contact" data-route="contact">Contact Us</a>
<a href="#support" data-route="support">Support</a>
</nav>
</header>
<main class="app">
<!-- HOME -->
<section id="page-home" class="page active" data-page="home" aria-labelledby="home-title">
<div class="hero hero-split">
<div class="hero-inner hero-split-inner">
<div class="hero-content">
<h1 id="home-title">UCaaS & Contact Center Experts</h1>
<p>
Queue North Technologies is an official 8x8 Certified Partner holding Sales, Sales Engineer, Build, Deployment, and Support Certifications.
We design, implement, and operate secure, scalable communication environments for SMB and enterprise organizations.
</p>
<div class="hero-actions">
<button id="cta-consultation" class="primary-btn">Request a Consultation</button>
</div>
<div class="hero-trust">
<span>Official 8x8 Certified Partner</span>
<span>Veteran Owned</span>
<span>25+ Years Experience</span>
<span>SMB to Enterprise</span>
</div>
</div>
<!-- IMAGE AREA (real image, no visible box) -->
<div class="hero-media" aria-label="Homepage visual">
<img
class="hero-photo"
src="assets/hero-tech.png"
alt="Technician working in a data center"
loading="eager"
decoding="async"
/>
<!-- Rotating phrases + final lockup (overlayed on the image, bottom-left) -->
<div class="hero-media-overlay" aria-hidden="true">
<div class="hero-rotator">
<span id="hero-rotator-text" class="hero-rotator-text"></span>
</div>
<div id="hero-lockup" class="hero-lockup hero-lockup-subtitle is-hidden">
<div class="hero-brand">Queue North</div>
<div>Where Stability Meets Direction</div>
</div>
</div>
</div>
</div>
</div>
<!-- Keep only a tight “why us” summary on Home -->
<section class="section">
<div class="container card-grid">
<div class="card">
<h3>End-to-End Ownership</h3>
<p>Architecture, deployment, adoption, and long-term operational support — with documentation and accountability.</p>
</div>
<div class="card">
<h3>Operational Clarity</h3>
<p>We align platform features to workflows, call flows, and real-world staffing — not marketing checklists.</p>
</div>
<div class="card">
<h3>Reliable Support</h3>
<p>Consistent escalation management, change control, and an operator mindset that sticks after go-live.</p>
</div>
</div>
</section>
</section>
<!-- ABOUT -->
<section id="page-about" class="page" data-page="about" aria-labelledby="about-title">
<section class="about-canvas" aria-labelledby="about-title">
<div class="about-panel about-panel-left">
<h2 id="about-title">About Queue North Technologies</h2>
<p>
Veteran-owned communications and networking partner focused on reliable, future-ready systems.
We tell you the truth and align technology to operations — not licensing structures.
</p>
</div>
<div class="about-panel about-panel-right">
<h3>What Sets Us Apart</h3>
<ul class="bullet-list">
<li>Trustworthy, straightforward guidance</li>
<li>Veteran-owned leadership and discipline</li>
<li>25+ years in telecommunications and networking</li>
<li>Flexible service levels and support governance</li>
<li>Only pay for the features and services you actually need</li>
</ul>
</div>
</section>
</section>
<!-- SERVICES -->
<section id="page-services" class="page" data-page="services" aria-labelledby="services-title">
<section class="page-hero alt-section">
<div class="container page-hero-inner">
<h2 id="services-title">Services</h2>
<p class="section-intro">
Modern communication, networking, and support services built around your operations today and your growth tomorrow
</p>
</div>
</section>
<section class="section">
<div class="container services-grid">
<a class="service-item" href="#unified-communications" data-route="unified-communications">
<img class="service-overview-image" src="assets/Unified communications in a modern office.png" alt="Unified Communications" loading="lazy" decoding="async" />
<div class="card service-overview-card">
<h3>Unified Communications</h3>
<p>Voice, meetings, and messaging that keep your people connected without adding operational friction.</p>
</div>
</a>
<a class="service-item" href="#contact-center" data-route="contact-center">
<img class="service-overview-image" src="assets/Modern call center in action.png" alt="Contact Center" loading="lazy" decoding="async" />
<div class="card service-overview-card">
<h3>Contact Center</h3>
<p>Customer engagement built with routing, reporting, and workflow control that support real operational performance.</p>
</div>
</a>
<a class="service-item" href="#managed-support" data-route="managed-support">
<img class="service-overview-image" src="assets/Managed Support.png" alt="Managed Services and Support" loading="lazy" decoding="async" />
<div class="card service-overview-card">
<h3>Managed Services &amp; Support</h3>
<p>Consistent support, clear accountability, and lifecycle management that keep your environment stable long after deployment.</p>
</div>
</a>
<a class="service-item" href="#consulting-training" data-route="consulting-training">
<img class="service-overview-image" src="assets/Consulting &amp; Training.png" alt="Consulting and Training" loading="lazy" decoding="async" />
<div class="card service-overview-card">
<h3>Consulting &amp; Training</h3>
<p>Straightforward guidance and practical training that help your team use technology with confidence and discipline.</p>
</div>
</a>
<a class="service-item" href="#infrastructure-cabling" data-route="infrastructure-cabling">
<img class="service-overview-image" src="assets/Cabling.png" alt="Infrastructure Cabling" loading="lazy" decoding="async" />
<div class="card service-overview-card">
<h3>Infrastructure Cabling</h3>
<p>Clean structured cabling that gives your business the physical foundation for reliable communication and growth.</p>
</div>
</a>
<a class="service-item" href="#wireless-access" data-route="wireless-access">
<img class="service-overview-image" src="assets/Wireless.png" alt="Wireless Access" loading="lazy" decoding="async" />
<div class="card service-overview-card">
<h3>Wireless Access</h3>
<p>Business WiFi designed for usable coverage, dependable performance, and fewer support headaches across your environment.</p>
</div>
</a>
<a class="service-item" href="#local-networking" data-route="local-networking">
<img class="service-overview-image" src="assets/Local Networking.png" alt="Local Networking" loading="lazy" decoding="async" />
<div class="card service-overview-card">
<h3>Local Networking</h3>
<p>Switching and routing built for stability, visibility, and secure local network performance.</p>
</div>
</a>
</div>
</section>
</section>
<section id="page-unified-communications" class="page" data-page="unified-communications" aria-labelledby="unified-communications-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="unified-communications-title" class="service-page-title">Unified Communications</h2>
<img class="service-page-image" src="assets/Unified communications in a modern office.png" alt="Unified Communications service" loading="lazy" decoding="async" />
<p class="service-page-tagline">Voice, meetings, and messaging that keep your people connected without adding operational friction.</p>
</div>
</section>
</section>
<section id="page-contact-center" class="page" data-page="contact-center" aria-labelledby="contact-center-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="contact-center-title" class="service-page-title">Contact Center</h2>
<img class="service-page-image" src="assets/Modern call center in action.png" alt="Contact Center service" loading="lazy" decoding="async" />
<p class="service-page-tagline">Customer engagement built with routing, reporting, and workflow control that support real operational performance.</p>
</div>
</section>
</section>
<section id="page-managed-support" class="page" data-page="managed-support" aria-labelledby="managed-support-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="managed-support-title" class="service-page-title">Managed Services &amp; Support</h2>
<img class="service-page-image" src="assets/Managed Support.png" alt="Managed Services and Support service" loading="lazy" decoding="async" />
<p class="service-page-tagline">Consistent support, clear accountability, and lifecycle management that keep your environment stable long after deployment.</p>
</div>
</section>
</section>
<section id="page-consulting-training" class="page" data-page="consulting-training" aria-labelledby="consulting-training-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="consulting-training-title" class="service-page-title">Consulting &amp; Training</h2>
<img class="service-page-image" src="assets/Consulting & Training.png" alt="Consulting and Training service" loading="lazy" decoding="async" />
<p class="service-page-tagline">Straightforward guidance and practical training that help your team use technology with confidence and discipline.</p>
</div>
</section>
</section>
<section id="page-infrastructure-cabling" class="page" data-page="infrastructure-cabling" aria-labelledby="infrastructure-cabling-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="infrastructure-cabling-title" class="service-page-title">Infrastructure Cabling</h2>
<img class="service-page-image" src="assets/Cabling.png" alt="Infrastructure Cabling service" loading="lazy" decoding="async" />
<p class="service-page-tagline">Clean structured cabling that gives your business the physical foundation for reliable communication and growth.</p>
</div>
</section>
</section>
<section id="page-wireless-access" class="page" data-page="wireless-access" aria-labelledby="wireless-access-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="wireless-access-title" class="service-page-title">Wireless Access</h2>
<img class="service-page-image" src="assets/Wireless.png" alt="Wireless Access service" loading="lazy" decoding="async" />
<p class="service-page-tagline">Business WiFi designed for usable coverage, dependable performance, and fewer support headaches across your environment.</p>
</div>
</section>
</section>
<section id="page-local-networking" class="page" data-page="local-networking" aria-labelledby="local-networking-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="local-networking-title" class="service-page-title">Local Networking</h2>
<img class="service-page-image" src="assets/Local Networking.png" alt="Local Networking service" loading="lazy" decoding="async" />
<p class="service-page-tagline">Switching and routing built for secure local performance, clean management, and the operational visibility your business needs.</p>
</div>
</section>
</section>
<!-- 8x8 -->
<section id="page-8x8" class="page" data-page="eightx8" aria-labelledby="eightx8-title">
<section class="page-hero">
<div class="container page-hero-inner">
<div>
<h2 id="eightx8-title">8x8 Certified Partner</h2>
<ul class="bullet-list">
<li>Solution design, licensing strategy, and platform fit</li>
<li>Deployment planning, number porting, and migrations</li>
<li>Configuration, routing, reporting, and operational governance</li>
<li>Post-deployment support, escalation, and change control</li>
</ul>
</div>
<div class="page-hero-media">
<div class="partner-lockup">
<img
class="partner-lockup-img"
src="assets/JointLogoWhite.png"
alt="Queue North Technologies | 8x8 Certified Partner"
/>
<div class="small-muted partner-lockup-caption">
Queue North holds 8x8 Sales, Sales Engineer, Build, Deployment, and Support Certifications — enabling full lifecycle delivery.
</div>
</div>
</div>
</div>
</section>
<section class="section alt-section">
<div class="container card-grid">
<div class="card">
<h3>UCaaS</h3>
<p>Voice, meetings, and messaging delivered with documentation, governance, and operational clarity.</p>
</div>
<div class="card">
<h3>Contact Center</h3>
<p>Queue strategy, routing logic, reporting, and workforce workflows built for real performance visibility.</p>
</div>
<div class="card">
<h3>Lifecycle Support</h3>
<p>We stay accountable after go-live — support structure, escalation, and change control that doesnt fall apart.</p>
</div>
</div>
</section>
</section>
<!-- INDUSTRIES -->
<section id="page-industries" class="page" data-page="industries" aria-labelledby="industries-title">
<section class="page-hero alt-section">
<div class="container page-hero-inner">
<div>
<h2 id="industries-title">Industries We Serve</h2>
<p class="section-intro">
We focus on sectors where communication, reliability, and integration directly impact operations and customer experience.
</p>
</div>
</div>
</section>
<section class="section">
<div class="container industries-grid">
<div class="industry-item">
<img class="industry-image" src="assets/Healthcare.png" alt="Healthcare" loading="lazy" decoding="async" />
<div class="card industry-card">
<h3>Healthcare</h3>
<p>Patient experience, scheduling, and staff coordination with a focus on compliance.</p>
</div>
</div>
<div class="industry-item">
<img class="industry-image" src="assets/Retail.png" alt="Retail" loading="lazy" decoding="async" />
<div class="card industry-card">
<h3>Retail</h3>
<p>Connect stores, back office, and support teams while improving customer interaction.</p>
</div>
</div>
<div class="industry-item">
<img class="industry-image" src="assets/Manufacturing.png" alt="Manufacturing" loading="lazy" decoding="async" />
<div class="card industry-card">
<h3>Manufacturing</h3>
<p>Reliable office-to-plant communications including paging and alerting for production environments.</p>
</div>
</div>
<div class="industry-item">
<!-- If you keep the space in the filename, use %20 -->
<img class="industry-image" src="assets/Financial%20Services.png" alt="Education & Finance" loading="lazy" decoding="async" />
<div class="card industry-card">
<h3>Education &amp; Finance</h3>
<p>Campus communications and customer-facing communications in tightly regulated environments.</p>
</div>
</div>
</div>
</section>
</section>
<section id="page-healthcare" class="page" data-page="healthcare" aria-labelledby="healthcare-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="healthcare-title" class="service-page-title">Healthcare</h2>
<img class="service-page-image" src="assets/Healthcare.png" alt="Healthcare industry" loading="lazy" decoding="async" />
<p class="service-page-tagline">Patient experience, scheduling, and staff coordination with a focus on compliance.</p>
</div>
</section>
</section>
<section id="page-retail" class="page" data-page="retail" aria-labelledby="retail-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="retail-title" class="service-page-title">Retail</h2>
<img class="service-page-image" src="assets/Retail.png" alt="Retail industry" loading="lazy" decoding="async" />
<p class="service-page-tagline">Connect stores, back office, and support teams while improving customer interaction.</p>
</div>
</section>
</section>
<section id="page-manufacturing" class="page" data-page="manufacturing" aria-labelledby="manufacturing-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="manufacturing-title" class="service-page-title">Manufacturing</h2>
<img class="service-page-image" src="assets/Manufacturing.png" alt="Manufacturing industry" loading="lazy" decoding="async" />
<p class="service-page-tagline">Reliable office-to-plant communications including paging and alerting for production environments.</p>
</div>
</section>
</section>
<section id="page-education-finance" class="page" data-page="education-finance" aria-labelledby="education-finance-title">
<section class="section service-page-section alt-section">
<div class="container service-page-content">
<h2 id="education-finance-title" class="service-page-title">Education &amp; Finance</h2>
<img class="service-page-image" src="assets/Financial%20Services.png" alt="Education and Finance industry" loading="lazy" decoding="async" />
<p class="service-page-tagline">Campus communications and customer-facing communications in tightly regulated environments.</p>
</div>
</section>
</section>
<!-- CONTACT -->
<section id="page-contact" class="page" data-page="contact" aria-labelledby="contact-title">
<section class="page-hero">
<div class="container page-hero-inner">
<div>
<h2 id="contact-title">Contact Us</h2>
<p class="section-intro">
If your environment provides no insight or accountability — or support is slow and inconsistent — we can help.
Share a few details and well provide clear direction.
</p>
</div>
</div>
</div>
</section>
<section class="section alt-section">
<div class="container two-column">
<div>
<h3>What well help you do</h3>
<ul class="bullet-list">
<li>Identify the features you actually need</li>
<li>Align solutions with operations and budget</li>
<li>Plan deployment, migration, and training</li>
<li>Ask how you qualify for our free migration</li>
</ul>
</div>
<div>
<div class="wf_customMessageBox" id="wf_splash" style="display:none" role="status" aria-live="polite">
<div class="wf_customCircle" aria-hidden="true">
<div class="wf_customCheckMark"></div>
</div>
<span id="wf_splash_info"></span>
<button type="button" class="wf_customClose" id="wf_splash_close" aria-label="Close message"></button>
</div>
<form
id="contact-form"
class="contact-form"
name="WebToLeads7130861000000581796"
method="POST"
action="https://crm.zoho.com/crm/WebToLeadForm"
target="zoho_webform_iframe"
accept-charset="UTF-8"
>
<!-- Zoho CRM required hidden fields. Do not remove. -->
<input type="hidden" name="xnQsjsdp" value="b78607b2ef073f134a736184c22aa442ba026b6b00cfdbcb8078d8dee0bb1bbd" />
<input type="hidden" name="zc_gad" id="zc_gad" value="" />
<input type="hidden" name="xmIwtLD" value="e1201f09c921b74ca7844fca8689433ad14277423595fe88de0e4cd6c58e43e743fb001043cb5229e129ff4ab8b2beea" />
<input type="hidden" name="actionType" value="TGVhZHM=" />
<input type="hidden" name="returnURL" value="null" />
<input type="text" name="aG9uZXlwb3Q" value="" tabindex="-1" autocomplete="off" aria-hidden="true" style="display:none;" />
<label for="Last_Name">
Name <span aria-hidden="true">*</span>
<input type="text" id="Last_Name" name="Last Name" maxlength="80" required />
</label>
<label for="Company">
Company <span aria-hidden="true">*</span>
<input type="text" id="Company" name="Company" maxlength="200" required />
</label>
<label for="Zip_Code">
Zip Code <span aria-hidden="true">*</span>
<input type="text" id="Zip_Code" name="Zip Code" maxlength="30" inputmode="numeric" autocomplete="postal-code" required />
</label>
<label for="Email">
Email <span aria-hidden="true">*</span>
<input type="email" id="Email" name="Email" maxlength="100" required />
</label>
<label for="Phone">
Phone
<input type="tel" id="Phone" name="Phone" maxlength="30" autocomplete="tel" />
</label>
<label for="Description">
How can we help? <span aria-hidden="true">*</span>
<textarea id="Description" name="Description" rows="4" required></textarea>
</label>
<button type="submit" id="formsubmit" class="primary-btn">Submit</button>
<p id="form-status" class="form-status" aria-live="polite"></p>
</form>
<iframe name="zoho_webform_iframe" id="zoho_webform_iframe" title="Zoho form submission" style="display:none;"></iframe>
<!-- Zoho CRM webform validation and splash-message submit handling. Scoped to this form. -->
<script>
function validateEmail7130861000000581796() {
var form = document.forms['WebToLeads7130861000000581796'];
var emailFld = form.querySelector('[name="Email"]');
if (!emailFld) return true;
var emailVal = emailFld.value.replace(/^\s+|\s+$/g, '');
if (emailVal.length !== 0) {
var atpos = emailVal.indexOf('@');
var dotpos = emailVal.lastIndexOf('.');
if (atpos < 1 || dotpos < atpos + 2 || dotpos + 2 >= emailVal.length) {
alert('Please enter a valid email address.');
emailFld.focus();
return false;
}
}
return true;
}
function checkMandatory7130861000000581796() {
var form = document.forms['WebToLeads7130861000000581796'];
var mandatoryFields = ['Company', 'Last Name', 'Email', 'Zip Code', 'Description'];
var labels = ['Company', 'Name', 'Email', 'Zip Code', 'How can we help?'];
for (var i = 0; i < mandatoryFields.length; i++) {
var fieldObj = form[mandatoryFields[i]];
if (fieldObj && fieldObj.value.replace(/^\s+|\s+$/g, '').length === 0) {
alert(labels[i] + ' cannot be empty.');
fieldObj.focus();
return false;
}
}
if (!validateEmail7130861000000581796()) {
return false;
}
var existingServiceField = form.querySelector('input[name="service"]');
if (existingServiceField) {
existingServiceField.remove();
}
var urlparams = new URLSearchParams(window.location.search);
if (urlparams.has('service') && urlparams.get('service') === 'smarturl') {
var serviceField = document.createElement('input');
serviceField.setAttribute('type', 'hidden');
serviceField.setAttribute('value', urlparams.get('service'));
serviceField.setAttribute('name', 'service');
form.appendChild(serviceField);
}
return true;
}
(function () {
var form = document.getElementById('contact-form');
var submitButton = document.getElementById('formsubmit');
var status = document.getElementById('form-status');
var splash = document.getElementById('wf_splash');
var splashInfo = document.getElementById('wf_splash_info');
var splashClose = document.getElementById('wf_splash_close');
var splashTimer;
function showSplash(message) {
if (!splash || !splashInfo) return;
splashInfo.textContent = message || 'Thank you. Your information has been submitted.';
splash.style.display = 'flex';
clearTimeout(splashTimer);
splashTimer = setTimeout(function () {
splash.style.display = 'none';
}, 5000);
}
if (splashClose) {
splashClose.addEventListener('click', function () {
if (splash) splash.style.display = 'none';
});
}
if (!form) return;
var submitPending = false;
var iframe = document.getElementById('zoho_webform_iframe');
if (iframe) {
iframe.addEventListener('load', function () {
if (!submitPending) return;
submitPending = false;
form.reset();
showSplash('Thank you. Your information has been submitted.');
if (submitButton) {
submitButton.removeAttribute('disabled');
submitButton.textContent = 'Submit';
}
});
}
form.addEventListener('submit', function (event) {
if (!checkMandatory7130861000000581796()) {
event.preventDefault();
return;
}
if (submitButton) {
submitButton.setAttribute('disabled', 'disabled');
submitButton.textContent = 'Submitting...';
}
if (status) {
status.textContent = '';
}
if (typeof _wfa_track !== 'undefined' && _wfa_track.wfa_submit) {
_wfa_track.wfa_submit(event);
}
submitPending = true;
setTimeout(function () {
if (submitPending) {
submitPending = false;
form.reset();
showSplash('Thank you. Your information has been submitted.');
if (submitButton) {
submitButton.removeAttribute('disabled');
submitButton.textContent = 'Submit';
}
}
}, 3500);
});
})();
</script>
<!-- Zoho CRM webform analytics. Do not remove. -->
<script id="wf_anal" src="https://crm.zohopublic.com/crm/WebFormAnalyticsServeServlet?rid=e44e9662530fc5bd9cdd3c43501fc243f89ba03759e7946c4b5e5016795b606b59b54d0e73c68671b2140fac5c8e788agid3b907524e85f9cba94899d77d7200771ee5d0ea567c43ec341d7b2ce40324d40gid26922a9cd1e8191a5f58ecb2524e0d22b8dd027eb943658ee681ab6890436af2gidefa1b1002d15951a0a2ac36cb33cdb4b5c6aeb110e6f4ac68b764345b9429653&tw=e048253ca680b107993ed5922e00cc1ebab3de97e797fce56fc6ad6af0dfc0bc"></script>
</div>
</div>
</section>
</section>
<!-- SUPPORT -->
<!-- SUPPORT -->
<section id="page-support" class="page" data-page="support" aria-labelledby="support-title">
<section class="page-hero alt-section">
<div class="container support-hero-inner">
<!-- LEFT: title + intro + form (centered) -->
<div class="support-hero-left">
<h2 id="support-title">Support</h2>
<p class="section-intro">
Need to sign up for the Queue North Support Center? Create an account to access our knowledge base and submit support tickets.
</p>
<div class="support-action-card" aria-label="Queue North Support Center options">
<a
class="primary-btn support-action-btn"
href="https://queuenorthtechnologiesllc.zohodesk.com/portal/en/signup"
target="_blank"
rel="noopener noreferrer"
>Create Account</a>
<p class="support-member-text">Already a member?</p>
<a
class="primary-btn support-action-btn"
href="https://queuenorthtechnologiesllc.zohodesk.com/portal/en/signin"
target="_blank"
rel="noopener noreferrer"
>Sign In</a>
<div class="support-phone-block">
<span>Or call our support team</span>
<span class="support-phone">(321) 730-8020</span>
</div>
</div>
</div>
<!-- RIGHT: image (no card/container styling) -->
<div class="support-hero-right" aria-label="Support visual">
<img
class="support-hero-image"
src="assets/support.png"
alt="Support agent assisting a customer"
loading="lazy"
decoding="async"
/>
</div>
</div>
</section>
</section>
</main>
<footer class="site-footer">
<div class="container footer-main">
<div class="footer-brand">
<div class="footer-heading">Queue North Technologies</div>
<p>Veteran-owned communications and networking partner.</p>
</div>
<div class="footer-column footer-phone">
<div class="footer-heading">Phone</div>
<a href="tel:+13217308020">Direct: (321) 730-8020</a>
<a href="tel:+18886562850">Toll-Free: (888) 656-2850</a>
</div>
<div class="footer-column footer-quick-links">
<div class="footer-heading">Quick Links</div>
<div class="footer-link-list">
<a href="#home" data-route="home">Home</a>
<a href="#contact" data-route="contact">Contact</a>
<a href="#support" data-route="support">Support</a>
</div>
</div>
<div class="footer-column footer-social">
<div class="footer-heading">LinkedIn</div>
<a class="footer-linkedin" href="https://linkedin.com/company/queue-north-technologies-llc" target="_blank" rel="noopener noreferrer" aria-label="Queue North Technologies on LinkedIn">
<span class="linkedin-badge" aria-hidden="true">in</span>
<span>Visit us on LinkedIn</span>
</a>
</div>
</div>
<div class="container footer-legal">
<div>© <span id="year"></span> Queue North Technologies. All rights reserved.</div>
<div>8x8® is a registered trademark of 8x8, Inc. Queue North Technologies is an independent certified partner and is not owned or operated by 8x8, Inc.</div>
</div>
</footer>
<script src="main.js"></script>
</body>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

5089
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "queuenorth-website",
"private": true,
"version": "0.8.3",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"",
"build": "vite build",
"preview": "vite preview",
"start": "node server/index.js",
"server": "node server/index.js",
"docker:build": "docker build -t queuenorth-website .",
"docker:run": "docker run -p 3001:3001 --rm --name queuenorth -v queuenorth-db:/app/db -v queuenorth-logs:/app/logs --env NODE_ENV=production queuenorth-website",
"docker:compose:up": "docker-compose up -d",
"docker:compose:down": "docker-compose down",
"docker:compose:logs": "docker-compose logs -f",
"docker:push": "bash scripts/docker-push.sh",
"docker:test": "bash scripts/docker-test.sh"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-visually-hidden": "^1.2.4",
"better-sqlite3": "^11.8.0",
"cors": "^2.8.6",
"express": "^4.21.2",
"express-rate-limit": "^8.5.1",
"helmet": "^8.1.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-helmet-async": "^3.0.0",
"react-router-dom": "^7.1.3",
"sonner": "^1.7.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

67
project-requirements.md Normal file
View File

@ -0,0 +1,67 @@
# Project Requirements — Queue North Website
These requirements apply to all agents working on Queue North Website.
## Project Philosophy
- Feel modern for 2026 standards
- Prioritize responsiveness and reactivity
- Provide smooth user interaction
- Avoid outdated UI/UX patterns
- Maintain fast perceived performance
- Remain lightweight and maintainable
- Prioritize usability over unnecessary complexity
## Technology Stack
- **Build:** Vite
- **Frontend:** React 19 with client-side routing (React Router 7)
- **Styling:** Tailwind CSS with custom Queue North theme
- **UI Components:** shadcn/ui-style local primitives (Button, Card, Input, etc.)
- **State:** TanStack Query for server state
- **Notifications:** Sonner (toast)
- **Backend:** Express (Node.js)
- **Database:** SQLite via better-sqlite3
- **NOT Next.js.** This project uses Vite + React SPA, not Next.js App Router.
## Frontend Standards
- React SPA with React Router (no SSR, no server components)
- shadcn/ui-style primitives in `src/components/ui/`
- Tailwind utilities cleanly and predictably
- Responsive design (mobile + desktop)
- Loading states, error states, accessible interfaces
- Queue North brand colors: navy, light blue, white palette
- Georgia font for numeric content
## Backend Standards
- Express.js REST API
- SQLite via better-sqlite3
- Lead capture endpoints (`/api/leads`, `/api/support`)
- Validate all input, sanitize user-supplied data
- Structured error handling, no silent failures
- Environment variables for configuration, no hardcoded secrets
## Database Standards
- SQLite only
- Validate schema changes before deployment
## Code Quality
- Readable, maintainable, no overengineering
- Remove dead code, consistent formatting
- Document non-obvious logic
- Prefer clarity over cleverness
## Security
- OWASP best practices
- Input validation on all endpoints
- No secrets in logs
- Review dependencies for vulnerabilities
## Requirement Change Policy
Requirements may NOT be modified without explicit approval from `_null`.

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 288">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<path class="cls-1" d="M172.73,132.13h-21.87l-5.04,9.59c-.69,1.49-1.83,3.16-1.83,3.16h-.23s-1.03-1.68-1.83-3.16l-5.04-9.59h-21.85l17.17,26.51-17.1,26.54h21.48l5.8-11.08c.57-1.03,1.37-3.02,1.37-3.02h.23s.8,1.99,1.37,3.02l5.89,11.08h21.5l-17.1-26.54,17.07-26.51h0Z"/>
<path class="cls-1" d="M75.88,171.67c-6.06,0-10.85-4.67-10.85-10.47,0-4.92,2.65-8.71,4.8-10.98,8.83,4.04,16.91,7.07,16.91,12.24,0,5.93-4.16,9.21-10.85,9.21h0ZM76.51,117.28c5.93,0,9.47,3.28,9.47,8.33,0,5.55-2.4,9.72-3.15,11.11-7.95-3.41-14.51-6.44-14.51-12.37,0-3.91,2.52-7.07,8.2-7.07h0ZM98.72,144.54c.88-1.14,8.83-10.85,8.83-20.57,0-16.79-13.76-26.12-30.54-26.12-21.08,0-30.29,12.87-30.29,26,0,7.7,3.41,13.13,8.2,17.29-2.78,2.15-12.49,10.35-12.49,21.96,0,14.39,11.48,28.02,33.44,28.02s33.44-13.76,33.44-27.51c0-9.09-4.54-14.89-10.6-19.06h0Z"/>
<path class="cls-1" d="M211.7,171.67c-6.06,0-10.85-4.67-10.85-10.47,0-4.92,2.65-8.71,4.8-10.98,8.83,4.04,16.91,7.07,16.91,12.24,0,5.93-4.17,9.21-10.85,9.21h0ZM212.33,117.28c5.93,0,9.47,3.28,9.47,8.33,0,5.55-2.4,9.72-3.15,11.11-7.95-3.41-14.51-6.44-14.51-12.37,0-3.91,2.52-7.07,8.2-7.07h0ZM234.54,144.54c.88-1.14,8.83-10.85,8.83-20.57,0-16.79-13.76-26.12-30.54-26.12-21.08,0-30.29,12.87-30.29,26,0,7.7,3.41,13.13,8.2,17.29-2.78,2.15-12.5,10.35-12.5,21.96,0,14.39,11.48,28.02,33.44,28.02s33.44-13.76,33.44-27.51c0-9.09-4.54-14.89-10.6-19.06h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 311.11 143.14">
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<path class="st0" d="M198.28,53.3h-32.56l-7.51,14.29c-1.02,2.21-2.72,4.71-2.72,4.71h-.34s-1.53-2.5-2.72-4.71l-7.51-14.29h-32.55l25.58,39.48-25.47,39.52h32l8.63-16.5c.85-1.53,2.04-4.5,2.04-4.5h.34s1.19,2.97,2.04,4.5l8.77,16.5h32.02l-25.47-39.52,25.42-39.48Z"/>
<path class="st0" d="M88.06,71.79c1.32-1.69,13.16-16.16,13.16-30.64,0-25-20.49-38.91-45.48-38.91C24.34,2.25,10.62,21.42,10.62,40.96c0,11.47,5.07,19.55,12.22,25.75-4.14,3.2-18.61,15.41-18.61,32.7,0,21.43,17.1,41.72,49.81,41.72s49.81-20.49,49.81-40.97c0-13.53-6.77-22.18-15.79-28.38ZM54.98,31.19c8.83,0,14.1,4.89,14.1,12.4,0,8.27-3.57,14.47-4.7,16.54-11.84-5.07-21.61-9.59-21.61-18.42,0-5.83,3.76-10.53,12.22-10.53ZM54.04,112.2c-9.02,0-16.16-6.95-16.16-15.6,0-7.33,3.95-12.97,7.14-16.35,13.16,6.01,25.19,10.53,25.19,18.23,0,8.83-6.2,13.72-16.16,13.72Z"/>
<path class="st0" d="M290.33,71.79c1.32-1.69,13.16-16.16,13.16-30.64,0-25-20.49-38.91-45.48-38.91-31.39,0-45.11,19.17-45.11,38.72,0,11.47,5.07,19.55,12.22,25.75-4.14,3.2-18.61,15.41-18.61,32.7,0,21.43,17.1,41.72,49.81,41.72s49.81-20.49,49.81-40.97c0-13.53-6.77-22.18-15.79-28.38ZM257.25,31.19c8.83,0,14.1,4.89,14.1,12.4,0,8.27-3.57,14.47-4.7,16.54-11.84-5.07-21.61-9.59-21.61-18.42,0-5.83,3.76-10.53,12.22-10.53ZM256.31,112.2c-9.02,0-16.16-6.95-16.16-15.6,0-7.33,3.95-12.97,7.14-16.35,13.16,6.01,25.19,10.53,25.19,18.23,0,8.83-6.2,13.72-16.16,13.72Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="216"
height="114"
fill="#049fd9"
id="svg24">
<path
d="m 106.48,76.238 c -0.282,-0.077 -4.621,-1.196 -9.232,-1.196 -8.73,0 -13.986,4.714 -13.986,11.734 0,6.214 4.397,9.313 9.674,10.98 0.585,0.193 1.447,0.463 2.021,0.653 2.349,0.739 4.224,1.837 4.224,3.739 0,2.127 -2.167,3.504 -6.878,3.504 -4.14,0 -8.109,-1.184 -8.945,-1.395 v 8.637 c 0.466,0.099 5.183,1.025 10.222,1.025 7.248,0 15.539,-3.167 15.539,-12.595 0,-4.573 -2.8,-8.783 -8.947,-10.737 L 97.559,89.755 C 96,89.263 93.217,88.466 93.217,86.181 c 0,-1.805 2.062,-3.076 5.859,-3.076 3.276,0 7.263,1.101 7.404,1.145 z m 80.041,18.243 c 0,5.461 -4.183,9.879 -9.796,9.879 -5.619,0 -9.791,-4.418 -9.791,-9.879 0,-5.45 4.172,-9.87 9.791,-9.87 5.613,0 9.796,4.42 9.796,9.87 m -9.796,-19.427 c -11.544,0 -19.823,8.707 -19.823,19.427 0,10.737 8.279,19.438 19.823,19.438 11.543,0 19.834,-8.701 19.834,-19.438 0,-10.72 -8.291,-19.427 -19.834,-19.427 M 70.561,113.251 H 61.089 V 75.719 h 9.472"
id="path10" />
<path
d="m 48.07,76.399 c -0.89,-0.264 -4.18,-1.345 -8.636,-1.345 -11.526,0 -19.987,8.218 -19.987,19.427 0,12.093 9.34,19.438 19.987,19.438 4.23,0 7.459,-1.002 8.636,-1.336 v -10.075 c -0.407,0.226 -3.503,1.992 -7.957,1.992 -6.31,0 -10.38,-4.441 -10.38,-10.019 0,-5.748 4.246,-10.011 10.38,-10.011 4.53,0 7.576,1.805 7.957,2.004"
id="path12" />
<use
xlink:href="#path12"
transform="translate(98.86)"
id="use14" />
<g
id="g22">
<path
d="m 61.061,4.759 c 0,-2.587 -2.113,-4.685 -4.703,-4.685 -2.589,0 -4.702,2.098 -4.702,4.685 v 49.84 c 0,2.602 2.113,4.699 4.702,4.699 2.59,0 4.703,-2.097 4.703,-4.699 z M 35.232,22.451 c 0,-2.586 -2.112,-4.687 -4.702,-4.687 -2.59,0 -4.702,2.101 -4.702,4.687 v 22.785 c 0,2.601 2.112,4.699 4.702,4.699 2.59,0 4.702,-2.098 4.702,-4.699 z M 9.404,35.383 C 9.404,32.796 7.292,30.699 4.702,30.699 2.115,30.699 0,32.796 0,35.383 v 9.853 c 0,2.601 2.115,4.699 4.702,4.699 2.59,0 4.702,-2.098 4.702,-4.699"
id="path16" />
<use
xlink:href="#path16"
transform="matrix(-1,0,0,1,112.717,0)"
id="use18" />
</g>
<use
xlink:href="#g22"
transform="matrix(-1,0,0,1,216,0)"
id="use20" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" xml:space="preserve">
<style type="text/css">
.st0{fill:#07182D;}
</style>
<g id="Background">
</g>
<g id="Layer_1">
<g>
<g>
<g>
<g>
<g>
<path class="st0" d="M496.8,323.1c-12,0-21,9.5-21,21.1c0,11.7,8.9,21.1,21,21.1c12,0,21-9.5,21-21.1
C517.8,332.5,508.8,323.1,496.8,323.1L496.8,323.1z M539.2,344.2c0,23-17.7,41.6-42.4,41.6c-24.7,0-42.4-18.6-42.4-41.6
c0-22.9,17.7-41.6,42.4-41.6C521.5,302.6,539.2,321.2,539.2,344.2L539.2,344.2z"/>
<path class="st0" d="M433,327.1c-0.8-0.4-7.4-4.3-17.1-4.3c-13.1,0-22.2,9.1-22.2,21.4c0,11.9,8.7,21.4,22.2,21.4
c9.5,0,16.2-3.8,17.1-4.3v21.6c-2.5,0.7-9.5,2.9-18.5,2.9c-22.8,0-42.8-15.7-42.8-41.6c0-24,18.1-41.6,42.8-41.6
c9.5,0,16.6,2.3,18.5,2.9V327.1L433,327.1z"/>
<path class="st0" d="M346.5,322.3c-0.3-0.1-8.8-2.5-15.8-2.5c-8.1,0-12.5,2.7-12.5,6.6c0,4.9,6,6.6,9.3,7.6l5.6,1.8
c13.2,4.2,19.1,13.2,19.1,23c0,20.2-17.7,27-33.3,27c-10.8,0-20.9-2-21.9-2.2v-18.5c1.8,0.5,10.3,3,19.1,3
c10.1,0,14.7-2.9,14.7-7.5c0-4.1-4-6.4-9-8c-1.2-0.4-3.1-1-4.3-1.4c-11.3-3.6-20.7-10.2-20.7-23.5c0-15,11.2-25.1,29.9-25.1
c9.9,0,19.2,2.4,19.8,2.6V322.3L346.5,322.3z"/>
<polygon class="st0" points="269.6,384.4 249.3,384.4 249.3,304 269.6,304 269.6,384.4 "/>
<path class="st0" d="M221.5,327.1c-0.8-0.4-7.3-4.3-17-4.3c-13.1,0-22.2,9.1-22.2,21.4c0,11.9,8.7,21.4,22.2,21.4
c9.5,0,16.2-3.8,17-4.3v21.6c-2.5,0.7-9.4,2.9-18.5,2.9c-22.8,0-42.8-15.7-42.8-41.6c0-24,18.1-41.6,42.8-41.6
c9.5,0,16.6,2.3,18.5,2.9V327.1L221.5,327.1z"/>
<path class="st0" d="M570.8,248.9L570.8,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C580.9,244.3,576.4,248.9,570.8,248.9L570.8,248.9z"/>
<path class="st0" d="M515.5,248.9L515.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C525.7,244.3,521.1,248.9,515.5,248.9L515.5,248.9z"/>
<path class="st0" d="M460.3,268.9L460.3,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C470.4,264.3,465.8,268.9,460.3,268.9L460.3,268.9z"/>
<path class="st0" d="M405,248.9L405,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C415.1,244.3,410.6,248.9,405,248.9L405,248.9z"/>
<path class="st0" d="M349.7,248.9L349.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C359.9,244.3,355.3,248.9,349.7,248.9L349.7,248.9z"/>
<path class="st0" d="M294.5,248.9L294.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C304.6,244.3,300,248.9,294.5,248.9L294.5,248.9z"/>
<path class="st0" d="M239.2,268.9L239.2,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C249.3,264.3,244.8,268.9,239.2,268.9L239.2,268.9z"/>
<path class="st0" d="M183.9,248.9L183.9,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C194.1,244.3,189.5,248.9,183.9,248.9L183.9,248.9z"/>
<path class="st0" d="M128.7,248.9L128.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C138.8,244.3,134.2,248.9,128.7,248.9L128.7,248.9z"/>
</g>
<path class="st0" d="M570.8,248.9L570.8,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C580.9,244.3,576.4,248.9,570.8,248.9L570.8,248.9z"/>
<path class="st0" d="M515.5,248.9L515.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C525.7,244.3,521.1,248.9,515.5,248.9L515.5,248.9z"/>
<path class="st0" d="M460.3,268.9L460.3,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C470.4,264.3,465.8,268.9,460.3,268.9L460.3,268.9z"/>
<path class="st0" d="M405,248.9L405,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C415.1,244.3,410.6,248.9,405,248.9L405,248.9z"/>
<path class="st0" d="M349.7,248.9L349.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C359.9,244.3,355.3,248.9,349.7,248.9L349.7,248.9z"/>
<path class="st0" d="M294.5,248.9L294.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C304.6,244.3,300,248.9,294.5,248.9L294.5,248.9z"/>
<path class="st0" d="M239.2,268.9L239.2,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C249.3,264.3,244.8,268.9,239.2,268.9L239.2,268.9z"/>
<path class="st0" d="M183.9,248.9L183.9,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C194.1,244.3,189.5,248.9,183.9,248.9L183.9,248.9z"/>
<path class="st0" d="M128.7,248.9L128.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C138.8,244.3,134.2,248.9,128.7,248.9L128.7,248.9z"/>
</g>
<g>
<path class="st0" d="M125.3,530.6v-67.4h29c5,0,9.4,1,13.1,3c3.8,2,6.7,4.8,8.8,8.3c2.1,3.6,3.1,7.8,3.1,12.6
c0,4.8-1.1,9-3.2,12.5c-2.1,3.5-5.1,6.2-9,8.1c-3.9,1.9-8.4,2.9-13.5,2.9h-17.4v-14.2H150c2.2,0,4-0.4,5.6-1.2s2.7-1.8,3.5-3.2
c0.8-1.4,1.2-3,1.2-4.9c0-2-0.4-3.6-1.2-5s-2-2.4-3.5-3.1s-3.4-1.1-5.6-1.1h-6.4v52.7H125.3z"/>
<path class="st0" d="M178.2,530.6l21.8-67.4h25l22.7,67.4h-20.7l-7.8-26.1c-1.7-5.7-3.3-11.6-4.7-17.7
c-1.5-6.1-2.9-12.2-4.2-18.2h4.2c-1.3,6-2.5,12.1-3.8,18.2s-2.8,12-4.4,17.7l-7.5,26.1H178.2z M194.4,517.6V504h37.1v13.7
H194.4z"/>
<path class="st0" d="M256.9,530.6v-67.4h29c5,0,9.4,0.9,13.1,2.7c3.8,1.8,6.7,4.4,8.8,7.9c2.1,3.4,3.1,7.5,3.1,12.3
c0,4.8-1.1,8.9-3.2,12.2c-2.1,3.3-5.1,5.8-9,7.5c-3.9,1.7-8.4,2.5-13.5,2.5h-17.4v-14.2h13.7c2.2,0,4-0.3,5.6-0.8
s2.7-1.4,3.5-2.6s1.2-2.7,1.2-4.7c0-1.9-0.4-3.5-1.2-4.7c-0.8-1.2-2-2.1-3.5-2.7c-1.5-0.6-3.4-0.9-5.6-0.9h-6.4v52.7H256.9z
M293.3,530.6l-16.5-30.9h19.5l16.9,30.9H293.3z"/>
<path class="st0" d="M319.7,477.9v-14.7h58.5v14.7h-20.1v52.7h-18.3v-52.7H319.7z"/>
<path class="st0" d="M387.9,530.6v-67.4h19.9l16.1,26.5c1.4,2.3,2.7,4.6,3.9,6.9c1.2,2.3,2.4,4.8,3.5,7.6s2.3,6,3.5,9.7h-1.6
c-0.2-2.4-0.3-5.3-0.6-8.5s-0.4-6.4-0.6-9.5c-0.2-3.1-0.3-5.8-0.3-7.9v-24.7h18.8v67.4h-19.9l-15-24.6
c-1.7-2.8-3.2-5.4-4.5-7.8c-1.3-2.4-2.5-5-3.7-7.7c-1.2-2.7-2.6-5.9-4.1-9.4h1.9c0.2,3.2,0.5,6.3,0.7,9.5
c0.2,3.1,0.4,6,0.5,8.7c0.1,2.7,0.2,4.9,0.2,6.8v24.6H387.9z"/>
<path class="st0" d="M462,530.6v-67.4h48.6v14.7h-30.3v11.4H508v14.4h-27.8v12.1h30.1v14.7H462z"/>
<path class="st0" d="M521.5,530.6v-67.4h29c5,0,9.4,0.9,13.1,2.7c3.8,1.8,6.7,4.4,8.8,7.9c2.1,3.4,3.1,7.5,3.1,12.3
c0,4.8-1.1,8.9-3.2,12.2c-2.1,3.3-5.1,5.8-9,7.5c-3.9,1.7-8.4,2.5-13.5,2.5h-17.4v-14.2h13.7c2.2,0,4-0.3,5.6-0.8
s2.7-1.4,3.5-2.6s1.2-2.7,1.2-4.7c0-1.9-0.4-3.5-1.2-4.7c-0.8-1.2-2-2.1-3.5-2.7c-1.5-0.6-3.4-0.9-5.6-0.9h-6.4v52.7H521.5z
M558,530.6l-16.5-30.9H561l16.9,30.9H558z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 700 700" style="enable-background:new 0 0 700 700;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g id="Background">
</g>
<g id="Layer_1">
<g>
<g>
<g>
<g>
<g>
<path class="st0" d="M496.8,323.1c-12,0-21,9.5-21,21.1c0,11.7,8.9,21.1,21,21.1c12,0,21-9.5,21-21.1
C517.8,332.5,508.8,323.1,496.8,323.1L496.8,323.1z M539.2,344.2c0,23-17.7,41.6-42.4,41.6c-24.7,0-42.4-18.6-42.4-41.6
c0-22.9,17.7-41.6,42.4-41.6C521.5,302.6,539.2,321.2,539.2,344.2L539.2,344.2z"/>
<path class="st0" d="M433,327.1c-0.8-0.4-7.4-4.3-17.1-4.3c-13.1,0-22.2,9.1-22.2,21.4c0,11.9,8.7,21.4,22.2,21.4
c9.5,0,16.2-3.8,17.1-4.3v21.6c-2.5,0.7-9.5,2.9-18.5,2.9c-22.8,0-42.8-15.7-42.8-41.6c0-24,18.1-41.6,42.8-41.6
c9.5,0,16.6,2.3,18.5,2.9V327.1L433,327.1z"/>
<path class="st0" d="M346.5,322.3c-0.3-0.1-8.8-2.5-15.8-2.5c-8.1,0-12.5,2.7-12.5,6.6c0,4.9,6,6.6,9.3,7.6l5.6,1.8
c13.2,4.2,19.1,13.2,19.1,23c0,20.2-17.7,27-33.3,27c-10.8,0-20.9-2-21.9-2.2v-18.5c1.8,0.5,10.3,3,19.1,3
c10.1,0,14.7-2.9,14.7-7.5c0-4.1-4-6.4-9-8c-1.2-0.4-3.1-1-4.3-1.4c-11.3-3.6-20.7-10.2-20.7-23.5c0-15,11.2-25.1,29.9-25.1
c9.9,0,19.2,2.4,19.8,2.6V322.3L346.5,322.3z"/>
<polygon class="st0" points="269.6,384.4 249.3,384.4 249.3,304 269.6,304 269.6,384.4 "/>
<path class="st0" d="M221.5,327.1c-0.8-0.4-7.3-4.3-17-4.3c-13.1,0-22.2,9.1-22.2,21.4c0,11.9,8.7,21.4,22.2,21.4
c9.5,0,16.2-3.8,17-4.3v21.6c-2.5,0.7-9.4,2.9-18.5,2.9c-22.8,0-42.8-15.7-42.8-41.6c0-24,18.1-41.6,42.8-41.6
c9.5,0,16.6,2.3,18.5,2.9V327.1L221.5,327.1z"/>
<path class="st0" d="M570.8,248.9L570.8,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C580.9,244.3,576.4,248.9,570.8,248.9L570.8,248.9z"/>
<path class="st0" d="M515.5,248.9L515.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C525.7,244.3,521.1,248.9,515.5,248.9L515.5,248.9z"/>
<path class="st0" d="M460.3,268.9L460.3,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C470.4,264.3,465.8,268.9,460.3,268.9L460.3,268.9z"/>
<path class="st0" d="M405,248.9L405,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C415.1,244.3,410.6,248.9,405,248.9L405,248.9z"/>
<path class="st0" d="M349.7,248.9L349.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C359.9,244.3,355.3,248.9,349.7,248.9L349.7,248.9z"/>
<path class="st0" d="M294.5,248.9L294.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C304.6,244.3,300,248.9,294.5,248.9L294.5,248.9z"/>
<path class="st0" d="M239.2,268.9L239.2,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C249.3,264.3,244.8,268.9,239.2,268.9L239.2,268.9z"/>
<path class="st0" d="M183.9,248.9L183.9,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C194.1,244.3,189.5,248.9,183.9,248.9L183.9,248.9z"/>
<path class="st0" d="M128.7,248.9L128.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C138.8,244.3,134.2,248.9,128.7,248.9L128.7,248.9z"/>
</g>
<path class="st0" d="M570.8,248.9L570.8,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C580.9,244.3,576.4,248.9,570.8,248.9L570.8,248.9z"/>
<path class="st0" d="M515.5,248.9L515.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C525.7,244.3,521.1,248.9,515.5,248.9L515.5,248.9z"/>
<path class="st0" d="M460.3,268.9L460.3,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C470.4,264.3,465.8,268.9,460.3,268.9L460.3,268.9z"/>
<path class="st0" d="M405,248.9L405,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C415.1,244.3,410.6,248.9,405,248.9L405,248.9z"/>
<path class="st0" d="M349.7,248.9L349.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C359.9,244.3,355.3,248.9,349.7,248.9L349.7,248.9z"/>
<path class="st0" d="M294.5,248.9L294.5,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C304.6,244.3,300,248.9,294.5,248.9L294.5,248.9z"/>
<path class="st0" d="M239.2,268.9L239.2,268.9c-5.6,0-10.1-4.6-10.1-10.1V152.3c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v106.5C249.3,264.3,244.8,268.9,239.2,268.9L239.2,268.9z"/>
<path class="st0" d="M183.9,248.9L183.9,248.9c-5.6,0-10.1-4.6-10.1-10.1v-48.6c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v48.6C194.1,244.3,189.5,248.9,183.9,248.9L183.9,248.9z"/>
<path class="st0" d="M128.7,248.9L128.7,248.9c-5.6,0-10.1-4.6-10.1-10.1v-20.9c0-5.6,4.6-10.1,10.1-10.1l0,0
c5.6,0,10.1,4.6,10.1,10.1v20.9C138.8,244.3,134.2,248.9,128.7,248.9L128.7,248.9z"/>
</g>
<g>
<path class="st0" d="M125.3,530.6v-67.4h29c5,0,9.4,1,13.1,3c3.8,2,6.7,4.8,8.8,8.3c2.1,3.6,3.1,7.8,3.1,12.6
c0,4.8-1.1,9-3.2,12.5c-2.1,3.5-5.1,6.2-9,8.1c-3.9,1.9-8.4,2.9-13.5,2.9h-17.4v-14.2H150c2.2,0,4-0.4,5.6-1.2s2.7-1.8,3.5-3.2
c0.8-1.4,1.2-3,1.2-4.9c0-2-0.4-3.6-1.2-5s-2-2.4-3.5-3.1s-3.4-1.1-5.6-1.1h-6.4v52.7H125.3z"/>
<path class="st0" d="M178.2,530.6l21.8-67.4h25l22.7,67.4h-20.7l-7.8-26.1c-1.7-5.7-3.3-11.6-4.7-17.7
c-1.5-6.1-2.9-12.2-4.2-18.2h4.2c-1.3,6-2.5,12.1-3.8,18.2s-2.8,12-4.4,17.7l-7.5,26.1H178.2z M194.4,517.6V504h37.1v13.7
H194.4z"/>
<path class="st0" d="M256.9,530.6v-67.4h29c5,0,9.4,0.9,13.1,2.7c3.8,1.8,6.7,4.4,8.8,7.9c2.1,3.4,3.1,7.5,3.1,12.3
c0,4.8-1.1,8.9-3.2,12.2c-2.1,3.3-5.1,5.8-9,7.5c-3.9,1.7-8.4,2.5-13.5,2.5h-17.4v-14.2h13.7c2.2,0,4-0.3,5.6-0.8
s2.7-1.4,3.5-2.6s1.2-2.7,1.2-4.7c0-1.9-0.4-3.5-1.2-4.7c-0.8-1.2-2-2.1-3.5-2.7c-1.5-0.6-3.4-0.9-5.6-0.9h-6.4v52.7H256.9z
M293.3,530.6l-16.5-30.9h19.5l16.9,30.9H293.3z"/>
<path class="st0" d="M319.7,477.9v-14.7h58.5v14.7h-20.1v52.7h-18.3v-52.7H319.7z"/>
<path class="st0" d="M387.9,530.6v-67.4h19.9l16.1,26.5c1.4,2.3,2.7,4.6,3.9,6.9c1.2,2.3,2.4,4.8,3.5,7.6s2.3,6,3.5,9.7h-1.6
c-0.2-2.4-0.3-5.3-0.6-8.5s-0.4-6.4-0.6-9.5c-0.2-3.1-0.3-5.8-0.3-7.9v-24.7h18.8v67.4h-19.9l-15-24.6
c-1.7-2.8-3.2-5.4-4.5-7.8c-1.3-2.4-2.5-5-3.7-7.7c-1.2-2.7-2.6-5.9-4.1-9.4h1.9c0.2,3.2,0.5,6.3,0.7,9.5
c0.2,3.1,0.4,6,0.5,8.7c0.1,2.7,0.2,4.9,0.2,6.8v24.6H387.9z"/>
<path class="st0" d="M462,530.6v-67.4h48.6v14.7h-30.3v11.4H508v14.4h-27.8v12.1h30.1v14.7H462z"/>
<path class="st0" d="M521.5,530.6v-67.4h29c5,0,9.4,0.9,13.1,2.7c3.8,1.8,6.7,4.4,8.8,7.9c2.1,3.4,3.1,7.5,3.1,12.3
c0,4.8-1.1,8.9-3.2,12.2c-2.1,3.3-5.1,5.8-9,7.5c-3.9,1.7-8.4,2.5-13.5,2.5h-17.4v-14.2h13.7c2.2,0,4-0.3,5.6-0.8
s2.7-1.4,3.5-2.6s1.2-2.7,1.2-4.7c0-1.9-0.4-3.5-1.2-4.7c-0.8-1.2-2-2.1-3.5-2.7c-1.5-0.6-3.4-0.9-5.6-0.9h-6.4v52.7H521.5z
M558,530.6l-16.5-30.9H561l16.9,30.9H558z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
public/assets/cabling.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
public/assets/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
public/assets/wireless.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

4
public/robots.txt Normal file
View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://queuenorth.com/sitemap.xml

26
public/site.webmanifest Normal file
View File

@ -0,0 +1,26 @@
{
"name": "Queue North Technologies",
"short_name": "Queue North",
"description": "Veteran-owned 8x8 Certified Partner — business phone, UCaaS, contact center, IT support, and networking solutions.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0B1B3F",
"icons": [
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}

88
public/sitemap.xml Normal file
View File

@ -0,0 +1,88 @@
<?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>

373
review.md Normal file
View File

@ -0,0 +1,373 @@
# Queue North Website Redesign Strategy
# Core Problem
Current website branding feels:
* too abstract
* too technical
* too personal
* too experimental
The site currently resembles:
* a developer portfolio
* infrastructure hobby project
* underground tech blog
Instead of:
* a mature B2B UCaaS provider
* managed IT partner
* enterprise communications company
This creates trust friction immediately.
Business buyers need confidence within seconds.
---
# Business Positioning
Queue North should position itself as:
## Primary Identity
Reliable business communications and IT infrastructure partner for SMB and enterprise clients.
## Supporting Identity
Modern, technically competent, responsive, security conscious.
Not:
* hacker aesthetic
* underground engineering lab
* mysterious tech collective
---
# Recommended Brand Direction
## Desired Feel
The website should feel:
* modern
* clean
* stable
* operationally mature
* enterprise capable
* technically sharp
* trustworthy
Think:
* RingCentral
* Zoom
* Cloudflare
* Cisco Meraki
* Dialpad
* 8x8
* Microsoft business products
But less corporate and less soulless.
Human but competent.
---
# Homepage Structure
# 1. Hero Section
## Goal
Instant clarity.
User should immediately understand:
* what Queue North does
* who it serves
* why it matters
## Recommended Headline
Business communications and IT that actually work.
Alternative:
Modern UCaaS and managed IT for businesses that cannot afford downtime.
## Supporting Text
Queue North delivers cloud communications, networking, managed IT, and infrastructure support for SMBs and enterprise teams.
## CTA Buttons
* Schedule Consultation
* View Services
Optional secondary:
* Contact Support
---
# 2. Trust Signals Section
This section should appear immediately after hero.
## Include
* uptime guarantees
* support response times
* certifications
* vendor partnerships
* years in business
* client industries
* deployment count
* SLA metrics
## Example Metrics
* 99.99% uptime
* 24/7 support
* multi site deployments
* secure cloud infrastructure
* enterprise grade failover
This is critical.
B2B buyers purchase risk reduction, not technology.
---
# 3. Services Section
## Recommended Layout
Clean enterprise card grid.
## Service Categories
### UCaaS
* hosted VoIP
* business phones
* call routing
* conferencing
* remote workforce support
### Managed IT
* endpoint management
* helpdesk
* patching
* infrastructure monitoring
### Networking
* SD WAN
* VPN
* firewall management
* switching
* wireless deployments
### Security
* MFA
* endpoint protection
* backups
* compliance
* monitoring
Each card should explain business outcomes, not technical jargon.
Bad:
"Kubernetes managed SIP orchestration"
Good:
"Reliable business communications with centralized management and failover"
Humans love inventing incomprehensible wording and then wondering why sales calls disappear.
---
# 4. Industry Use Cases
Very important for B2B trust.
## Example Industries
* healthcare
* logistics
* retail
* manufacturing
* legal
* finance
* distributed offices
Each section should explain:
* operational problems
* compliance needs
* uptime requirements
* remote work needs
---
# 5. Why Queue North
## Focus On
* responsiveness
* reliability
* technical depth
* direct support
* proactive monitoring
* vendor neutrality
## Avoid
Generic corporate fluff like:
* innovative solutions
* digital transformation
* next generation synergy nonsense
Every B2B site writes this garbage and nobody believes any of it anymore.
---
# 6. Testimonials / Case Studies
Mandatory.
Enterprise buyers need validation.
## Include
* measurable outcomes
* reduced downtime
* migration success
* support quality
* deployment scale
Even 2 or 3 strong case studies massively improve credibility.
---
# 7. Support & Operations
This is where technical sophistication can appear.
## Good Technical Signals
* network operations center visuals
* uptime dashboards
* support workflows
* monitoring systems
* escalation paths
## Bad Technical Signals
* hacker visuals
* terminal cosplay
* random code snippets
* obscure infrastructure references
Technical competence should feel controlled and operational.
Not chaotic.
---
# Visual Design Recommendations
# Colors
## Base
* white
* dark slate
* muted blue
* graphite
## Accent
* blue
* teal
* restrained cyan
Avoid:
* neon green
* hacker black/red
* cyberpunk palettes
Those aesthetics destroy enterprise trust surprisingly fast.
---
# Typography
## Recommended
* Inter
* Geist
* IBM Plex Sans
Professional sans serif.
Monospace only for tiny UI accents if needed.
---
# Layout Style
## Use
* large spacing
* strong hierarchy
* clean sections
* restrained motion
* clear CTAs
## Avoid
* excessive animations
* overloaded visuals
* scrolling gimmicks
* terminal-first design
Enterprise sites should feel efficient.
---
# Recommended Technical Stack
## Best Option
### Astro or Next.js
With:
* Tailwind
* Framer Motion lightly used
* CMS integration
* fast performance
* accessibility focus
---
# Key Messaging Shift
## Current Impression
"Interesting technical person"
## Required Impression
"Reliable communications and IT partner for serious businesses"
That distinction changes everything about the design language.

22
scripts/docker-push.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# docker-push.sh — Tag and push dev image to Forgejo registry
# Usage: ./scripts/docker-push.sh
# Requires: ~/.openclaw/docker-registry.env (chmod 600)
set -euo pipefail
cd "$(dirname "$0")/.."
source ~/.openclaw/docker-registry.env
# Build image via docker compose
DOCKER_API_VERSION=1.44 docker compose build
# Tag and push dev
IMAGE_NAME="queue-north-website-queuenorth"
docker tag "${IMAGE_NAME}:latest" "${FORGEJO_REGISTRY}/null/queue-north-website:dev"
echo "$FORGEJO_REGISTRY_TOKEN" | docker login "$FORGEJO_REGISTRY" -u "$FORGEJO_REGISTRY_USER" --password-stdin
docker push "${FORGEJO_REGISTRY}/null/queue-north-website:dev"
docker logout "$FORGEJO_REGISTRY"
echo "✓ Pushed dev image"

18
scripts/docker-test.sh Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# docker-test.sh — Build and run Queue North Website in Docker for testing
# Usage: ./scripts/docker-test.sh
# Access: http://localhost:3001
set -euo pipefail
cd "$(dirname "$0")/.."
# Stop and remove existing container
DOCKER_API_VERSION=1.44 docker compose down 2>/dev/null || true
# Clean build
rm -rf dist node_modules/.vite 2>/dev/null
DOCKER_API_VERSION=1.44 docker compose up -d --build
echo "✓ Running on http://localhost:3001"
echo " Health check: http://localhost:3001/api/health"

946
server/index.js Normal file
View File

@ -0,0 +1,946 @@
import express from 'express'
import path from 'path'
import { fileURLToPath } from 'url'
import { existsSync, mkdirSync, readFileSync } from 'fs'
import sqlite3 from 'better-sqlite3'
import z from 'zod'
import rateLimit from 'express-rate-limit'
import helmet from 'helmet'
import cors from 'cors'
// --- Setup ---
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = express()
const loadLocalEnv = () => {
const envPath = path.resolve(process.cwd(), '.env')
if (!existsSync(envPath)) return
const envFile = readFileSync(envPath, 'utf8')
for (const line of envFile.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/)
if (!match) continue
const [, key, rawValue] = match
if (process.env[key] !== undefined) continue
process.env[key] = rawValue.replace(/^(['"])(.*)\1$/, '$2')
}
}
loadLocalEnv()
// Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting
app.set('trust proxy', 1)
const dbPath = path.join(__dirname, '../db/queuenorth.db')
const dbDir = path.dirname(dbPath)
// Create db directory if it doesn't exist
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true })
}
// --- Logger ---
const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info
const log = {
info: (...args) => { if (currentLevel >= LOG_LEVELS.info) console.log(`[${new Date().toISOString()}] INFO `, ...args) },
warn: (...args) => { if (currentLevel >= LOG_LEVELS.warn) console.warn(`[${new Date().toISOString()}] WARN `, ...args) },
error: (...args) => { if (currentLevel >= LOG_LEVELS.error) console.error(`[${new Date().toISOString()}] ERROR`, ...args) },
debug: (...args) => { if (currentLevel >= LOG_LEVELS.debug) console.debug(`[${new Date().toISOString()}] DEBUG`, ...args) },
}
// --- Rate Limiting ---
const rateLimitWindowMs = 60 * 1000 // 1 minute
const rateLimitMax = (() => {
const val = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '5', 10)
if (isNaN(val) || val < 1) {
log.warn('[RateLimit] Invalid RATE_LIMIT_PER_MINUTE, defaulting to 5')
return 5
}
return val
})()
const apiLimiter = rateLimit({
windowMs: rateLimitWindowMs,
max: rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
log.warn(`Rate limit exceeded for IP: ${req.ip}`)
res.status(429).json({
error: 'Too Many Requests',
message: 'Please try again later.',
retryAfter: Math.ceil(rateLimitWindowMs / 1000),
})
},
})
// --- Security Headers (Helmet) ---
const isDev = process.env.NODE_ENV === 'development'
const cspDirectives = {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://crm.zohopublic.com', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: isDev
? ["'self'", 'ws://localhost:*', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/']
: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
frameSrc: ["'self'", 'https://crm.zoho.com', 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'", 'https://crm.zoho.com'],
}
// Note: connectSrc currently allows 'self' only. Zoho API calls are server-to-server
// and are not affected by CSP. If client-side Zoho calls are added in the future,
// add Zoho domains here (e.g., 'https://www.zohoapis.com', 'https://accounts.zoho.com')
app.use(helmet({
contentSecurityPolicy: {
directives: cspDirectives,
},
crossOriginEmbedderPolicy: false, // Prevent CSP issues with embedded content
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: { policy: 'same-origin' },
dnsPrefetchControl: { allow: false },
frameguard: { action: 'deny' },
hidePoweredBy: true,
hsts: { maxAge: 31536000, includeSubDomains: true },
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
referrerPolicy: { policy: 'same-origin' },
xssFilter: true,
}))
log.info('[Security] Helmet enabled with CSP configured')
// Redirect HTTP to HTTPS in production
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] === 'http') {
return res.redirect(301, `https://${req.headers.host}${req.url}`)
}
next()
})
}
// --- CORS Configuration ---
const corsOrigin = process.env.CORS_ORIGIN || 'https://queuenorth.com' // Default to production domain
const corsConfig = cors({
origin: corsOrigin === '*' ? corsOrigin : (corsOrigin === 'null' ? undefined : corsOrigin),
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-RateLimit-Remaining', 'X-RateLimit-Reset'],
maxAge: 86400, // 24 hours
credentials: true,
})
app.use(corsConfig)
log.info(`[CORS] Enabled with origin: ${corsOrigin}`)
// Middleware — JSON body parsing only on POST routes (issue #14)
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
// Rate limiting for API routes only
app.use('/api', apiLimiter)
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now()
res.on('finish', () => {
const ms = Date.now() - start
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'
log[level](`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`)
})
next()
})
// --- Database ---
const db = sqlite3(dbPath)
// Initialize schema
const initSchema = () => {
// Check if leads table exists and needs UNIQUE constraint migration
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='leads'").get()
if (tableExists) {
// Check if UNIQUE constraint already exists on email
const pragma = db.prepare("PRAGMA table_info(leads)").all()
const emailCol = pragma.find(col => col.name === 'email')
if (emailCol && !emailCol.pk) {
// UNIQUE constraint doesn't exist, need to add it via migration
log.info('[DB] Adding UNIQUE constraint on leads.email via migration')
// Migrate leads table to add UNIQUE constraint
db.exec(
[
'CREATE TABLE IF NOT EXISTS leads_new (',
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
' company TEXT NOT NULL,',
' name TEXT NOT NULL,',
' email TEXT NOT NULL UNIQUE,',
' phone TEXT,',
' zip TEXT,',
' message TEXT,',
' service_interest TEXT,',
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP',
')'
].join('\n')
)
// Copy existing data (deduplicate - keep first occurrence per email)
db.exec(
[
'INSERT OR IGNORE INTO leads_new (id, company, name, email, phone, zip, message, service_interest, created_at)',
'SELECT id, company, name, email, phone, zip, message, service_interest, created_at',
'FROM leads'
].join('\n')
)
// Drop old table
db.exec('DROP TABLE leads')
// Rename new table
db.exec('ALTER TABLE leads_new RENAME TO leads')
log.info('[DB] UNIQUE constraint added on leads.email')
}
}
// Leads table (now with UNIQUE constraint on email, either from migration or fresh)
db.exec(`
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
phone TEXT,
zip TEXT,
message TEXT,
service_interest TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// Support requests table
db.exec(`
CREATE TABLE IF NOT EXISTS support_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
company TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
issue TEXT NOT NULL,
priority TEXT DEFAULT 'medium',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
}
initSchema()
// --- Sanitization Helper ---
const sanitizeString = (input, maxLength) => {
if (typeof input !== 'string') return input
// Trim whitespace
let sanitized = input.trim()
// Remove HTML/script tags to prevent XSS
sanitized = sanitized.replace(/<script[^>]*>.*?<\/script>/gi, '')
sanitized = sanitized.replace(/<[^>]*>/g, '')
// Truncate to max length
sanitized = sanitized.substring(0, maxLength)
// Convert empty strings to undefined so they become NULL in DB
return sanitized === '' ? undefined : sanitized
}
const sanitizePayload = (data, fields) => {
const result = { ...data }
for (const [field, maxLength] of Object.entries(fields)) {
if (result[field] !== undefined) {
result[field] = sanitizeString(result[field], maxLength)
}
}
return result
}
// --- Validation Schemas ---
const leadSchema = z.object({
company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'),
name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'),
email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'),
phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
zip: z.string({ required_error: 'ZIP code is required' }).trim().min(1, 'ZIP code is required').max(10, 'ZIP code must be 10 characters or less'),
message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)),
service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
recaptcha_token: z.string().max(4096, 'Security verification token is too long').optional().or(z.literal('').transform(() => undefined)),
company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it
})
const supportSchema = z.object({
name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'),
company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'),
email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'),
phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
issue: z.string().min(10, 'Please provide at least 10 characters describing your issue').trim().max(5000, 'Issue description must be 5000 characters or less'),
priority: z.enum(['low', 'medium', 'high'], {
errorMap: () => ({ message: 'Priority must be low, medium, or high' }),
}).transform((val) => val?.toLowerCase() ?? undefined).optional().or(z.literal('').transform(() => undefined)),
recaptcha_token: z.string().max(4096, 'Security verification token is too long').optional().or(z.literal('').transform(() => undefined)),
company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it
})
// --- Google reCAPTCHA Verification ---
const RECAPTCHA_ENABLED = process.env.RECAPTCHA_ENABLED === 'true'
const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY || null
const RECAPTCHA_MIN_SCORE = Number.parseFloat(process.env.RECAPTCHA_MIN_SCORE || '0.5')
const RECAPTCHA_TIMEOUT_MS = 5000
async function verifyRecaptcha(token, req) {
if (!RECAPTCHA_ENABLED) return { success: true }
if (!RECAPTCHA_SECRET_KEY) {
log.error('[reCAPTCHA] RECAPTCHA_ENABLED=true but RECAPTCHA_SECRET_KEY is not configured')
return { success: false, message: 'Security verification is unavailable. Please try again later.' }
}
if (!token) {
return { success: false, message: 'Security verification is required' }
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), RECAPTCHA_TIMEOUT_MS)
try {
const params = new URLSearchParams({
secret: RECAPTCHA_SECRET_KEY,
response: token,
remoteip: req.ip,
})
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
signal: controller.signal,
})
if (!response.ok) {
log.warn(`[reCAPTCHA] Verification request failed with status ${response.status}`)
return { success: false, message: 'Security verification failed. Please try again.' }
}
const result = await response.json()
const score = typeof result.score === 'number' ? result.score : null
const scoreAccepted = score === null || score >= RECAPTCHA_MIN_SCORE
if (!result.success || !scoreAccepted) {
log.warn('[reCAPTCHA] Verification rejected', {
success: result.success,
score,
action: result.action,
errors: result['error-codes'],
})
return { success: false, message: 'Security verification failed. Please try again.' }
}
return { success: true }
} catch (err) {
if (err.name === 'AbortError') {
log.warn('[reCAPTCHA] Verification timed out')
} else {
log.error('[reCAPTCHA] Verification error:', err.message)
}
return { success: false, message: 'Security verification failed. Please try again.' }
} finally {
clearTimeout(timeoutId)
}
}
// --- Zoho CRM Forwarding (best-effort, fire-and-forget) ---
const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true'
const ZOHO_CASES_ENABLED = process.env.ZOHO_CASES_ENABLED === 'true'
const ZOHO_API_DOMAIN = process.env.ZOHO_API_DOMAIN || 'https://www.zohoapis.com'
const ZOHO_ACCOUNTS_DOMAIN = process.env.ZOHO_ACCOUNTS_DOMAIN || 'https://accounts.zoho.com'
const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || null
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || null
const ZOHO_FORWARDING_MODE = (process.env.ZOHO_FORWARDING_MODE || 'api').toLowerCase()
const ZOHO_WEBTOLEAD_ENABLED = process.env.ZOHO_WEBTOLEAD_ENABLED === 'true'
const ZOHO_WEBTOLEAD_URL = process.env.ZOHO_WEBTOLEAD_URL || 'https://crm.zoho.com/crm/WebToLeadForm'
const ZOHO_WEBTOLEAD_XNQSJSDP = process.env.ZOHO_WEBTOLEAD_XNQSJSDP || null
const ZOHO_WEBTOLEAD_XMIWTLD = process.env.ZOHO_WEBTOLEAD_XMIWTLD || null
const ZOHO_WEBTOLEAD_ACTION_TYPE = process.env.ZOHO_WEBTOLEAD_ACTION_TYPE || 'TGVhZHM='
const ZOHO_WEBTOLEAD_RETURN_URL = process.env.ZOHO_WEBTOLEAD_RETURN_URL || 'null'
const ZOHO_WEBTOLEAD_ZC_GAD = process.env.ZOHO_WEBTOLEAD_ZC_GAD || ''
// In-memory access token cache
let zohoAccessToken = null
let zohoTokenExpiry = 0
// 10 second timeout for all Zoho API calls
const ZOHO_TIMEOUT_MS = 10000
async function getZohoAccessToken() {
// Return cached token if still valid (with 60s buffer)
if (zohoAccessToken && Date.now() < zohoTokenExpiry - 60000) {
return zohoAccessToken
}
try {
// Token endpoint is on the ACCOUNTS domain, NOT the API domain
// US: accounts.zoho.com | EU: accounts.zoho.eu | IN: accounts.zoho.in
const url = `${ZOHO_ACCOUNTS_DOMAIN}/oauth/v2/token`
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: ZOHO_CLIENT_ID,
client_secret: ZOHO_CLIENT_SECRET,
refresh_token: ZOHO_REFRESH_TOKEN,
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS)
let response
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
signal: controller.signal,
})
} catch (err) {
if (err.name === 'AbortError') {
log.warn('[Zoho] Token fetch timed out after', ZOHO_TIMEOUT_MS, 'ms')
} else {
log.error('[Zoho] Token fetch error:', err.message)
}
clearTimeout(timeoutId)
return null
} finally {
clearTimeout(timeoutId)
}
// Issue #3: Check response.ok before parsing JSON
if (!response.ok) {
const text = await response.text()
log.error(`[Zoho] Token fetch failed (${response.status}):`, text)
return null
}
const data = await response.json()
if (data.access_token) {
zohoAccessToken = data.access_token
zohoTokenExpiry = Date.now() + (data.expires_in || 3600) * 1000
log.info('[Zoho] Access token acquired, expires in', data.expires_in || 3600, 'seconds')
return zohoAccessToken
} else {
log.error('[Zoho] Token exchange failed:', JSON.stringify(data))
return null
}
} catch (err) {
log.error('[Zoho] Token acquisition error:', err.message)
return null
}
}
async function forwardToZoho(leadData) {
if (!ZOHO_ENABLED) return
// Short-circuit if Zoho credentials are missing
if (!ZOHO_CLIENT_ID || !ZOHO_CLIENT_SECRET || !ZOHO_REFRESH_TOKEN) {
log.warn("[Zoho] Skipping forwarding - ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN not configured")
return
}
let accessToken = await getZohoAccessToken()
if (!accessToken) {
// Retry once — token refresh can fail transiently
log.warn('[Zoho] First token refresh failed, retrying...')
// Clear cached token to force a fresh attempt
zohoAccessToken = null
zohoTokenExpiry = 0
accessToken = await getZohoAccessToken()
if (!accessToken) {
log.warn('[Zoho] No access token available after retry, skipping lead forwarding')
return
}
}
// Issue #8: Prevent double-slash in URL path
// Use upsert to handle duplicates gracefully (insert new or update existing by email)
const url = `${ZOHO_API_DOMAIN.replace(/\/$/, "")}/crm/v8/Leads/upsert`
// Split full name into First_Name / Last_Name for Zoho
// Zoho requires Last_Name (mandatory), First_Name is optional
const nameParts = (leadData.name || '').trim().split(/\s+/)
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : (nameParts[0] || 'Unknown')
const firstName = nameParts.length > 1 ? nameParts[0] : ''
// Build Description with service interest appended for Zoho visibility
const descriptionParts = []
if (leadData.message) descriptionParts.push(leadData.message)
if (leadData.service_interest) descriptionParts.push(`Service Interest: ${leadData.service_interest}`)
const description = descriptionParts.join('\n\n')
const payload = {
data: [
{
First_Name: firstName || undefined,
Last_Name: lastName,
Company: leadData.company || '',
Email: leadData.email || '',
Phone: leadData.phone || '',
Zip_Code: leadData.zip || '',
Description: description || '',
Lead_Source: 'Website',
},
],
duplicate_check_fields: ['Email'],
trigger: ['workflow'],
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS)
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Zoho-oauthtoken ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: controller.signal,
})
// Issue #3: Check response.ok before processing
if (!response.ok) {
const text = await response.text()
log.error(`[Zoho] Lead forwarding failed (${response.status}):`, text)
return
}
const result = await response.json()
log.info("[Zoho] Lead forwarded successfully:", result.data?.[0]?.details?.id || "no id returned")
} catch (fetchErr) {
if (fetchErr.name === "AbortError") {
log.warn("[Zoho] Lead forwarding timed out after", ZOHO_TIMEOUT_MS, "ms")
} else {
log.error("[Zoho] Forwarding error:", fetchErr.message)
}
} finally {
clearTimeout(timeoutId)
}
}
async function forwardToZohoWebToLead(leadData) {
if (!ZOHO_WEBTOLEAD_ENABLED) return
if (!ZOHO_WEBTOLEAD_XNQSJSDP || !ZOHO_WEBTOLEAD_XMIWTLD) {
log.warn('[Zoho WebToLead] Skipping forwarding - hidden form tokens are not configured')
return
}
const descriptionParts = []
if (leadData.message) descriptionParts.push(leadData.message)
if (leadData.service_interest) descriptionParts.push(`Service Interest: ${leadData.service_interest}`)
const payload = new URLSearchParams({
xnQsjsdp: ZOHO_WEBTOLEAD_XNQSJSDP,
zc_gad: ZOHO_WEBTOLEAD_ZC_GAD,
xmIwtLD: ZOHO_WEBTOLEAD_XMIWTLD,
actionType: ZOHO_WEBTOLEAD_ACTION_TYPE,
returnURL: ZOHO_WEBTOLEAD_RETURN_URL,
aG9uZXlwb3Q: '',
Company: leadData.company || '',
'Last Name': leadData.name || 'Unknown',
Email: leadData.email || '',
Phone: leadData.phone || '',
'Zip Code': leadData.zip || '',
Description: descriptionParts.join('\n\n'),
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS)
try {
const response = await fetch(ZOHO_WEBTOLEAD_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: payload.toString(),
signal: controller.signal,
})
if (!response.ok) {
const text = await response.text()
log.error(`[Zoho WebToLead] Forwarding failed (${response.status}):`, text.slice(0, 500))
return
}
log.info('[Zoho WebToLead] Lead forwarded successfully')
} catch (fetchErr) {
if (fetchErr.name === 'AbortError') {
log.warn('[Zoho WebToLead] Lead forwarding timed out after', ZOHO_TIMEOUT_MS, 'ms')
} else {
log.error('[Zoho WebToLead] Forwarding error:', fetchErr.message)
}
} finally {
clearTimeout(timeoutId)
}
}
function forwardLeadToZoho(leadData) {
if (ZOHO_FORWARDING_MODE === 'webtolead') {
return forwardToZohoWebToLead(leadData)
}
return forwardToZoho(leadData)
}
// --- Zoho Cases Forwarding (best-effort, fire-and-forget) ---
async function forwardSupportToZoho(supportData) {
if (!ZOHO_CASES_ENABLED) return
// Short-circuit if Zoho credentials are missing
if (!ZOHO_CLIENT_ID || !ZOHO_CLIENT_SECRET || !ZOHO_REFRESH_TOKEN) {
log.warn("[Zoho Cases] Skipping forwarding - ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN not configured")
return
}
let accessToken = await getZohoAccessToken()
if (!accessToken) {
// Retry once — token refresh can fail transiently
log.warn('[Zoho Cases] First token refresh failed, retrying...')
// Clear cached token to force a fresh attempt
zohoAccessToken = null
zohoTokenExpiry = 0
accessToken = await getZohoAccessToken()
if (!accessToken) {
log.warn('[Zoho Cases] No access token available after retry, skipping support forwarding')
return
}
}
// Map priority to Zoho format
const priorityMap = {
low: 'Low',
medium: 'Medium',
high: 'High',
}
const priority = priorityMap[supportData.priority] || 'Medium'
// Build description with name and company since Cases don't have Company field directly
const descriptionParts = []
descriptionParts.push(`Name: ${supportData.name}`)
descriptionParts.push(`Company: ${supportData.company}`)
if (supportData.phone) descriptionParts.push(`Phone: ${supportData.phone}`)
descriptionParts.push(`\n${supportData.issue}`)
const description = descriptionParts.join('\n')
const payload = {
data: [
{
Subject: supportData.issue,
Priority: priority,
Email: supportData.email || '',
Description: description || '',
Case_Origin: 'Website',
},
],
trigger: ['workflow'],
}
// Issue #8: Prevent double-slash in URL path
const url = `${ZOHO_API_DOMAIN.replace(/\/$/, '')}/crm/v8/Cases`
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS)
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Zoho-oauthtoken ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: controller.signal,
})
// Issue #3: Check response.ok before processing
if (!response.ok) {
const text = await response.text()
log.error(`[Zoho Cases] Support forwarding failed (${response.status}):`, text)
return
}
const result = await response.json()
log.info("[Zoho Cases] Support forwarded successfully:", result.data?.[0]?.details?.id || "no id returned")
} catch (fetchErr) {
if (fetchErr.name === "AbortError") {
log.warn("[Zoho Cases] Support forwarding timed out after", ZOHO_TIMEOUT_MS, "ms")
} else {
log.error("[Zoho Cases] Forwarding error:", fetchErr.message)
}
} finally {
clearTimeout(timeoutId)
}
}
// --- API Routes ---
// Health check
app.get('/api/health', (req, res) => {
try {
// Verify DB connection by executing a simple query
db.prepare('SELECT 1').get()
res.json({ status: 'ok', db: 'ok', timestamp: new Date().toISOString() })
} catch (err) {
log.error('Health check DB verification failed:', err.message)
res.status(503).json({ error: 'Service unavailable', db: 'error', timestamp: new Date().toISOString() })
}
})
// Submit lead
app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => {
// Honeypot check - if filled, it's a bot
if (req.body.company_website) {
log.info('[Spam] Honeypot triggered, ignoring submission')
// Return success to bot so it doesn't retry
return res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
}
let sanitized
try {
const parsed = leadSchema.safeParse(req.body)
if (!parsed.success) {
const fieldErrors = {}
for (const issue of parsed.error.issues) {
if (issue.path[0]) {
fieldErrors[issue.path[0]] = issue.message
}
}
return res.status(400).json({
error: 'Validation failed',
fields: fieldErrors,
})
}
const recaptcha = await verifyRecaptcha(parsed.data.recaptcha_token, req)
if (!recaptcha.success) {
return res.status(400).json({
error: 'Validation failed',
fields: { recaptcha_token: recaptcha.message },
})
}
// Sanitize parsed data before insert (trim, strip tags, truncate)
sanitized = sanitizePayload(parsed.data, {
company: 200,
name: 100,
email: 254,
phone: 50,
zip: 10,
message: 5000,
service_interest: 50,
})
const stmt = db.prepare(`
INSERT INTO leads (company, name, email, phone, zip, message, service_interest)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(
sanitized.company,
sanitized.name,
sanitized.email,
sanitized.phone || null,
sanitized.zip || null,
sanitized.message || null,
sanitized.service_interest || null
)
log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`)
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
forwardLeadToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
} catch (err) {
// Issue #6: Handle duplicate email error with 409 Conflict
const errorMsg = err.message?.toLowerCase() || ''
if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) {
log.warn(`Duplicate lead email: ${sanitized.email}`)
// Still forward to Zoho (non-blocking) for existing leads
forwardLeadToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message))
return res.status(409).json({
error: 'Duplicate lead',
message: 'A lead with this email already exists'
})
}
log.error('Error submitting lead:', err)
res.status(500).json({ error: 'Failed to submit lead' })
}
})
// Submit support request
app.post('/api/support', express.json({ limit: '1mb' }), async (req, res) => {
// Honeypot check - if filled, it's a bot
if (req.body.company_website) {
log.info('[Spam] Honeypot triggered, ignoring submission')
// Return success to bot so it doesn't retry
return res.json({ success: true, message: "Thanks! We'll get back to you soon." })
}
try {
const parsed = supportSchema.safeParse(req.body)
if (!parsed.success) {
const fieldErrors = {}
for (const issue of parsed.error.issues) {
if (issue.path[0]) {
fieldErrors[issue.path[0]] = issue.message
}
}
return res.status(400).json({
error: 'Validation failed',
fields: fieldErrors,
})
}
const recaptcha = await verifyRecaptcha(parsed.data.recaptcha_token, req)
if (!recaptcha.success) {
return res.status(400).json({
error: 'Validation failed',
fields: { recaptcha_token: recaptcha.message },
})
}
// Sanitize parsed data before insert (trim, strip tags, truncate)
const sanitized = sanitizePayload(parsed.data, {
name: 100,
company: 200,
email: 254,
phone: 50,
issue: 5000,
priority: 10,
})
const stmt = db.prepare(`
INSERT INTO support_requests (name, company, email, phone, issue, priority)
VALUES (?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(
sanitized.name,
sanitized.company,
sanitized.email,
sanitized.phone || null,
sanitized.issue,
sanitized.priority || 'medium'
)
log.info(`Support request submitted: ${sanitized.email} from ${sanitized.company} priority=${sanitized.priority || 'medium'} (id: ${result.lastInsertRowid})`)
// Fire-and-forget Zoho Cases forwarding (best-effort, non-blocking)
forwardSupportToZoho(sanitized).catch(err => log.error('[Zoho Cases] Forwarding error:', err.message))
res.json({ success: true, message: "Thanks! We'll get back to you soon." })
} catch (err) {
log.error('Error submitting support request:', err)
res.status(500).json({ error: 'Failed to submit support request' })
}
})
// --- Request timeout middleware (30 seconds) ---
const REQUEST_TIMEOUT_MS = 30000
const timeoutMiddleware = (req, res, next) => {
const timeout = setTimeout(() => {
if (!res.headersSent) {
log.warn(`Request timeout: ${req.method} ${req.originalUrl}`)
res.status(504).json({ error: 'Request timeout' })
}
}, REQUEST_TIMEOUT_MS)
res.on('finish', () => clearTimeout(timeout))
res.on('close', () => clearTimeout(timeout))
next()
}
// --- Global error handlers ---
process.on('uncaughtException', (err) => {
log.error('Uncaught exception:', err.message)
log.error('Stack:', err.stack)
log.error('Shutting down due to uncaught exception...')
process.exit(1)
})
process.on('unhandledRejection', (reason, promise) => {
log.error('Unhandled rejection at:', promise)
log.error('Reason:', reason)
log.error('Shutting down due to unhandled rejection...')
process.exit(1)
})
// --- Start Server ---
const PORT = process.env.SERVER_PORT || 3001
// Register timeout middleware BEFORE catch-all routes
app.use(timeoutMiddleware)
// --- 404 catch-all for API routes (must be after all API routes) ---
app.use((req, res, next) => {
if (req.path.startsWith('/api')) {
log.warn(`API route not found: ${req.method} ${req.originalUrl}`)
return res.status(404).json({ error: 'Not found' })
}
next()
})
// Static file serving for SPA
app.use(express.static(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'))
})
app.listen(PORT, () => {
log.info(`Server running on http://localhost:${PORT}`)
log.info(`Health check: http://localhost:${PORT}/api/health`)
log.info(`Zoho lead forwarding mode: ${ZOHO_FORWARDING_MODE}`)
if (ZOHO_FORWARDING_MODE === 'webtolead') {
log.info(`Zoho WebToLead forwarding: ${ZOHO_WEBTOLEAD_ENABLED ? 'ENABLED' : 'DISABLED'}`)
} else if (ZOHO_ENABLED) {
log.info(`Zoho CRM API forwarding: ENABLED`)
log.info(`Zoho API domain: ${ZOHO_API_DOMAIN}`)
log.info(`Zoho Accounts domain: ${ZOHO_ACCOUNTS_DOMAIN}`)
log.info(`Zoho Cases forwarding: ${process.env.ZOHO_CASES_ENABLED === 'true' ? 'ENABLED' : 'DISABLED'}`)
} else {
log.info('Zoho CRM API forwarding: DISABLED (set ZOHO_ENABLED=true to enable)')
}
log.info(`Rate limiting: ${rateLimitMax} requests per ${rateLimitWindowMs / 1000} seconds`)
log.info(`Security headers: Helmet enabled with CSP configured`)
log.info(`CORS origin: ${corsOrigin}`)
})

20
src/App.jsx Normal file
View File

@ -0,0 +1,20 @@
import { Outlet } from 'react-router-dom'
import Header from './components/layout/Header.jsx'
import Footer from './components/layout/Footer.jsx'
import ScrollToTop from './components/ScrollToTop.jsx'
import './index.css'
function App() {
return (
<div className="min-h-screen flex flex-col font-sans bg-background text-text">
<ScrollToTop />
<Header />
<main className="flex-1">
<Outlet />
</main>
<Footer />
</div>
)
}
export default App

View File

@ -0,0 +1,50 @@
import { Component } from 'react'
import { Link } from 'react-router-dom'
class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, info) {
console.error('[ErrorBoundary] Uncaught error:', error, info.componentStack)
}
render() {
if (!this.state.hasError) return this.props.children
return (
<div className="min-h-screen bg-primary-navy flex items-center justify-center px-4">
<div className="text-center text-white max-w-md">
<p className="text-primary-cyan text-sm font-semibold uppercase tracking-widest mb-4">Something went wrong</p>
<h1 className="text-4xl font-bold mb-4">Unexpected Error</h1>
<p className="text-white/70 mb-8">
A problem occurred while loading this page. Please try refreshing, or contact us if the issue continues.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
onClick={() => window.location.reload()}
className="inline-flex h-11 items-center justify-center rounded-md bg-primary-cyan px-6 text-sm font-semibold text-primary-navy hover:bg-white transition-colors"
>
Refresh Page
</button>
<Link
to="/"
onClick={() => this.setState({ hasError: false, error: null })}
className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
>
Back to Home
</Link>
</div>
</div>
</div>
)
}
}
export default ErrorBoundary

View File

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

46
src/components/SEO.jsx Normal file
View File

@ -0,0 +1,46 @@
import { Helmet } from 'react-helmet-async'
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 schemas = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []
return (
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
{/* Canonical URL — prevents duplicate content */}
<link rel="canonical" href={url} />
{/* Open Graph — Facebook, LinkedIn, iMessage, Google Messages, Slack */}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:type" content={type} />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:locale" content="en_US" />
<meta property="og:image" content={image} />
<meta property="og:image:secure_url" content={image} />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={DEFAULT_IMAGE_ALT} />
{/* Twitter / X — also used by Apple Messages on iOS 13+ */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
<meta name="twitter:image:alt" content={DEFAULT_IMAGE_ALT} />
{schemas.map((schema, i) => (
<script key={i} type="application/ld+json">{JSON.stringify(schema)}</script>
))}
</Helmet>
)
}
export default SEO

View File

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

View File

@ -0,0 +1,221 @@
import { Link } from 'react-router-dom'
const Footer = () => {
const currentYear = new Date().getFullYear()
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',
}
const quickLinks = [
{ name: 'Home', href: '/' },
{ name: 'Services', href: '/services' },
{ name: 'Industries', href: '/industries' },
{ name: 'About', href: '/about' },
{ name: 'Contact', href: '/contact' },
{ name: 'Support', href: '/support' },
]
const services = [
{ name: 'Unified Communications', href: '/services/unified-communications' },
{ name: 'Contact Center', href: '/services/contact-center' },
{ name: 'Managed Support', href: '/services/managed-support' },
{ name: 'Consulting & Training', href: '/services/consulting-training' },
{ name: 'Infrastructure Cabling', href: '/services/infrastructure-cabling' },
{ name: 'Wireless Access', href: '/services/wireless-access' },
{ name: 'Local Networking', href: '/services/local-networking' },
]
const industries = [
{ name: 'Healthcare', href: '/industries/healthcare' },
{ name: 'Retail', href: '/industries/retail' },
{ name: 'Manufacturing', href: '/industries/manufacturing' },
{ name: 'Education & Finance', href: '/industries/education-finance' },
]
return (
<footer className="bg-primary-navy text-white">
<div className="container mx-auto px-4 pt-24 pb-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Company Info */}
<div>
<Link to="/" className="flex items-center gap-3 mb-3 group" aria-label="Queue North Technologies Home">
<img
src="/logo.png"
alt="Queue North Technologies"
className="brand-logo-on-dark h-12 w-auto shrink-0 transition-opacity group-hover:opacity-90"
/>
<span className="font-bold text-sm leading-tight tracking-tight text-white sm:text-xl sm:whitespace-nowrap">Queue North Technologies</span>
</Link>
<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>
<a href={`tel:+1${companyInfo.phone.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call ${companyInfo.phone}`}>{companyInfo.phone}</a>
</div>
<div>
<a href={`tel:+1${companyInfo.tollFree.replace(/\D/g, '')}`} className="hover:text-primary-cyan transition-colors" aria-label={`Call toll-free ${companyInfo.tollFree}`}>{companyInfo.tollFree} (Toll-Free)</a>
</div>
</div>
<Link
to="/contact#contact-form"
className="inline-flex items-center gap-2 rounded-md text-sm font-semibold px-5 py-2.5 bg-primary-cyan text-primary-navy hover:bg-white transition-colors duration-200"
aria-label="Request a free consultation"
>
Get a Free Quote
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
{/* Quick Links */}
<div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Quick Links</h3>
<ul className="space-y-2">
{quickLinks.map((link) => (
<li key={link.name}>
<Link
to={link.href}
className="text-navy-light hover:text-white transition-colors text-sm"
aria-label={link.name}
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Services */}
<div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Services</h3>
<ul className="space-y-2">
{services.map((service) => (
<li key={service.name}>
<Link
to={service.href}
className="text-navy-light hover:text-white transition-colors text-sm"
aria-label={service.name}
>
{service.name}
</Link>
</li>
))}
</ul>
</div>
{/* Industries */}
<div className="lg:pt-16">
<h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-primary-cyan">Industries</h3>
<ul className="space-y-2">
{industries.map((industry) => (
<li key={industry.name}>
<Link
to={industry.href}
className="text-navy-light hover:text-white transition-colors text-sm"
aria-label={industry.name}
>
{industry.name}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Veteran Owned & Operated */}
<div className="border-t border-white/10 mt-10 py-6">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex h-24 w-20 items-center justify-center rounded-md border border-white/10 bg-white p-1 shadow-sm">
<img
src="/assets/brand/veteran-owned-certified.webp"
alt="SBA Veteran-Owned Certified badge"
className="h-full w-full object-contain"
/>
</span>
<p className="text-primary-cyan text-xs font-semibold uppercase tracking-[0.18em]">
Veteran Owned &amp; Operated
</p>
<div className="flex flex-wrap justify-center gap-2">
<span className="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-white/85">
United States Marine Corps
</span>
<span className="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-white/85">
United States Air Force
</span>
</div>
</div>
</div>
{/* Bottom */}
<div className="border-t border-white/10 pt-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex flex-col gap-1">
<p className="text-navy-light text-xs">
© {currentYear} Queue North Technologies. All rights reserved.
</p>
</div>
<div className="flex items-center gap-4">
<a
href="https://linkedin.com/company/queue-north-technologies-llc"
target="_blank"
rel="noopener noreferrer"
className="text-navy-light hover:text-primary-cyan transition-colors"
aria-label="Follow Queue North on LinkedIn"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
<span className="sr-only">LinkedIn</span>
</a>
<a
href="https://www.facebook.com/QueueNorth"
target="_blank"
rel="noopener noreferrer"
className="text-navy-light hover:text-primary-cyan transition-colors"
aria-label="Follow Queue North on Facebook"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
<span className="sr-only">Facebook</span>
</a>
<a
href="https://www.instagram.com/queue_north/"
target="_blank"
rel="noopener noreferrer"
className="text-navy-light hover:text-primary-cyan transition-colors"
aria-label="Follow Queue North Technologies on Instagram"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7.75 2h8.5A5.76 5.76 0 0122 7.75v8.5A5.76 5.76 0 0116.25 22h-8.5A5.76 5.76 0 012 16.25v-8.5A5.76 5.76 0 017.75 2zm0 2A3.75 3.75 0 004 7.75v8.5A3.75 3.75 0 007.75 20h8.5A3.75 3.75 0 0020 16.25v-8.5A3.75 3.75 0 0016.25 4h-8.5zM12 7a5 5 0 110 10 5 5 0 010-10zm0 2a3 3 0 100 6 3 3 0 000-6zm5.25-2.5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z" />
</svg>
<span className="sr-only">Instagram</span>
</a>
</div>
</div>
<p className="text-navy-light text-xs mt-4">
8x8® is a registered trademark of 8x8, Inc. Queue North Technologies is an independent certified partner and is not owned or operated by 8x8, Inc.
</p>
</div>
</div>
</footer>
)
}
export default Footer

View File

@ -0,0 +1,265 @@
import { useState, useEffect } from 'react'
import { Sheet, SheetTrigger, SheetContent, SheetTitle } from '@/components/ui/Sheet'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { Link, useLocation } from 'react-router-dom'
const Header = () => {
const [isScrolled, setIsScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [openDropdown, setOpenDropdown] = useState(null)
const location = useLocation()
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
useEffect(() => {
setOpenDropdown(null)
}, [location.pathname])
const navLinks = [
{ name: 'Home', href: '/' },
{ name: 'Services', href: '/services' },
{ name: 'Industries', href: '/industries' },
{ name: 'About', href: '/about' },
{ name: 'Contact', href: '/contact' },
{ name: 'Support', href: '/support' },
]
const serviceLinks = [
{ name: 'Unified Communications', href: '/services/unified-communications' },
{ name: 'Contact Center', href: '/services/contact-center' },
{ name: 'Managed Support', href: '/services/managed-support' },
{ name: 'Consulting & Training', href: '/services/consulting-training' },
{ name: 'Infrastructure Cabling', href: '/services/infrastructure-cabling' },
{ name: 'Wireless Access', href: '/services/wireless-access' },
{ name: 'Local Networking', href: '/services/local-networking' },
]
const industryLinks = [
{ name: 'Healthcare', href: '/industries/healthcare' },
{ name: 'Retail', href: '/industries/retail' },
{ name: 'Manufacturing', href: '/industries/manufacturing' },
{ name: 'Education & Finance', href: '/industries/education-finance' },
]
const closeMobileMenu = () => setMobileMenuOpen(false)
const closeDropdown = () => setOpenDropdown(null)
const isActive = (href) => location.pathname === href
return (
<header className={`sticky top-0 z-40 w-full transition-all duration-300 ${isScrolled ? 'bg-primary-navy shadow-md' : 'bg-primary-navy/95'}`}>
<div className="container mx-auto px-4">
<div className="flex h-16 md:h-18 items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-3">
<Link to="/" className="flex items-center gap-3" aria-label="Queue North Technologies Home">
<img
src="/logo.png"
alt="Queue North Technologies"
className="brand-logo-on-dark h-12 md:h-16 w-auto flex-shrink-0"
/>
<span className="font-bold text-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
</Link>
</div>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-6" aria-label="Main navigation">
{navLinks.map((link) => {
const hasDropdown = link.name === 'Services' || link.name === 'Industries'
return (
<div
key={link.name}
className="relative"
onMouseEnter={() => hasDropdown && setOpenDropdown(link.name)}
onMouseLeave={closeDropdown}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
closeDropdown()
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
closeDropdown()
}
}}
>
<Link
to={link.href}
onFocus={() => hasDropdown && setOpenDropdown(link.name)}
onClick={closeDropdown}
className={`text-sm font-medium transition-colors ${isActive(link.href) ? 'text-white underline underline-offset-4' : 'text-white/70 hover:text-white'}`}
>
{link.name}
</Link>
{/* Services Dropdown */}
{link.name === 'Services' && (
<div className={`absolute top-full left-0 w-64 bg-white rounded-md shadow-xl border border-gray-200 pt-2 ${openDropdown === 'Services' ? 'block' : 'hidden'}`}>
<div className="p-2">
{serviceLinks.map((service) => (
<Link
key={service.name}
to={service.href}
onClick={closeDropdown}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
>
{service.name}
</Link>
))}
</div>
</div>
)}
{/* Industries Dropdown */}
{link.name === 'Industries' && (
<div className={`absolute top-full left-0 w-64 bg-white rounded-md shadow-xl border border-gray-200 pt-2 ${openDropdown === 'Industries' ? 'block' : 'hidden'}`}>
<div className="p-2">
{industryLinks.map((industry) => (
<Link
key={industry.name}
to={industry.href}
onClick={closeDropdown}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
>
{industry.name}
</Link>
))}
</div>
</div>
)}
</div>
)
})}
</nav>
{/* CTA Button */}
<div className="hidden md:block">
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 bg-primary-cyan text-primary-navy hover:bg-cyan-600 transition-colors" aria-label="Request a consultation">
Request Consultation
</Link>
</div>
{/* Mobile Menu */}
<div className="md:hidden">
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<button className="p-2 text-white hover:text-primary-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-primary-cyan rounded-md" aria-label="Open navigation menu">
<span className="sr-only">Open menu</span>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</SheetTrigger>
<SheetContent side="right" aria-describedby={undefined} className="w-[85vw] max-w-[300px] sm:w-[350px] bg-primary-navy text-white">
<VisuallyHidden.Root asChild>
<SheetTitle>Navigation Menu</SheetTitle>
</VisuallyHidden.Root>
{/* Logo + phone */}
<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">
<img src="/logo.png" alt="Queue North Technologies" className="brand-logo-on-dark h-12 w-auto" />
</Link>
<Link to="/" onClick={closeMobileMenu} className="font-bold text-sm tracking-tight whitespace-nowrap text-white leading-tight">
Queue North Technologies
</Link>
</div>
{/* Scrollable nav */}
<nav className="flex-1 min-h-0 overflow-y-auto" aria-label="Mobile navigation">
{/* Main links — Services and Industries handled as sections below */}
<ul className="space-y-1 mb-6">
{navLinks
.filter(link => link.name !== 'Services' && link.name !== 'Industries')
.map((link) => (
<li key={link.name}>
<Link
to={link.href}
onClick={closeMobileMenu}
className={`flex items-center py-3 px-2 rounded-md text-base font-medium transition-colors ${isActive(link.href) ? 'text-white bg-white/10' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
>
{link.name}
</Link>
</li>
))}
</ul>
{/* Services section */}
<div className="mb-6">
<Link
to="/services"
onClick={closeMobileMenu}
className={`block py-2 px-2 text-xs font-semibold uppercase tracking-wider mb-1 transition-colors ${isActive('/services') ? 'text-primary-cyan' : 'text-primary-cyan/80 hover:text-primary-cyan'}`}
>
Services
</Link>
<ul className="space-y-1">
{serviceLinks.map((service) => (
<li key={service.name}>
<Link
to={service.href}
onClick={closeMobileMenu}
className={`flex items-center py-3 px-4 rounded-md text-sm transition-colors ${isActive(service.href) ? 'text-white font-semibold bg-white/10' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
aria-label={service.name}
>
{service.name}
</Link>
</li>
))}
</ul>
</div>
{/* Industries section */}
<div>
<Link
to="/industries"
onClick={closeMobileMenu}
className={`block py-2 px-2 text-xs font-semibold uppercase tracking-wider mb-1 transition-colors ${isActive('/industries') ? 'text-primary-cyan' : 'text-primary-cyan/80 hover:text-primary-cyan'}`}
>
Industries
</Link>
<ul className="space-y-1">
{industryLinks.map((industry) => (
<li key={industry.name}>
<Link
to={industry.href}
onClick={closeMobileMenu}
className={`flex items-center py-3 px-4 rounded-md text-sm transition-colors ${isActive(industry.href) ? 'text-white font-semibold bg-white/10' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
aria-label={industry.name}
>
{industry.name}
</Link>
</li>
))}
</ul>
</div>
</nav>
{/* CTA */}
<div className="pt-4 border-t border-white/10">
<Link
to="/contact#contact-form"
onClick={closeMobileMenu}
className="inline-flex items-center justify-center gap-2 w-full rounded-md text-sm font-semibold h-11 px-4 bg-primary-cyan text-primary-navy hover:bg-white transition-colors duration-200"
aria-label="Get a free quote"
>
Get a Free Quote
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</header>
)
}
export default Header

View File

@ -0,0 +1,147 @@
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/Sheet'
import { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
const MobileNav = () => {
const [isOpen, setIsOpen] = useState(false)
const location = useLocation()
const primaryLinks = [
{ name: 'Home', href: '/' },
{ name: 'About', href: '/about' },
{ name: 'Contact', href: '/contact' },
{ name: 'Support', href: '/support' },
]
const services = [
{ name: 'Unified Communications', href: '/services/unified-communications' },
{ name: 'Contact Center', href: '/services/contact-center' },
{ name: 'Managed Support', href: '/services/managed-support' },
{ name: 'Consulting & Training', href: '/services/consulting-training' },
{ name: 'Infrastructure Cabling', href: '/services/infrastructure-cabling' },
{ name: 'Wireless Access', href: '/services/wireless-access' },
{ name: 'Local Networking', href: '/services/local-networking' },
]
const industries = [
{ name: 'Healthcare', href: '/industries/healthcare' },
{ name: 'Retail', href: '/industries/retail' },
{ name: 'Manufacturing', href: '/industries/manufacturing' },
{ name: 'Education & Finance', href: '/industries/education-finance' },
]
const closeMobileMenu = () => {
setIsOpen(false)
}
const isActive = (href) => location.pathname === href
return (
<div className="md:hidden">
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<button className="p-2 text-white hover:text-primary-cyan focus:outline-none focus:ring-2 focus:ring-primary-cyan rounded-md" aria-label="Open navigation menu">
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<span className="sr-only">Open menu</span>
</button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[350px] bg-primary-navy text-white">
<div className="flex flex-col h-full">
<div className="flex items-center gap-3 mb-6">
<img
src="/logo.png"
alt="Queue North Technologies"
className="brand-logo-on-dark h-12 w-auto"
/>
<span className="font-bold text-xl leading-tight">Queue North Technologies</span>
</div>
<nav className="flex flex-col space-y-6">
{/* Primary Links */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Primary</h4>
<ul className="space-y-2">
{primaryLinks.map((link) => (
<li key={link.name}>
<Link
to={link.href}
onClick={closeMobileMenu}
className={`block text-base font-medium py-2 transition-colors ${isActive(link.href) ? 'text-white font-semibold' : 'text-navy-light hover:text-white'}`}
aria-label={link.name}
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Services */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3" aria-hidden="true">Services</h4>
<ul className="space-y-2">
{services.map((service) => (
<li key={service.name}>
<Link
to={service.href}
onClick={closeMobileMenu}
className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(service.href) ? 'text-white font-semibold' : 'text-navy-light hover:text-white'}`}
aria-label={'Learn about ' + service.name}
>
{service.name}
</Link>
</li>
))}
</ul>
</div>
{/* Industries */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3" aria-hidden="true">Industries</h4>
<ul className="space-y-2">
{industries.map((industry) => (
<li key={industry.name}>
<Link
to={industry.href}
onClick={closeMobileMenu}
className={`block text-sm py-2 border-b border-white/10 last:border-0 transition-colors ${isActive(industry.href) ? 'text-white font-semibold' : 'text-navy-light hover:text-white'}`}
aria-label={'Learn about ' + industry.name + ' industry solutions'}
>
{industry.name}
</Link>
</li>
))}
</ul>
</div>
</nav>
<div className="mt-auto pt-6">
<Link
to="/contact#contact-form"
onClick={closeMobileMenu}
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 w-full bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors"
aria-label="Request a consultation"
>
Request Consultation
</Link>
</div>
</div>
</SheetContent>
</Sheet>
</div>
)
}
export default MobileNav

View File

@ -0,0 +1,3 @@
export { default as Header } from './Header.jsx'
export { default as Footer } from './Footer.jsx'
export { default as MobileNav } from './MobileNav.jsx'

View File

@ -0,0 +1,27 @@
import * as React from 'react'
const Badge = React.forwardRef(
({ className = '', variant = 'default', ...props }, ref) => {
const baseStyles = 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-primary-navy focus:ring-offset-2'
const variants = {
default: 'border-transparent bg-primary-navy text-white hover:bg-primary-navy-dark',
secondary: 'border-transparent bg-section-alt text-text hover:bg-opacity-80',
outline: 'text-text',
success: 'border-transparent bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
warning: 'border-transparent bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
error: 'border-transparent bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
}
return (
<div
ref={ref}
className={`${baseStyles} ${variants[variant]} ${className}`}
{...props}
/>
)
}
)
Badge.displayName = 'Badge'
export { Badge }

View File

@ -0,0 +1,31 @@
import * as React from 'react'
const Button = React.forwardRef(({ className = '', variant = 'default', size = 'default', ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'
const variants = {
default: 'bg-primary-navy text-white hover:bg-primary-navy-dark',
secondary: 'bg-section-alt text-text hover:bg-opacity-80',
outline: 'border border-border bg-background hover:bg-section-alt hover:text-text',
ghost: 'hover:bg-section-alt hover:text-text',
link: 'text-primary-navy underline-offset-4 hover:underline',
}
const sizes = {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
}
return (
<button
ref={ref}
className={`${baseStyles} ${variants[variant] || ''} ${sizes[size] || ''} ${className}`}
{...props}
/>
)
})
Button.displayName = 'Button'
export { Button }

View File

@ -0,0 +1,65 @@
import * as React from 'react'
const Card = React.forwardRef(
({ className = '', ...props }, ref) => (
<div
ref={ref}
className={`rounded-md border border-border bg-card text-text shadow-sm ${className}`}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef(
({ className = '', ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-6 ${className}`}
{...props}
/>
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef(
({ className = '', ...props }, ref) => (
<h3
ref={ref}
className={`text-2xl font-semibold leading-none tracking-tight ${className}`}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef(
({ className = '', ...props }, ref) => (
<p
ref={ref}
className={`text-sm text-muted ${className}`}
{...props}
/>
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef(
({ className = '', ...props }, ref) => (
<div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef(
({ className = '', ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-6 pt-0 ${className}`}
{...props}
/>
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,17 @@
import * as React from 'react'
const Input = React.forwardRef(
({ className = '', type, ...props }, ref) => {
return (
<input
type={type}
className={`flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-[#F8FAFC] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@ -0,0 +1,33 @@
import * as React from 'react'
const Select = ({ children, className = '', ...props }) => {
return (
<div className={`relative ${className}`}>
<select
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-[#F8FAFC] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none"
{...props}
>
{children}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
)
}
Select.displayName = 'Select'
export { Select }

View File

@ -0,0 +1,88 @@
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
ref={ref}
className={`fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ${className}`}
{...props}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sideClasses = {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
}
const SheetContent = React.forwardRef(({ className, children, side = 'right', ...props }, ref) => (
<SheetPrimitive.Portal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={`fixed z-50 flex flex-col gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out ${sideClasses[side] ?? sideClasses.right} ${className ?? ''}`}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm text-white opacity-60 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary-navy disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }) => (
<div
className={`flex flex-col space-y-2 text-center sm:text-left ${className}`}
{...props}
/>
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({ className, ...props }) => (
<div
className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}
{...props}
/>
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={`text-lg font-semibold text-text ${className}`}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={`text-sm text-muted ${className}`}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,16 @@
import * as React from 'react'
const Textarea = React.forwardRef(
({ className = '', ...props }, ref) => {
return (
<textarea
className={`flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-[#F8FAFC] placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

78
src/data/industries.js Normal file
View File

@ -0,0 +1,78 @@
export const industries = [
{
id: 'healthcare',
name: 'Healthcare',
shortDesc: 'HIPAA-compliant communications and infrastructure for medical providers',
fullDesc: 'Patient experience, scheduling, and staff coordination with a focus on compliance.',
icon: 'heart-pulse',
painPoints: [
'HIPAA compliance and data security',
'Multi-location coordination',
'Emergency response communications',
'Patient portal and telehealth integration',
],
solutions: [
'HIPAA-compliant voice and messaging',
'Secure video conferencing for telehealth',
'Distributed network infrastructure',
'24/7 uptime with redundant systems',
],
},
{
id: 'retail',
name: 'Retail',
shortDesc: 'Connect stores, teams, and customers with scalable retail communications',
fullDesc: 'Connect stores, back office, and support teams while improving customer interaction.',
icon: 'shopping-cart',
painPoints: [
'Multi-store coordination',
'Centralized management of remote locations',
'Customer engagement and loyalty',
'Inventory and POS system connectivity',
],
solutions: [
'Store-to-headquarters connectivity',
'Mobile workforce management',
'Customer engagement platforms',
'Scalable network for growing chains',
],
},
{
id: 'manufacturing',
name: 'Manufacturing',
shortDesc: 'Industrial communications for production floors and distributed operations',
fullDesc: 'Reliable office-to-plant communications including paging and alerting for production environments.',
icon: 'factory',
painPoints: [
'Production floor connectivity',
'Remote facility coordination',
'Safety and emergency communications',
'Integration with manufacturing systems',
],
solutions: [
'Industrial-grade networking hardware',
'Wireless coverage for production floors',
'Safety communication systems',
'Integration with ERP and MES systems',
],
},
{
id: 'education-finance',
name: 'Education & Finance',
shortDesc: 'Secure, reliable communications for schools and financial institutions',
fullDesc: 'Campus communications and customer-facing communications in tightly regulated environments.',
icon: 'landmark',
painPoints: [
'Data security and privacy',
'Regulatory compliance',
'Multi-campus or branch coordination',
'Remote learning and virtual meetings',
],
solutions: [
'Secure communication platforms',
'Compliance-ready audit trails',
'Campus-wide connectivity',
'Video conferencing for virtual learning',
],
},
]

157
src/data/services.js Normal file
View File

@ -0,0 +1,157 @@
export const services = [
{
id: 'unified-communications',
name: 'Unified Communications',
shortDesc: 'Modernize your business communications with seamless integration',
homeDesc: 'Stop juggling separate phone, video, and messaging systems. One platform, one bill, zero headaches.',
fullDesc: `Voice, meetings, and messaging that keep your people connected without adding operational friction.
As an 8x8 Certified Partner, we have deep expertise in implementing and supporting 8x8 UCaaS solutions, ensuring our customers get the most value from their investment. Our 8x8 expertise includes VoIP implementation, cloud PBX migration, unified communications deployments, and ongoing system support.
Our solutions include Cisco Webex, Cisco Unified Communications Manager, and 8x8 UCaaS platforms.`,
icon: 'message-circle',
benefits: [
'Seamless voice, video, and messaging integration',
'Mobile and desktop app support',
'Persistent chat and file sharing',
'Presence indicators for real-time visibility',
'8x8 UCaaS implementation and migration',
'VoIP and cloud PBX setup',
],
idealFor: [
'Remote and hybrid teams',
'Distributed workforces',
'Enterprises needing collaboration tools',
'Businesses with multiple locations',
],
},
{
id: 'contact-center',
name: 'Contact Center',
shortDesc: 'Deliver exceptional customer experiences with modern contact center solutions',
homeDesc: 'Your customers reach a real person faster. Lower wait times, happier callers, better reviews.',
fullDesc: `Customer engagement built with routing, reporting, and workflow control that support real operational performance.
As an 8x8 Certified Partner, we deliver enterprise-grade contact center solutions with 99.999% uptime reliability. Our 8x8 expertise includes contact center setup, omnichannel routing, AI-powered agent assistance, and real-time analytics dashboards.
We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading platforms.`,
icon: 'users',
image: '/assets/modern-call-center.webp',
benefits: [
'Omnichannel customer interactions',
'Real-time analytics and reporting',
'AI-powered agent assistance',
'Scalable cloud infrastructure',
'8x8 Contact Center setup and optimization',
'99.999% uptime reliability',
],
idealFor: [
'Customer service teams',
'B2C businesses with high volume',
'Enterprises with 24/7 support needs',
'Outsourced contact center operations',
],
},
{
id: 'managed-support',
name: 'Managed Support',
shortDesc: 'Expert IT support with proactive monitoring and rapid response',
homeDesc: 'Your IT runs itself. 24/7 monitoring catches problems before you even notice them.',
fullDesc: 'Consistent support, clear accountability, and lifecycle management that keep your environment stable long after deployment.',
icon: 'life-buoy',
benefits: [
'24/7 proactive monitoring',
'Rapid response SLAs',
'Dedicated support engineers',
'Comprehensive IT help desk',
],
idealFor: [
'Businesses without dedicated IT staff',
'Small to mid-sized enterprises',
'Organizations needing extended support',
'Businesses with critical IT dependencies',
],
},
{
id: 'consulting-training',
name: 'Consulting & Training',
shortDesc: 'Expert guidance and training for communications and infrastructure',
homeDesc: 'Get a clear plan, not a sales pitch. We map your needs, implement the right solution, and train your team.',
fullDesc: 'Straightforward guidance and practical training that help your team use technology with confidence and discipline.',
icon: 'graduation-cap',
benefits: [
'Strategic technology planning',
'Implementation and migration support',
'Hands-on user and admin training',
'Ongoing consultation and optimization',
],
idealFor: [
'Organizations undergoing digital transformation',
'Teams adopting new technologies',
'Businesses upgrading existing systems',
'Enterprises needing strategic guidance',
],
},
{
id: 'infrastructure-cabling',
name: 'Infrastructure Cabling',
shortDesc: 'Professional structured cabling for reliable network performance',
homeDesc: 'Bad cabling means dropped calls and slow networks. We build it right the first time.',
fullDesc: 'Clean structured cabling that gives your business the physical foundation for reliable communication and growth.',
icon: 'link',
image: '/assets/cabling-unsplash.jpg',
benefits: [
'Cat6/Cat6a and fiber optic installations',
'Data center cabling solutions',
'Structured cabling design and documentation',
'Testing and certification',
],
idealFor: [
'New construction projects',
'Network upgrades',
'Data center installations',
'Office relocations',
],
},
{
id: 'wireless-access',
name: 'Wireless Access',
shortDesc: 'Enterprise-grade Wi-Fi solutions for reliable mobile connectivity',
homeDesc: 'Wi-Fi that just works — everywhere in your building. No dead zones, no complaints.',
fullDesc: 'Business Wi-Fi designed for usable coverage, dependable performance, and fewer support headaches across your environment.',
icon: 'wifi',
image: '/assets/wireless.webp',
benefits: [
'Enterprise Wi-Fi design and deployment',
'High-density coverage solutions',
'Guest Wi-Fi with captive portal',
'Site surveys and optimization',
],
idealFor: [
'Office buildings and campuses',
'Retail locations',
'Healthcare facilities',
'Manufacturing plants',
],
},
{
id: 'local-networking',
name: 'Local Networking',
shortDesc: 'Robust local network infrastructure for business-critical operations',
homeDesc: 'A network that doesn\'t go down when it matters. Secure, fast, and built for your workload.',
fullDesc: 'Switching and routing built for stability, visibility, and secure local network performance.',
icon: 'network',
benefits: [
'Enterprise-grade routing and switching',
'Network security and segmentation',
'Wireless controller management',
'Network monitoring and maintenance',
],
idealFor: [
'Businesses with on-premise servers',
'Multi-location organizations',
'Enterprises needing network redundancy',
'Organizations with strict security requirements',
],
},
]

25
src/hooks/useDebounce.js Normal file
View File

@ -0,0 +1,25 @@
import { useState, useEffect } from 'react'
/**
* Debounce hook - delays updating the value until after the specified delay
* @param {any} value - The value to debounce
* @param {number} delay - The debounce delay in milliseconds
* @returns {any} - The debounced value
*/
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
export default useDebounce

77
src/index.css Normal file
View File

@ -0,0 +1,77 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
body {
font-family: 'Inter', sans-serif;
color: #0F172A;
background-color: #F8FAFC;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
img {
max-width: 100%;
display: block;
}
.brand-logo-on-dark {
filter:
drop-shadow(0 1px 0 rgba(255, 255, 255, 0.6))
drop-shadow(1px 0 0 rgba(255, 255, 255, 0.45));
}
a {
color: #0EA5E9;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Container - custom max-width */
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 16px;
}
/* Section spacing - mobile first */
.section {
padding: 4rem 0;
}
/* Desktop section spacing */
@media (min-width: 1024px) {
.section {
padding: 6rem 0;
}
}
/* Hero section styling */
.hero {
min-height: 70vh;
display: flex;
align-items: center;
background: linear-gradient(135deg, #0B2A3C 0%, #071A2A 100%);
color: white;
padding: 4rem 0 5rem;
}
/* Light section background */
.section-alt {
background: #EEF6FB;
}

124
src/lib/api.js Normal file
View File

@ -0,0 +1,124 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
export async function submitLead(data) {
const response = await fetch(`${API_BASE_URL}/leads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const error = new Error(errorData.error || `API error: ${response.status}`)
error.response = { status: response.status }
error.fields = errorData.fields
throw error
}
return response.json()
}
export async function submitSupport(data) {
const response = await fetch(`${API_BASE_URL}/support`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const error = new Error(errorData.error || `API error: ${response.status}`)
error.response = { status: response.status }
error.fields = errorData.fields
throw error
}
return response.json()
}
// Exponential backoff retry helper (deprecated, kept for other API calls)
const retryFetch = async (fn, { maxRetries = 3, baseDelay = 1000 } = {}) => {
let lastError
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (err) {
lastError = err
// Don't retry on client errors (except 429), only server errors and network failures
if (err instanceof TypeError && err.message === 'Failed to fetch') {
// Network failure - retry
} else if (err.response && err.response.status >= 500) {
// 5xx server error - retry
} else if (err.response && err.response.status === 429) {
// 429 Too Many Requests - check Retry-After header
const retryAfter = err.response.headers.get('Retry-After')
if (retryAfter) {
const delay = parseInt(retryAfter, 10) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
} else {
// Other errors (4xx except 429) - don't retry
throw err
}
// Wait with exponential backoff before retry
if (attempt < maxRetries) {
const delay = baseDelay * Math.pow(2, attempt)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
throw lastError
}
export const api = {
get: async (endpoint) => {
return await retryFetch(async () => {
let response
try {
response = await fetch(`${API_BASE_URL}${endpoint}`)
} catch (err) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
}
throw new Error(`Network error: ${err.message}`)
}
if (!response.ok) {
const errorData = new Error(`API error: ${response.statusText}`)
errorData.response = { status: response.status, statusText: response.statusText }
throw errorData
}
return response.json()
}, { maxRetries: 3, baseDelay: 1000 })
},
post: async (endpoint, data) => {
return await retryFetch(async () => {
let response
try {
response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
} catch (err) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
throw new Error('Unable to reach the server. This may be a network or CORS issue.')
}
throw new Error(`Network error: ${err.message}`)
}
if (!response.ok) {
let errorData
try {
errorData = await response.json()
} catch {
const err = new Error(`Server error (${response.status}): ${response.statusText}`)
err.response = { status: response.status, statusText: response.statusText }
throw err
}
const error = new Error(errorData.error || `API error: ${response.statusText}`)
error.response = { status: response.status }
throw error
}
return response.json()
}, { maxRetries: 3, baseDelay: 1000 })
},
}

22
src/main.jsx Normal file
View File

@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
import { HelmetProvider } from 'react-helmet-async'
import router from './router.jsx'
import App from './App.jsx'
import ErrorBoundary from './components/ErrorBoundary.jsx'
// Wrap the router with providers
const Root = () => (
<StrictMode>
<HelmetProvider>
<ErrorBoundary>
<RouterProvider router={router} />
<Toaster position="top-right" />
</ErrorBoundary>
</HelmetProvider>
</StrictMode>
)
createRoot(document.getElementById('root')).render(<Root />)

294
src/pages/About.jsx Normal file
View File

@ -0,0 +1,294 @@
import SEO from '@/components/SEO'
import { Link } from 'react-router-dom'
import { ArrowRight, Award, CheckCircle2, Compass, Cpu, Handshake, Headphones, Route, Wrench } from 'lucide-react'
const proofPoints = [
{ label: '25+ years', detail: 'Communications and infrastructure experience', icon: Award, containerClass: '' },
{
label: '8x8 Certified Partner',
detail: 'Sales, engineering, build, deployment, and support',
logo: '/assets/brand/8x8-logo-white.svg',
logoAlt: '8x8 Certified Partner logo',
logoClassName: 'h-6 w-14',
containerClass: 'px-2',
},
{
label: 'Cisco Partner',
detail: 'Networking and communications implementation',
logo: '/assets/brand/Cisco-Partner-Logo_trasnp_w.png',
logoAlt: 'Cisco Partner certification logo',
logoClassName: 'h-full w-full scale-[2]',
containerClass: 'p-1 overflow-hidden',
},
{
label: 'Veteran-Owned Certified',
detail: 'Disciplined delivery and direct accountability',
logo: '/assets/brand/veteran-owned-certified-mark.webp',
logoAlt: 'SBA logo for Veteran-Owned Certified',
logoClassName: 'h-full w-full',
containerClass: 'p-1',
},
]
const operatingPrinciples = [
{
title: 'Own the outcome',
desc: 'We stay close to implementation details so decisions survive real production conditions.',
icon: Wrench,
accent: 'text-primary-blue',
bg: 'bg-sky-50',
border: 'border-t-primary-blue',
},
{
title: 'Recommend what fits',
desc: 'We align platforms, budgets, users, and support realities before anyone signs a contract.',
icon: Handshake,
accent: 'text-teal-600',
bg: 'bg-teal-50',
border: 'border-t-teal-500',
},
{
title: 'Design for operations',
desc: 'Documentation, migration planning, training, and escalation paths are part of the work.',
icon: Route,
accent: 'text-cyan-600',
bg: 'bg-cyan-50',
border: 'border-t-cyan-400',
},
{
title: 'Support after go-live',
desc: 'Our team remains accountable after deployment, when reliability starts to matter most.',
icon: Headphones,
accent: 'text-amber-600',
bg: 'bg-amber-50',
border: 'border-t-accent-gold',
},
]
const capabilities = [
'UCaaS and cloud PBX migration',
'Contact center implementation',
'Network infrastructure and cabling',
'Wireless access design',
'Vendor-neutral consulting',
'Managed support and monitoring',
'Cloud migration support',
'Disaster recovery planning',
]
const nameMeanings = [
{
title: 'Queue',
desc: 'Rooted in technology that works - proven, stable, and repeatable. We focus on environments that behave predictably in real production conditions.',
icon: Cpu,
border: 'border-t-primary-blue',
iconWrap: 'bg-sky-50 text-primary-blue',
},
{
title: 'North',
desc: 'A responsible direction forward - evaluating and adopting new technology carefully, with governance, security, and long-term ownership in mind.',
icon: Compass,
border: 'border-t-teal-500',
iconWrap: 'bg-teal-50 text-teal-600',
}
]
const About = () => {
return (
<>
<SEO
title="About Queue North | Veteran-Owned 8x8 Partner — 25+ Years of Service"
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."
url="https://queuenorth.com/about"
/>
{/* Page Hero */}
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
<div className="absolute inset-0 -z-10">
<img
src="/assets/about-image.webp"
alt="Compass on a dark navigation map"
className="h-full w-full object-cover object-[66%_top] md:object-[62%_top]"
/>
<div className="absolute inset-0 bg-primary-navy/88" />
<div className="absolute inset-0 bg-gradient-to-r from-primary-navy via-primary-navy/95 to-primary-navy/60" />
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_0.75fr] gap-10 lg:gap-14 items-center">
<div>
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan">
<Compass className="h-4 w-4" aria-hidden="true" />
About
</div>
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
Accountable communications infrastructure, built around how your business actually works.
</h1>
<p className="mt-6 text-lg md:text-xl text-white/75 max-w-3xl leading-relaxed">
Queue North helps organizations choose, implement, and support the phone, contact center, network, and IT systems that keep daily operations moving.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-3">
<Link to="/contact#contact-form" className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
Start a Conversation
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<Link to="/services" className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors">
View Services
</Link>
</div>
</div>
<div className="rounded-md border border-white/15 bg-white/10 p-6 shadow-xl backdrop-blur">
<h2 className="text-lg font-semibold mb-5">At a glance</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{proofPoints.map((point) => {
const Icon = point.icon
return (
<div key={point.label} className="flex gap-4 rounded-md border border-white/10 bg-white/5 p-3">
<span className={`flex h-10 w-16 shrink-0 items-center justify-center rounded-md bg-white/10 text-primary-cyan ${point.containerClass}`}>
{point.logo ? (
<img
src={point.logo}
alt={point.logoAlt}
className={`${point.logoClassName} object-contain`}
/>
) : (
<Icon className="h-7 w-7" aria-hidden="true" />
)}
</span>
<div>
<h3 className="font-semibold">{point.label}</h3>
<p className="text-sm text-white/70">{point.detail}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
{/* Name */}
<section className="bg-white py-16 lg:py-24 border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[0.78fr_1.22fr] gap-10 lg:gap-14 items-start">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-primary-blue">The name</p>
<h2 className="mt-3 text-4xl font-bold leading-tight text-primary-navy md:text-5xl">Queue North</h2>
<p className="mt-4 text-xl font-semibold text-soft-text">Where Stability Meets Direction</p>
<p className="mt-6 text-lg leading-relaxed text-soft-text">
The name reflects how we approach technology: dependable systems first, then a responsible path forward.
</p>
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{nameMeanings.map((item) => {
const Icon = item.icon
return (
<div key={item.title} className={`rounded-md border border-border border-t-[3px] bg-background p-7 shadow-sm ${item.border}`}>
<span className={`flex h-12 w-12 items-center justify-center rounded-md ${item.iconWrap}`}>
<Icon className="h-6 w-6" aria-hidden="true" />
</span>
<h3 className="mt-6 text-3xl font-bold text-primary-navy">{item.title}</h3>
<p className="mt-4 text-base leading-relaxed text-soft-text">{item.desc}</p>
</div>
)
})}
</div>
</div>
</div>
</section>
{/* Operating Model */}
<section className="bg-background py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[0.8fr_1.2fr] gap-10 lg:gap-14 items-start">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-primary-blue">In practice</p>
<h2 className="mt-3 text-3xl md:text-4xl font-bold text-primary-navy">
Stability first, then the right path forward.
</h2>
<p className="mt-5 text-lg leading-relaxed text-soft-text">
Many businesses are asked to pick platforms before anyone has mapped the operational reality. Queue North starts with the environment, the people using it, and the support model that has to carry it after launch.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{operatingPrinciples.map((item) => {
const Icon = item.icon
return (
<div key={item.title} className={`rounded-md border border-border border-t-[3px] bg-white p-6 shadow-sm transition-all hover:shadow-md ${item.border}`}>
<div className="flex items-center gap-4">
<span className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-md ${item.bg} ${item.accent}`}>
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<h3 className="text-left text-xl font-semibold text-primary-navy">{item.title}</h3>
</div>
<p className="mt-3 text-sm leading-relaxed text-soft-text">{item.desc}</p>
</div>
)
})}
</div>
</div>
</div>
</section>
{/* Capabilities */}
<section className="bg-white py-16 lg:py-24 border-y border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[0.75fr_1.25fr] gap-10 lg:gap-14 items-start">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-primary-blue">Expertise</p>
<h2 className="mt-3 text-3xl md:text-4xl font-bold text-primary-navy">
Practical coverage from assessment through support.
</h2>
<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.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{capabilities.map((item, index) => (
<div key={item} className="flex items-center gap-3 rounded-md border border-border bg-white p-4 shadow-sm">
<CheckCircle2 className={`h-5 w-5 shrink-0 ${index % 2 === 0 ? 'text-primary-blue' : 'text-teal-600'}`} aria-hidden="true" />
<span className="text-sm font-medium text-text">{item}</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="bg-section-alt py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="rounded-md bg-primary-navy px-6 py-10 text-white shadow-md sm:px-8 lg:flex lg:items-center lg:justify-between lg:gap-10 lg:px-10">
<div>
<h2 className="text-3xl md:text-4xl font-bold">Need a cleaner path forward?</h2>
<p className="mt-3 text-white/70 max-w-2xl">
Talk with a team that can help evaluate the current state, recommend the right path, and stay accountable after deployment.
</p>
</div>
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
<Link
to="/contact#contact-form"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Request Consultation
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<a
href="tel:+13217308020"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
>
Call (321) 730-8020
</a>
</div>
</div>
</div>
</section>
</>
)
}
export default About

426
src/pages/Contact.jsx Normal file
View File

@ -0,0 +1,426 @@
import SEO from '@/components/SEO'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder'
import { ArrowRight } from 'lucide-react'
import { submitLead } from '@/lib/api'
const isRecaptchaConfigured = Boolean(import.meta.env.VITE_RECAPTCHA_SITE_KEY)
const Contact = () => {
const [formState, setFormState] = useState({
'Last Name': '',
Company: '',
Email: '',
Phone: '',
'Zip Code': '',
Description: '',
company_website: '',
})
const [errors, setErrors] = useState({
'Last Name': '',
Company: '',
Email: '',
'Zip Code': '',
Description: '',
recaptcha_token: '',
})
const [debouncedErrors, setDebouncedErrors] = useState(errors)
const [isSubmitting, setIsSubmitting] = useState(false)
const [recaptchaToken, setRecaptchaToken] = useState('')
const [recaptchaResetKey, setRecaptchaResetKey] = useState(0)
useEffect(() => {
const t = setTimeout(() => setDebouncedErrors(errors), 300)
return () => clearTimeout(t)
}, [errors])
const validateForm = () => {
const newErrors = { 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' }
if (!formState.Company.trim()) newErrors.Company = 'Company name is required'
if (!formState['Last Name'].trim()) newErrors['Last Name'] = 'Name is required'
if (!formState['Zip Code'].trim()) newErrors['Zip Code'] = 'ZIP code is required'
if (!formState.Description.trim()) newErrors.Description = 'Message is required'
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formState.Email.trim()) {
newErrors.Email = 'Email is required'
} else if (!emailRegex.test(formState.Email)) {
newErrors.Email = 'Please enter a valid email address'
}
if (isRecaptchaConfigured && !recaptchaToken) {
newErrors.recaptcha_token = 'Security verification is required'
}
const hasErrors = Object.values(newErrors).some(error => error !== '')
setErrors(newErrors)
if (hasErrors) {
toast.error('Please fix the errors in the form')
return false
}
return true
}
const resetForm = () => {
setFormState({
'Last Name': '',
Company: '',
Email: '',
Phone: '',
'Zip Code': '',
Description: '',
company_website: '',
})
setErrors({ 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' })
setRecaptchaToken('')
setRecaptchaResetKey(prev => prev + 1)
}
const mapApiErrors = (fields = {}) => ({
'Last Name': fields.name || '',
Company: fields.company || '',
Email: fields.email || '',
'Zip Code': fields.zip || '',
Description: fields.message || '',
recaptcha_token: fields.recaptcha_token || '',
})
const handleSubmit = async (e) => {
e.preventDefault()
if (!validateForm()) return
setIsSubmitting(true)
try {
const result = await submitLead({
company: formState.Company,
name: formState['Last Name'],
email: formState.Email,
phone: formState.Phone,
zip: formState['Zip Code'],
message: formState.Description,
recaptcha_token: recaptchaToken,
company_website: formState.company_website,
})
toast.success(result.message || "Thanks! We'll be in touch shortly.")
resetForm()
} catch (err) {
if (err.fields) {
setErrors(mapApiErrors(err.fields))
if (err.fields.recaptcha_token) {
setRecaptchaToken('')
setRecaptchaResetKey(prev => prev + 1)
}
}
toast.error(err.message || 'Failed to submit lead')
} finally {
setIsSubmitting(false)
}
}
const handleChange = (e) => {
const { name, value } = e.target
setFormState(prev => ({ ...prev, [name]: value }))
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }))
}
const handleRecaptchaVerify = useCallback((token) => {
setRecaptchaToken(token)
setErrors(prev => ({ ...prev, recaptcha_token: '' }))
}, [])
const handleRecaptchaExpired = useCallback(() => {
setRecaptchaToken('')
setErrors(prev => ({ ...prev, recaptcha_token: 'Security verification expired. Please try again.' }))
}, [])
const contactDetails = [
{
label: 'Phone',
icon: (
<svg className="h-5 w-5 text-primary-cyan" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
),
content: (
<div>
<a href="tel:+13217308020" className="block text-white hover:text-primary-cyan transition-colors">(321) 730-8020</a>
<a href="tel:+18886562850" className="block text-white/70 text-sm hover:text-primary-cyan transition-colors mt-0.5">(888) 656-2850 Toll-Free</a>
</div>
),
},
{
label: 'Office',
icon: (
<svg className="h-5 w-5 text-primary-cyan" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<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>
),
content: (
<a
href="https://maps.google.com/?q=7901+4th+St+N+St+Petersburg+FL+33702"
target="_blank"
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>
),
},
{
label: 'Hours',
icon: (
<svg className="h-5 w-5 text-primary-cyan" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
content: <p className="text-white/80 text-sm">Mon Fri: 8:00 AM 6:00 PM CT</p>,
},
]
const trustPoints = [
<div key="8x8"><span className="font-numeric">8x8</span> Certified Partner with proven expertise</div>,
'Cisco Certified Partner',
<div key="veteran"><span className="font-numeric">25+</span> years of experience</div>,
'SMB to Enterprise solutions',
'No vendor bias — we recommend what fits',
]
return (
<>
<SEO
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."
url="https://queuenorth.com/contact"
/>
{/* Hero */}
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24">
<div className="absolute inset-0 -z-10">
<img
src="/assets/hero-tech.webp"
alt="Queue North communications infrastructure consultation"
className="h-full w-full object-cover object-center"
/>
<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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan mb-6">
Contact
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Let's Talk</h1>
<p className="text-xl text-white/70 max-w-2xl">
Tell us about your business and we'll cut through the noise to find what actually works for you.
</p>
<a
href="#contact-form"
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Send a Message
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</section>
{/* Contact Body */}
<section id="contact-form" className="bg-background py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-0 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-5 rounded-none sm:rounded-md overflow-hidden shadow-none sm:shadow-xl border-y sm:border border-border">
{/* Left: Info panel — order 2 on mobile so form appears first */}
<div className="lg:col-span-2 order-2 lg:order-1 bg-primary-navy text-white p-8 lg:p-10 flex flex-col gap-10">
<div className="space-y-7">
{contactDetails.map((item) => (
<div key={item.label} className="flex items-start gap-4">
<div className="w-10 h-10 bg-white/10 rounded-md flex items-center justify-center flex-shrink-0">
{item.icon}
</div>
<div>
<p className="text-white/50 text-xs uppercase tracking-wider mb-1">{item.label}</p>
{item.content}
</div>
</div>
))}
</div>
<div className="border-t border-white/10" />
<div>
<p className="text-white/50 text-xs uppercase tracking-wider mb-5">Why Queue North Technologies</p>
<ul className="space-y-4">
{trustPoints.map((point, i) => (
<li key={i} className="flex items-start gap-3 text-white/80 text-sm leading-relaxed">
<svg className="h-4 w-4 text-primary-cyan flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{point}
</li>
))}
</ul>
</div>
</div>
{/* Right: Form panel — order 1 on mobile so it appears first */}
<div className="lg:col-span-3 order-1 lg:order-2 bg-white p-6 sm:p-8 lg:p-10">
<h2 className="text-2xl font-bold text-primary-navy mb-1">Send Us a Message</h2>
<p className="text-soft-text text-sm mb-8">We typically respond within one business day.</p>
<form
id="contact-form"
onSubmit={handleSubmit}
noValidate
className={`space-y-5 ${isSubmitting ? 'opacity-70 pointer-events-none' : ''}`}
>
{/* Honeypot */}
<input
type="text"
name="company_website"
value={formState.company_website}
onChange={handleChange}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ display: 'none' }}
/>
{/* Company */}
<div>
<label htmlFor="Company" className="block text-sm font-medium text-text mb-1.5">
Company Name <span className="text-red-500">*</span>
</label>
<Input
type="text"
id="Company"
name="Company"
value={formState.Company}
onChange={handleChange}
required
placeholder="Your company name"
className={debouncedErrors.Company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{debouncedErrors.Company && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Company}</p>}
</div>
{/* Name + Email */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="Last_Name" className="block text-sm font-medium text-text mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<Input
type="text"
id="Last_Name"
name="Last Name"
value={formState['Last Name']}
onChange={handleChange}
required
placeholder="Your full name"
className={debouncedErrors['Last Name'] ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{debouncedErrors['Last Name'] && <p className="text-xs text-red-500 mt-1">{debouncedErrors['Last Name']}</p>}
</div>
<div>
<label htmlFor="Email" className="block text-sm font-medium text-text mb-1.5">
Email <span className="text-red-500">*</span>
</label>
<Input
type="email"
id="Email"
name="Email"
value={formState.Email}
onChange={handleChange}
required
placeholder="you@company.com"
className={debouncedErrors.Email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{debouncedErrors.Email && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Email}</p>}
</div>
</div>
{/* Phone + ZIP */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="Phone" className="block text-sm font-medium text-text mb-1.5">
Phone <span className="text-soft-text font-normal">(optional)</span>
</label>
<Input
type="tel"
id="Phone"
name="Phone"
value={formState.Phone}
onChange={handleChange}
placeholder="(555) 123-4567"
/>
</div>
<div>
<label htmlFor="Zip_Code" className="block text-sm font-medium text-text mb-1.5">
ZIP Code <span className="text-red-500">*</span>
</label>
<Input
type="text"
id="Zip_Code"
name="Zip Code"
value={formState['Zip Code']}
onChange={handleChange}
required
autoComplete="postal-code"
inputMode="numeric"
placeholder="33702"
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>}
</div>
</div>
{/* Message */}
<div>
<label htmlFor="Description" className="block text-sm font-medium text-text mb-1.5">
Message <span className="text-red-500">*</span>
</label>
<Textarea
id="Description"
name="Description"
value={formState.Description}
onChange={handleChange}
required
placeholder="Tell us about your needs..."
className={`w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-[#F8FAFC] placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-navy focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${debouncedErrors.Description ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
rows={5}
/>
{debouncedErrors.Description && <p className="text-xs text-red-500 mt-1">{debouncedErrors.Description}</p>}
</div>
<RecaptchaPlaceholder
error={debouncedErrors.recaptcha_token}
onVerify={handleRecaptchaVerify}
onExpired={handleRecaptchaExpired}
resetKey={recaptchaResetKey}
/>
<Button type="submit" className="w-full h-11" disabled={isSubmitting}>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Sending...
</>
) : (
'Send Message'
)}
</Button>
</form>
</div>
</div>
</div>
</section>
</>
)
}
export default Contact

406
src/pages/Home.jsx Normal file
View File

@ -0,0 +1,406 @@
import SEO from '@/components/SEO'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import { services } from '@/data/services'
import { industries } from '@/data/industries'
import { Link } from 'react-router-dom'
import { ArrowRight, MapPin, MessageCircle, Users, LifeBuoy, GraduationCap, Link as LinkIcon, Wifi, Network, Headphones, UserCheck, Activity, ShieldCheck, HeartPulse, ShoppingCart, Factory, Landmark, Building2 } from 'lucide-react'
// Icon map for industries - converts icon string to lucide component
const industryIcons = {
'heart-pulse': HeartPulse,
'shopping-cart': ShoppingCart,
factory: Factory,
landmark: Landmark,
}
const serviceAccentStyles = [
{
card: 'border-t-[3px] border-t-primary-blue hover:border-primary-blue/50',
iconWrap: 'bg-sky-50',
icon: 'text-primary-blue',
link: 'text-primary-blue hover:text-primary-navy',
},
{
card: 'border-t-[3px] border-t-teal-500 hover:border-teal-500/50',
iconWrap: 'bg-teal-50',
icon: 'text-teal-600',
link: 'text-teal-700 hover:text-primary-navy',
},
{
card: 'border-t-[3px] border-t-cyan-400 hover:border-cyan-400/60',
iconWrap: 'bg-cyan-50',
icon: 'text-cyan-600',
link: 'text-cyan-700 hover:text-primary-navy',
},
{
card: 'border-t-[3px] border-t-accent-gold hover:border-accent-gold/60',
iconWrap: 'bg-amber-50',
icon: 'text-amber-600',
link: 'text-amber-700 hover:text-primary-navy',
},
]
const industryAccentStyles = [
{ card: 'border-t-[3px] border-t-rose-400 hover:border-rose-400/60', iconWrap: 'bg-rose-50', icon: 'text-rose-600' },
{ card: 'border-t-[3px] border-t-primary-blue hover:border-primary-blue/50', iconWrap: 'bg-sky-50', icon: 'text-primary-blue' },
{ card: 'border-t-[3px] border-t-accent-gold hover:border-accent-gold/60', iconWrap: 'bg-amber-50', icon: 'text-amber-600' },
{ card: 'border-t-[3px] border-t-teal-500 hover:border-teal-500/50', iconWrap: 'bg-teal-50', icon: 'text-teal-600' },
]
const Home = () => {
const organizationLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Queue North Technologies',
url: 'https://queuenorth.com',
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',
},
contactPoint: [
{
'@type': 'ContactPoint',
telephone: '+1-321-730-8020',
contactType: 'customer service',
areaServed: 'US',
},
{
'@type': 'ContactPoint',
telephone: '+1-888-656-2850',
contactType: 'customer service',
contactOption: 'TollFree',
areaServed: 'US',
},
],
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',
],
}
return (
<>
<SEO
title="Queue North Technologies | Business Communications & IT Partner"
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."
url="https://queuenorth.com"
jsonLd={[organizationLd, localBusinessLd]}
/>
{/* Hero Section */}
<section className="relative isolate overflow-hidden bg-primary-navy text-white">
<div className="absolute inset-0 -z-10">
<img
src="/assets/hero-tech.webp"
alt="Queue North technician working inside a communications rack"
className="h-full w-full object-cover object-center"
/>
<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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 md:py-24 lg:py-28">
<div className="max-w-5xl">
<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">
Veteran-owned communications and infrastructure partner
</div>
<h1 className="mt-6 max-w-4xl text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.05]">
Business communications built for uptime, support, and accountability.
</h1>
<p className="mt-6 text-lg md:text-2xl text-white/80 max-w-2xl leading-relaxed">
Business phone, contact center, network, and IT support built around one accountable implementation partner.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-3">
<Link to="/contact#contact-form" className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors" aria-label="Schedule a consultation">
Schedule Consultation
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<Link to="/services" className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/45 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors" aria-label="View our services">
View Services
</Link>
</div>
</div>
</div>
</section>
{/* Partner Proof */}
<section className="bg-white border-b border-border py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 gap-x-4 gap-y-6 lg:grid-cols-4">
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-2">
<img
src="/assets/brand/8x8-logo-dark-gray.png"
alt="8x8 Certified Partner logo"
className="h-full w-full object-contain"
/>
</span>
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">8x8 Certified Partner</span>
</div>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1 overflow-hidden">
<img
src="/assets/brand/cisco-partner-logo-midnight.svg"
alt="Cisco Partner certification logo"
className="h-full w-full object-contain scale-[1.5]"
/>
</span>
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Cisco Certified Partner</span>
</div>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white p-1">
<img
src="/assets/brand/veteran-owned-certified-mark.webp"
alt="SBA logo for Veteran-Owned Certified"
className="h-full w-full object-contain"
/>
</span>
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Veteran-Owned Certified</span>
</div>
<div className="flex flex-col items-center gap-2 lg:flex-row lg:gap-3 lg:justify-start">
<span className="font-numeric flex h-16 w-20 shrink-0 items-center justify-center rounded-md border border-border bg-white text-2xl font-semibold text-primary-navy">
25+
</span>
<span className="text-sm font-semibold leading-tight text-primary-navy text-center lg:text-left">Years Experience</span>
</div>
</div>
</div>
</section>
{/* Services Section */}
<section className="bg-background py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-4">What We Handle</h2>
<p className="text-xl text-soft-text max-w-2xl mx-auto">
From phones to firewalls, we keep your business running
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{services.map((service, index) => {
const accent = serviceAccentStyles[index % serviceAccentStyles.length]
return (
<Card key={service.id} className={`h-full transition-all hover:shadow-md ${accent.card}`}>
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<div className={`${accent.iconWrap} p-2 rounded-md`} aria-hidden="true">
{service.icon === 'message-circle' && <MessageCircle className={`w-6 h-6 ${accent.icon}`} />}
{service.icon === 'users' && <Users className={`w-6 h-6 ${accent.icon}`} />}
{service.icon === 'life-buoy' && <LifeBuoy className={`w-6 h-6 ${accent.icon}`} />}
{service.icon === 'graduation-cap' && <GraduationCap className={`w-6 h-6 ${accent.icon}`} />}
{service.icon === 'link' && <LinkIcon className={`w-6 h-6 ${accent.icon}`} />}
{service.icon === 'wifi' && <Wifi className={`w-6 h-6 ${accent.icon}`} />}
{service.icon === 'network' && <Network className={`w-6 h-6 ${accent.icon}`} />}
</div>
<CardTitle className="text-left text-primary-navy text-xl" aria-label={service.name}>{service.name}</CardTitle>
</div>
<CardDescription className="font-medium text-teal-900" aria-label={service.homeDesc}>{service.homeDesc}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<Link to={`/services/${service.id}`} className={`inline-flex items-center gap-1 text-sm font-semibold ${accent.link}`} aria-label={`Learn more about ${service.name}`}>
Learn more
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</section>
{/* Why Queue North */}
<section className="bg-section-alt py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-4">Why Queue North</h2>
<p className="text-xl text-soft-text max-w-2xl mx-auto mb-6">
Four concrete differentiators that set us apart
</p>
<div>
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors" aria-label="Request a consultation">
Request Consultation
</Link>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="bg-white/80 border-t-[3px] border-t-primary-blue">
<CardContent className="pt-6">
<div className="bg-sky-50 text-primary-blue p-3 rounded-md mb-4 inline-flex">
<Headphones className="w-6 h-6" />
</div>
<h3 className="text-lg font-semibold text-primary-navy mb-2">Responsiveness</h3>
<p className="text-sm text-soft-text">
When you call, a human answers not a ticket queue, not a chatbot. Real support from people who know your system.
</p>
</CardContent>
</Card>
<Card className="bg-white/80 border-t-[3px] border-t-teal-500">
<CardContent className="pt-6">
<div className="bg-teal-50 text-teal-600 p-3 rounded-md mb-4 inline-flex">
<UserCheck className="w-6 h-6" />
</div>
<h3 className="text-lg font-semibold text-primary-navy mb-2">Direct Support</h3>
<p className="text-sm text-soft-text">
No account managers between you and the engineer solving your problem. You talk to the team that designed and deployed your system.
</p>
</CardContent>
</Card>
<Card className="bg-white/80 border-t-[3px] border-t-cyan-400">
<CardContent className="pt-6">
<div className="bg-cyan-50 text-cyan-600 p-3 rounded-md mb-4 inline-flex">
<Activity className="w-6 h-6" />
</div>
<h3 className="text-lg font-semibold text-primary-navy mb-2">Proactive Monitoring</h3>
<p className="text-sm text-soft-text">
We catch problems before you notice them. 24/7 monitoring means we're often resolving issues before your phone rings.
</p>
</CardContent>
</Card>
<Card className="bg-white/80 border-t-[3px] border-t-accent-gold">
<CardContent className="pt-6">
<div className="bg-amber-50 text-amber-600 p-3 rounded-md mb-4 inline-flex">
<ShieldCheck className="w-6 h-6" />
</div>
<h3 className="text-lg font-semibold text-primary-navy mb-2">Vendor Neutrality</h3>
<p className="text-sm text-soft-text">
As an 8x8 and Cisco Certified Partner, we recommend what works best for you not what pays the highest commission. We've tested the alternatives so you don't have to.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Industries Section */}
<section className="bg-background py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-4">Industries We Serve</h2>
<p className="text-xl text-soft-text max-w-2xl mx-auto">
Tailored solutions for healthcare, retail, manufacturing, and more
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{industries.map((industry, index) => {
const IconComponent = industryIcons[industry.icon] || Building2
const accent = industryAccentStyles[index % industryAccentStyles.length]
return (
<Card key={industry.id} className={`h-full transition-all hover:shadow-md ${accent.card}`}>
<CardContent className="p-6">
<div className={`w-14 h-14 rounded-md ${accent.iconWrap} flex items-center justify-center mb-4`} aria-hidden="true">
<IconComponent className={`w-8 h-8 ${accent.icon}`} />
</div>
<h3 className="text-left text-xl font-semibold text-primary-navy mb-3" aria-label={industry.name}>{industry.name}</h3>
<p className="text-sm text-soft-text mb-4" aria-label={industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}>{industry.homeDesc || 'Industry-specific solutions designed to address your unique challenges and requirements.'}</p>
<Link to={`/industries/${industry.id}`} className="inline-flex items-center gap-1 text-sm font-semibold text-primary-navy hover:text-primary-blue" aria-label={`Learn more about ${industry.name} industry solutions`}>
Learn more
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</CardContent>
</Card>
)
})}
</div>
</div>
</section>
{/* Final CTA - Free Migration Offer */}
<section className="bg-section-alt py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-6">
What we'll help you do
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="flex items-start gap-3 justify-center sm:justify-start">
<div className="bg-primary-navy/10 p-2 rounded-md flex-shrink-0">
<MapPin className="w-5 h-5 text-primary-navy" />
</div>
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
Identify the features you actually need
</p>
</div>
<div className="flex items-start gap-3 justify-center sm:justify-start">
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
<Users className="w-5 h-5 text-teal-600" />
</div>
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
Align solutions with operations and budget
</p>
</div>
<div className="flex items-start gap-3 justify-center sm:justify-start">
<div className="bg-teal-500/10 p-2 rounded-md flex-shrink-0">
<Network className="w-5 h-5 text-teal-600" />
</div>
<p className="text-center sm:text-left text-sm md:text-base text-soft-text">
Plan deployment, migration, and training
</p>
</div>
<div className="flex items-start gap-3 justify-center sm:justify-start">
<div className="bg-teal-600/10 p-2 rounded-md flex-shrink-0">
<ShieldCheck className="w-5 h-5 text-teal-600" />
</div>
<p className="text-center sm:text-left text-sm md:text-base text-teal-700 font-semibold">
Ask how you qualify for our free migration
</p>
</div>
</div>
<p className="text-lg text-soft-text mb-8 max-w-2xl mx-auto">
Share a few details and we'll provide clear direction.
</p>
<Link to="/contact#contact-form" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-6 bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors" aria-label="Request a consultation">
Request Consultation
</Link>
</div>
</section>
</>
)
}
export default Home

143
src/pages/Industries.jsx Normal file
View File

@ -0,0 +1,143 @@
import SEO from '@/components/SEO'
import { industries } from '@/data/industries'
import { ArrowRight, Building2, CheckCircle2, HeartPulse, ShoppingCart, Factory, Landmark } from 'lucide-react'
import { Link } from 'react-router-dom'
const iconMap = {
'heart-pulse': HeartPulse,
'shopping-cart': ShoppingCart,
'factory': Factory,
'landmark': Landmark,
}
const industryAccentStyles = [
{ card: 'border-t-[3px] border-t-rose-400 hover:border-rose-400/60', iconWrap: 'bg-rose-50', icon: 'text-rose-600' },
{ card: 'border-t-[3px] border-t-primary-blue hover:border-primary-blue/50', iconWrap: 'bg-sky-50', icon: 'text-primary-blue' },
{ card: 'border-t-[3px] border-t-accent-gold hover:border-accent-gold/60', iconWrap: 'bg-amber-50', icon: 'text-amber-600' },
{ card: 'border-t-[3px] border-t-teal-500 hover:border-teal-500/50', iconWrap: 'bg-teal-50', icon: 'text-teal-600' },
]
const Industries = () => {
return (
<>
<SEO
title="Industries We Serve | Queue North Technologies"
description="Queue North Technologies serves healthcare, retail, manufacturing, education, and finance industries with tailored communications and IT solutions."
url="https://queuenorth.com/industries"
/>
{/* Hero */}
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
<div className="absolute inset-0 -z-10">
<img
src="/assets/local-networking.webp"
alt="Network infrastructure used by businesses across industries"
className="h-full w-full object-cover object-center"
/>
<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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan mb-6">
<Building2 className="h-4 w-4" aria-hidden="true" />
Industries
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-6">
Built around how your industry actually works.
</h1>
<p className="text-lg md:text-xl text-white/75 max-w-2xl leading-relaxed mb-8">
Communications and infrastructure solutions shaped by the compliance, workflow, and connectivity demands of your specific environment.
</p>
<Link
to="/contact#contact-form"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Talk to a Specialist
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</div>
</section>
{/* Industries Grid */}
<section className="bg-background py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-10 max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-wide text-primary-blue">Industry fit</p>
<h2 className="mt-3 text-3xl md:text-4xl font-bold text-primary-navy">Communications needs change by environment.</h2>
<p className="mt-4 text-lg text-soft-text">
We adapt platform, network, and support recommendations to the way your teams actually operate.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{industries.map((industry, index) => {
const Icon = iconMap[industry.icon] || Building2
const accent = industryAccentStyles[index % industryAccentStyles.length]
return (
<div
key={industry.id}
className={`group flex flex-col rounded-md border border-border bg-white p-8 shadow-sm hover:shadow-md transition-all ${accent.card}`}
>
<div className={`flex h-14 w-14 items-center justify-center rounded-md ${accent.iconWrap} mb-6 flex-shrink-0`}>
<Icon className={`h-7 w-7 ${accent.icon}`} aria-hidden="true" />
</div>
<h2 className="text-2xl font-bold text-primary-navy mb-3">{industry.name}</h2>
<p className="text-soft-text leading-relaxed mb-6">{industry.shortDesc}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{industry.solutions.slice(0, 3).map((solution, i) => (
<li key={i} className="flex items-start gap-3 text-sm text-text">
<CheckCircle2 className="h-4 w-4 text-primary-blue flex-shrink-0 mt-0.5" aria-hidden="true" />
{solution}
</li>
))}
</ul>
<Link
to={`/industries/${industry.id}`}
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary-navy hover:text-primary-blue transition-colors"
aria-label={`Learn more about ${industry.name} solutions`}
>
See how we help
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
)
})}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-section-alt py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="rounded-md bg-primary-navy px-6 py-10 text-white shadow-md sm:px-8 lg:flex lg:items-center lg:justify-between lg:gap-10 lg:px-10">
<div>
<h2 className="text-3xl md:text-4xl font-bold mb-3">Don't see your industry?</h2>
<p className="text-white/70 max-w-2xl">
We work with businesses across all sectors. If you have specific compliance, connectivity, or workflow requirements, let's talk.
</p>
</div>
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
<Link
to="/contact#contact-form"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Talk to a Specialist
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<a
href="tel:+13217308020"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
>
Call (321) 730-8020
</a>
</div>
</div>
</div>
</section>
</>
)
}
export default Industries

View File

@ -0,0 +1,142 @@
import SEO from '@/components/SEO'
import { useParams } from 'react-router-dom'
import { industries } from '@/data/industries'
import { Link } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { AlertCircle, ArrowLeft, ArrowRight, Building2, CheckCircle2 } from 'lucide-react'
const IndustryDetail = () => {
const { slug } = useParams()
const industry = industries.find(i => i.id === slug)
if (!industry) {
return (
<section className="bg-background py-12 lg:py-20">
<SEO
title="Industry Not Found | Queue North Technologies"
description="The industry page you are looking for does not exist."
url="https://queuenorth.com/industries"
/>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-primary-navy mb-4">Industry Not Found</h1>
<p className="text-xl text-soft-text mb-8">The industry you're looking for doesn't exist.</p>
<Link to="/industries" className="text-primary-navy hover:underline">
Back to Industries
</Link>
</div>
</div>
</section>
)
}
const industryTitle = `${industry.name} | Queue North Technologies`
const industryDesc = industry.shortDesc || `Learn about Queue North Technologies solutions for the ${industry.name} industry.`
const industryUrl = `https://queuenorth.com/industries/${industry.id}`
return (
<>
<SEO
title={industryTitle}
description={industryDesc}
url={industryUrl}
/>
{/* Page Hero */}
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
<div className="absolute inset-0 -z-10">
<img
src="/assets/modern-call-center.webp"
alt="Business communications team supporting industry operations"
className="h-full w-full object-cover object-center"
/>
<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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl">
<Link to="/industries" className="mb-5 inline-flex items-center gap-2 text-sm font-semibold text-primary-cyan hover:text-white">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Industries
</Link>
<h1 className="text-4xl md:text-5xl font-bold mb-6">{industry.name}</h1>
<p className="text-xl text-white/75 max-w-3xl leading-relaxed">{industry.shortDesc}</p>
</div>
</div>
</section>
{/* Main Content */}
<section className="bg-background py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Left Column - Main Content */}
<div className="lg:col-span-2">
<div className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Industry Overview</h2>
<p className="text-lg text-soft-text mb-6 leading-relaxed">
{industry.fullDesc}
</p>
</div>
<div className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Pain Points We Solve</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{industry.painPoints.map((painPoint, index) => (
<div key={index} className="flex items-start gap-3 rounded-md border border-border bg-white p-4 shadow-sm">
<AlertCircle className="h-5 w-5 text-rose-600 flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="text-sm font-medium text-text">{painPoint}</span>
</div>
))}
</div>
</div>
<div className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Our Solutions</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{industry.solutions.map((solution, index) => (
<div key={index} className="flex items-start gap-3 rounded-md border border-border bg-white p-4 shadow-sm">
<CheckCircle2 className="h-5 w-5 text-teal-600 flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="text-sm font-medium text-text">{solution}</span>
</div>
))}
</div>
</div>
</div>
{/* Right Column - Sidebar */}
<div className="lg:col-span-1">
<Card className="lg:sticky top-24 border-t-[3px] border-t-teal-500">
<CardHeader>
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-teal-50 text-teal-600">
<Building2 className="h-5 w-5" aria-hidden="true" />
</span>
<CardTitle className="text-primary-navy text-xl">Industry Insights</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold text-text mb-2">Industry</h3>
<p className="text-soft-text">{industry.name}</p>
</div>
<div className="pt-4 border-t border-border">
<Link to="/contact#contact-form" className="flex w-full items-center justify-center gap-2 bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
Request Consultation
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<div className="pt-2">
<Link to="/industries" className="text-primary-navy hover:underline">
Back to Industries
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
</>
)
}
export default IndustryDetail

171
src/pages/NotFound.jsx Normal file
View File

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

171
src/pages/ServiceDetail.jsx Normal file
View File

@ -0,0 +1,171 @@
import SEO from '@/components/SEO'
import { useParams } from 'react-router-dom'
import { services } from '@/data/services'
import { Link } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { ArrowLeft, ArrowRight, CheckCircle2, Info, Zap } from 'lucide-react'
const serviceImageAlt = {
'contact-center': 'Modern contact center operations dashboard and support team',
'infrastructure-cabling': 'Close-up of network cabling connected to infrastructure equipment',
'wireless-access': 'Wireless coverage planning map used for enterprise Wi-Fi design',
}
const ServiceDetail = () => {
const { slug } = useParams()
const service = services.find(s => s.id === slug)
if (!service) {
return (
<section className="bg-background py-16 md:py-24">
<SEO
title="Service Not Found | Queue North Technologies"
description="The service page you are looking for does not exist."
url="https://queuenorth.com/services"
/>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-primary-navy mb-4">Service Not Found</h1>
<p className="text-xl text-soft-text mb-8">The service you're looking for doesn't exist.</p>
<Link to="/services" className="text-primary-navy hover:underline">
Back to Services
</Link>
</div>
</div>
</section>
)
}
const serviceTitle = `${service.name} | Queue North Technologies`
const serviceDesc = service.shortDesc || `Learn about ${service.name} from Queue North Technologies.`
const serviceUrl = `https://queuenorth.com/services/${service.id}`
const serviceDetailLd = {
'@context': 'https://schema.org',
'@type': 'Service',
name: service.name,
description: service.shortDesc,
provider: {
'@type': 'Organization',
name: 'Queue North Technologies',
url: 'https://queuenorth.com',
},
areaServed: {
'@type': 'Place',
name: 'United States',
},
}
return (
<>
<SEO
title={serviceTitle}
description={serviceDesc}
url={serviceUrl}
jsonLd={serviceDetailLd}
/>
{/* Page Hero */}
<section className="relative isolate overflow-hidden bg-primary-navy py-16 md:py-24 text-white">
<div className="absolute inset-0 -z-10">
<img
src={service.image || '/assets/hero-tech.webp'}
alt={serviceImageAlt[service.id] || `${service.name} service visual`}
className="h-full w-full object-cover object-center"
/>
<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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl">
<div>
<Link to="/services" className="mb-5 inline-flex items-center gap-2 text-sm font-semibold text-primary-cyan hover:text-white">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Services
</Link>
<h1 className="text-4xl md:text-5xl font-bold mb-6">{service.name}</h1>
<p className="text-xl text-white/75 max-w-3xl leading-relaxed">{service.shortDesc}</p>
<div className="mt-8">
<Link to="/contact#contact-form" className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors">
Request This Service
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</div>
</div>
</div>
</section>
{/* Main Content */}
<section className="bg-background py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Left Column - Main Content */}
<div className="lg:col-span-2">
<div className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">What This Solves</h2>
<p className="text-lg text-soft-text mb-6 leading-relaxed">
{service.fullDesc}
</p>
</div>
<div className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Key Benefits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{service.benefits.map((benefit, index) => (
<div key={index} className="flex items-start gap-3 rounded-md border border-border bg-white p-4 shadow-sm">
<CheckCircle2 className="h-5 w-5 text-primary-blue flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="text-sm font-medium text-text">{benefit}</span>
</div>
))}
</div>
</div>
<div className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Ideal For</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{service.idealFor.map((item, index) => (
<div key={index} className="flex items-start gap-3 rounded-md border border-border bg-white p-4 shadow-sm">
<Zap className="h-5 w-5 text-teal-600 flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="text-sm font-medium text-text">{item}</span>
</div>
))}
</div>
</div>
</div>
{/* Right Column - Sidebar */}
<div className="lg:col-span-1">
<Card className="lg:sticky top-24 border-t-[3px] border-t-primary-blue">
<CardHeader>
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-sky-50 text-primary-blue">
<Info className="h-5 w-5" aria-hidden="true" />
</span>
<CardTitle className="text-primary-navy text-xl">Quick Info</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold text-text mb-2">Service</h3>
<p className="text-soft-text">{service.name}</p>
</div>
<div className="pt-4 border-t border-border">
<Link to="/contact#contact-form" className="block w-full bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
Request This Service
</Link>
</div>
<div className="pt-2">
<Link to="/services" className="text-primary-navy hover:underline">
Back to Services
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
</>
)
}
export default ServiceDetail

211
src/pages/Services.jsx Normal file
View File

@ -0,0 +1,211 @@
import SEO from '@/components/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'
const serviceLd = {
'@context': 'https://schema.org',
'@type': 'Service',
serviceType: 'Business Communications and IT Services',
provider: {
'@type': 'Organization',
name: 'Queue North Technologies',
url: 'https://queuenorth.com',
},
areaServed: {
'@type': 'Place',
name: 'United States',
},
}
const iconMap = {
'message-circle': MessageCircle,
'users': Users,
'life-buoy': LifeBuoy,
'graduation-cap': GraduationCap,
'link': LinkIcon,
'wifi': Wifi,
'network': Network,
}
const serviceAccentStyles = [
{
card: 'border-t-[3px] border-t-primary-blue hover:border-primary-blue/50',
iconWrap: 'bg-sky-50',
icon: 'text-primary-blue',
link: 'text-primary-blue hover:text-primary-navy',
},
{
card: 'border-t-[3px] border-t-teal-500 hover:border-teal-500/50',
iconWrap: 'bg-teal-50',
icon: 'text-teal-600',
link: 'text-teal-700 hover:text-primary-navy',
},
{
card: 'border-t-[3px] border-t-cyan-400 hover:border-cyan-400/60',
iconWrap: 'bg-cyan-50',
icon: 'text-cyan-600',
link: 'text-cyan-700 hover:text-primary-navy',
},
{
card: 'border-t-[3px] border-t-accent-gold hover:border-accent-gold/60',
iconWrap: 'bg-amber-50',
icon: 'text-amber-600',
link: 'text-amber-700 hover:text-primary-navy',
},
]
const serviceHighlights = [
{ label: 'Discovery first', icon: CheckCircle2 },
{ label: 'Vendor-neutral guidance', icon: ShieldCheck },
{ label: 'Deployment and support', icon: PhoneCall },
]
const Services = () => {
return (
<>
<SEO
title="Business Phone, UCaaS & IT Services | Queue North Technologies"
description="Explore Queue North Technologies services: unified communications, contact center, managed IT support, consulting & training, infrastructure cabling, wireless access, and local networking."
url="https://queuenorth.com/services"
jsonLd={serviceLd}
/>
{/* Hero */}
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
<div className="absolute inset-0 -z-10">
<img
src="/assets/hero-tech.webp"
alt="Queue North technician working inside a communications rack"
className="h-full w-full object-cover object-center"
/>
<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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan mb-6">
<Layers className="h-4 w-4" aria-hidden="true" />
What We Do
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-6">
Everything your business communications needs.
</h1>
<p className="text-lg md:text-xl text-white/75 max-w-2xl leading-relaxed mb-8">
From phone systems to full network infrastructure designed, deployed, and supported by one accountable team.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Link
to="/contact#contact-form"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Get a Free Quote
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
to="/support"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
>
Existing Client? Get Support
</Link>
</div>
<div className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-3">
{serviceHighlights.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex items-center gap-2 rounded-md border border-white/15 bg-white/10 px-3 py-2 text-sm font-semibold text-white/85">
<Icon className="h-4 w-4 text-primary-cyan" aria-hidden="true" />
{item.label}
</div>
)
})}
</div>
</div>
</div>
</section>
{/* Services Grid */}
<section className="bg-background py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-10 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy">Services built around the whole environment</h2>
<p className="mt-3 max-w-2xl text-lg text-soft-text">
Choose a starting point below, or bring us the messy version and we will map the practical path forward.
</p>
</div>
<Link
to="/contact#contact-form"
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-primary-navy/20 px-4 text-sm font-semibold text-primary-navy hover:border-primary-blue hover:text-primary-blue transition-colors"
>
Talk through options
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{services.map((service, index) => {
const Icon = iconMap[service.icon]
const accent = serviceAccentStyles[index % serviceAccentStyles.length]
return (
<div key={service.id} className={`group flex h-full flex-col rounded-md border border-border bg-white p-6 shadow-sm hover:shadow-md transition-all ${accent.card}`}>
<div className="mb-5 flex items-center gap-4">
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-md ${accent.iconWrap}`}>
{Icon && <Icon className={`h-6 w-6 ${accent.icon}`} aria-hidden="true" />}
</div>
<h2 className="text-left text-xl font-semibold text-primary-navy">{service.name}</h2>
</div>
<p className="text-soft-text text-sm leading-relaxed flex-1 mb-5">{service.homeDesc}</p>
{service.idealFor?.[0] && (
<p className="mb-6 rounded-md bg-section-alt px-3 py-2 text-xs font-semibold text-primary-navy">
Best fit: {service.idealFor[0]}
</p>
)}
<Link
to={`/services/${service.id}`}
className={`inline-flex items-center gap-1.5 text-sm font-semibold transition-colors ${accent.link}`}
aria-label={`Learn more about ${service.name}`}
>
Learn more
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
)
})}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-section-alt py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="rounded-md bg-primary-navy px-6 py-10 text-white shadow-md sm:px-8 lg:flex lg:items-center lg:justify-between lg:gap-10 lg:px-10">
<div>
<h2 className="text-3xl md:text-4xl font-bold mb-3">Not sure where to start?</h2>
<p className="max-w-2xl text-white/75">
Tell us what you're trying to fix. We'll help you identify the right service path before you spend a dollar.
</p>
</div>
<div className="mt-7 flex flex-col gap-3 sm:flex-row lg:mt-0 lg:flex-shrink-0">
<Link
to="/contact#contact-form"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-6 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Schedule a Consultation
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
<a
href="tel:+13217308020"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
>
Call (321) 730-8020
</a>
</div>
</div>
</div>
</section>
</>
)
}
export default Services

232
src/pages/Support.jsx Normal file
View File

@ -0,0 +1,232 @@
import SEO from '@/components/SEO'
import { AlertCircle, ArrowRight, CheckCircle2, Clock3, ExternalLink, LifeBuoy, ShieldCheck, TicketCheck, Wrench } from 'lucide-react'
const portalLinks = [
{
label: 'Sign in',
href: 'https://queuenorthtechnologiesllc.zohodesk.com/portal/en/signin',
},
{
label: 'Create account',
href: 'https://queuenorthtechnologiesllc.zohodesk.com/portal/en/signup',
},
]
const responseTargets = [
{ priority: 'Low', context: 'General request', target: '24 hours' },
{ priority: 'Medium', context: 'Standard issue', target: '4 hours' },
{ priority: 'High', context: 'Critical outage', target: '1 hour' },
]
const supportedSystems = [
'8x8 Communications Platform',
'Cisco Webex',
'VoIP Phone Systems',
'Contact Center Solutions',
'Network Infrastructure',
'Cloud Migration Support',
]
const Support = () => {
return (
<>
<SEO
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"
/>
{/* Page Hero */}
<section className="relative isolate overflow-hidden bg-primary-navy py-16 lg:py-24 text-white">
<div className="absolute inset-0 -z-10">
<img
src="/assets/modern-call-center.webp"
alt="Support team managing communications requests"
className="h-full w-full object-cover object-center"
/>
<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>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_0.72fr] gap-10 lg:gap-14 items-center">
<div>
<div className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary-cyan">
<LifeBuoy className="h-4 w-4" aria-hidden="true" />
Support
</div>
<h1 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-bold leading-tight">
Get help without getting handed off.
</h1>
<p className="mt-6 text-lg md:text-xl text-white/75 max-w-2xl leading-relaxed">
Sign in to the client portal to manage tickets, create a new support request, or escalate a service-impacting issue through one clear path.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-3">
<a
href={portalLinks[0].href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Sign In
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
<a
href={portalLinks[1].href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-full sm:w-auto h-11 items-center justify-center gap-2 rounded-md border border-white/40 px-5 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
>
Create Account
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
<div className="rounded-md border border-white/15 bg-white/10 p-6 shadow-xl backdrop-blur">
<h2 className="text-lg font-semibold mb-5">Choose the right path</h2>
<div className="space-y-4">
<div className="flex gap-4">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/10 text-primary-cyan">
<TicketCheck className="h-5 w-5" aria-hidden="true" />
</span>
<div>
<h3 className="font-semibold">Existing client</h3>
<p className="text-sm text-white/70">Sign in to create, update, and track support tickets.</p>
</div>
</div>
<div className="flex gap-4">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/10 text-primary-cyan">
<AlertCircle className="h-5 w-5" aria-hidden="true" />
</span>
<div>
<h3 className="font-semibold">Active outage</h3>
<a href="tel:+13217308020" className="text-sm font-semibold text-primary-cyan hover:text-white transition-colors" aria-label="Call Queue North support">
Call (321) 730-8020
</a>
</div>
</div>
<div className="flex gap-4">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/10 text-primary-cyan">
<Wrench className="h-5 w-5" aria-hidden="true" />
</span>
<div>
<h3 className="font-semibold">Planned work</h3>
<p className="text-sm text-white/70">Use the portal for moves, adds, changes, and deployments.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Support Portal */}
<section id="support-portal" className="bg-background py-16 lg:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[0.95fr_1.05fr] gap-8 lg:gap-10 items-start">
<div className="space-y-6">
<div className="rounded-md border border-border border-t-[3px] border-t-teal-500 bg-white p-6 shadow-sm">
<div className="flex items-center gap-3 mb-5">
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-section-alt text-primary-navy">
<Clock3 className="h-5 w-5" aria-hidden="true" />
</span>
<h2 className="text-xl font-bold text-primary-navy">Response Targets</h2>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
{responseTargets.map((item) => (
<div key={item.priority} className="rounded-md border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<p className="font-semibold text-text">{item.priority}</p>
<p className="font-numeric text-xl text-primary-navy">{item.target}</p>
</div>
<p className="mt-1 text-sm text-soft-text">{item.context}</p>
</div>
))}
</div>
</div>
<div className="rounded-md border border-border border-t-[3px] border-t-cyan-400 bg-section-alt p-6">
<div className="flex items-center gap-3 mb-5">
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-white text-primary-navy">
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
</span>
<h2 className="text-xl font-bold text-primary-navy">Covered Systems</h2>
</div>
<ul className="space-y-3">
{supportedSystems.map((item) => (
<li key={item} className="flex items-center gap-3 text-sm text-text">
<CheckCircle2 className="h-5 w-5 shrink-0 text-primary-blue" aria-hidden="true" />
{item}
</li>
))}
</ul>
</div>
</div>
<div>
<div className="rounded-md border border-border border-t-[3px] border-t-accent-gold bg-white p-6 shadow-sm lg:p-8">
<div className="flex items-start gap-4 border-b border-border pb-6">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-primary-navy text-white">
<TicketCheck className="h-6 w-6" aria-hidden="true" />
</span>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-primary-blue">Client Portal</p>
<h2 className="mt-2 text-3xl font-bold text-primary-navy">Manage support in Zoho Desk.</h2>
<p className="mt-3 text-sm leading-relaxed text-soft-text">
Existing clients can sign in to submit new tickets, review open items, and keep communications tied to the right account.
</p>
</div>
</div>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<a
href={portalLinks[0].href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-primary-navy px-5 text-sm font-semibold text-white hover:bg-primary-navy-dark transition-colors"
>
Sign In
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
<a
href={portalLinks[1].href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-border bg-white px-5 text-sm font-semibold text-primary-navy hover:bg-section-alt transition-colors"
>
Create Account
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
</div>
<div className="mt-8 grid gap-3">
{[
'Submit and track support tickets',
'Add updates without starting a new thread',
'Keep request history tied to your account',
].map((item) => (
<div key={item} className="flex items-center gap-3 rounded-md border border-border bg-background p-4 text-sm font-medium text-text">
<CheckCircle2 className="h-5 w-5 shrink-0 text-primary-blue" aria-hidden="true" />
{item}
</div>
))}
</div>
<div className="mt-8 rounded-md border border-border bg-section-alt p-4">
<p className="text-sm font-semibold text-primary-navy">Service-impacting issue?</p>
<p className="mt-1 text-sm text-soft-text">
Call Queue North directly for urgent outages or time-sensitive escalations.
</p>
<a href="tel:+13217308020" className="mt-3 inline-flex text-sm font-semibold text-primary-blue hover:text-primary-navy transition-colors">
(321) 730-8020
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</>
)
}
export default Support

33
src/router.jsx Normal file
View File

@ -0,0 +1,33 @@
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'
const router = createBrowserRouter([
{
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

2629
styles.css

File diff suppressed because it is too large Load Diff

61
tailwind.config.js Normal file
View File

@ -0,0 +1,61 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: '#F8FAFC',
'section-alt': '#EEF6FB',
card: '#FFFFFF',
border: '#D8E3EA',
text: '#0F172A',
muted: '#475569',
'soft-text': '#64748B',
'navy-light': '#68A3B8',
primary: {
navy: '#0B2A3C',
'navy-dark': '#071A2A',
blue: '#0EA5E9',
cyan: '#22D3EE',
},
accent: {
gold: '#F59E0B',
},
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
numeric: ['Georgia', 'serif'],
},
spacing: {
'18': '4.5rem',
'22': '5.5rem',
'24': '6rem',
'26': '6.5rem',
'28': '7rem',
'32': '8rem',
'36': '9rem',
'40': '10rem',
'48': '12rem',
},
maxWidth: {
'container': '1280px',
},
boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
borderRadius: {
'sm': '0.25rem',
DEFAULT: '0.5rem',
'lg': '0.75rem',
'xl': '1rem',
},
},
},
plugins: [require('tailwindcss-animate')],
}

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

25
vite.config.js Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: process.env.NODE_ENV !== 'production',
},
})