Compare commits

..

24 Commits
v0.1.0 ... dev

Author SHA1 Message Date
null 796d372e79 chore: add docker-push.sh, docker-test.sh, npm scripts, bump v0.5.0
- docker-push.sh: build + tag + push dev image to Forgejo registry
- docker-test.sh: rebuild and run container for local testing
- npm scripts: docker:push and docker:test
- Version bump to 0.5.0 (Phase 5)
2026-05-14 01:18:44 -05:00
null c4985e37bc feat: Phase 5 SPA fixes, mobile menu, assets, and redesign planning
- Fix BrowserRouter → RouterProvider (routes were disconnected)
- Strip TS generics from .jsx files (Card, Badge, Dialog, Input, Textarea)
- Fix useToast import from sonner (Contact, Support)
- Merge mobile Sheet into Header (DialogTrigger outside Dialog)
- Add SPA catch-all route for client-side navigation
- Add CSP style-src for Google Fonts
- Copy all image assets to public/ (were 404)
- Replace placeholder logo with real Queue North logo
- Fix SheetContent positional CSS + install tailwindcss-animate
- Add visually hidden SheetTitle for accessibility
- Update README and FUTURE.md with Phase 5 redesign batches
- Add review.md (redesign assessment, exempt from git)
2026-05-13 22:07:35 -05:00
null c2d5873f08 feat: error handling hardening, 404 catch-all, health check DB test, request timeout, global error handlers (v0.4.8) 2026-05-13 19:59:19 -05:00
null 7257633d94 feat: rate limiting, helmet security headers, CORS, trust proxy, Docker env vars (v0.4.7) 2026-05-13 18:37:32 -05:00
null 39ee1fe537 feat: structured logging with timestamps, request logging, and submission details (v0.4.6) 2026-05-13 18:31:52 -05:00
null 6bfd804313 feat: Zoho CRM forwarding layer with OAuth2 token management (v0.4.6) 2026-05-13 18:28:56 -05:00
null 4ac0fa250d feat: server-side validation + input sanitization (v0.4.5) 2026-05-13 18:18:07 -05:00
null ee5af44b58 docs: update README phase 4 checkmark for validation 2026-05-13 18:10:16 -05:00
null 931c9a9095 feat: client-side form validation + Sonner feedback (v0.4.4) 2026-05-13 18:10:04 -05:00
null 21b5418461 docs: update README phases with checkmarks for completed work 2026-05-13 18:03:03 -05:00
null 71347d070b chore: bump version to 0.4.3 (SQLite persistence verified) 2026-05-13 04:14:07 -05:00
null 87203bcded fix: consolidate legacy CSS, fix dynamic routes, convert anchors to Link components
- Remove duplicate App.css, consolidate into index.css as single Tailwind entry point
- Move maxWidth.container to tailwind.config.js theme extension
- Update App.jsx import from ./App.css to ./index.css
- Fix router.jsx to use dynamic :slug routes for services and industries
- Fix ServiceDetail.jsx and IndustryDetail.jsx to use useParams()
- Convert Header.jsx and MobileNav.jsx <a> tags to React Router <Link> components
- Add scripts/docker-test.sh for persistence verification
- Add project-requirements.md
2026-05-13 00:29:45 -05:00
null a7fa18ec63 chore: add .learnings/ to gitignore 2026-05-12 02:50:04 -05:00
null f03229dd50 feat: Phase 3 Batch 4 — inner pages layout system with consistent hero/card/CTA pattern (v0.3.4) 2026-05-12 02:45:25 -05:00
null 35aaa639ec feat: Phase 3 Batch 3 — header/footer/mobilenav polish + fix Button.jsx TS generics (v0.3.3)
- Sticky dark navy header with clean nav and CTA
- Reorganized MobileNav with Primary/Services/Industries sections
- Dark navy footer with cyan accent headers
- Added navy-light color token
- Fixed Button.jsx: removed TypeScript generic syntax that broke esbuild
- Replaced asChild Button usage with styled anchor tags
2026-05-12 02:39:35 -05:00
null 76aa71691f feat: Phase 3 Batch 2 — home page redesign with hero, trust bar, services, CTA (v0.3.2) 2026-05-12 02:31:23 -05:00
null 287e2b79f6 feat: Phase 3 Batch 1 — theme tokens, spacing scale, container width (v0.3.1) 2026-05-12 02:26:18 -05:00
null 0b7da4d237 chore: bump to v0.3.0 — Phase 3 Visual Overhaul baseline 2026-05-12 02:15:36 -05:00
null ba0d039cdc fix: reduce Docker image from 331MB to 215MB — remove duplicate node_modules layer
v0.2.2: Removed COPY --from=builder node_modules from runner stage.
The full dev+prod modules (116MB) were being copied as a permanent
Docker layer, then npm ci --omit=dev installed a separate prod-only
set on top. Now only the prod install runs, cutting 116MB.
2026-05-12 02:04:52 -05:00
null 1f3e3864f9 feat: Docker batch 0.2.1 — production-ready containerization
- Multi-stage Dockerfile with non-root nodejs user
- Healthcheck using Node 20 built-in fetch (no wget)
- docker-entrypoint.sh: root permission fix, then exec to nodejs
- server/db.js: deferred SQLite init for Docker volume permissions
- docker-compose.yml with named volumes for persistence
- .dockerignore and .env.example added
- README updated with Docker usage section

Security reviewed by Private Hudson. All blockers resolved.
2026-05-12 01:57:55 -05:00
null c83dc08660 feat: complete phase 2 layout rebuild 2026-05-12 01:18:57 -05:00
null d2bb91fd72 docs: track overhaul plan 2026-05-12 01:10:34 -05:00
null 8352558240 chore: keep project docs private 2026-05-12 01:09:21 -05:00
null bd17e964b3 chore: bump phase 1 checkpoint to 0.1.1 2026-05-12 01:05:44 -05:00
80 changed files with 1998 additions and 3460 deletions

76
.dockerignore Normal file
View File

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

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# Environment configuration
# Copy this file to .env and customize as needed
NODE_ENV=production
SERVER_PORT=3001

10
.gitignore vendored
View File

@ -1,4 +1,11 @@
# Project docs managed in repo unless explicitly excluded elsewhere # Private project/agent docs — never commit
DEVELOPMENT_LOG.md
PROJECT.md
STRUCTURE.md
FUTURE.md
HISTORY.md
BUILD_SUMMARY.md
SCRIPTS.md
# Dependencies # Dependencies
node_modules/ node_modules/
@ -26,3 +33,4 @@ pnpm-debug.log*
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
.learnings/

View File

@ -1,125 +0,0 @@
# 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

View File

@ -1,38 +0,0 @@
# 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.

68
Dockerfile Normal file
View File

