Compare commits
No commits in common. "dev" and "v0.1.0" have entirely different histories.
|
|
@ -1,76 +0,0 @@
|
|||
# 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
|
|
@ -1,32 +0,0 @@
|
|||
# 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=
|
||||
|
|
@ -1,14 +1,4 @@
|
|||
# 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
|
||||
|
||||
# Project docs managed in repo unless explicitly excluded elsewhere
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
|
@ -36,7 +26,3 @@ pnpm-debug.log*
|
|||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
.learnings/
|
||||
Levi.md
|
||||
Queue-North-Website.code-workspace
|
||||
Working Site.zip
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
# Queue North Website — Build Summary
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### Phase 1: Foundation
|
||||
- ✅ Vite + React + Tailwind setup
|
||||
- ✅ React Router with all required routes
|
||||
- ✅ Express backend with /api/health, /api/leads, /api/support
|
||||
- ✅ SQLite database with leads and support_requests tables
|
||||
- ✅ TypeScript type definitions via @types packages
|
||||
|
||||
### Phase 2: UI Components
|
||||
- ✅ Layout components: Header, Footer, MobileNav
|
||||
- ✅ shadcn/ui-style primitives: Button, Card, Input, Textarea, Select, Badge, Sheet
|
||||
- ✅ TanStack Query provider for server state
|
||||
- ✅ Sonner for toast notifications
|
||||
- ✅ Lucide React for icons
|
||||
|
||||
### Phase 3: Pages
|
||||
- ✅ Home page with hero, services preview, industries, CTAs
|
||||
- ✅ About page
|
||||
- ✅ Services page and individual service detail pages
|
||||
- ✅ Industries page and individual industry detail pages
|
||||
- ✅ 8x8 partner page
|
||||
- ✅ Contact page with form submission to /api/leads
|
||||
- ✅ Support page with form submission to /api/support
|
||||
|
||||
### Data Files
|
||||
- ✅ Services data (7 services with full descriptions)
|
||||
- ✅ Industries data (4 industries with pain points/solutions)
|
||||
|
||||
### Scripts
|
||||
- ✅ npm run dev (frontend + backend concurrently)
|
||||
- ✅ npm run build (production build)
|
||||
- ✅ npm run preview
|
||||
- ✅ npm run server
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
$ npm run build
|
||||
✓ built in 1.10s
|
||||
dist/index.html 0.99 kB
|
||||
dist/assets/index-CsZTyVVr.css 20.07 kB
|
||||
dist/assets/index-G07G4G_D.js 333.59 kB
|
||||
```
|
||||
|
||||
```
|
||||
$ npm run server
|
||||
Server running on http://localhost:3001
|
||||
Health check: http://localhost:3001/api/health
|
||||
{"status":"ok","timestamp":"2026-05-12T05:48:42.213Z"}
|
||||
```
|
||||
|
||||
## Next Steps (for Scarlett)
|
||||
|
||||
1. Run `npm run dev` to start both frontend and backend servers
|
||||
2. Test the application in browser at http://localhost:5173
|
||||
3. Verify all routes work correctly
|
||||
4. Test contact form submission
|
||||
5. Test support form submission
|
||||
6. Check mobile responsiveness
|
||||
7. Run `npm run build` to verify production build
|
||||
|
||||
## Known Issues / Limitations
|
||||
|
||||
- Sheet component doesn't use TypeScript generics (simplified for build)
|
||||
- Image assets need to be updated from actual Queue North branding
|
||||
- The database creates in `db/` directory which should be .gitignored
|
||||
- Consider adding rate limiting for API endpoints
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files:
|
||||
- `server/index.js` - Express backend
|
||||
- `server/db/schema.sql` - SQLite schema (created on first run)
|
||||
- `src/router.jsx` - React Router configuration
|
||||
- `src/lib/api.js` - API helper with TanStack Query
|
||||
- `src/lib/queryClient.js` - QueryClient configuration
|
||||
- `src/data/services.js` - Services data
|
||||
- `src/data/industries.js` - Industries data
|
||||
- All component files in `src/components/`
|
||||
- All page files in `src/pages/`
|
||||
|
||||
### Modified Files:
|
||||
- `package.json` - Added dependencies (sonner, @radix-ui/react-dialog, lucide-react)
|
||||
- `vite.config.js` - Added path alias for @/ imports
|
||||
- `index.html` - Updated to use proper logo path
|
||||
- `src/App.jsx` - Added MobileNav component
|
||||
- `src/App.css` - Updated with proper Tailwind imports
|
||||
- `tailwind.config.js` - Already had Scarlett's color palette
|
||||
- `README.md` - Already had overhaul plan context
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
zip TEXT,
|
||||
message TEXT,
|
||||
service_interest TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Built with 🔒 Security in mind
|
||||
Data integrity maintained
|
||||
API contracts documented
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Queue-North-Website — Development Log
|
||||
|
||||
## v0.1.0 — Phase 1 Foundation — 2026-05-12
|
||||
|
||||
**Scarlett** — Design brief and UI polish
|
||||
- Added the design implementation brief to `OVERHAUL_PLAN.md`.
|
||||
- Established light-first business palette and layout rules.
|
||||
- Completed focused UI/accessibility polish after Neo scaffold.
|
||||
- Added Georgia numeric font token guidance and applied numeric styling to visible trust metric content.
|
||||
- Improved Sheet/Header accessibility and standardized selected page containers/spacing.
|
||||
- Confirmed old `styles.css` was used only as legacy context, not ported into the new design.
|
||||
|
||||
**Neo** — Phase 1 implementation
|
||||
- Built Vite + React + Tailwind foundation.
|
||||
- Added React Router route structure.
|
||||
- Added Express backend and better-sqlite3 database integration.
|
||||
- Added contact/support form API wiring.
|
||||
- Set package version to `0.1.0` for Phase 1.
|
||||
|
||||
**Bishop** — Verification
|
||||
- Verified `package.json` version matches Phase 1 (`0.1.0`).
|
||||
- Verified frontend build with `npm run build`.
|
||||
- Verified backend health endpoint responds OK.
|
||||
- Verified required routes and API paths are configured.
|
||||
- Confirmed `.gitignore` excludes `node_modules/`, `dist/`, and SQLite runtime database files.
|
||||
- Approved Phase 1 for Ripley commit/push to `dev`.
|
||||
|
||||
**Ripley** — Coordination and final gate
|
||||
- Verified repository remote and pushed README setup commit.
|
||||
- Documented phase-based versioning in PROJECT.md, OVERHAUL_PLAN.md, and STRUCTURE.md.
|
||||
- Documented the phase completion rule: after every verified phase, Ripley commits and pushes to `dev`.
|
||||
- Running final build/health checks before committing Phase 1.
|
||||
|
||||
## v0.0.1 — 2026-05-11
|
||||
|
||||
**Ripley** — Project initialized
|
||||
- Created project directory at `/home/kaspa/.openclaw/Projects/Queue-North-Website/`.
|
||||
- Set up initial PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md.
|
||||
94
Dockerfile
|
|
@ -1,94 +0,0 @@
|
|||
# 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"]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Queue-North-Website — Planning
|
||||
|
||||
## Next Items
|
||||
*Awaiting project requirements from _null.*
|
||||
|
||||
---
|
||||
|
||||
*Add items here as they are defined. Priority levels: CRITICAL, HIGH, MEDIUM, LOW*
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Queue-North-Website — Changelog
|
||||
|
||||
## v0.1.0 — Phase 1 Foundation — 2026-05-12
|
||||
|
||||
### Added
|
||||
- Rebuilt project foundation on Vite + React SPA with React Router.
|
||||
- Added Tailwind CSS with Queue North light-first business palette.
|
||||
- Added shadcn/ui-style local primitives for buttons, cards, inputs, textarea, select, badge, sheet, and dialog usage.
|
||||
- Added Sonner toast support and TanStack Query provider/API helper.
|
||||
- Added Express backend with `/api/health`, `/api/leads`, and `/api/support`.
|
||||
- Added better-sqlite3 storage for `leads` and `support_requests`.
|
||||
- Added all planned frontend routes for home, about, services, service details, industries, industry details, 8x8, contact, and support.
|
||||
- Added Phase 1 documentation, build summary, script reference, and phase-based versioning rules.
|
||||
|
||||
### Changed
|
||||
- Replaced the static HTML/CSS/JS entry with the Vite React entry.
|
||||
- Updated README to point to `OVERHAUL_PLAN.md` as the design source of truth.
|
||||
- Standardized versioning so Phase 1 uses `0.1.x`, Phase 2 uses `0.2.x`, and later phases follow the same pattern.
|
||||
- Added Bishop verification rules and the requirement that Ripley pushes to `dev` after each verified phase.
|
||||
|
||||
### Verified
|
||||
- `npm run build` passes.
|
||||
- Backend health endpoint responds successfully at `/api/health`.
|
||||
- Required routes are configured.
|
||||
- Contact and support API paths exist and write through SQLite.
|
||||
|
||||
## v0.0.1 — Project Initialization — 2026-05-11
|
||||
|
||||
### Added
|
||||
- Project initialized with PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md.
|
||||
|
|
@ -810,7 +810,7 @@ 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.
|
||||
- Iterations/fixes 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.
|
||||
|
|
@ -818,7 +818,7 @@ Version numbers must correlate directly to the active overhaul phase.
|
|||
- **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`.
|
||||
Rule: the minor version maps to the phase number; the patch version maps to work inside that phase. Do not use unrelated semantic version bumps during the overhaul.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -860,14 +860,12 @@ Goals:
|
|||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -968,6 +966,12 @@ Recommended pipeline:
|
|||
|
||||
## Recommendation
|
||||
|
||||
Start with **Scarlett first**, then move to Neo.
|
||||
|
||||
Reason:
|
||||
|
||||
This is primarily a brand and layout overhaul. If the app is scaffolded before the visual system is defined, the team may build clean code around the wrong structure. The better path is:
|
||||
|
||||
```txt
|
||||
Design system first → scaffold/build → polish → verify
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
# Queue North Website
|
||||
|
||||
## Overview
|
||||
Project: Queue-North-Website
|
||||
Created: 2026-05-11
|
||||
Status: Active (Phase 1 Complete - 0.1.0)
|
||||
Rebuild Phase: 1 (Vite + React + Express + SQLite)
|
||||
|
||||
## Description
|
||||
Website for Queue North Technologies — an 8x8 Certified Partner delivering UCaaS, Contact Center, deployment, and managed lifecycle support for SMB and enterprise organizations.
|
||||
|
||||
## Tech Stack (Phase 1)
|
||||
- **Vite** — build tool and dev server
|
||||
- **React 19** — SPA with client-side routing via React Router 7
|
||||
- **Tailwind CSS** — utility-first styling with custom theme
|
||||
- **shadcn/ui-style** — component primitives built in
|
||||
- **Sonner** — toast notifications
|
||||
- **TanStack Query** — server state management
|
||||
- **Express** — backend API server
|
||||
- **better-sqlite3** — local SQLite database
|
||||
|
||||
## Directory Structure (Phase 1)
|
||||
- `index.html` — Entry point (Vite + React entry)
|
||||
- `src/main.jsx` — React entry point with QueryClient and Toaster
|
||||
- `src/App.jsx` — Layout wrapper with Header, MobileNav, Footer
|
||||
- `src/router.jsx` — React Router configuration
|
||||
- `src/lib/api.js` — API helper with TanStack Query
|
||||
- `src/data/services.js` — Services data
|
||||
- `src/data/industries.js` — Industries data
|
||||
- `src/components/ui/` — UI primitives (Button, Card, Input, etc.)
|
||||
- `src/components/layout/` — Header, Footer, MobileNav
|
||||
- `src/pages/` — Route pages (Home, About, Services, etc.)
|
||||
- `server/index.js` — Express backend with SQLite
|
||||
- `db/queuenorth.db` — SQLite database (created on first run)
|
||||
- `assets/` — Images, icons, logos
|
||||
|
||||
## Git
|
||||
- **Branch:** `dev` (working), `main` (stable)
|
||||
- **Remote:** `ssh://forgejo/null/Queue-North-Website.git`
|
||||
|
||||
## Versioning
|
||||
|
||||
Version numbers must correlate to the active overhaul phase.
|
||||
|
||||
- Phase 1 releases use `0.1.x`
|
||||
- Phase 1 baseline: `0.1.0`
|
||||
- Phase 1 patches/iterations: `0.1.1`, `0.1.2`, etc.
|
||||
- Phase 2 releases use `0.2.x`
|
||||
- Phase 2 baseline: `0.2.0`
|
||||
- Phase 2 patches/iterations: `0.2.1`, `0.2.2`, etc.
|
||||
- Phase 3 releases use `0.3.x`
|
||||
- Phase 4 releases use `0.4.x`
|
||||
- Phase 5 releases use `0.5.x`
|
||||
|
||||
Do not use unrelated semantic version bumps during the overhaul. The minor number tracks the phase; the patch number tracks changes within that phase.
|
||||
|
||||
## Phase Completion Git Rule
|
||||
|
||||
Push to `dev` after every completed and verified phase.
|
||||
|
||||
- Agents do not touch git.
|
||||
- Bishop verifies and updates docs.
|
||||
- Ripley performs final checks, commits, and pushes to `dev`.
|
||||
|
||||
## 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
|
||||
187
README.md
|
|
@ -88,64 +88,6 @@ Primary structure:
|
|||
/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:
|
||||
|
|
@ -163,131 +105,6 @@ Initial SQLite tables:
|
|||
|
||||
Contact and support forms should submit through Express, save to SQLite, and show user feedback with Sonner.
|
||||
|
||||
## Agent Plan
|
||||
## Design Source of Truth
|
||||
|
||||
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.
|
||||
See [OVERHAUL_PLAN.md](./OVERHAUL_PLAN.md) for the full rebuild plan and Scarlett's design implementation brief.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
# Queue North Website — Script Reference
|
||||
|
||||
Run these from the project root.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Start frontend and backend together:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend runs through Vite. Backend runs through Express.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Creates the production frontend build in `dist/`.
|
||||
|
||||
## Preview Frontend Build
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Start Backend Only
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Equivalent compatibility script:
|
||||
|
||||
```bash
|
||||
npm run server
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/health
|
||||
```
|
||||
|
||||
Expected response shape:
|
||||
|
||||
```json
|
||||
{"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# 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
|
||||
|
||||
## Versioning Rules for Bishop
|
||||
|
||||
Bishop owns verification documentation and must ensure version numbers correlate to the active overhaul phase.
|
||||
|
||||
- Phase 1 uses `0.1.x`
|
||||
- Phase 1 baseline: `0.1.0`
|
||||
- Phase 1 follow-up fixes/iterations: `0.1.1`, `0.1.2`, etc.
|
||||
- Phase 2 uses `0.2.x`
|
||||
- Phase 2 baseline: `0.2.0`
|
||||
- Phase 2 follow-up fixes/iterations: `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 work inside that phase. Do not use unrelated semantic version bumps during this overhaul.
|
||||
|
||||
Before Bishop marks work verified, Bishop must check:
|
||||
- `package.json` version follows the active phase
|
||||
- `PROJECT.md` version/status matches the active phase
|
||||
- `HISTORY.md` release notes use the same version
|
||||
- Any verification summary references the correct phase/version
|
||||
|
||||
## Phase Completion Git Rule
|
||||
|
||||
Ripley must push to `dev` after every completed and verified phase.
|
||||
|
||||
- Agents do not touch git.
|
||||
- Bishop verifies and updates docs.
|
||||
- Ripley performs final local checks, commits, and pushes to `dev`.
|
||||
- This applies to Phase 1 (`0.1.x`), Phase 2 (`0.2.x`), and all later phases.
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
- All agents must read this file before starting any task
|
||||
- All agents report back to Ripley — no agent-to-agent handoffs
|
||||
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
|
@ -1,48 +0,0 @@
|
|||
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:
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
# 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 ~5–10 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 ~5–10 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.
|
||||
29
index.html
|
|
@ -2,33 +2,10 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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" />
|
||||
<title>Queue North Technologies | Modern Communications Infrastructure</title>
|
||||
<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="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">
|
||||
|
|
|
|||
|
|
@ -1,28 +1,24 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.7.0",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.7.0",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"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"
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
|
|
@ -41,6 +37,7 @@
|
|||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
|
@ -777,6 +774,7 @@
|
|||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
|
|
@ -798,6 +796,7 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -807,12 +806,14 @@
|
|||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
|
@ -823,6 +824,7 @@
|
|||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
|
|
@ -836,6 +838,7 @@
|
|||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
|
|
@ -845,6 +848,7 @@
|
|||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
|
|
@ -1185,70 +1189,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.4.tgz",
|
||||
"integrity": "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
|
@ -1645,6 +1585,32 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz",
|
||||
"integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz",
|
||||
"integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -1879,12 +1845,14 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
|
|
@ -1898,6 +1866,7 @@
|
|||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
|
|
@ -2003,6 +1972,7 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -2074,6 +2044,7 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
|
|
@ -2182,6 +2153,7 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -2242,6 +2214,7 @@
|
|||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
|
|
@ -2266,6 +2239,7 @@
|
|||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
|
|
@ -2319,6 +2293,7 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -2392,27 +2367,11 @@
|
|||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
|
|
@ -2508,12 +2467,14 @@
|
|||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
|
|
@ -2720,24 +2681,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -2757,6 +2700,7 @@
|
|||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
|
|
@ -2773,6 +2717,7 @@
|
|||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
|
|
@ -2785,6 +2730,7 @@
|
|||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
|
|
@ -2800,6 +2746,7 @@
|
|||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
|
|
@ -2883,6 +2830,7 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -2978,6 +2926,7 @@
|
|||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
|
|
@ -3032,15 +2981,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -3105,24 +3045,6 @@
|
|||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
@ -3136,6 +3058,7 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
|
|
@ -3148,6 +3071,7 @@
|
|||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.3"
|
||||
|
|
@ -3163,6 +3087,7 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3182,6 +3107,7 @@
|
|||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
|
|
@ -3194,6 +3120,7 @@
|
|||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
|
|
@ -3203,6 +3130,7 @@
|
|||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
|
|
@ -3212,6 +3140,7 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
|
|
@ -3244,6 +3173,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
|
@ -3256,20 +3186,9 @@
|
|||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
|
@ -3320,6 +3239,7 @@
|
|||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
|
|
@ -3338,6 +3258,7 @@
|
|||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
|
|
@ -3417,6 +3338,7 @@
|
|||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
|
|
@ -3428,6 +3350,7 @@
|
|||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3492,6 +3415,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3501,6 +3425,7 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3510,6 +3435,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -3561,6 +3487,7 @@
|
|||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
|
|
@ -3573,12 +3500,14 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
|
@ -3591,6 +3520,7 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3600,6 +3530,7 @@
|
|||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -3609,6 +3540,7 @@
|
|||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3637,6 +3569,7 @@
|
|||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
|
|
@ -3654,6 +3587,7 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
||||
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3679,6 +3613,7 @@
|
|||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3721,6 +3656,7 @@
|
|||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3746,6 +3682,7 @@
|
|||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
|
|
@ -3759,6 +3696,7 @@
|
|||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
|
|
@ -3830,6 +3768,7 @@
|
|||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3906,26 +3845,6 @@
|
|||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-helmet-async": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz",
|
||||
"integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"shallowequal": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
|
@ -4060,6 +3979,7 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
|
|
@ -4083,6 +4003,7 @@
|
|||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
|
|
@ -4105,6 +4026,7 @@
|
|||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -4126,6 +4048,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
|
|
@ -4181,6 +4104,7 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -4318,12 +4242,6 @@
|
|||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
|
|
@ -4468,6 +4386,7 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -4532,6 +4451,7 @@
|
|||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
|
|
@ -4570,6 +4490,7 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -4582,6 +4503,7 @@
|
|||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
|
@ -4615,15 +4537,6 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
|
|
@ -4656,6 +4569,7 @@
|
|||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
|
|
@ -4665,6 +4579,7 @@
|
|||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
|
|
@ -4677,6 +4592,7 @@
|
|||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -4693,6 +4609,7 @@
|
|||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
|
|
@ -4710,6 +4627,7 @@
|
|||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -4722,6 +4640,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
|
|
@ -4753,6 +4672,7 @@
|
|||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
|
|
@ -5084,6 +5004,35 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.13",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
package.json
|
|
@ -1,38 +1,27 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"private": true,
|
||||
"version": "0.8.3",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
"server": "node server/index.js"
|
||||
},
|
||||
"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",
|
||||
"express": "^4.21.2",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2"
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
|
|
@ -40,10 +29,10 @@
|
|||
"@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",
|
||||
"vite": "^6.0.7",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.7"
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
# 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`.
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
|
@ -1,13 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
|
@ -1,14 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
|
@ -1,35 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
|
@ -1,94 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
|
@ -1,94 +0,0 @@
|
|||
<?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>
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 44 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://queuenorth.com/sitemap.xml
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://queuenorth.com</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/about</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services/unified-communications</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services/contact-center</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services/managed-support</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services/consulting-training</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services/infrastructure-cabling</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services/wireless-access</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/services/local-networking</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/industries</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/industries/healthcare</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/industries/retail</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/industries/manufacturing</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/industries/education-finance</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/contact</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://queuenorth.com/support</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
373
review.md
|
|
@ -1,373 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
#!/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"
|
||||
885
server/index.js
|
|
@ -1,229 +1,38 @@
|
|||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync } 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 })
|
||||
if (!existsSync(path.dirname(dbPath))) {
|
||||
mkdirSync(path.dirname(dbPath), { 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()
|
||||
})
|
||||
// Middleware
|
||||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.static(path.join(__dirname, '../dist')))
|
||||
|
||||
// --- 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)
|
||||
// Leads table
|
||||
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,
|
||||
email TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
zip TEXT,
|
||||
message TEXT,
|
||||
|
|
@ -249,698 +58,104 @@ const initSchema = () => {
|
|||
|
||||
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
|
||||
company: z.string().min(1, 'Company name is required'),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Valid email is required'),
|
||||
phone: z.string().optional(),
|
||||
zip: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
service_interest: z.string().optional(),
|
||||
})
|
||||
|
||||
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
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
company: z.string().min(1, 'Company name is required'),
|
||||
email: z.string().email('Valid email is required'),
|
||||
phone: z.string().optional(),
|
||||
issue: z.string().min(10, 'Please provide more details about your issue'),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
})
|
||||
|
||||
// --- 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() })
|
||||
}
|
||||
res.json({ status: 'ok', 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
|
||||
app.post('/api/leads', (req, res) => {
|
||||
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,
|
||||
details: parsed.error.format(),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
stmt.run(
|
||||
parsed.data.company,
|
||||
parsed.data.name,
|
||||
parsed.data.email,
|
||||
parsed.data.phone || null,
|
||||
parsed.data.zip || null,
|
||||
parsed.data.message || null,
|
||||
parsed.data.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." })
|
||||
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)
|
||||
console.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." })
|
||||
}
|
||||
|
||||
app.post('/api/support', (req, res) => {
|
||||
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,
|
||||
details: parsed.error.format(),
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
stmt.run(
|
||||
parsed.data.name,
|
||||
parsed.data.company,
|
||||
parsed.data.email,
|
||||
parsed.data.phone || null,
|
||||
parsed.data.issue,
|
||||
parsed.data.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." })
|
||||
res.json({ success: true, message: 'Thanks! We\'ll get back to you soon.' })
|
||||
} catch (err) {
|
||||
log.error('Error submitting support request:', err)
|
||||
console.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}`)
|
||||
console.log(`Server running on http://localhost:${PORT}`)
|
||||
console.log(`Health check: http://localhost:${PORT}/api/health`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
/* App styles */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: #0F172A;
|
||||
background-color: #F8FAFC;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0EA5E9;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
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'
|
||||
import MobileNav from './components/layout/MobileNav.jsx'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans bg-background text-text">
|
||||
<ScrollToTop />
|
||||
<Header />
|
||||
<MobileNav />
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
|
@ -17,4 +17,4 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { Component } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error('[ErrorBoundary] Uncaught error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary-navy flex items-center justify-center px-4">
|
||||
<div className="text-center text-white max-w-md">
|
||||
<p className="text-primary-cyan text-sm font-semibold uppercase tracking-widest mb-4">Something went wrong</p>
|
||||
<h1 className="text-4xl font-bold mb-4">Unexpected Error</h1>
|
||||
<p className="text-white/70 mb-8">
|
||||
A problem occurred while loading this page. Please try refreshing, or contact us if the issue continues.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md bg-primary-cyan px-6 text-sm font-semibold text-primary-navy hover:bg-white transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md border border-white/35 px-6 text-sm font-semibold text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,21 +1,11 @@
|
|||
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: '8x8', href: '/8x8' },
|
||||
{ name: 'About', href: '/about' },
|
||||
{ name: 'Contact', href: '/contact' },
|
||||
{ name: 'Support', href: '/support' },
|
||||
|
|
@ -40,177 +30,82 @@ const Footer = () => {
|
|||
|
||||
return (
|
||||
<footer className="bg-primary-navy text-white">
|
||||
<div className="container mx-auto px-4 pt-24 pb-12">
|
||||
<div className="container mx-auto px-4 py-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"
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Queue North"
|
||||
className="h-6 w-auto"
|
||||
/>
|
||||
<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>
|
||||
<span className="font-bold text-lg">Queue North</span>
|
||||
</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>
|
||||
<p className="text-soft-text text-sm mb-4">
|
||||
Modern communications infrastructure without the vendor noise.
|
||||
</p>
|
||||
<p className="text-soft-text text-sm">
|
||||
© {currentYear} Queue North Technologies. All rights reserved.
|
||||
</p>
|
||||
</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>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4 text-lg">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}
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-soft-text hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</a>
|
||||
</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>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4 text-lg">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}
|
||||
<a
|
||||
href={service.href}
|
||||
className="text-soft-text hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</a>
|
||||
</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>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4 text-lg">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}
|
||||
<a
|
||||
href={industry.href}
|
||||
className="text-soft-text hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{industry.name}
|
||||
</Link>
|
||||
</a>
|
||||
</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 & 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.
|
||||
<div className="border-t border-white/10 mt-8 pt-8 text-center">
|
||||
<p className="text-soft-text text-sm">
|
||||
8x8 Certified Partner | Veteran Owned | 25+ Years Experience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
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'
|
||||
import { SheetTrigger } from '@/components/ui/Sheet'
|
||||
|
||||
const Header = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [openDropdown, setOpenDropdown] = useState(null)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
|
|
@ -17,248 +12,77 @@ const Header = () => {
|
|||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setOpenDropdown(null)
|
||||
}, [location.pathname])
|
||||
|
||||
const navLinks = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Services', href: '/services' },
|
||||
{ name: 'Industries', href: '/industries' },
|
||||
{ name: '8x8', href: '/8x8' },
|
||||
{ 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"
|
||||
<>
|
||||
<header className={`sticky top-0 z-40 w-full border-b transition-all duration-300 ${isScrolled ? 'bg-background/90 backdrop-blur shadow-sm -translate-y-px' : 'bg-transparent'}`}>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Queue North Technologies"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
<span className="font-bold text-sm sm:text-xl lg:text-2xl text-white whitespace-nowrap tracking-tight">Queue North Technologies</span>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="font-bold text-xl text-primary-navy hidden sm:block">Queue North</span>
|
||||
</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
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
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()
|
||||
}
|
||||
}}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-text hover:text-primary-navy transition-colors"
|
||||
>
|
||||
<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>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</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>
|
||||
{/* CTA Button */}
|
||||
<div className="hidden md:flex">
|
||||
<a
|
||||
href="/contact"
|
||||
className="bg-primary-navy text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-primary-navy-dark transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
</a>
|
||||
</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>
|
||||
{/* Mobile Menu Toggle */}
|
||||
<SheetTrigger asChild>
|
||||
<button className="md:hidden p-2 text-text hover:text-primary-navy focus:outline-none focus:ring-2 focus:ring-primary-navy rounded-md">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,28 @@
|
|||
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 = [
|
||||
const navLinks = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Services', href: '/services' },
|
||||
{ name: 'Industries', href: '/industries' },
|
||||
{ name: '8x8', href: '/8x8' },
|
||||
{ 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">
|
||||
<button className="p-2 text-text hover:text-primary-navy focus:outline-none">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
|
|
@ -57,85 +39,38 @@ const MobileNav = () => {
|
|||
<span className="sr-only">Open menu</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[350px] bg-primary-navy text-white">
|
||||
<SheetContent side="right" className="w-[280px] sm:w-[320px]">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Queue North Technologies"
|
||||
className="brand-logo-on-dark h-12 w-auto"
|
||||
src="/logo.svg"
|
||||
alt="Queue North"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
<span className="font-bold text-xl leading-tight">Queue North Technologies</span>
|
||||
<span className="font-bold text-xl text-primary-navy">Queue North</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 className="flex flex-col space-y-4">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
onClick={closeMobileMenu}
|
||||
className="text-base font-medium text-text hover:text-primary-navy py-2 border-b border-gray-100 last:border-0"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<Link
|
||||
to="/contact#contact-form"
|
||||
<a
|
||||
href="/contact"
|
||||
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"
|
||||
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 Consultation
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,30 @@
|
|||
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}
|
||||
/>
|
||||
)
|
||||
const Badge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'error'
|
||||
}
|
||||
)
|
||||
>(({ 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-ring 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-foreground',
|
||||
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 }
|
||||
export { Badge }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
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 Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'link'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
}
|
||||
>(({ 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-ring 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',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
ghost: 'hover:bg-secondary hover:text-secondary-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
|
|
@ -21,11 +27,11 @@ const Button = React.forwardRef(({ className = '', variant = 'default', size = '
|
|||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variants[variant] || ''} ${sizes[size] || ''} ${className}`}
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button }
|
||||
export { Button }
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Card = React.forwardRef(
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`rounded-md border border-border bg-card text-text shadow-sm ${className}`}
|
||||
className={`rounded-xl border border-border bg-card text-card-foreground shadow-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef(
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -22,7 +22,7 @@ const CardHeader = React.forwardRef(
|
|||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef(
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
|
|
@ -33,25 +33,25 @@ const CardTitle = React.forwardRef(
|
|||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef(
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={`text-sm text-muted ${className}`}
|
||||
className={`text-sm text-muted-foreground ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef(
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef(
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = ({ className, ...props }) => (
|
||||
<DialogPrimitive.Overlay
|
||||
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}
|
||||
/>
|
||||
)
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }) => (
|
||||
<div
|
||||
className={`flex flex-col space-y-1.5 text-center sm:text-left ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }) => (
|
||||
<div
|
||||
className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={`text-lg font-semibold leading-none tracking-tight ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={`text-sm text-muted-foreground ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Input = React.forwardRef(
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ 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}`}
|
||||
className={`flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -14,4 +14,4 @@ const Input = React.forwardRef(
|
|||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
export { Input }
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ 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"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring 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"
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
|
|||
|
|
@ -17,23 +17,18 @@ const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
|||
))
|
||||
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 ?? ''}`}
|
||||
className={`fixed z-50 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 ${side === 'top' ? 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top' : side === 'bottom' ? 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom' : side === 'left' ? 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left' : 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right'} sm:rounded-lg ${className}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
{...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">
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
|
|
@ -61,7 +56,7 @@ SheetFooter.displayName = 'SheetFooter'
|
|||
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={`text-lg font-semibold text-text ${className}`}
|
||||
className={`text-lg font-semibold text-foreground ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
@ -70,7 +65,7 @@ SheetTitle.displayName = SheetPrimitive.Title.displayName
|
|||
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={`text-sm text-muted ${className}`}
|
||||
className={`text-sm text-muted-foreground ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Textarea = React.forwardRef(
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ 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}`}
|
||||
className={`flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { Button } from './Button.jsx'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card.jsx'
|
||||
import { Input } from './Input.jsx'
|
||||
import { Textarea } from './Textarea.jsx'
|
||||
import { Select } from './Select.jsx'
|
||||
import { Badge } from './Badge.jsx'
|
||||
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetClose, SheetFooter, SheetOverlay } from './Sheet.jsx'
|
||||
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from './Dialog.jsx'
|
||||
|
||||
export {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
Badge,
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetClose,
|
||||
SheetFooter,
|
||||
SheetOverlay,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export { default as Button } from './Button.jsx'
|
||||
export { default as Card } from './Card.jsx'
|
||||
export { default as CardContent } from './CardContent.jsx'
|
||||
export { default as CardHeader } from './CardHeader.jsx'
|
||||
export { default as CardTitle } from './CardTitle.jsx'
|
||||
export { default as CardDescription } from './CardDescription.jsx'
|
||||
export { default as Input } from './Input.jsx'
|
||||
export { default as Textarea } from './Textarea.jsx'
|
||||
export { default as Select } from './Select.jsx'
|
||||
export { default as Badge } from './Badge.jsx'
|
||||
export { default as Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetClose, SheetFooter, SheetOverlay } from './Sheet.jsx'
|
||||
export { default as Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from './Dialog.jsx'
|
||||
|
|
@ -3,7 +3,7 @@ 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.',
|
||||
fullDesc: 'Healthcare providers need secure, reliable communications that protect patient data while enabling seamless collaboration. Our solutions are designed specifically for healthcare environments, with HIPAA-compliant options and features that support clinical workflows and patient care.',
|
||||
icon: 'heart-pulse',
|
||||
painPoints: [
|
||||
'HIPAA compliance and data security',
|
||||
|
|
@ -22,7 +22,7 @@ export const industries = [
|
|||
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.',
|
||||
fullDesc: 'Retail businesses need communication systems that scale from single stores to national chains. Our solutions help retail organizations connect stores, manage teams, and engage customers with reliable voice, data, and digital communication tools.',
|
||||
icon: 'shopping-cart',
|
||||
painPoints: [
|
||||
'Multi-store coordination',
|
||||
|
|
@ -41,7 +41,7 @@ export const industries = [
|
|||
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.',
|
||||
fullDesc: 'Manufacturing operations require communications systems that work in challenging environments and support both office and production floor needs. Our solutions bridge the gap between office systems and industrial operations.',
|
||||
icon: 'factory',
|
||||
painPoints: [
|
||||
'Production floor connectivity',
|
||||
|
|
@ -60,7 +60,7 @@ export const industries = [
|
|||
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.',
|
||||
fullDesc: 'Educational institutions and financial organizations need communications systems that balance accessibility with stringent security requirements. Our solutions help these organizations communicate effectively while protecting sensitive data.',
|
||||
icon: 'landmark',
|
||||
painPoints: [
|
||||
'Data security and privacy',
|
||||
|
|
|
|||
|
|
@ -3,20 +3,13 @@ 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.`,
|
||||
fullDesc: 'Transform your business communications with our comprehensive Unified Communications solutions. We help you integrate voice, video, messaging, and collaboration tools into a single, intuitive platform that works across all your devices and locations.',
|
||||
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',
|
||||
|
|
@ -29,21 +22,13 @@ Our solutions include Cisco Webex, Cisco Unified Communications Manager, and 8x8
|
|||
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.`,
|
||||
fullDesc: 'Create exceptional customer experiences with our contact center solutions. Our cloud-based platforms help you manage customer interactions across phone, email, chat, and social media channels with powerful analytics and workforce management tools.',
|
||||
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',
|
||||
|
|
@ -56,8 +41,7 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
|
|||
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.',
|
||||
fullDesc: 'Our Managed Support services provide comprehensive IT help desk and infrastructure support. With 24/7 monitoring, rapid response times, and dedicated support engineers, we ensure your technology infrastructure runs smoothly so you can focus on your business.',
|
||||
icon: 'life-buoy',
|
||||
benefits: [
|
||||
'24/7 proactive monitoring',
|
||||
|
|
@ -76,8 +60,7 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
|
|||
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.',
|
||||
fullDesc: 'Our consulting and training services help you make the most of your communications infrastructure. From strategic planning and implementation to hands-on training, we provide the expertise your team needs to succeed.',
|
||||
icon: 'graduation-cap',
|
||||
benefits: [
|
||||
'Strategic technology planning',
|
||||
|
|
@ -96,10 +79,8 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
|
|||
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.',
|
||||
fullDesc: 'Our infrastructure cabling services ensure your physical network foundation supports current and future needs. We design and install copper and fiber optic cabling systems that provide high-performance, scalable connectivity for your entire organization.',
|
||||
icon: 'link',
|
||||
image: '/assets/cabling-unsplash.jpg',
|
||||
benefits: [
|
||||
'Cat6/Cat6a and fiber optic installations',
|
||||
'Data center cabling solutions',
|
||||
|
|
@ -117,10 +98,8 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
|
|||
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.',
|
||||
fullDesc: 'Our wireless access solutions provide robust, high-performance Wi-Fi coverage for your entire facility. From site surveys and design to installation and optimization, we ensure seamless mobile connectivity for employees and guests.',
|
||||
icon: 'wifi',
|
||||
image: '/assets/wireless.webp',
|
||||
benefits: [
|
||||
'Enterprise Wi-Fi design and deployment',
|
||||
'High-density coverage solutions',
|
||||
|
|
@ -138,8 +117,7 @@ We support Cisco Webex Contact Center, 8x8 Contact Center, and other leading pla
|
|||
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.',
|
||||
fullDesc: 'Build a reliable local network infrastructure that supports your business operations. Our networking solutions include routing, switching, firewall configuration, and network segmentation to ensure secure, high-performance connectivity throughout your organization.',
|
||||
icon: 'network',
|
||||
benefits: [
|
||||
'Enterprise-grade routing and switching',
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
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
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -19,7 +18,6 @@ body {
|
|||
background-color: #F8FAFC;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
|
|
@ -27,12 +25,6 @@ img {
|
|||
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;
|
||||
|
|
@ -42,9 +34,8 @@ a:hover {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Container - custom max-width */
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
|
|
|||
136
src/lib/api.js
|
|
@ -1,124 +1,30 @@
|
|||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
import { queryClient } from './queryClient'
|
||||
|
||||
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
|
||||
}
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
|
||||
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 })
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
|
||||
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 })
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `API error: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
|
||||
export { queryClient }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
31
src/main.jsx
|
|
@ -1,22 +1,25 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
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 = () => (
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<HelmetProvider>
|
||||
<ErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</ErrorBoundary>
|
||||
</HelmetProvider>
|
||||
</StrictMode>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
createRoot(document.getElementById('root')).render(<Root />)
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
const EightXEight = () => {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
{/* Page Hero */}
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">8x8 Certified Partner</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">
|
||||
As an 8x8 Certified Partner, we help organizations implement and manage cloud communications solutions that drive business success.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* About 8x8 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-16">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-4">What is 8x8?</h2>
|
||||
<p className="text-lg text-soft-text mb-6 leading-relaxed">
|
||||
8x8 is a leading provider of cloud communications and contact center solutions. Their platform combines voice, video, chat, email, and contact center capabilities into a single, unified solution that helps businesses communicate more effectively and serve customers better.
|
||||
</p>
|
||||
<p className="text-lg text-soft-text leading-relaxed">
|
||||
As a certified partner, we have deep expertise in implementing and supporting 8x8 solutions, ensuring our customers get the most value from their investment.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rounded-xl overflow-hidden shadow-lg">
|
||||
<img
|
||||
src="/assets/8x8_Logo_White.svg"
|
||||
alt="8x8 Logo"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Our Expertise */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">8x8 Expertise</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
'VoIP Implementation',
|
||||
'Cloud PBX Migration',
|
||||
'Contact Center Setup',
|
||||
'Unified Communications',
|
||||
'Video Conferencing',
|
||||
'Collaboration Tools',
|
||||
'System Integration',
|
||||
'Ongoing Support',
|
||||
].map((expertise, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{expertise}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">Why Choose 8x8</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: 'Scalability', desc: 'Easily scale your communications as your business grows' },
|
||||
{ title: 'Reliability', desc: '99.999% uptime guarantee for mission-critical communications' },
|
||||
{ title: 'Integration', desc: 'Seamlessly integrate with your favorite business applications' },
|
||||
{ title: 'Mobility', desc: 'Full-featured mobile apps for remote and traveling employees' },
|
||||
{ title: 'Analytics', desc: 'Real-time insights and reporting to optimize performance' },
|
||||
{ title: 'Support', desc: '24/7 expert support to keep your communications running' },
|
||||
].map((benefit, index) => (
|
||||
<div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm">
|
||||
<h3 className="text-xl font-semibold text-primary-navy mb-3">{benefit.title}</h3>
|
||||
<p className="text-soft-text">{benefit.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="bg-section-alt rounded-xl p-8 md:p-12 text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-6">Ready to Explore 8x8 Solutions?</h2>
|
||||
<p className="text-xl text-soft-text mb-8 max-w-2xl mx-auto">
|
||||
Schedule a free consultation with our 8x8 certified experts.
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block bg-primary-navy text-white px-8 py-3 rounded-md font-bold text-lg hover:bg-primary-navy-dark transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EightXEight
|
||||
|
|
@ -1,293 +1,99 @@
|
|||
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"
|
||||
/>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
|
||||
{/* 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>
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">About Queue North</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">
|
||||
We're communications and infrastructure partners who cut through the vendor noise to deliver what actually works for your business.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
{/* Company Story */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-16">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-4">Our Story</h2>
|
||||
<p className="text-lg text-soft-text mb-6 leading-relaxed">
|
||||
Founded in 2000, Queue North Technologies began with a simple mission: help businesses navigate the complex world of communications technology. What started as a small team of communications specialists has grown into a full-service provider for SMB and enterprise organizations across multiple industries.
|
||||
</p>
|
||||
<p className="text-lg text-soft-text mb-6 leading-relaxed">
|
||||
Our journey began when our founders saw too many businesses paying too much for solutions that didn't fit their actual needs. We believed in a different approach — one focused on understanding your business challenges first, then selecting or integrating the right technologies to solve them.
|
||||
</p>
|
||||
<p className="text-lg text-soft-text leading-relaxed">
|
||||
Today, we continue that mission as an 8x8 Certified Partner, helping organizations modernize their communications, streamline their operations, and focus on what matters most — their customers and their growth.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rounded-xl overflow-hidden shadow-lg">
|
||||
<img
|
||||
src="/assets/about-image.png"
|
||||
alt="Our Team"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</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>
|
||||
{/* Our Values */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">Our Values</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: 'Business First', desc: 'We focus on your business outcomes, not just technology' },
|
||||
{ title: 'Honesty', desc: 'We tell you what you need, not just what we can sell' },
|
||||
{ title: 'Partnership', desc: 'We work with you, not just for you' },
|
||||
{ title: 'Expertise', desc: 'Our team has real, proven experience in your industry' },
|
||||
{ title: 'Reliability', desc: 'When we say we will do something, we do it' },
|
||||
{ title: 'Support', desc: 'Our job doesn\'t end when installation completes' },
|
||||
].map((value, index) => (
|
||||
<div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm">
|
||||
<h3 className="text-xl font-semibold text-primary-navy mb-3">{value.title}</h3>
|
||||
<p className="text-soft-text">{value.desc}</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>
|
||||
{/* Our Expertise */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8">Our Expertise</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
'8x8 Certified Partner',
|
||||
'VoIP and UCaaS Solutions',
|
||||
'Contact Center Implementation',
|
||||
'Network Infrastructure',
|
||||
'Cloud Migration',
|
||||
'Cybersecurity for Communications',
|
||||
'Disaster Recovery Planning',
|
||||
'24/7 Support & Monitoring',
|
||||
].map((expertise, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{expertise}</span>
|
||||
</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 className="bg-section-alt rounded-xl p-8 md:p-12 text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-6">Ready to Learn More?</h2>
|
||||
<p className="text-xl text-soft-text mb-8 max-w-2xl mx-auto">
|
||||
Schedule a free consultation with our communications experts to discuss your needs.
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block bg-primary-navy text-white px-8 py-3 rounded-md font-bold text-lg hover:bg-primary-navy-dark transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
</a>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,425 +1,227 @@
|
|||
import SEO from '@/components/SEO'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useToast } 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)
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const Contact = () => {
|
||||
const { toast } = useToast()
|
||||
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 || '',
|
||||
company: '',
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
zip: '',
|
||||
message: '',
|
||||
service_interest: '',
|
||||
})
|
||||
|
||||
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,
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data) => api.post('/leads', data),
|
||||
onSuccess: () => {
|
||||
toast.success('Thanks! We\'ll be in touch shortly.')
|
||||
setFormState({
|
||||
company: '',
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
zip: '',
|
||||
message: '',
|
||||
service_interest: '',
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to submit form. Please try again.')
|
||||
},
|
||||
})
|
||||
|
||||
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 handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate(formState)
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
|
||||
{/* Page Hero */}
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">Contact Us</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">
|
||||
Have questions about our services? We're here to help. Fill out the form and we'll get back to you shortly.
|
||||
</p>
|
||||
</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" />
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Left - Info */}
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">Get in Touch</h2>
|
||||
<p className="text-soft-text mb-6">
|
||||
Our team of communications and infrastructure experts is ready to help you find the right solution for your business needs.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<h3 className="font-semibold text-text mb-2">Hours of Operation</h3>
|
||||
<p className="text-soft-text">Monday - Friday: 8:00 AM - 6:00 PM CT</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text mb-2">Email</h3>
|
||||
<p className="text-soft-text">info@queuenorth.com</p>
|
||||
</div>
|
||||
</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 className="bg-section-alt rounded-lg p-6">
|
||||
<h3 className="font-semibold text-primary-navy mb-4">Why Choose Queue North?</h3>
|
||||
<ul className="space-y-3">
|
||||
{[
|
||||
'8x8 Certified Partner with proven expertise',
|
||||
'25+ years of industry experience',
|
||||
'SMB to Enterprise solutions',
|
||||
'Focus on your business outcomes',
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-center gap-3 text-text">
|
||||
<svg className="h-5 w-5 text-primary-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
{/* Right - Form */}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
||||
Company Name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="company"
|
||||
name="company"
|
||||
value={formState.company}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Your company name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formState.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text mb-2">
|
||||
Email <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formState.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-text mb-2">
|
||||
Phone (Optional)
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formState.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="zip" className="block text-sm font-medium text-text mb-2">
|
||||
ZIP Code (Optional)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
value={formState.zip}
|
||||
onChange={handleChange}
|
||||
placeholder="12345"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="service_interest" className="block text-sm font-medium text-text mb-2">
|
||||
Service Interest (Optional)
|
||||
</label>
|
||||
<Select
|
||||
id="service_interest"
|
||||
name="service_interest"
|
||||
value={formState.service_interest}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
<option value="unified-communications">Unified Communications</option>
|
||||
<option value="contact-center">Contact Center</option>
|
||||
<option value="managed-support">Managed Support</option>
|
||||
<option value="consulting-training">Consulting & Training</option>
|
||||
<option value="infrastructure-cabling">Infrastructure Cabling</option>
|
||||
<option value="wireless-access">Wireless Access</option>
|
||||
<option value="local-networking">Local Networking</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-text mb-2">
|
||||
Message <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formState.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Tell us about your needs..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Submitting...' : 'Request Consultation'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,209 +1,65 @@
|
|||
import SEO from '@/components/SEO'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
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]}
|
||||
/>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* 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
|
||||
<section className="bg-primary-navy text-white py-16 md:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
||||
Modern Communications Infrastructure Without the Vendor Noise
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-section-alt mb-8 max-w-2xl">
|
||||
We deliver UCaaS, Contact Center, deployment, and managed lifecycle support for SMB and enterprise organizations.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button variant="default" size="lg" className="bg-white text-primary-navy hover:bg-gray-100">
|
||||
Request Consultation
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="border-white text-white hover:bg-white/10">
|
||||
Explore Services
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-wrap gap-4">
|
||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">8x8 Certified Partner</span>
|
||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">Veteran Owned</span>
|
||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-numeric">25+ Years Experience</span>
|
||||
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">SMB to Enterprise</span>
|
||||
</div>
|
||||
</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 className="hidden lg:block">
|
||||
<div className="relative rounded-xl overflow-hidden shadow-2xl">
|
||||
<img
|
||||
src="/assets/hero-tech.png"
|
||||
alt="Communications Infrastructure"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-navy/50 to-transparent" />
|
||||
</div>
|
||||
</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>
|
||||
{/* Trust Bar */}
|
||||
<section className="bg-section-alt py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-semibold text-primary-navy mb-2">Trusted Partner</h2>
|
||||
<p className="text-soft-text">8x8 Certified Partner with proven expertise</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center items-center gap-8 md:gap-16 opacity-70">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/assets/8x8_Logo_White.svg" alt="8x8" className="h-8" />
|
||||
<span className="font-medium">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 className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary-navy rounded-full flex items-center justify-center text-white font-bold">V</div>
|
||||
<span className="font-medium">Veteran Owned</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -211,105 +67,72 @@ const Home = () => {
|
|||
|
||||
{/* 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="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-4">What We Handle</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-4">Our Services</h2>
|
||||
<p className="text-xl text-soft-text max-w-2xl mx-auto">
|
||||
From phones to firewalls, we keep your business running
|
||||
Comprehensive communications and infrastructure solutions for every business need
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
{services.map((service) => (
|
||||
<Card key={service.id} className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary-navy">{service.name}</CardTitle>
|
||||
<CardDescription>{service.shortDesc}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-soft-text mb-4">{service.fullDesc}</p>
|
||||
<a href={`/services/${service.id}`} className="text-primary-navy font-medium hover:underline">
|
||||
Learn more →
|
||||
</a>
|
||||
</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="container mx-auto px-4">
|
||||
<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 className="text-xl text-soft-text max-w-2xl mx-auto">
|
||||
Three pillars of our approach to communications and infrastructure
|
||||
</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.
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary-navy">Architecture</CardTitle>
|
||||
<CardDescription>Strategic Design</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-soft-text mb-4">
|
||||
We design communications architectures that scale with your business, not against it. Our approach ensures your infrastructure supports your goals, not complicates them.
|
||||
</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.
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary-navy">Deployment</CardTitle>
|
||||
<CardDescription>Seamless Implementation</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-soft-text mb-4">
|
||||
Our deployment process minimizes disruption and maximizes efficiency. We handle everything from planning to go-live, ensuring smooth transitions and quick ROI.
|
||||
</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.
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary-navy">Lifecycle Support</CardTitle>
|
||||
<CardDescription>Ongoing Optimization</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-soft-text mb-4">
|
||||
Our support extends beyond installation. We continuously monitor, optimize, and evolve your communications infrastructure to meet changing business needs.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -319,7 +142,7 @@ const Home = () => {
|
|||
|
||||
{/* 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="container mx-auto px-4">
|
||||
<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">
|
||||
|
|
@ -327,79 +150,46 @@ const Home = () => {
|
|||
</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>
|
||||
)
|
||||
})}
|
||||
{[
|
||||
{ name: 'Healthcare', href: '/industries/healthcare' },
|
||||
{ name: 'Retail', href: '/industries/retail' },
|
||||
{ name: 'Manufacturing', href: '/industries/manufacturing' },
|
||||
{ name: 'Education & Finance', href: '/industries/education-finance' },
|
||||
].map((industry) => (
|
||||
<Card key={industry.name} className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-semibold text-primary-navy mb-3">{industry.name}</h3>
|
||||
<p className="text-sm text-soft-text mb-4">
|
||||
Industry-specific solutions designed to address your unique challenges and requirements.
|
||||
</p>
|
||||
<a href={industry.href} className="text-primary-navy font-medium hover:underline">
|
||||
Learn more →
|
||||
</a>
|
||||
</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
|
||||
{/* Final CTA */}
|
||||
<section className="bg-primary-navy text-white py-16 md:py-24">
|
||||
<div className="container mx-auto px-4 text-center max-w-7xl">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||
Tell us what you're trying to fix. We'll help map the path.
|
||||
</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 className="text-xl text-section-alt mb-8 max-w-2xl mx-auto">
|
||||
Schedule a free consultation with our communications experts.
|
||||
</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">
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block bg-white text-primary-navy px-8 py-3 rounded-md font-bold text-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,142 +1,59 @@
|
|||
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>
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
{/* Page Hero */}
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">Industries We Serve</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">
|
||||
Tailored communications and infrastructure solutions designed for the unique challenges of your industry.
|
||||
</p>
|
||||
</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" />
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{industries.map((industry) => (
|
||||
<div key={industry.id} className="group cursor-pointer">
|
||||
<div className="rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow bg-card border border-border">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-section-alt flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-6 w-6 text-primary-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</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>
|
||||
<h3 className="text-xl font-semibold text-primary-navy group-hover:text-primary-navy-dark transition-colors">
|
||||
{industry.name}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-soft-text mb-4">{industry.shortDesc}</p>
|
||||
<a href={`/industries/${industry.id}`} className="text-primary-navy font-medium hover:underline inline-flex items-center gap-1">
|
||||
Learn more
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</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 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 className="mt-16 bg-section-alt rounded-xl p-8 md:p-12 text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-6">Can't Find Your Industry?</h2>
|
||||
<p className="text-xl text-soft-text mb-8 max-w-2xl mx-auto">
|
||||
We work with businesses across all industries. Contact us to discuss your specific needs.
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block bg-primary-navy text-white px-8 py-3 rounded-md font-bold text-lg hover:bg-primary-navy-dark transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
</a>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,141 +1,101 @@
|
|||
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)
|
||||
const IndustryDetail = ({ name }) => {
|
||||
const industry = industries.find(i => i.id === name)
|
||||
|
||||
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 className="container mx-auto px-4 py-16 md:py-24">
|
||||
<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>
|
||||
<a href="/industries" className="text-primary-navy hover:underline">
|
||||
Back to Industries
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
{/* 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 className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">{industry.name}</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">{industry.shortDesc}</p>
|
||||
</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="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Left Column - Main Content */}
|
||||
<div className="lg:col-span-2">
|
||||
<section 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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">Pain Points We Solve</h2>
|
||||
<div className="space-y-4">
|
||||
{industry.painPoints.map((painPoint, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-red-100 text-red-700 flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{painPoint}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">Our Solutions</h2>
|
||||
<div className="space-y-4">
|
||||
{industry.solutions.map((solution, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-green-100 text-green-700 flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{solution}</span>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
{/* Right Column - Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-24">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary-navy">Industry Insights</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-text mb-2">Industry</h4>
|
||||
<p className="text-soft-text">{industry.name}</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<a href="/contact" className="block w-full bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
|
||||
Request Consultation
|
||||
</a>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<a href="/industries" className="text-primary-navy hover:underline">
|
||||
← Back to Industries
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,170 +1,105 @@
|
|||
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)
|
||||
const ServiceDetail = ({ name }) => {
|
||||
const service = services.find(s => s.id === name)
|
||||
|
||||
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 className="container mx-auto px-4 py-16 md:py-24">
|
||||
<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>
|
||||
<a href="/services" className="text-primary-navy hover:underline">
|
||||
Back to Services
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
{/* 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 className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">{service.name}</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">{service.shortDesc}</p>
|
||||
</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="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Left Column - Main Content */}
|
||||
<div className="lg:col-span-2">
|
||||
<section 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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">How Queue North Helps</h2>
|
||||
<div className="space-y-4">
|
||||
{service.benefits.map((benefit, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{benefit}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">Ideal For</h2>
|
||||
<div className="space-y-4">
|
||||
{service.idealFor.map((item, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0 mt-1">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{item}</span>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
{/* Right Column - Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-24">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary-navy">Quick Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-text mb-2">Service</h4>
|
||||
<p className="text-soft-text">{service.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-text mb-2">Category</h4>
|
||||
<p className="text-soft-text capitalize">{service.id.replace('-', ' ')}</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<a href="/contact" className="block w-full bg-primary-navy text-white px-4 py-3 rounded-md text-center font-medium hover:bg-primary-navy-dark transition-colors">
|
||||
Request This Service
|
||||
</a>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<a href="/services" className="text-primary-navy hover:underline">
|
||||
← Back to Services
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,210 +1,67 @@
|
|||
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>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
|
||||
{/* Page Hero */}
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">Our Services</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">
|
||||
Comprehensive communications and infrastructure solutions designed to help your business thrive in today's digital landscape.
|
||||
</p>
|
||||
</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>
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{services.map((service) => (
|
||||
<div key={service.id} className="group cursor-pointer">
|
||||
<div className="rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow bg-card border border-border">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-section-alt flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-6 w-6 text-primary-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</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>
|
||||
<h3 className="text-xl font-semibold text-primary-navy group-hover:text-primary-navy-dark transition-colors">
|
||||
{service.name}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-soft-text mb-4">{service.shortDesc}</p>
|
||||
<p className="text-sm text-soft-text mb-4">{service.fullDesc}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{service.benefits.slice(0, 2).map((benefit, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-section-alt rounded text-xs text-soft-text">
|
||||
{benefit}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<a href={`/services/${service.id}`} className="text-primary-navy font-medium hover:underline inline-flex items-center gap-1">
|
||||
Learn more
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</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 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 className="mt-16 bg-section-alt rounded-xl p-8 md:p-12 text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary-navy mb-6">Looking for Something Specific?</h2>
|
||||
<p className="text-xl text-soft-text mb-8 max-w-2xl mx-auto">
|
||||
Don't see exactly what you're looking for? We can help you find the right solution.
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block bg-primary-navy text-white px-8 py-3 rounded-md font-bold text-lg hover:bg-primary-navy-dark transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
</a>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,231 +1,211 @@
|
|||
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',
|
||||
]
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useToast } from 'sonner'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const Support = () => {
|
||||
const { toast } = useToast()
|
||||
const [formState, setFormState] = useState({
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
issue: '',
|
||||
priority: 'medium',
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data) => api.post('/support', data),
|
||||
onSuccess: () => {
|
||||
toast.success('Thanks! We\'ll get back to you soon.')
|
||||
setFormState({
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
issue: '',
|
||||
priority: 'medium',
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to submit form. Please try again.')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate(formState)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
setFormState(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-16 md:py-24">
|
||||
{/* 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>
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-primary-navy mb-6">Support</h1>
|
||||
<p className="text-xl text-soft-text max-w-3xl">
|
||||
Need help with your communications or infrastructure? Submit a support request and we'll get back to you promptly.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
{/* Support Form */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Left - Info */}
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">Support Services</h2>
|
||||
<p className="text-soft-text mb-6">
|
||||
We provide comprehensive support for all our services, including 24/7 monitoring, rapid response, and dedicated support engineers.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text mb-2">Support Hours</h3>
|
||||
<p className="text-soft-text">24/7 Monitoring with rapid response SLAs</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text mb-2">Priority Levels</h3>
|
||||
<p className="text-soft-text">
|
||||
Low: General inquiries (response within 24 hours)<br/>
|
||||
Medium: Standard issues (response within 4 hours)<br/>
|
||||
High: Critical issues (response within 1 hour)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-section-alt rounded-lg p-6">
|
||||
<h3 className="font-semibold text-primary-navy mb-4">What We Support</h3>
|
||||
<ul className="space-y-3">
|
||||
{[
|
||||
'8x8 Communications Platform',
|
||||
'VoIP Phone Systems',
|
||||
'Contact Center Solutions',
|
||||
'Network Infrastructure',
|
||||
'Cloud Migration Support',
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-center gap-3 text-text">
|
||||
<svg className="h-5 w-5 text-primary-navy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
{/* Right - Form */}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formState.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Your full name"
|
||||
/>
|
||||
</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>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
||||
Company Name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="company"
|
||||
name="company"
|
||||
value={formState.company}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Your company name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text mb-2">
|
||||
Email <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formState.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-text mb-2">
|
||||
Phone (Optional)
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formState.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-text mb-2">
|
||||
Priority <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Select
|
||||
id="priority"
|
||||
name="priority"
|
||||
value={formState.priority}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="low">Low - General inquiries (24 hours)</option>
|
||||
<option value="medium">Medium - Standard issues (4 hours)</option>
|
||||
<option value="high">High - Critical issues (1 hour)</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="issue" className="block text-sm font-medium text-text mb-2">
|
||||
Describe Your Issue <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="issue"
|
||||
name="issue"
|
||||
value={formState.issue}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Please describe your issue in detail..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,26 +6,33 @@ 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 EightXEight from './pages/8x8.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 />
|
||||
),
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: 'about', element: <About /> },
|
||||
{ path: 'services', element: <Services /> },
|
||||
{ path: 'services/:slug', element: <ServiceDetail /> },
|
||||
{ path: 'services/unified-communications', element: <ServiceDetail name="unified-communications" /> },
|
||||
{ path: 'services/contact-center', element: <ServiceDetail name="contact-center" /> },
|
||||
{ path: 'services/managed-support', element: <ServiceDetail name="managed-support" /> },
|
||||
{ path: 'services/consulting-training', element: <ServiceDetail name="consulting-training" /> },
|
||||
{ path: 'services/infrastructure-cabling', element: <ServiceDetail name="infrastructure-cabling" /> },
|
||||
{ path: 'services/wireless-access', element: <ServiceDetail name="wireless-access" /> },
|
||||
{ path: 'services/local-networking', element: <ServiceDetail name="local-networking" /> },
|
||||
{ path: 'industries', element: <Industries /> },
|
||||
{ path: 'industries/:slug', element: <IndustryDetail /> },
|
||||
{ path: 'industries/healthcare', element: <IndustryDetail name="healthcare" /> },
|
||||
{ path: 'industries/retail', element: <IndustryDetail name="retail" /> },
|
||||
{ path: 'industries/manufacturing', element: <IndustryDetail name="manufacturing" /> },
|
||||
{ path: 'industries/education-finance', element: <IndustryDetail name="education-finance" /> },
|
||||
{ path: '8x8', element: <EightXEight /> },
|
||||
{ path: 'contact', element: <Contact /> },
|
||||
{ path: 'support', element: <Support /> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ export default {
|
|||
text: '#0F172A',
|
||||
muted: '#475569',
|
||||
'soft-text': '#64748B',
|
||||
'navy-light': '#68A3B8',
|
||||
primary: {
|
||||
navy: '#0B2A3C',
|
||||
'navy-dark': '#071A2A',
|
||||
|
|
@ -32,16 +31,7 @@ export default {
|
|||
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)',
|
||||
|
|
@ -57,5 +47,5 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
plugins: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
|
@ -20,6 +20,6 @@ export default defineConfig({
|
|||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: process.env.NODE_ENV !== 'production',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
|
|
|
|||