@ -0,0 +1,68 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first for layer caching
COPY package.json package-lock.json* ./
# Install all dependencies for build
RUN npm ci
# Copy source files
COPY . .
# Build the frontend
RUN npm run build
# 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_ENABLED=false
ENV ZOHO_API_DOMAIN=https://www.zohoapis.com
ENV ZOHO_CLIENT_ID=
ENV ZOHO_CLIENT_SECRET=
ENV ZOHO_REFRESH_TOKEN=
ENV ZOHO_REDIRECT_URI=
# Create app directory structure
RUN mkdir -p /app/db /app/logs
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# 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
# Install production dependencies only in runtime stage
RUN npm ci --omit=dev
# Install su-exec for switching to non-root user
RUN apk add --no-cache su-exec && \
rm -rf /var/cache/apk/*
# Expose backend port
EXPOSE 3001
# 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 => r.ok ? 0 : 1).catch(() => 1)" || exit 1
# Run the Express server via entrypoint (runs as root, then switches to nodejs)
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server/index.js"]

View File

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

View File

@ -1,30 +0,0 @@
# 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.

View File

@ -810,7 +810,7 @@ Version numbers must correlate directly to the active overhaul phase.
- **Phase 1** uses `0.1.x` - **Phase 1** uses `0.1.x`
- First Phase 1 release: `0.1.0` - First Phase 1 release: `0.1.0`
- Iterations/fixes within Phase 1: `0.1.1`, `0.1.2`, etc. - Every completed agent pass/checkpoint within Phase 1: `0.1.1`, `0.1.2`, etc.
- **Phase 2** uses `0.2.x` - **Phase 2** uses `0.2.x`
- First Phase 2 release: `0.2.0` - First Phase 2 release: `0.2.0`
- Iterations/fixes within Phase 2: `0.2.1`, `0.2.2`, etc. - 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 4** uses `0.4.x`
- **Phase 5** uses `0.5.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 the overhaul. 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`.
--- ---
@ -860,12 +860,14 @@ Goals:
- Port existing business content into React components. - Port existing business content into React components.
- Replace hash routing with React Router. - Replace hash routing with React Router.
- Move repeated content into data files. - Move repeated content into data files.
- Remove legacy `styles.css` file.
Result: Result:
- Site content exists in the new React app. - Site content exists in the new React app.
- Routes are clean and shareable. - Routes are clean and shareable.
- Structure is maintainable. - Structure is maintainable.
- Old global stylesheet removed.
--- ---
@ -966,12 +968,6 @@ Recommended pipeline:
## Recommendation ## 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 ```txt
Design system first → scaffold/build → polish → verify Design system first → scaffold/build → polish → verify
``` ```

View File

@ -1,69 +0,0 @@
# 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
View File

@ -88,6 +88,64 @@ Primary structure:
/support /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 ## Backend Goals
Initial API endpoints: Initial API endpoints:
@ -105,6 +163,131 @@ Initial SQLite tables:
Contact and support forms should submit through Express, save to SQLite, and show user feedback with Sonner. Contact and support forms should submit through Express, save to SQLite, and show user feedback with Sonner.
## Design Source of Truth ## Agent Plan
See [OVERHAUL_PLAN.md](./OVERHAUL_PLAN.md) for the full rebuild plan and Scarlett's design implementation brief. The overhaul is handled through the agent pipeline below:
1. **Scarlett** — design system, Tailwind/shadcn layout direction, responsive polish, accessibility review
2. **Neo** — Vite/React implementation, Express API, SQLite/database work, build-system changes
3. **Private Hudson** — security review for API routes, form handling, validation, data exposure, dependency risks, and backend hardening
4. **Scarlett** — UI polish pass after implementation changes
5. **Bishop** — build/runtime verification, route checks, documentation verification, version consistency
6. **Ripley** — final local checks, commit, tag, and push to `dev`
Agents do not touch git. Ripley owns all commits, tags, and pushes.
## Batch Pipeline Rule
Work is dispatched as task batches. A batch runs through the required agents, then Ripley pushes that completed batch once.
Example Docker batch:
```txt
Neo → Private Hudson → Bishop → Ripley
```
The whole Docker batch is one checkpoint: `0.2.1`.
Do not increment the patch version for each individual agent inside the same batch. Increment only after the full task batch finishes and is ready to push.
Notes, tags, and checkpoint labels should use only the version number, such as `0.2.1`.
## Design Direction
Based on the redesign review (see `review.md`), the site should feel:
- **Modern, clean, stable** — not experimental, not hacker aesthetic
- **Business-first** — B2B UCaaS/IT partner, not a dev portfolio
- **Trust-forward** — 8x8 partnership, certifications, uptime SLAs front and center
- **Human but competent** — less corporate fluff, more concrete outcomes
Color palette evolution (not rip-and-replace):
- Keep navy dark base, add teal/cyan accents for depth and hierarchy
- Improve contrast and spacing
- Mobile-first — SMB decision-makers browse on phones
Reference brands: RingCentral, Cloudflare, Dialpad — modern but enterprise-trustworthy.
See [review.md](./review.md) for the full redesign assessment.
## Docker Deployment
The application can be containerized using Docker for consistent deployment across environments.
### Prerequisites
- Docker (v20+)
- Docker Compose (v2+)
### Quick Start
#### Using Docker Compose (Recommended)
```bash
# Build and start the container
npm run docker:compose:up
# View logs
npm run docker:compose:logs
# Stop the container
npm run docker:compose:down
```
#### Manual Docker Build
```bash
# Build the image
npm run docker:build
# Run the container
npm run docker:run
```
### Environment Variables
Set the following in the `.env` file (not included in image by default):
```env
NODE_ENV=production
SERVER_PORT=3001
```
### Data Persistence
SQLite database is persisted in the `./db` directory. Data will survive container restarts.
**Note on data persistence:**
The application uses Docker named volumes (`queuenorth-db` and `queuenorth-logs`) to persist data. Docker manages the ownership and permissions of these volumes automatically, so no manual setup is required.
If you prefer to use host bind mounts instead, ensure your host `./db` and `./logs` directories are owned by UID 1001:
```bash
mkdir -p ./db ./logs
sudo chown -R 1001:1001 ./db ./logs
```
If you encounter "unable to open database file" errors, verify the host directory is writable by the container's UID (1001) or use named volumes as shown above.
### Health Check
The container includes a health check at `/api/health`. A healthy container returns:
```json
{"status":"ok","timestamp":"2026-05-12T..."}
```
### Ports
- Backend API: `3001` (host) → `3001` (container)
### Build Optimization
The `.dockerignore` excludes:
- `node_modules` (reinstalled in container)
- `dist` (built in container)
- `db/` (mounted as volume)
- `.git`, logs, private docs
This ensures minimal image size and reproducible builds.

View File

@ -1,57 +0,0 @@
# 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":"..."}
```

View File

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

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
version: "3.8"
services:
queuenorth:
build:
context: .
dockerfile: Dockerfile
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_ENABLED=false
- ZOHO_API_DOMAIN=https://www.zohoapis.com
- ZOHO_CLIENT_ID=
- ZOHO_CLIENT_SECRET=
- ZOHO_REFRESH_TOKEN=
- ZOHO_REDIRECT_URI=
restart: unless-stopped
# Container runs as non-root user (UID 1001) for security
volumes:
queuenorth-db:
queuenorth-logs:

17
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# Ensure database and logs directories exist with proper permissions
# We run as root first (before USER directive), fix permissions, then exec to nodejs
set -e
# Create directories if they don't exist
mkdir -p /app/db
mkdir -p /app/logs
# Make directories world-writable to allow the nodejs user to create files
chmod 777 /app/db
chmod 777 /app/logs
# Run the Express server as nodejs user
exec su-exec nodejs node server/index.js

205
package-lock.json generated
View File

@ -1,22 +1,27 @@
{ {
"name": "queuenorth-website", "name": "queuenorth-website",
"version": "0.0.0", "version": "0.4.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "queuenorth-website", "name": "queuenorth-website",
"version": "0.0.0", "version": "0.4.8",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"better-sqlite3": "^11.8.0", "better-sqlite3": "^11.8.0",
"cors": "^2.8.6",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^8.5.1",
"helmet": "^8.1.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.3", "react-router-dom": "^7.1.3",
"sonner": "^1.7.0", "sonner": "^1.7.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2", "zod": "^3.24.2",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@ -37,7 +42,6 @@
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -774,7 +778,6 @@
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@ -796,7 +799,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@ -806,14 +808,12 @@
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@ -824,7 +824,6 @@
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "2.0.5", "@nodelib/fs.stat": "2.0.5",
@ -838,7 +837,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@ -848,7 +846,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.scandir": "2.1.5", "@nodelib/fs.scandir": "2.1.5",
@ -1189,6 +1186,70 @@
} }
} }
}, },
"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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1845,14 +1906,12 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/anymatch": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
@ -1866,7 +1925,6 @@
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
@ -1972,7 +2030,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -2044,7 +2101,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@ -2153,7 +2209,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@ -2214,7 +2269,6 @@
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"anymatch": "~3.1.2", "anymatch": "~3.1.2",
@ -2239,7 +2293,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
@ -2293,7 +2346,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@ -2367,11 +2419,27 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"cssesc": "bin/cssesc" "cssesc": "bin/cssesc"
@ -2467,14 +2535,12 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
@ -2681,6 +2747,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express/node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -2700,7 +2784,6 @@
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.stat": "^2.0.2",
@ -2717,7 +2800,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
@ -2730,7 +2812,6 @@
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"reusify": "^1.0.4" "reusify": "^1.0.4"
@ -2746,7 +2827,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@ -2830,7 +2910,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -2926,7 +3005,6 @@
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.3" "is-glob": "^4.0.3"
@ -2981,6 +3059,15 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -3045,6 +3132,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "license": "ISC"
}, },
"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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -3058,7 +3154,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"binary-extensions": "^2.0.0" "binary-extensions": "^2.0.0"
@ -3071,7 +3166,6 @@
"version": "2.16.2", "version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"hasown": "^2.0.3" "hasown": "^2.0.3"
@ -3087,7 +3181,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -3107,7 +3200,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@ -3120,7 +3212,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@ -3130,7 +3221,6 @@
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
@ -3173,7 +3263,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@ -3186,7 +3275,6 @@
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
@ -3239,7 +3327,6 @@
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@ -3258,7 +3345,6 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@ -3338,7 +3424,6 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"any-promise": "^1.0.0", "any-promise": "^1.0.0",
@ -3350,7 +3435,6 @@
"version": "3.3.12", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3415,7 +3499,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -3425,7 +3508,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -3435,7 +3517,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@ -3487,7 +3568,6 @@
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
@ -3500,14 +3580,12 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@ -3520,7 +3598,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -3530,7 +3607,6 @@
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@ -3540,7 +3616,6 @@
"version": "8.5.14", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -3569,7 +3644,6 @@
"version": "15.1.0", "version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"postcss-value-parser": "^4.0.0", "postcss-value-parser": "^4.0.0",
@ -3587,7 +3661,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -3613,7 +3686,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -3656,7 +3728,6 @@
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -3682,7 +3753,6 @@
"version": "6.1.2", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
@ -3696,7 +3766,6 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
@ -3768,7 +3837,6 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3979,7 +4047,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pify": "^2.3.0" "pify": "^2.3.0"
@ -4003,7 +4070,6 @@
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"picomatch": "^2.2.1" "picomatch": "^2.2.1"
@ -4026,7 +4092,6 @@
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@ -4048,7 +4113,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"iojs": ">=1.0.0", "iojs": ">=1.0.0",
@ -4104,7 +4168,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -4386,7 +4449,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -4451,7 +4513,6 @@
"version": "3.35.1", "version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/gen-mapping": "^0.3.2",
@ -4490,7 +4551,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -4503,7 +4563,6 @@
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
@ -4537,6 +4596,15 @@
"node": ">=14.0.0" "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": { "node_modules/tar-fs": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@ -4569,7 +4637,6 @@
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"any-promise": "^1.0.0" "any-promise": "^1.0.0"
@ -4579,7 +4646,6 @@
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"thenify": ">= 3.1.0 < 4" "thenify": ">= 3.1.0 < 4"
@ -4592,7 +4658,6 @@
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -4609,7 +4674,6 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@ -4627,7 +4691,6 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -4640,7 +4703,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@ -4672,7 +4734,6 @@
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/tslib": { "node_modules/tslib": {

View File

@ -1,27 +1,39 @@
{ {
"name": "queuenorth-website", "name": "queuenorth-website",
"private": true, "private": true,
"version": "0.1.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"", "dev": "concurrently \"vite\" \"node server/index.js\"",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "node server/index.js", "start": "node server/index.js",
"server": "node server/index.js" "server": "node server/index.js",
"docker:build": "docker build -t queuenorth-website .",
"docker:run": "docker run -p 3001:3001 --rm --name queuenorth -v queuenorth-db:/app/db -v queuenorth-logs:/app/logs --env NODE_ENV=production queuenorth-website",
"docker:compose:up": "docker-compose up -d",
"docker:compose:down": "docker-compose down",
"docker:compose:logs": "docker-compose logs -f",
"docker:push": "bash scripts/docker-push.sh",
"docker:test": "bash scripts/docker-test.sh"
}, },
"dependencies": { "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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.3", "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", "sonner": "^1.7.0",
"@radix-ui/react-dialog": "^1.1.0", "tailwindcss-animate": "^1.0.7",
"lucide-react": "^0.468.0" "zod": "^3.24.2",
"zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
@ -29,10 +41,10 @@
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"concurrently": "^9.1.2" "tailwindcss": "^3.4.17",
"vite": "^6.0.7"
} }
} }

67
project-requirements.md Normal file
View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/assets/Cabling.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
public/assets/Retail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
public/assets/Wireless.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
public/assets/contact.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
public/assets/hero-tech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/assets/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
public/assets/old-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
public/assets/support.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

8
public/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 346 KiB

373
review.md Normal file
View File

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

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

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

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

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

55
server/db.js Normal file
View File

@ -0,0 +1,55 @@
// Database initialization - loaded at runtime after entrypoint runs
import path from 'path'
import { existsSync, mkdirSync, chmodSync } from 'fs'
import sqlite3 from 'better-sqlite3'
const dbPath = path.join(new URL(import.meta.url).pathname, '..', 'db', 'queuenorth.db')
const dbDir = path.dirname(dbPath)
// Ensure db directory exists with proper permissions
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true })
try { chmodSync(dbDir, 0o777) } catch (e) {}
}
// Ensure db file has proper permissions if it exists
if (existsSync(dbPath)) {
try { chmodSync(dbPath, 0o666) } catch (e) {}
}
// Initialize the database
export const db = sqlite3(dbPath)
// Initialize schema
export const initSchema = () => {
// 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,
phone TEXT,
zip TEXT,
message TEXT,
service_interest TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
// Support requests table
db.exec(`
CREATE TABLE IF NOT EXISTS support_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
company TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
issue TEXT NOT NULL,
priority TEXT DEFAULT 'medium',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
}
initSchema()

View File

@ -1,25 +1,125 @@
import express from 'express' import express from 'express'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { existsSync, mkdirSync } from 'fs' import { existsSync, mkdirSync, chmodSync } from 'fs'
import sqlite3 from 'better-sqlite3' import sqlite3 from 'better-sqlite3'
import z from 'zod' import z from 'zod'
import rateLimit from 'express-rate-limit'
import helmet from 'helmet'
import cors from 'cors'
// --- Setup --- // --- Setup ---
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
const app = express() const app = express()
// 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 dbPath = path.join(__dirname, '../db/queuenorth.db')
const dbDir = path.dirname(dbPath)
// Create db directory if it doesn't exist // Create db directory if it doesn't exist
if (!existsSync(path.dirname(dbPath))) { if (!existsSync(dbDir)) {
mkdirSync(path.dirname(dbPath), { recursive: true }) mkdirSync(dbDir, { recursive: true })
// Try to set writable permissions, ignore if running as non-root
try { chmodSync(dbDir, 0o755) } catch (e) {}
} }
// --- 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 = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '5', 10)
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 cspDirectives = {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
}
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')
// --- CORS Configuration ---
const corsOrigin = process.env.CORS_ORIGIN || '*' // Default to * for development
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 // Middleware
app.use(express.json()) app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true, limit: '1mb' }))
app.use(express.static(path.join(__dirname, '../dist')))
// Rate limiting for API routes only
app.use('/api', apiLimiter)
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now()
res.on('finish', () => {
const ms = Date.now() - start
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'
log[level](`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`)
})
next()
})
// --- Database --- // --- Database ---
const db = sqlite3(dbPath) const db = sqlite3(dbPath)
@ -58,63 +158,208 @@ const initSchema = () => {
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
return sanitized.substring(0, maxLength)
}
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 --- // --- Validation Schemas ---
const leadSchema = z.object({ const leadSchema = z.object({
company: z.string().min(1, 'Company name is required'), 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'), 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'), email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'),
phone: z.string().optional(), phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
zip: z.string().optional(), zip: z.string().trim().max(10, 'ZIP code must be 10 characters or less').optional().or(z.literal('').transform(() => undefined)),
message: z.string().optional(), message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)),
service_interest: z.string().optional(), service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
}) })
const supportSchema = z.object({ const supportSchema = z.object({
name: z.string().min(1, 'Name is required'), 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'), 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'), email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'),
phone: z.string().optional(), 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 more details about your issue'), 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']).optional(), 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)),
}) })
// --- Zoho CRM Forwarding (best-effort, fire-and-forget) ---
const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true'
const ZOHO_API_DOMAIN = process.env.ZOHO_API_DOMAIN || 'https://www.zohoapis.com'
const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || ''
const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || ''
const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || ''
const ZOHO_REDIRECT_URI = process.env.ZOHO_REDIRECT_URI || ''
// In-memory access token cache
let zohoAccessToken = null
let zohoTokenExpiry = 0
async function getZohoAccessToken() {
// Return cached token if still valid (with 60s buffer)
if (zohoAccessToken && Date.now() < zohoTokenExpiry - 60000) {
return zohoAccessToken
}
try {
const url = `${ZOHO_API_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,
redirect_uri: ZOHO_REDIRECT_URI,
})
const response = await fetch(`${url}?${params.toString()}`, { method: 'POST' })
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
try {
const accessToken = await getZohoAccessToken()
if (!accessToken) {
log.warn('[Zoho] No access token available, skipping lead forwarding')
return
}
const url = `${ZOHO_API_DOMAIN}/crm/v8/Leads`
const payload = {
data: [
{
Company: leadData.company || '',
Last_Name: leadData.name || 'Unknown',
Email: leadData.email || '',
Phone: leadData.phone || '',
Zip_Code: leadData.zip || '',
Description: leadData.message || '',
Service_Interest: leadData.service_interest || '',
},
],
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Zoho-oauthtoken ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (response.ok) {
const result = await response.json()
log.info('[Zoho] Lead forwarded successfully:', result.data?.[0]?.details?.id || 'no id returned')
} else {
const text = await response.text()
log.error(`[Zoho] Lead forwarding failed (${response.status}):`, text)
}
} catch (err) {
log.error('[Zoho] Forwarding error:', err.message)
}
}
// --- API Routes --- // --- API Routes ---
// Health check // Health check
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }) try {
// Verify DB connection by executing a simple query
db.prepare('SELECT 1').get()
res.json({ status: 'ok', db: 'ok', timestamp: new Date().toISOString() })
} catch (err) {
log.error('Health check DB verification failed:', err.message)
res.status(503).json({ error: 'Service unavailable', db: 'error', timestamp: new Date().toISOString() })
}
}) })
// Submit lead // Submit lead
app.post('/api/leads', (req, res) => { app.post('/api/leads', (req, res) => {
try { try {
const parsed = leadSchema.safeParse(req.body) const parsed = leadSchema.safeParse(req.body)
if (!parsed.success) { 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({ return res.status(400).json({
error: 'Validation failed', error: 'Validation failed',
details: parsed.error.format(), fields: fieldErrors,
}) })
} }
// Sanitize parsed data before insert (trim, strip tags, truncate)
const sanitized = sanitizePayload(parsed.data, {
company: 200,
name: 100,
email: 254,
phone: 50,
zip: 10,
message: 5000,
service_interest: 50,
})
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO leads (company, name, email, phone, zip, message, service_interest) INSERT INTO leads (company, name, email, phone, zip, message, service_interest)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`) `)
stmt.run( const result = stmt.run(
parsed.data.company, sanitized.company,
parsed.data.name, sanitized.name,
parsed.data.email, sanitized.email,
parsed.data.phone || null, sanitized.phone || null,
parsed.data.zip || null, sanitized.zip || null,
parsed.data.message || null, sanitized.message || null,
parsed.data.service_interest || null sanitized.service_interest || null
) )
res.json({ success: true, message: 'Thanks! We\'ll be in touch shortly.' }) log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`)
// Fire-and-forget Zoho forwarding (best-effort, non-blocking)
forwardToZoho(sanitized)
res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
} catch (err) { } catch (err) {
console.error('Error submitting lead:', err) log.error('Error submitting lead:', err)
res.status(500).json({ error: 'Failed to submit lead' }) res.status(500).json({ error: 'Failed to submit lead' })
} }
}) })
@ -123,39 +368,120 @@ app.post('/api/leads', (req, res) => {
app.post('/api/support', (req, res) => { app.post('/api/support', (req, res) => {
try { try {
const parsed = supportSchema.safeParse(req.body) const parsed = supportSchema.safeParse(req.body)
if (!parsed.success) { 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({ return res.status(400).json({
error: 'Validation failed', error: 'Validation failed',
details: parsed.error.format(), fields: fieldErrors,
}) })
} }
// 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(` const stmt = db.prepare(`
INSERT INTO support_requests (name, company, email, phone, issue, priority) INSERT INTO support_requests (name, company, email, phone, issue, priority)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`) `)
stmt.run( const result = stmt.run(
parsed.data.name, sanitized.name,
parsed.data.company, sanitized.company,
parsed.data.email, sanitized.email,
parsed.data.phone || null, sanitized.phone || null,
parsed.data.issue, sanitized.issue,
parsed.data.priority || 'medium' sanitized.priority || 'medium'
) )
res.json({ success: true, message: 'Thanks! We\'ll get back to you soon.' }) log.info(`Support request submitted: ${sanitized.email} from ${sanitized.company} priority=${sanitized.priority || 'medium'} (id: ${result.lastInsertRowid})`)
res.json({ success: true, message: "Thanks! We'll get back to you soon." })
} catch (err) { } catch (err) {
console.error('Error submitting support request:', err) log.error('Error submitting support request:', err)
res.status(500).json({ error: 'Failed to submit support request' }) res.status(500).json({ error: 'Failed to submit support request' })
} }
}) })
// --- 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'))
})
// --- 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()
}
app.use(timeoutMiddleware)
// --- 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 --- // --- Start Server ---
const PORT = process.env.SERVER_PORT || 3001 const PORT = process.env.SERVER_PORT || 3001
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`) log.info(`Server running on http://localhost:${PORT}`)
console.log(`Health check: http://localhost:${PORT}/api/health`) log.info(`Health check: http://localhost:${PORT}/api/health`)
if (ZOHO_ENABLED) {
log.info(`Zoho CRM forwarding: ENABLED (domain: ${ZOHO_API_DOMAIN})`)
} else {
log.info('Zoho CRM 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}`)
}) })

View File

@ -1,69 +0,0 @@
/* 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;
}

View File

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

View File

@ -1,6 +1,12 @@
const Footer = () => { const Footer = () => {
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const companyInfo = {
name: 'Queue North',
tagline: 'Modern communications infrastructure without the vendor noise.',
address: 'Your trusted partner in UCaaS, Contact Center, and infrastructure solutions.',
}
const quickLinks = [ const quickLinks = [
{ name: 'Home', href: '/' }, { name: 'Home', href: '/' },
{ name: 'Services', href: '/services' }, { name: 'Services', href: '/services' },
@ -31,34 +37,31 @@ const Footer = () => {
return ( return (
<footer className="bg-primary-navy text-white"> <footer className="bg-primary-navy text-white">
<div className="container mx-auto px-4 py-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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
{/* Company Info */} {/* Company Info */}
<div> <div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<img <img
src="/logo.svg" src="/logo.svg"
alt="Queue North" alt="Queue North"
className="h-6 w-auto" className="h-8 w-auto"
/> />
<span className="font-bold text-lg">Queue North</span> <span className="font-bold text-lg">Queue North</span>
</div> </div>
<p className="text-soft-text text-sm mb-4"> <p className="text-navy-light text-sm mb-3">{companyInfo.tagline}</p>
Modern communications infrastructure without the vendor noise. <p className="text-navy-light text-sm mb-4">{companyInfo.address}</p>
</p> <p className="text-navy-light text-xs">© {currentYear} Queue North Technologies. All rights reserved.</p>
<p className="text-soft-text text-sm">
© {currentYear} Queue North Technologies. All rights reserved.
</p>
</div> </div>
{/* Quick Links */} {/* Quick Links */}
<div> <div>
<h3 className="font-semibold mb-4 text-lg">Quick Links</h3> <h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-cyan">Quick Links</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{quickLinks.map((link) => ( {quickLinks.map((link) => (
<li key={link.name}> <li key={link.name}>
<a <a
href={link.href} href={link.href}
className="text-soft-text hover:text-white transition-colors text-sm" className="text-navy-light hover:text-white transition-colors text-sm"
> >
{link.name} {link.name}
</a> </a>
@ -69,13 +72,13 @@ const Footer = () => {
{/* Services */} {/* Services */}
<div> <div>
<h3 className="font-semibold mb-4 text-lg">Services</h3> <h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-cyan">Services</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{services.map((service) => ( {services.map((service) => (
<li key={service.name}> <li key={service.name}>
<a <a
href={service.href} href={service.href}
className="text-soft-text hover:text-white transition-colors text-sm" className="text-navy-light hover:text-white transition-colors text-sm"
> >
{service.name} {service.name}
</a> </a>
@ -86,13 +89,13 @@ const Footer = () => {
{/* Industries */} {/* Industries */}
<div> <div>
<h3 className="font-semibold mb-4 text-lg">Industries</h3> <h3 className="font-semibold mb-4 text-sm uppercase tracking-wider text-cyan">Industries</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{industries.map((industry) => ( {industries.map((industry) => (
<li key={industry.name}> <li key={industry.name}>
<a <a
href={industry.href} href={industry.href}
className="text-soft-text hover:text-white transition-colors text-sm" className="text-navy-light hover:text-white transition-colors text-sm"
> >
{industry.name} {industry.name}
</a> </a>
@ -103,8 +106,8 @@ const Footer = () => {
</div> </div>
{/* Bottom */} {/* Bottom */}
<div className="border-t border-white/10 mt-8 pt-8 text-center"> <div className="border-t border-white/10 pt-8">
<p className="text-soft-text text-sm"> <p className="text-center text-navy-light text-sm">
8x8 Certified Partner | Veteran Owned | 25+ Years Experience 8x8 Certified Partner | Veteran Owned | 25+ Years Experience
</p> </p>
</div> </div>

View File

@ -1,8 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { SheetTrigger } from '@/components/ui/Sheet' import { Sheet, SheetTrigger, SheetContent, SheetTitle } from '@/components/ui/Sheet'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { Link } from 'react-router-dom'
const Header = () => { const Header = () => {
const [isScrolled, setIsScrolled] = useState(false) const [isScrolled, setIsScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@ -22,68 +25,134 @@ const Header = () => {
{ name: 'Support', href: '/support' }, { 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)
return ( return (
<> <header className={`sticky top-0 z-40 w-full transition-all duration-300 ${isScrolled ? 'bg-primary-navy shadow-md' : 'bg-primary-navy/95'}`}>
<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="container mx-auto px-4"> <div className="flex h-16 items-center justify-between">
<div className="flex h-16 items-center justify-between"> {/* Logo */}
{/* Logo */} <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <img
<img src="/logo.svg"
src="/logo.svg" alt="Queue North Technologies"
alt="Queue North Technologies" className="h-8 w-auto flex-shrink-0"
className="h-8 w-auto" />
/> <span className="font-bold text-xl text-white hidden sm:block tracking-tight">Queue North</span>
<span className="font-bold text-xl text-primary-navy hidden sm:block">Queue North</span> </div>
</div>
{/* Desktop Nav */} {/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-6"> <nav className="hidden md:flex items-center gap-6">
{navLinks.map((link) => ( {navLinks.map((link) => (
<a <Link
key={link.name} key={link.name}
href={link.href} to={link.href}
className="text-sm font-medium text-text hover:text-primary-navy transition-colors" className="text-sm font-medium text-navy-light hover:text-white transition-colors"
>
{link.name}
</a>
))}
</nav>
{/* 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 {link.name}
</a> </Link>
</div> ))}
</nav>
{/* Mobile Menu Toggle */} {/* CTA Button */}
<SheetTrigger asChild> <div className="hidden md:block">
<button className="md:hidden p-2 text-text hover:text-primary-navy focus:outline-none focus:ring-2 focus:ring-primary-navy rounded-md"> <Link to="/contact" className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-3 bg-primary-navy text-white hover:bg-primary-navy-dark transition-colors">
<span className="sr-only">Open menu</span> Request Consultation
<svg </Link>
className="h-6 w-6" </div>
fill="none"
stroke="currentColor" {/* Mobile Menu */}
viewBox="0 0 24 24" <div className="md:hidden">
> <Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<path <SheetTrigger asChild>
strokeLinecap="round" <button className="p-2 text-white hover:text-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-cyan rounded-md">
strokeLinejoin="round" <span className="sr-only">Open menu</span>
strokeWidth="2" <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
d="M4 6h16M4 12h16M4 18h16" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
/> </svg>
</svg> </button>
</button> </SheetTrigger>
</SheetTrigger> <SheetContent side="right" className="w-[300px] sm:w-[350px] bg-primary-navy text-white">
<VisuallyHidden.Root asChild>
<SheetTitle>Navigation Menu</SheetTitle>
</VisuallyHidden.Root>
<div className="flex flex-col h-full">
<div className="flex items-center gap-3 mb-6">
<img src="/logo.svg" alt="Queue North" className="h-9 w-auto" />
<span className="font-bold text-xl">Queue North</span>
</div>
<nav className="flex flex-col space-y-6">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Primary</h4>
<ul className="space-y-2">
{navLinks.map((link) => (
<li key={link.name}>
<Link to={link.href} onClick={closeMobileMenu} className="block text-base font-medium text-navy-light hover:text-white transition-colors py-2">
{link.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Services</h4>
<ul className="space-y-2">
{serviceLinks.map((service) => (
<li key={service.name}>
<Link to={service.href} onClick={closeMobileMenu} className="block text-sm text-navy-light hover:text-white transition-colors py-2 border-b border-white/10 last:border-0">
{service.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Industries</h4>
<ul className="space-y-2">
{industryLinks.map((industry) => (
<li key={industry.name}>
<Link to={industry.href} onClick={closeMobileMenu} className="block text-sm text-navy-light hover:text-white transition-colors py-2 border-b border-white/10 last:border-0">
{industry.name}
</Link>
</li>
))}
</ul>
</div>
</nav>
<div className="mt-auto pt-6">
<Link to="/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">
Request Consultation
</Link>
</div>
</div>
</SheetContent>
</Sheet>
</div> </div>
</div> </div>
</header> </div>
</> </header>
) )
} }
export default Header export default Header

View File

@ -1,19 +1,34 @@
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/Sheet' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/Sheet'
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom'
const MobileNav = () => { const MobileNav = () => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const navLinks = [ const primaryLinks = [
{ name: 'Home', href: '/' }, { name: 'Home', href: '/' },
{ name: 'Services', href: '/services' },
{ name: 'Industries', href: '/industries' },
{ name: '8x8', href: '/8x8' },
{ name: 'About', href: '/about' }, { name: 'About', href: '/about' },
{ name: 'Contact', href: '/contact' }, { name: 'Contact', href: '/contact' },
{ name: 'Support', href: '/support' }, { 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 = () => { const closeMobileMenu = () => {
setIsOpen(false) setIsOpen(false)
} }
@ -22,7 +37,7 @@ const MobileNav = () => {
<div className="md:hidden"> <div className="md:hidden">
<Sheet open={isOpen} onOpenChange={setIsOpen}> <Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<button className="p-2 text-text hover:text-primary-navy focus:outline-none"> <button className="p-2 text-white hover:text-cyan focus:outline-none focus:ring-2 focus:ring-cyan rounded-md">
<svg <svg
className="h-6 w-6" className="h-6 w-6"
fill="none" fill="none"
@ -39,38 +54,81 @@ const MobileNav = () => {
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
</button> </button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" className="w-[280px] sm:w-[320px]"> <SheetContent side="right" className="w-[300px] sm:w-[350px] bg-primary-navy text-white">
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-3 mb-6">
<img <img
src="/logo.svg" src="/logo.svg"
alt="Queue North" alt="Queue North"
className="h-8 w-auto" className="h-9 w-auto"
/> />
<span className="font-bold text-xl text-primary-navy">Queue North</span> <span className="font-bold text-xl">Queue North</span>
</div> </div>
<nav className="flex flex-col space-y-4"> <nav className="flex flex-col space-y-6">
{navLinks.map((link) => ( {/* Primary Links */}
<a <div>
key={link.name} <h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Primary</h4>
href={link.href} <ul className="space-y-2">
onClick={closeMobileMenu} {primaryLinks.map((link) => (
className="text-base font-medium text-text hover:text-primary-navy py-2 border-b border-gray-100 last:border-0" <li key={link.name}>
> <Link
{link.name} to={link.href}
</a> onClick={closeMobileMenu}
))} className="block text-base font-medium text-navy-light hover:text-white transition-colors py-2"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Services */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Services</h4>
<ul className="space-y-2">
{services.map((service) => (
<li key={service.name}>
<Link
to={service.href}
onClick={closeMobileMenu}
className="block text-sm text-navy-light hover:text-white transition-colors py-2 border-b border-white/10 last:border-0"
>
{service.name}
</Link>
</li>
))}
</ul>
</div>
{/* Industries */}
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Industries</h4>
<ul className="space-y-2">
{industries.map((industry) => (
<li key={industry.name}>
<Link
to={industry.href}
onClick={closeMobileMenu}
className="block text-sm text-navy-light hover:text-white transition-colors py-2 border-b border-white/10 last:border-0"
>
{industry.name}
</Link>
</li>
))}
</ul>
</div>
</nav> </nav>
<div className="mt-auto pt-6"> <div className="mt-auto pt-6">
<a <Link
href="/contact" to="/contact"
onClick={closeMobileMenu} onClick={closeMobileMenu}
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" 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"
> >
Request Consultation Request Consultation
</a> </Link>
</div> </div>
</div> </div>
</SheetContent> </SheetContent>

View File

@ -1,30 +1,27 @@
import * as React from 'react' import * as React from 'react'
const Badge = React.forwardRef< const Badge = React.forwardRef(
HTMLDivElement, ({ className = '', variant = 'default', ...props }, ref) => {
React.HTMLAttributes<HTMLDivElement> & { 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'
variant?: 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'error'
} const variants = {
>(({ className = '', variant = 'default', ...props }, ref) => { default: 'border-transparent bg-primary-navy text-white hover:bg-primary-navy-dark',
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' secondary: 'border-transparent bg-section-alt text-text hover:bg-opacity-80',
outline: 'text-foreground',
const variants = { success: 'border-transparent bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
default: 'border-transparent bg-primary-navy text-white hover:bg-primary-navy-dark', warning: 'border-transparent bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
secondary: 'border-transparent bg-section-alt text-text hover:bg-opacity-80', error: 'border-transparent bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
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 ( return (
<div <div
ref={ref} ref={ref}
className={`${baseStyles} ${variants[variant]} ${className}`} className={`${baseStyles} ${variants[variant]} ${className}`}
{...props} {...props}
/> />
) )
}) }
)
Badge.displayName = 'Badge' Badge.displayName = 'Badge'
export { Badge } export { Badge }

View File

@ -1,12 +1,6 @@
import * as React from 'react' import * as React from 'react'
const Button = React.forwardRef< const Button = React.forwardRef(({ className = '', variant = 'default', size = 'default', ...props }, ref) => {
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 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 = { const variants = {
@ -27,11 +21,11 @@ const Button = React.forwardRef<
return ( return (
<button <button
ref={ref} ref={ref}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} className={`${baseStyles} ${variants[variant] || ''} ${sizes[size] || ''} ${className}`}
{...props} {...props}
/> />
) )
}) })
Button.displayName = 'Button' Button.displayName = 'Button'
export { Button } export { Button }

View File

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const Card = React.forwardRef(
({ className = '', ...props }, ref) => ( ({ className = '', ...props }, ref) => (
<div <div
ref={ref} ref={ref}
@ -11,7 +11,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
) )
Card.displayName = 'Card' Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef(
({ className = '', ...props }, ref) => ( ({ className = '', ...props }, ref) => (
<div <div
ref={ref} ref={ref}
@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
) )
CardHeader.displayName = 'CardHeader' CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( const CardTitle = React.forwardRef(
({ className = '', ...props }, ref) => ( ({ className = '', ...props }, ref) => (
<h3 <h3
ref={ref} ref={ref}
@ -33,7 +33,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
) )
CardTitle.displayName = 'CardTitle' CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( const CardDescription = React.forwardRef(
({ className = '', ...props }, ref) => ( ({ className = '', ...props }, ref) => (
<p <p
ref={ref} ref={ref}
@ -44,14 +44,14 @@ const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttribu
) )
CardDescription.displayName = 'CardDescription' CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef(
({ className = '', ...props }, ref) => ( ({ className = '', ...props }, ref) => (
<div ref={ref} className={`p-6 pt-0 ${className}`} {...props} /> <div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
) )
) )
CardContent.displayName = 'CardContent' CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardFooter = React.forwardRef(
({ className = '', ...props }, ref) => ( ({ className = '', ...props }, ref) => (
<div <div
ref={ref} ref={ref}
@ -62,4 +62,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
) )
CardFooter.displayName = 'CardFooter' CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -18,10 +18,8 @@ const DialogOverlay = ({ className, ...props }) => (
) )
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef(
React.ElementRef<typeof DialogPrimitive.Content>, ({ className = '', children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className = '', children, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@ -55,10 +53,8 @@ const DialogFooter = ({ className, ...props }) => (
) )
DialogFooter.displayName = 'DialogFooter' DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef(
React.ElementRef<typeof DialogPrimitive.Title>, ({ className = '', ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className = '', ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={`text-lg font-semibold leading-none tracking-tight ${className}`} className={`text-lg font-semibold leading-none tracking-tight ${className}`}
@ -67,10 +63,8 @@ const DialogTitle = React.forwardRef<
)) ))
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef(
React.ElementRef<typeof DialogPrimitive.Description>, ({ className = '', ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className = '', ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={`text-sm text-muted-foreground ${className}`} className={`text-sm text-muted-foreground ${className}`}
@ -88,4 +82,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} }

View File

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>( const Input = React.forwardRef(
({ className = '', type, ...props }, ref) => { ({ className = '', type, ...props }, ref) => {
return ( return (
<input <input
@ -14,4 +14,4 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
) )
Input.displayName = 'Input' Input.displayName = 'Input'
export { Input } export { Input }

View File

@ -17,14 +17,19 @@ const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
)) ))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 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) => ( const SheetContent = React.forwardRef(({ className, children, side = 'right', ...props }, ref) => (
<SheetPrimitive.Portal> <SheetPrimitive.Portal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
ref={ref} ref={ref}
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}`} 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 ?? ''}`}
role="dialog"
aria-modal="true"
{...props} {...props}
> >
{children} {children}

View File

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>( const Textarea = React.forwardRef(
({ className = '', ...props }, ref) => { ({ className = '', ...props }, ref) => {
return ( return (
<textarea <textarea
@ -13,4 +13,4 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
) )
Textarea.displayName = 'Textarea' Textarea.displayName = 'Textarea'
export { Textarea } export { Textarea }

View File

@ -34,8 +34,9 @@ a:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Container - custom max-width */
.container { .container {
max-width: 1200px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 0 16px; padding: 0 16px;
} }

View File

@ -1,8 +1,9 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import router from './router.jsx'
import App from './App.jsx' import App from './App.jsx'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -13,13 +14,14 @@ const queryClient = new QueryClient({
}, },
}) })
createRoot(document.getElementById('root')).render( // Wrap the router with providers
const Root = () => (
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <RouterProvider router={router} />
<App /> <Toaster position="top-right" />
<Toaster position="top-right" />
</BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>
) )
createRoot(document.getElementById('root')).render(<Root />)

View File

@ -34,7 +34,7 @@ const EightXEight = () => {
{/* Our Expertise */} {/* Our Expertise */}
<section className="mb-16"> <section className="mb-16">
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">8x8 Expertise</h2> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
'VoIP Implementation', 'VoIP Implementation',
'Cloud PBX Migration', 'Cloud PBX Migration',
@ -45,13 +45,13 @@ const EightXEight = () => {
'System Integration', 'System Integration',
'Ongoing Support', 'Ongoing Support',
].map((expertise, index) => ( ].map((expertise, index) => (
<div key={index} className="flex items-center gap-3"> <div key={index} className="flex items-center gap-2 p-3 rounded-lg border border-border bg-card">
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0"> <div className="h-5 w-5 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<span className="text-lg text-text">{expertise}</span> <span className="text-sm font-medium text-text">{expertise}</span>
</div> </div>
))} ))}
</div> </div>
@ -60,7 +60,7 @@ const EightXEight = () => {
{/* Benefits */} {/* Benefits */}
<section className="mb-16"> <section className="mb-16">
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">Why Choose 8x8</h2> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[ {[
{ title: 'Scalability', desc: 'Easily scale your communications as your business grows' }, { title: 'Scalability', desc: 'Easily scale your communications as your business grows' },
{ title: 'Reliability', desc: '99.999% uptime guarantee for mission-critical communications' }, { title: 'Reliability', desc: '99.999% uptime guarantee for mission-critical communications' },
@ -69,7 +69,7 @@ const EightXEight = () => {
{ title: 'Analytics', desc: 'Real-time insights and reporting to optimize performance' }, { title: 'Analytics', desc: 'Real-time insights and reporting to optimize performance' },
{ title: 'Support', desc: '24/7 expert support to keep your communications running' }, { title: 'Support', desc: '24/7 expert support to keep your communications running' },
].map((benefit, index) => ( ].map((benefit, index) => (
<div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm"> <div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm hover:shadow-md transition-shadow">
<h3 className="text-xl font-semibold text-primary-navy mb-3">{benefit.title}</h3> <h3 className="text-xl font-semibold text-primary-navy mb-3">{benefit.title}</h3>
<p className="text-soft-text">{benefit.desc}</p> <p className="text-soft-text">{benefit.desc}</p>
</div> </div>

View File

@ -37,7 +37,7 @@ const About = () => {
{/* Our Values */} {/* Our Values */}
<section className="mb-16"> <section className="mb-16">
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">Our Values</h2> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[ {[
{ title: 'Business First', desc: 'We focus on your business outcomes, not just technology' }, { 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: 'Honesty', desc: 'We tell you what you need, not just what we can sell' },
@ -46,7 +46,7 @@ const About = () => {
{ title: 'Reliability', desc: 'When we say we will do something, we do it' }, { title: 'Reliability', desc: 'When we say we will do something, we do it' },
{ title: 'Support', desc: 'Our job doesn\'t end when installation completes' }, { title: 'Support', desc: 'Our job doesn\'t end when installation completes' },
].map((value, index) => ( ].map((value, index) => (
<div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm"> <div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm hover:shadow-md transition-shadow">
<h3 className="text-xl font-semibold text-primary-navy mb-3">{value.title}</h3> <h3 className="text-xl font-semibold text-primary-navy mb-3">{value.title}</h3>
<p className="text-soft-text">{value.desc}</p> <p className="text-soft-text">{value.desc}</p>
</div> </div>
@ -56,8 +56,8 @@ const About = () => {
{/* Our Expertise */} {/* Our Expertise */}
<section className="mb-16"> <section className="mb-16">
<h2 className="text-3xl font-bold text-primary-navy mb-8">Our Expertise</h2> <h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">Our Expertise</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
'8x8 Certified Partner', '8x8 Certified Partner',
'VoIP and UCaaS Solutions', 'VoIP and UCaaS Solutions',
@ -68,13 +68,13 @@ const About = () => {
'Disaster Recovery Planning', 'Disaster Recovery Planning',
'24/7 Support & Monitoring', '24/7 Support & Monitoring',
].map((expertise, index) => ( ].map((expertise, index) => (
<div key={index} className="flex items-center gap-3"> <div key={index} className="flex items-center gap-2 p-3 rounded-lg border border-border bg-card">
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0"> <div className="h-5 w-5 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<span className="text-lg text-text">{expertise}</span> <span className="text-sm font-medium text-text">{expertise}</span>
</div> </div>
))} ))}
</div> </div>

View File

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { useToast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
@ -8,7 +8,6 @@ import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
const Contact = () => { const Contact = () => {
const { toast } = useToast()
const [formState, setFormState] = useState({ const [formState, setFormState] = useState({
company: '', company: '',
name: '', name: '',
@ -18,6 +17,12 @@ const Contact = () => {
message: '', message: '',
service_interest: '', service_interest: '',
}) })
const [errors, setErrors] = useState({
company: '',
name: '',
email: '',
message: '',
})
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data) => api.post('/leads', data), mutationFn: (data) => api.post('/leads', data),
@ -32,20 +37,63 @@ const Contact = () => {
message: '', message: '',
service_interest: '', service_interest: '',
}) })
setErrors({
company: '',
name: '',
email: '',
message: '',
})
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || 'Failed to submit form. Please try again.') toast.error(error.message || 'Failed to submit form. Please try again.')
}, },
}) })
const validateForm = () => {
const newErrors = {
company: '',
name: '',
email: '',
message: '',
}
// Validate required fields
if (!formState.company.trim()) newErrors.company = 'Company name is required'
if (!formState.name.trim()) newErrors.name = 'Name is required'
if (!formState.message.trim()) newErrors.message = 'Message is required'
// Validate email format
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'
}
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 handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault() e.preventDefault()
if (!validateForm()) return
mutation.mutate(formState) mutation.mutate(formState)
} }
const handleChange = (e) => { const handleChange = (e) => {
const { name, value } = e.target const { name, value } = e.target
setFormState(prev => ({ ...prev, [name]: value })) setFormState(prev => ({ ...prev, [name]: value }))
// Clear error for this field as user types
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }))
}
} }
return ( return (
@ -101,7 +149,7 @@ const Contact = () => {
{/* Right - Form */} {/* Right - Form */}
<div> <div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
<div> <div>
<label htmlFor="company" className="block text-sm font-medium text-text mb-2"> <label htmlFor="company" className="block text-sm font-medium text-text mb-2">
Company Name <span className="text-red-600">*</span> Company Name <span className="text-red-600">*</span>
@ -114,7 +162,11 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
required required
placeholder="Your company name" placeholder="Your company name"
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.company && (
<p className="text-xs text-red-600 mt-1">{errors.company}</p>
)}
</div> </div>
<div> <div>
@ -129,7 +181,11 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
required required
placeholder="Your full name" placeholder="Your full name"
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.name && (
<p className="text-xs text-red-600 mt-1">{errors.name}</p>
)}
</div> </div>
<div> <div>
@ -144,7 +200,11 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
required required
placeholder="your.email@example.com" placeholder="your.email@example.com"
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.email && (
<p className="text-xs text-red-600 mt-1">{errors.email}</p>
)}
</div> </div>
<div> <div>
@ -200,7 +260,7 @@ const Contact = () => {
<label htmlFor="message" className="block text-sm font-medium text-text mb-2"> <label htmlFor="message" className="block text-sm font-medium text-text mb-2">
Message <span className="text-red-600">*</span> Message <span className="text-red-600">*</span>
</label> </label>
<Textarea <textarea
id="message" id="message"
name="message" name="message"
value={formState.message} value={formState.message}
@ -208,7 +268,11 @@ const Contact = () => {
required required
placeholder="Tell us about your needs..." placeholder="Tell us about your needs..."
rows={5} rows={5}
className={`flex min-h-[80px] w-full rounded-md border 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 ${errors.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
/> />
{errors.message && (
<p className="text-xs text-red-600 mt-1">{errors.message}</p>
)}
</div> </div>
<Button <Button

View File

@ -1,8 +1,13 @@
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import { services } from '@/data/services' import { services } from '@/data/services'
import { industries } from '@/data/industries'
import { useNavigate } from 'react-router-dom'
import { MapPin } from 'lucide-react'
const Home = () => { const Home = () => {
const navigate = useNavigate()
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Hero Section */} {/* Hero Section */}
@ -83,9 +88,9 @@ const Home = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-soft-text mb-4">{service.fullDesc}</p> <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"> <Button variant="link" className="text-primary-navy p-0 h-auto text-sm" onClick={() => navigate(`/services/${service.id}`)}>
Learn more Learn more
</a> </Button>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -150,21 +155,16 @@ const Home = () => {
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[ {industries.map((industry) => (
{ name: 'Healthcare', href: '/industries/healthcare' }, <Card key={industry.id} className="hover:shadow-md transition-shadow cursor-pointer">
{ 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"> <CardContent className="p-6">
<h3 className="text-xl font-semibold text-primary-navy mb-3">{industry.name}</h3> <h3 className="text-xl font-semibold text-primary-navy mb-3">{industry.name}</h3>
<p className="text-sm text-soft-text mb-4"> <p className="text-sm text-soft-text mb-4">
Industry-specific solutions designed to address your unique challenges and requirements. Industry-specific solutions designed to address your unique challenges and requirements.
</p> </p>
<a href={industry.href} className="text-primary-navy font-medium hover:underline"> <Button variant="link" className="text-primary-navy p-0 h-auto text-sm" onClick={() => navigate(industry.href)}>
Learn more Learn more
</a> </Button>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -181,12 +181,9 @@ const Home = () => {
<p className="text-xl text-section-alt mb-8 max-w-2xl mx-auto"> <p className="text-xl text-section-alt mb-8 max-w-2xl mx-auto">
Schedule a free consultation with our communications experts. Schedule a free consultation with our communications experts.
</p> </p>
<a <Button variant="default" size="lg" onClick={() => navigate('/contact')}>
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 Request Consultation
</a> </Button>
</div> </div>
</section> </section>
</div> </div>

View File

@ -12,7 +12,7 @@ const Industries = () => {
</section> </section>
{/* Industries Grid */} {/* Industries Grid */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
{industries.map((industry) => ( {industries.map((industry) => (
<div key={industry.id} className="group cursor-pointer"> <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="rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow bg-card border border-border">
@ -28,7 +28,24 @@ const Industries = () => {
</h3> </h3>
</div> </div>
<p className="text-soft-text mb-4">{industry.shortDesc}</p> <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">
<div className="mb-4">
<h4 className="text-sm font-semibold text-text mb-2">Pain Points We Solve</h4>
<div className="space-y-2">
{industry.painPoints.slice(0, 2).map((painPoint, index) => (
<div key={index} className="flex items-start gap-2 text-sm text-soft-text">
<div className="h-4 w-4 rounded-full bg-red-100 text-red-600 flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="h-2 w-2" 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>{painPoint}</span>
</div>
))}
</div>
</div>
<a href={`/industries/${industry.id}`} className="inline-flex items-center gap-1 text-primary-navy font-medium hover:underline mt-2">
Learn more Learn more
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />

View File

@ -1,8 +1,10 @@
import { useParams } from 'react-router-dom'
import { industries } from '@/data/industries' import { industries } from '@/data/industries'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
const IndustryDetail = ({ name }) => { const IndustryDetail = () => {
const industry = industries.find(i => i.id === name) const { slug } = useParams()
const industry = industries.find(i => i.id === slug)
if (!industry) { if (!industry) {
return ( return (
@ -39,7 +41,7 @@ const IndustryDetail = ({ name }) => {
<section className="mb-12"> <section className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Pain Points We Solve</h2> <h2 className="text-2xl font-bold text-primary-navy mb-4">Pain Points We Solve</h2>
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{industry.painPoints.map((painPoint, index) => ( {industry.painPoints.map((painPoint, index) => (
<div key={index} className="flex items-start gap-3"> <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"> <div className="h-6 w-6 rounded-full bg-red-100 text-red-700 flex items-center justify-center flex-shrink-0 mt-1">
@ -55,7 +57,7 @@ const IndustryDetail = ({ name }) => {
<section className="mb-12"> <section className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Our Solutions</h2> <h2 className="text-2xl font-bold text-primary-navy mb-4">Our Solutions</h2>
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{industry.solutions.map((solution, index) => ( {industry.solutions.map((solution, index) => (
<div key={index} className="flex items-start gap-3"> <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"> <div className="h-6 w-6 rounded-full bg-green-100 text-green-700 flex items-center justify-center flex-shrink-0 mt-1">

View File

@ -1,8 +1,10 @@
import { useParams } from 'react-router-dom'
import { services } from '@/data/services' import { services } from '@/data/services'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
const ServiceDetail = ({ name }) => { const ServiceDetail = () => {
const service = services.find(s => s.id === name) const { slug } = useParams()
const service = services.find(s => s.id === slug)
if (!service) { if (!service) {
return ( return (
@ -38,8 +40,8 @@ const ServiceDetail = ({ name }) => {
</section> </section>
<section className="mb-12"> <section className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">How Queue North Helps</h2> <h2 className="text-2xl font-bold text-primary-navy mb-4">Key Benefits</h2>
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{service.benefits.map((benefit, index) => ( {service.benefits.map((benefit, index) => (
<div key={index} className="flex items-start gap-3"> <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"> <div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0 mt-1">
@ -55,7 +57,7 @@ const ServiceDetail = ({ name }) => {
<section className="mb-12"> <section className="mb-12">
<h2 className="text-2xl font-bold text-primary-navy mb-4">Ideal For</h2> <h2 className="text-2xl font-bold text-primary-navy mb-4">Ideal For</h2>
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{service.idealFor.map((item, index) => ( {service.idealFor.map((item, index) => (
<div key={index} className="flex items-start gap-3"> <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"> <div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0 mt-1">

View File

@ -28,15 +28,19 @@ const Services = () => {
</h3> </h3>
</div> </div>
<p className="text-soft-text mb-4">{service.shortDesc}</p> <p className="text-soft-text mb-4">{service.shortDesc}</p>
<p className="text-sm text-soft-text mb-4">{service.fullDesc}</p> <div className="space-y-2 mb-4">
<div className="flex flex-wrap gap-2 mb-4"> {service.benefits.slice(0, 3).map((benefit, index) => (
{service.benefits.slice(0, 2).map((benefit, index) => ( <div key={index} className="flex items-center gap-2 text-sm text-soft-text">
<span key={index} className="px-2 py-1 bg-section-alt rounded text-xs text-soft-text"> <div className="h-4 w-4 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
{benefit} <svg className="h-2 w-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<span>{benefit}</span>
</div>
))} ))}
</div> </div>
<a href={`/services/${service.id}`} className="text-primary-navy font-medium hover:underline inline-flex items-center gap-1"> <a href={`/services/${service.id}`} className="inline-flex items-center gap-1 text-primary-navy font-medium hover:underline mt-2">
Learn more Learn more
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />

View File

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { useToast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
@ -8,7 +8,6 @@ import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
const Support = () => { const Support = () => {
const { toast } = useToast()
const [formState, setFormState] = useState({ const [formState, setFormState] = useState({
name: '', name: '',
company: '', company: '',
@ -17,6 +16,12 @@ const Support = () => {
issue: '', issue: '',
priority: 'medium', priority: 'medium',
}) })
const [errors, setErrors] = useState({
name: '',
company: '',
email: '',
issue: '',
})
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data) => api.post('/support', data), mutationFn: (data) => api.post('/support', data),
@ -30,20 +35,68 @@ const Support = () => {
issue: '', issue: '',
priority: 'medium', priority: 'medium',
}) })
setErrors({
name: '',
company: '',
email: '',
issue: '',
})
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || 'Failed to submit form. Please try again.') toast.error(error.message || 'Failed to submit form. Please try again.')
}, },
}) })
const validateForm = () => {
const newErrors = {
name: '',
company: '',
email: '',
issue: '',
}
// Validate required fields
if (!formState.name.trim()) newErrors.name = 'Name is required'
if (!formState.company.trim()) newErrors.company = 'Company name is required'
if (!formState.issue.trim()) newErrors.issue = 'Please describe your issue'
// Validate email format
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'
}
// Validate issue minimum length (10 chars matches server-side Zod rule)
if (formState.issue.trim().length < 10) {
newErrors.issue = 'Issue description must be at least 10 characters'
}
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 handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault() e.preventDefault()
if (!validateForm()) return
mutation.mutate(formState) mutation.mutate(formState)
} }
const handleChange = (e) => { const handleChange = (e) => {
const { name, value } = e.target const { name, value } = e.target
setFormState(prev => ({ ...prev, [name]: value })) setFormState(prev => ({ ...prev, [name]: value }))
// Clear error for this field as user types
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }))
}
} }
return ( return (
@ -104,7 +157,7 @@ const Support = () => {
{/* Right - Form */} {/* Right - Form */}
<div> <div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-text mb-2"> <label htmlFor="name" className="block text-sm font-medium text-text mb-2">
Name <span className="text-red-600">*</span> Name <span className="text-red-600">*</span>
@ -117,7 +170,11 @@ const Support = () => {
onChange={handleChange} onChange={handleChange}
required required
placeholder="Your full name" placeholder="Your full name"
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.name && (
<p className="text-xs text-red-600 mt-1">{errors.name}</p>
)}
</div> </div>
<div> <div>
@ -132,7 +189,11 @@ const Support = () => {
onChange={handleChange} onChange={handleChange}
required required
placeholder="Your company name" placeholder="Your company name"
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.company && (
<p className="text-xs text-red-600 mt-1">{errors.company}</p>
)}
</div> </div>
<div> <div>
@ -147,7 +208,11 @@ const Support = () => {
onChange={handleChange} onChange={handleChange}
required required
placeholder="your.email@example.com" placeholder="your.email@example.com"
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/> />
{errors.email && (
<p className="text-xs text-red-600 mt-1">{errors.email}</p>
)}
</div> </div>
<div> <div>
@ -184,7 +249,7 @@ const Support = () => {
<label htmlFor="issue" className="block text-sm font-medium text-text mb-2"> <label htmlFor="issue" className="block text-sm font-medium text-text mb-2">
Describe Your Issue <span className="text-red-600">*</span> Describe Your Issue <span className="text-red-600">*</span>
</label> </label>
<Textarea <textarea
id="issue" id="issue"
name="issue" name="issue"
value={formState.issue} value={formState.issue}
@ -192,7 +257,11 @@ const Support = () => {
required required
placeholder="Please describe your issue in detail..." placeholder="Please describe your issue in detail..."
rows={5} rows={5}
className={`flex min-h-[80px] w-full rounded-md border 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 ${errors.issue ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
/> />
{errors.issue && (
<p className="text-xs text-red-600 mt-1">{errors.issue}</p>
)}
</div> </div>
<Button <Button

View File

@ -18,18 +18,9 @@ const router = createBrowserRouter([
{ index: true, element: <Home /> }, { index: true, element: <Home /> },
{ path: 'about', element: <About /> }, { path: 'about', element: <About /> },
{ path: 'services', element: <Services /> }, { path: 'services', element: <Services /> },
{ path: 'services/unified-communications', element: <ServiceDetail name="unified-communications" /> }, { path: 'services/:slug', element: <ServiceDetail /> },
{ 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', element: <Industries /> },
{ path: 'industries/healthcare', element: <IndustryDetail name="healthcare" /> }, { path: 'industries/:slug', element: <IndustryDetail /> },
{ 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: '8x8', element: <EightXEight /> },
{ path: 'contact', element: <Contact /> }, { path: 'contact', element: <Contact /> },
{ path: 'support', element: <Support /> }, { path: 'support', element: <Support /> },

2629
styles.css

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ export default {
text: '#0F172A', text: '#0F172A',
muted: '#475569', muted: '#475569',
'soft-text': '#64748B', 'soft-text': '#64748B',
'navy-light': '#68A3B8',
primary: { primary: {
navy: '#0B2A3C', navy: '#0B2A3C',
'navy-dark': '#071A2A', 'navy-dark': '#071A2A',
@ -31,7 +32,16 @@ export default {
spacing: { spacing: {
'18': '4.5rem', '18': '4.5rem',
'22': '5.5rem', '22': '5.5rem',
'24': '6rem',
'26': '6.5rem', '26': '6.5rem',
'28': '7rem',
'32': '8rem',
'36': '9rem',
'40': '10rem',
'48': '12rem',
},
maxWidth: {
'container': '1280px',
}, },
boxShadow: { boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
@ -47,5 +57,5 @@ export default {
}, },
}, },
}, },
plugins: [], plugins: [require('tailwindcss-animate')],
} }