Compare commits
23 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
796d372e79 | |
|
|
c4985e37bc | |
|
|
c2d5873f08 | |
|
|
7257633d94 | |
|
|
39ee1fe537 | |
|
|
6bfd804313 | |
|
|
4ac0fa250d | |
|
|
ee5af44b58 | |
|
|
931c9a9095 | |
|
|
21b5418461 | |
|
|
71347d070b | |
|
|
87203bcded | |
|
|
a7fa18ec63 | |
|
|
f03229dd50 | |
|
|
35aaa639ec | |
|
|
76aa71691f | |
|
|
287e2b79f6 | |
|
|
0b7da4d237 | |
|
|
ba0d039cdc | |
|
|
1f3e3864f9 | |
|
|
c83dc08660 | |
|
|
d2bb91fd72 | |
|
|
8352558240 |
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Environment configuration
|
||||
# Copy this file to .env and customize as needed
|
||||
|
||||
NODE_ENV=production
|
||||
SERVER_PORT=3001
|
||||
|
|
@ -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
|
||||
node_modules/
|
||||
|
|
@ -26,3 +33,4 @@ pnpm-debug.log*
|
|||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
.learnings/
|
||||
|
|
|
|||
125
BUILD_SUMMARY.md
|
|
@ -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
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
# Queue-North-Website — Development Log
|
||||
|
||||
## v0.1.1 — Phase 1 Agent-Pass Checkpoint — 2026-05-12
|
||||
|
||||
**Ripley** — Version correction and push rule alignment
|
||||
- Corrected Phase 1 version from `0.1.0` to `0.1.1` after user clarified that each completed agent pass/checkpoint should be uploaded to `dev` and reflected as a patch increment.
|
||||
- Updated package/version docs to make patch increments map to completed agent passes within the active phase.
|
||||
- Re-ran build and backend health check before committing/pushing.
|
||||
|
||||
## 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.
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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*
|
||||
41
HISTORY.md
|
|
@ -1,41 +0,0 @@
|
|||
# Queue-North-Website — Changelog
|
||||
|
||||
## v0.1.1 — Phase 1 Agent-Pass Checkpoint — 2026-05-12
|
||||
|
||||
### Changed
|
||||
- Corrected Phase 1 version to reflect completed agent-pass checkpoint semantics.
|
||||
- Documented that every completed agent pass/checkpoint within a phase increments the patch version.
|
||||
- Confirmed Phase 1 verified output is pushed to `dev` at `0.1.1`.
|
||||
|
||||
### Verified
|
||||
- `npm run build` passes.
|
||||
- Backend health endpoint responds successfully at `/api/health`.
|
||||
|
||||
## 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.
|
||||
|
|
@ -818,7 +818,7 @@ Version numbers must correlate directly to the active overhaul phase.
|
|||
- **Phase 4** uses `0.4.x`
|
||||
- **Phase 5** uses `0.5.x`
|
||||
|
||||
Rule: the minor version maps to the phase number; the patch version maps to each completed agent pass/checkpoint 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.
|
||||
- Replace hash routing with React Router.
|
||||
- Move repeated content into data files.
|
||||
- Remove legacy `styles.css` file.
|
||||
|
||||
Result:
|
||||
|
||||
- Site content exists in the new React app.
|
||||
- Routes are clean and shareable.
|
||||
- Structure is maintainable.
|
||||
- Old global stylesheet removed.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -966,12 +968,6 @@ Recommended pipeline:
|
|||
|
||||
## Recommendation
|
||||
|
||||
Start with **Scarlett first**, then move to Neo.
|
||||
|
||||
Reason:
|
||||
|
||||
This is primarily a brand and layout overhaul. If the app is scaffolded before the visual system is defined, the team may build clean code around the wrong structure. The better path is:
|
||||
|
||||
```txt
|
||||
Design system first → scaffold/build → polish → verify
|
||||
```
|
||||
|
|
|
|||
69
PROJECT.md
|
|
@ -1,69 +0,0 @@
|
|||
# Queue North Website
|
||||
|
||||
## Overview
|
||||
Project: Queue-North-Website
|
||||
Created: 2026-05-11
|
||||
Status: Active (Phase 1 Complete - 0.1.1)
|
||||
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`
|
||||
- Every completed agent pass/checkpoint within Phase 1 increments patch: `0.1.1`, `0.1.2`, etc.
|
||||
- Phase 2 releases use `0.2.x`
|
||||
- Phase 2 baseline: `0.2.0`
|
||||
- Phase 2 patches/iterations: `0.2.1`, `0.2.2`, etc.
|
||||
- Phase 3 releases use `0.3.x`
|
||||
- Phase 4 releases use `0.4.x`
|
||||
- Phase 5 releases use `0.5.x`
|
||||
|
||||
Do not use unrelated semantic version bumps during the overhaul. The minor number tracks the phase; the patch number tracks changes within that phase.
|
||||
|
||||
## Phase Completion Git Rule
|
||||
|
||||
Push to `dev` after every completed and verified phase.
|
||||
|
||||
- Agents do not touch git.
|
||||
- Bishop verifies and updates docs.
|
||||
- Ripley performs final checks, commits, and pushes to `dev`.
|
||||
|
||||
## Conventions
|
||||
- Follow AGENTS.md for agent dispatch protocol
|
||||
- Ripley coordinates, Neo codes, Scarlett styles, Bishop verifies, Hudson secures
|
||||
- All agents read STRUCTURE.md before starting tasks
|
||||
- Ripley owns git — no agent touches git directly
|
||||
187
README.md
|
|
@ -88,6 +88,64 @@ Primary structure:
|
|||
/support
|
||||
```
|
||||
|
||||
## Overhaul Phases
|
||||
|
||||
Version numbers correlate directly to the active phase:
|
||||
|
||||
- **Phase 1 — Stack Scaffold**: `0.1.x` ✅ Complete
|
||||
- ~~Vite + React app foundation~~ ✅
|
||||
- ~~Tailwind CSS setup~~ ✅
|
||||
- ~~shadcn/ui-style primitives~~ ✅
|
||||
- ~~React Router~~ ✅
|
||||
- ~~Express backend~~ ✅
|
||||
- ~~better-sqlite3 database~~ ✅
|
||||
- ~~Initial API health/contact/support paths~~ ✅
|
||||
|
||||
- **Phase 2 — Layout Rebuild**: `0.2.x` ✅ Complete
|
||||
- ~~App shell: Header, Footer, layout wrapper, mobile nav~~ ✅
|
||||
- ~~Route pages fully built and navigable~~ ✅
|
||||
- ~~Existing business content ported into React~~ ✅
|
||||
- ~~Repeated service/industry content moved into data files~~ ✅
|
||||
- ~~Static hash routing fully replaced by React Router~~ ✅
|
||||
|
||||
- **Phase 3 — Visual Overhaul**: `0.3.x` ✅ Complete
|
||||
- ~~Modern light-first business design~~ ✅
|
||||
- ~~Tailwind theme polish~~ ✅
|
||||
- ~~Typography, spacing, radius, shadows, and responsive rhythm~~ ✅
|
||||
- ~~Refined service/industry cards and CTA sections~~ ✅
|
||||
- ~~Mobile-first layout polish~~ ✅
|
||||
|
||||
- **Phase 4 — Forms + Backend Hardening**: `0.4.x` ✅ Complete
|
||||
- ~~Contact and support forms fully wired to Express~~ ✅
|
||||
- ~~SQLite persistence verified~~ ✅
|
||||
- ~~Client-side validation + Sonner feedback~~ ✅
|
||||
- ~~Server-side validation + input sanitization~~ ✅
|
||||
- ~~Optional Zoho forwarding layer~~ ✅
|
||||
- ~~Rate limiting + security headers + CORS~~ ✅
|
||||
- ~~Backend/API hardening as needed~~ ✅
|
||||
|
||||
- **Phase 5 — Verification + Redesign**: `0.5.x` 🔄 In Progress
|
||||
- ~~SPA router fix (BrowserRouter → RouterProvider)~~ ✅
|
||||
- ~~TS generics stripped from .jsx files~~ ✅
|
||||
- ~~Mobile menu Sheet/Dialog fix~~ ✅
|
||||
- ~~DialogTitle accessibility fix~~ ✅
|
||||
- ~~SPA catch-all route for client-side navigation~~ ✅
|
||||
- ~~Image assets copied to public/ (were 404)~~ ✅
|
||||
- ~~Real Queue North logo replacing placeholder~~ ✅
|
||||
- ~~CSP updated for Google Fonts~~ ✅
|
||||
- ~~Hamburger menu + SheetContent CSS fix~~ ✅
|
||||
- ~~tailwindcss-animate installed and configured~~ ✅
|
||||
- Hero section rewrite — B2B clarity, 8x8 partnership prominence
|
||||
- Trust signals section — metrics, badges, certifications
|
||||
- Services rewrite — business outcomes over technical jargon
|
||||
- Why Queue North refinement — concrete differentiators
|
||||
- Footer + CTA pass — contact paths everywhere
|
||||
- Remaining P0/P1 audit fixes (Zoho, su-exec, email constraint)
|
||||
- Accessibility checks
|
||||
- Final push to `dev` for the completed phase
|
||||
|
||||
Patch versions increment for completed task batches after the full pipeline finishes. Dispatch a task batch, run it through the required agents, then push that completed batch once. Example: Docker task batch goes through Neo → Private Hudson → Bishop → Ripley, then pushes as `0.2.1`. Notes/tags should use the version number only.
|
||||
|
||||
## Backend Goals
|
||||
|
||||
Initial API endpoints:
|
||||
|
|
@ -105,6 +163,131 @@ Initial SQLite tables:
|
|||
|
||||
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.
|
||||
|
|
|
|||
57
SCRIPTS.md
|
|
@ -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":"..."}
|
||||
```
|
||||
56
STRUCTURE.md
|
|
@ -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`
|
||||
- Every completed agent pass/checkpoint within Phase 1 increments patch: `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 each completed agent pass/checkpoint 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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -1,22 +1,27 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "queuenorth-website",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.8",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"helmet": "^8.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.3",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
|
@ -37,7 +42,6 @@
|
|||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
|
@ -774,7 +778,6 @@
|
|||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
|
|
@ -796,7 +799,6 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -806,14 +808,12 @@
|
|||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
|
@ -824,7 +824,6 @@
|
|||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
|
|
@ -838,7 +837,6 @@
|
|||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
|
|
@ -848,7 +846,6 @@
|
|||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
|
|
@ -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": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
|
@ -1845,14 +1906,12 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
|
|
@ -1866,7 +1925,6 @@
|
|||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
|
|
@ -1972,7 +2030,6 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -2044,7 +2101,6 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
|
|
@ -2153,7 +2209,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -2214,7 +2269,6 @@
|
|||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
|
|
@ -2239,7 +2293,6 @@
|
|||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
|
|
@ -2293,7 +2346,6 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -2367,11 +2419,27 @@
|
|||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
|
|
@ -2467,14 +2535,12 @@
|
|||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
|
|
@ -2681,6 +2747,24 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -2700,7 +2784,6 @@
|
|||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
|
|
@ -2717,7 +2800,6 @@
|
|||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
|
|
@ -2730,7 +2812,6 @@
|
|||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
|
|
@ -2746,7 +2827,6 @@
|
|||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
|
|
@ -2830,7 +2910,6 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -2926,7 +3005,6 @@
|
|||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
|
|
@ -2981,6 +3059,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
|
@ -3045,6 +3132,15 @@
|
|||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
@ -3058,7 +3154,6 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
|
|
@ -3071,7 +3166,6 @@
|
|||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.3"
|
||||
|
|
@ -3087,7 +3181,6 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3107,7 +3200,6 @@
|
|||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
|
|
@ -3120,7 +3212,6 @@
|
|||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
|
|
@ -3130,7 +3221,6 @@
|
|||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
|
|
@ -3173,7 +3263,6 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
|
@ -3186,7 +3275,6 @@
|
|||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
|
|
@ -3239,7 +3327,6 @@
|
|||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
|
|
@ -3258,7 +3345,6 @@
|
|||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
|
|
@ -3338,7 +3424,6 @@
|
|||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
|
|
@ -3350,7 +3435,6 @@
|
|||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3415,7 +3499,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3425,7 +3508,6 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3435,7 +3517,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -3487,7 +3568,6 @@
|
|||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
|
|
@ -3500,14 +3580,12 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
|
|
@ -3520,7 +3598,6 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -3530,7 +3607,6 @@
|
|||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
|
@ -3540,7 +3616,6 @@
|
|||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3569,7 +3644,6 @@
|
|||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
|
|
@ -3587,7 +3661,6 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
||||
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3613,7 +3686,6 @@
|
|||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3656,7 +3728,6 @@
|
|||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -3682,7 +3753,6 @@
|
|||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
|
|
@ -3696,7 +3766,6 @@
|
|||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
|
|
@ -3768,7 +3837,6 @@
|
|||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3979,7 +4047,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
|
|
@ -4003,7 +4070,6 @@
|
|||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
|
|
@ -4026,7 +4092,6 @@
|
|||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -4048,7 +4113,6 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
|
|
@ -4104,7 +4168,6 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -4386,7 +4449,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -4451,7 +4513,6 @@
|
|||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
|
|
@ -4490,7 +4551,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -4503,7 +4563,6 @@
|
|||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
|
@ -4537,6 +4596,15 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
|
|
@ -4569,7 +4637,6 @@
|
|||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
|
|
@ -4579,7 +4646,6 @@
|
|||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
|
|
@ -4592,7 +4658,6 @@
|
|||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -4609,7 +4674,6 @@
|
|||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
|
|
@ -4627,7 +4691,6 @@
|
|||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -4640,7 +4703,6 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
|
|
@ -4672,7 +4734,6 @@
|
|||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
|
|
|
|||
36
package.json
|
|
@ -1,27 +1,39 @@
|
|||
{
|
||||
"name": "queuenorth-website",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node server/index.js",
|
||||
"server": "node server/index.js"
|
||||
"server": "node server/index.js",
|
||||
"docker:build": "docker build -t queuenorth-website .",
|
||||
"docker:run": "docker run -p 3001:3001 --rm --name queuenorth -v queuenorth-db:/app/db -v queuenorth-logs:/app/logs --env NODE_ENV=production queuenorth-website",
|
||||
"docker:compose:up": "docker-compose up -d",
|
||||
"docker:compose:down": "docker-compose down",
|
||||
"docker:compose:logs": "docker-compose logs -f",
|
||||
"docker:push": "bash scripts/docker-push.sh",
|
||||
"docker:test": "bash scripts/docker-test.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"helmet": "^8.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-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",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
|
|
@ -29,10 +41,10 @@
|
|||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.7",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.2",
|
||||
"postcss": "^8.4.49",
|
||||
"concurrently": "^9.1.2"
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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 |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 346 KiB |
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
416
server/index.js
|
|
@ -1,25 +1,125 @@
|
|||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { existsSync, mkdirSync, chmodSync } from 'fs'
|
||||
import sqlite3 from 'better-sqlite3'
|
||||
import z from 'zod'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import helmet from 'helmet'
|
||||
import cors from 'cors'
|
||||
|
||||
// --- Setup ---
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const app = express()
|
||||
|
||||
// Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting
|
||||
app.set('trust proxy', 1)
|
||||
const dbPath = path.join(__dirname, '../db/queuenorth.db')
|
||||
const dbDir = path.dirname(dbPath)
|
||||
|
||||
// Create db directory if it doesn't exist
|
||||
if (!existsSync(path.dirname(dbPath))) {
|
||||
mkdirSync(path.dirname(dbPath), { recursive: true })
|
||||
if (!existsSync(dbDir)) {
|
||||
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
|
||||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.static(path.join(__dirname, '../dist')))
|
||||
app.use(express.json({ limit: '1mb' }))
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
|
||||
|
||||
// Rate limiting for API routes only
|
||||
app.use('/api', apiLimiter)
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now()
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - start
|
||||
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'
|
||||
log[level](`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`)
|
||||
})
|
||||
next()
|
||||
})
|
||||
|
||||
// --- Database ---
|
||||
const db = sqlite3(dbPath)
|
||||
|
|
@ -58,63 +158,208 @@ const initSchema = () => {
|
|||
|
||||
initSchema()
|
||||
|
||||
// --- Sanitization Helper ---
|
||||
const sanitizeString = (input, maxLength) => {
|
||||
if (typeof input !== 'string') return input
|
||||
// Trim whitespace
|
||||
let sanitized = input.trim()
|
||||
// Remove HTML/script tags to prevent XSS
|
||||
sanitized = sanitized.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
||||
sanitized = sanitized.replace(/<[^>]*>/g, '')
|
||||
// Truncate to max length
|
||||
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 ---
|
||||
const leadSchema = z.object({
|
||||
company: z.string().min(1, 'Company name is required'),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Valid email is required'),
|
||||
phone: z.string().optional(),
|
||||
zip: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
service_interest: z.string().optional(),
|
||||
company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'),
|
||||
name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'),
|
||||
email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'),
|
||||
phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
|
||||
zip: z.string().trim().max(10, 'ZIP code must be 10 characters or less').optional().or(z.literal('').transform(() => undefined)),
|
||||
message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)),
|
||||
service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
|
||||
})
|
||||
|
||||
const supportSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
company: z.string().min(1, 'Company name is required'),
|
||||
email: z.string().email('Valid email is required'),
|
||||
phone: z.string().optional(),
|
||||
issue: z.string().min(10, 'Please provide more details about your issue'),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'),
|
||||
company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'),
|
||||
email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'),
|
||||
phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)),
|
||||
issue: z.string().min(10, 'Please provide at least 10 characters describing your issue').trim().max(5000, 'Issue description must be 5000 characters or less'),
|
||||
priority: z.enum(['low', 'medium', 'high'], {
|
||||
errorMap: () => ({ message: 'Priority must be low, medium, or high' }),
|
||||
}).transform((val) => val?.toLowerCase() ?? undefined).optional().or(z.literal('').transform(() => undefined)),
|
||||
})
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
// Health check
|
||||
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
|
||||
app.post('/api/leads', (req, res) => {
|
||||
try {
|
||||
const parsed = leadSchema.safeParse(req.body)
|
||||
|
||||
|
||||
if (!parsed.success) {
|
||||
const fieldErrors = {}
|
||||
for (const issue of parsed.error.issues) {
|
||||
if (issue.path[0]) {
|
||||
fieldErrors[issue.path[0]] = issue.message
|
||||
}
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
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(`
|
||||
INSERT INTO leads (company, name, email, phone, zip, message, service_interest)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
stmt.run(
|
||||
parsed.data.company,
|
||||
parsed.data.name,
|
||||
parsed.data.email,
|
||||
parsed.data.phone || null,
|
||||
parsed.data.zip || null,
|
||||
parsed.data.message || null,
|
||||
parsed.data.service_interest || null
|
||||
const result = stmt.run(
|
||||
sanitized.company,
|
||||
sanitized.name,
|
||||
sanitized.email,
|
||||
sanitized.phone || null,
|
||||
sanitized.zip || null,
|
||||
sanitized.message || 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) {
|
||||
console.error('Error submitting lead:', err)
|
||||
log.error('Error submitting lead:', err)
|
||||
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) => {
|
||||
try {
|
||||
const parsed = supportSchema.safeParse(req.body)
|
||||
|
||||
|
||||
if (!parsed.success) {
|
||||
const fieldErrors = {}
|
||||
for (const issue of parsed.error.issues) {
|
||||
if (issue.path[0]) {
|
||||
fieldErrors[issue.path[0]] = issue.message
|
||||
}
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
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(`
|
||||
INSERT INTO support_requests (name, company, email, phone, issue, priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
stmt.run(
|
||||
parsed.data.name,
|
||||
parsed.data.company,
|
||||
parsed.data.email,
|
||||
parsed.data.phone || null,
|
||||
parsed.data.issue,
|
||||
parsed.data.priority || 'medium'
|
||||
const result = stmt.run(
|
||||
sanitized.name,
|
||||
sanitized.company,
|
||||
sanitized.email,
|
||||
sanitized.phone || null,
|
||||
sanitized.issue,
|
||||
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) {
|
||||
console.error('Error submitting support request:', err)
|
||||
log.error('Error submitting support request:', err)
|
||||
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 ---
|
||||
const PORT = process.env.SERVER_PORT || 3001
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`)
|
||||
console.log(`Health check: http://localhost:${PORT}/api/health`)
|
||||
log.info(`Server running on http://localhost:${PORT}`)
|
||||
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}`)
|
||||
})
|
||||
|
|
|
|||
69
src/App.css
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import { Outlet } from 'react-router-dom'
|
||||
import Header from './components/layout/Header.jsx'
|
||||
import Footer from './components/layout/Footer.jsx'
|
||||
import MobileNav from './components/layout/MobileNav.jsx'
|
||||
import './App.css'
|
||||
import './index.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans bg-background text-text">
|
||||
<Header />
|
||||
<MobileNav />
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
|
@ -17,4 +15,4 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
const Footer = () => {
|
||||
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 = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Services', href: '/services' },
|
||||
|
|
@ -31,34 +37,31 @@ const Footer = () => {
|
|||
return (
|
||||
<footer className="bg-primary-navy text-white">
|
||||
<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 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Queue North"
|
||||
className="h-6 w-auto"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
<span className="font-bold text-lg">Queue North</span>
|
||||
</div>
|
||||
<p className="text-soft-text text-sm mb-4">
|
||||
Modern communications infrastructure without the vendor noise.
|
||||
</p>
|
||||
<p className="text-soft-text text-sm">
|
||||
© {currentYear} Queue North Technologies. All rights reserved.
|
||||
</p>
|
||||
<p className="text-navy-light text-sm mb-3">{companyInfo.tagline}</p>
|
||||
<p className="text-navy-light text-sm mb-4">{companyInfo.address}</p>
|
||||
<p className="text-navy-light text-xs">© {currentYear} Queue North Technologies. All rights reserved.</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<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">
|
||||
{quickLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
<a
|
||||
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}
|
||||
</a>
|
||||
|
|
@ -69,13 +72,13 @@ const Footer = () => {
|
|||
|
||||
{/* Services */}
|
||||
<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">
|
||||
{services.map((service) => (
|
||||
<li key={service.name}>
|
||||
<a
|
||||
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}
|
||||
</a>
|
||||
|
|
@ -86,13 +89,13 @@ const Footer = () => {
|
|||
|
||||
{/* Industries */}
|
||||
<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">
|
||||
{industries.map((industry) => (
|
||||
<li key={industry.name}>
|
||||
<a
|
||||
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}
|
||||
</a>
|
||||
|
|
@ -103,8 +106,8 @@ const Footer = () => {
|
|||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="border-t border-white/10 mt-8 pt-8 text-center">
|
||||
<p className="text-soft-text text-sm">
|
||||
<div className="border-t border-white/10 pt-8">
|
||||
<p className="text-center text-navy-light text-sm">
|
||||
8x8 Certified Partner | Veteran Owned | 25+ Years Experience
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
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 [isScrolled, setIsScrolled] = useState(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
|
|
@ -22,68 +25,134 @@ const Header = () => {
|
|||
{ 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 (
|
||||
<>
|
||||
<header className={`sticky top-0 z-40 w-full border-b transition-all duration-300 ${isScrolled ? 'bg-background/90 backdrop-blur shadow-sm -translate-y-px' : 'bg-transparent'}`}>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Queue North Technologies"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
<span className="font-bold text-xl text-primary-navy hidden sm:block">Queue North</span>
|
||||
</div>
|
||||
<header className={`sticky top-0 z-40 w-full transition-all duration-300 ${isScrolled ? 'bg-primary-navy shadow-md' : 'bg-primary-navy/95'}`}>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Queue North Technologies"
|
||||
className="h-8 w-auto flex-shrink-0"
|
||||
/>
|
||||
<span className="font-bold text-xl text-white hidden sm:block tracking-tight">Queue North</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-text hover:text-primary-navy 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"
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.name}
|
||||
to={link.href}
|
||||
className="text-sm font-medium text-navy-light hover:text-white transition-colors"
|
||||
>
|
||||
Request Consultation
|
||||
</a>
|
||||
</div>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<SheetTrigger asChild>
|
||||
<button className="md:hidden p-2 text-text hover:text-primary-navy focus:outline-none focus:ring-2 focus:ring-primary-navy rounded-md">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
{/* CTA Button */}
|
||||
<div className="hidden md:block">
|
||||
<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">
|
||||
Request Consultation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="md:hidden">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button className="p-2 text-white hover:text-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-cyan rounded-md">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<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>
|
||||
</header>
|
||||
</>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
export default Header
|
||||
|
|
@ -1,19 +1,34 @@
|
|||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/Sheet'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const MobileNav = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const navLinks = [
|
||||
const primaryLinks = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Services', href: '/services' },
|
||||
{ name: 'Industries', href: '/industries' },
|
||||
{ name: '8x8', href: '/8x8' },
|
||||
{ name: 'About', href: '/about' },
|
||||
{ name: 'Contact', href: '/contact' },
|
||||
{ name: 'Support', href: '/support' },
|
||||
]
|
||||
|
||||
const services = [
|
||||
{ name: 'Unified Communications', href: '/services/unified-communications' },
|
||||
{ name: 'Contact Center', href: '/services/contact-center' },
|
||||
{ name: 'Managed Support', href: '/services/managed-support' },
|
||||
{ name: 'Consulting & Training', href: '/services/consulting-training' },
|
||||
{ name: 'Infrastructure Cabling', href: '/services/infrastructure-cabling' },
|
||||
{ name: 'Wireless Access', href: '/services/wireless-access' },
|
||||
{ name: 'Local Networking', href: '/services/local-networking' },
|
||||
]
|
||||
|
||||
const industries = [
|
||||
{ name: 'Healthcare', href: '/industries/healthcare' },
|
||||
{ name: 'Retail', href: '/industries/retail' },
|
||||
{ name: 'Manufacturing', href: '/industries/manufacturing' },
|
||||
{ name: 'Education & Finance', href: '/industries/education-finance' },
|
||||
]
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
|
@ -22,7 +37,7 @@ const MobileNav = () => {
|
|||
<div className="md:hidden">
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<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
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
|
|
@ -39,38 +54,81 @@ const MobileNav = () => {
|
|||
<span className="sr-only">Open menu</span>
|
||||
</button>
|
||||
</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 items-center gap-2 mb-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
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>
|
||||
|
||||
<nav className="flex flex-col space-y-4">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
onClick={closeMobileMenu}
|
||||
className="text-base font-medium text-text hover:text-primary-navy py-2 border-b border-gray-100 last:border-0"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
<nav className="flex flex-col space-y-6">
|
||||
{/* Primary Links */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-navy-light mb-3">Primary</h4>
|
||||
<ul className="space-y-2">
|
||||
{primaryLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
to={link.href}
|
||||
onClick={closeMobileMenu}
|
||||
className="block text-base font-medium 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>
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<a
|
||||
href="/contact"
|
||||
<Link
|
||||
to="/contact"
|
||||
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
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,27 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Badge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'error'
|
||||
}
|
||||
>(({ className = '', variant = 'default', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
|
||||
|
||||
const variants = {
|
||||
default: 'border-transparent bg-primary-navy text-white hover:bg-primary-navy-dark',
|
||||
secondary: 'border-transparent bg-section-alt text-text hover:bg-opacity-80',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
warning: 'border-transparent bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
|
||||
error: 'border-transparent bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
}
|
||||
const Badge = React.forwardRef(
|
||||
({ className = '', variant = 'default', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
|
||||
|
||||
const variants = {
|
||||
default: 'border-transparent bg-primary-navy text-white hover:bg-primary-navy-dark',
|
||||
secondary: 'border-transparent bg-section-alt text-text hover:bg-opacity-80',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
warning: 'border-transparent bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
|
||||
error: 'border-transparent bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variants[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variants[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = 'Badge'
|
||||
|
||||
export { Badge }
|
||||
export { Badge }
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'link'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
}
|
||||
>(({ className = '', variant = 'default', size = 'default', ...props }, ref) => {
|
||||
const Button = React.forwardRef(({ className = '', variant = 'default', size = 'default', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'
|
||||
|
||||
const variants = {
|
||||
|
|
@ -27,11 +21,11 @@ const Button = React.forwardRef<
|
|||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
className={`${baseStyles} ${variants[variant] || ''} ${sizes[size] || ''} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button }
|
||||
export { Button }
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
const Card = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -11,7 +11,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
const CardHeader = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
|||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
const CardTitle = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
|
|
@ -33,7 +33,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
|||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
const CardDescription = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
|
|
@ -44,14 +44,14 @@ const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttribu
|
|||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
const CardContent = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
const CardFooter = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -62,4 +62,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
|||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
|
@ -18,10 +18,8 @@ const DialogOverlay = ({ className, ...props }) => (
|
|||
)
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
const DialogContent = React.forwardRef(
|
||||
({ className = '', children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
|
|
@ -55,10 +53,8 @@ const DialogFooter = ({ className, ...props }) => (
|
|||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
const DialogTitle = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={`text-lg font-semibold leading-none tracking-tight ${className}`}
|
||||
|
|
@ -67,10 +63,8 @@ const DialogTitle = React.forwardRef<
|
|||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
const DialogDescription = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={`text-sm text-muted-foreground ${className}`}
|
||||
|
|
@ -88,4 +82,4 @@ export {
|
|||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
const Input = React.forwardRef(
|
||||
({ className = '', type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
|
|
@ -14,4 +14,4 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
export { Input }
|
||||
|
|
@ -17,14 +17,19 @@ const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
|||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sideClasses = {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef(({ className, children, side = 'right', ...props }, ref) => (
|
||||
<SheetPrimitive.Portal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={`fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out ${side === 'top' ? 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top' : side === 'bottom' ? 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom' : side === 'left' ? 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left' : 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right'} sm:rounded-lg ${className}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={`fixed z-50 flex flex-col gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out ${sideClasses[side] ?? sideClasses.right} ${className ?? ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
const Textarea = React.forwardRef(
|
||||
({ className = '', ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
|
|
@ -13,4 +13,4 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
|||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea }
|
||||
|
|
@ -34,8 +34,9 @@ a:hover {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Container - custom max-width */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
|
|
|||
16
src/main.jsx
|
|
@ -1,8 +1,9 @@
|
|||
import { StrictMode } from 'react'
|
||||
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 { Toaster } from 'sonner'
|
||||
import router from './router.jsx'
|
||||
import App from './App.jsx'
|
||||
|
||||
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>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
createRoot(document.getElementById('root')).render(<Root />)
|
||||
|
|
@ -34,7 +34,7 @@ const EightXEight = () => {
|
|||
{/* Our Expertise */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">8x8 Expertise</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
'VoIP Implementation',
|
||||
'Cloud PBX Migration',
|
||||
|
|
@ -45,13 +45,13 @@ const EightXEight = () => {
|
|||
'System Integration',
|
||||
'Ongoing Support',
|
||||
].map((expertise, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div key={index} className="flex items-center gap-2 p-3 rounded-lg border border-border bg-card">
|
||||
<div className="h-5 w-5 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{expertise}</span>
|
||||
<span className="text-sm font-medium text-text">{expertise}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -60,7 +60,7 @@ const EightXEight = () => {
|
|||
{/* Benefits */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">Why Choose 8x8</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<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: '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: 'Support', desc: '24/7 expert support to keep your communications running' },
|
||||
].map((benefit, index) => (
|
||||
<div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm">
|
||||
<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>
|
||||
<p className="text-soft-text">{benefit.desc}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const About = () => {
|
|||
{/* Our Values */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8 text-center">Our Values</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<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: '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: 'Support', desc: 'Our job doesn\'t end when installation completes' },
|
||||
].map((value, index) => (
|
||||
<div key={index} className="p-6 rounded-lg border border-border bg-card shadow-sm">
|
||||
<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>
|
||||
<p className="text-soft-text">{value.desc}</p>
|
||||
</div>
|
||||
|
|
@ -56,8 +56,8 @@ const About = () => {
|
|||
|
||||
{/* Our Expertise */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold text-primary-navy mb-8">Our Expertise</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<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 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
'8x8 Certified Partner',
|
||||
'VoIP and UCaaS Solutions',
|
||||
|
|
@ -68,13 +68,13 @@ const About = () => {
|
|||
'Disaster Recovery Planning',
|
||||
'24/7 Support & Monitoring',
|
||||
].map((expertise, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="h-6 w-6 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div key={index} className="flex items-center gap-2 p-3 rounded-lg border border-border bg-card">
|
||||
<div className="h-5 w-5 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg text-text">{expertise}</span>
|
||||
<span className="text-sm font-medium text-text">{expertise}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useToast } from 'sonner'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
|
@ -8,7 +8,6 @@ import { Select } from '@/components/ui/Select'
|
|||
import { api } from '@/lib/api'
|
||||
|
||||
const Contact = () => {
|
||||
const { toast } = useToast()
|
||||
const [formState, setFormState] = useState({
|
||||
company: '',
|
||||
name: '',
|
||||
|
|
@ -18,6 +17,12 @@ const Contact = () => {
|
|||
message: '',
|
||||
service_interest: '',
|
||||
})
|
||||
const [errors, setErrors] = useState({
|
||||
company: '',
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data) => api.post('/leads', data),
|
||||
|
|
@ -32,20 +37,63 @@ const Contact = () => {
|
|||
message: '',
|
||||
service_interest: '',
|
||||
})
|
||||
setErrors({
|
||||
company: '',
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
if (!validateForm()) return
|
||||
mutation.mutate(formState)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
setFormState(prev => ({ ...prev, [name]: value }))
|
||||
// Clear error for this field as user types
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -101,7 +149,7 @@ const Contact = () => {
|
|||
|
||||
{/* Right - Form */}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
||||
Company Name <span className="text-red-600">*</span>
|
||||
|
|
@ -114,7 +162,11 @@ const Contact = () => {
|
|||
onChange={handleChange}
|
||||
required
|
||||
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>
|
||||
|
|
@ -129,7 +181,11 @@ const Contact = () => {
|
|||
onChange={handleChange}
|
||||
required
|
||||
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>
|
||||
|
|
@ -144,7 +200,11 @@ const Contact = () => {
|
|||
onChange={handleChange}
|
||||
required
|
||||
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>
|
||||
|
|
@ -200,7 +260,7 @@ const Contact = () => {
|
|||
<label htmlFor="message" className="block text-sm font-medium text-text mb-2">
|
||||
Message <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formState.message}
|
||||
|
|
@ -208,7 +268,11 @@ const Contact = () => {
|
|||
required
|
||||
placeholder="Tell us about your needs..."
|
||||
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>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { Button } from '@/components/ui/Button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { services } from '@/data/services'
|
||||
import { industries } from '@/data/industries'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { MapPin } from 'lucide-react'
|
||||
|
||||
const Home = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Hero Section */}
|
||||
|
|
@ -83,9 +88,9 @@ const Home = () => {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-soft-text mb-4">{service.fullDesc}</p>
|
||||
<a href={`/services/${service.id}`} className="text-primary-navy font-medium hover:underline">
|
||||
<Button variant="link" className="text-primary-navy p-0 h-auto text-sm" onClick={() => navigate(`/services/${service.id}`)}>
|
||||
Learn more →
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -150,21 +155,16 @@ const Home = () => {
|
|||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ name: 'Healthcare', href: '/industries/healthcare' },
|
||||
{ name: 'Retail', href: '/industries/retail' },
|
||||
{ name: 'Manufacturing', href: '/industries/manufacturing' },
|
||||
{ name: 'Education & Finance', href: '/industries/education-finance' },
|
||||
].map((industry) => (
|
||||
<Card key={industry.name} className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
{industries.map((industry) => (
|
||||
<Card key={industry.id} className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-semibold text-primary-navy mb-3">{industry.name}</h3>
|
||||
<p className="text-sm text-soft-text mb-4">
|
||||
Industry-specific solutions designed to address your unique challenges and requirements.
|
||||
</p>
|
||||
<a href={industry.href} className="text-primary-navy font-medium hover:underline">
|
||||
<Button variant="link" className="text-primary-navy p-0 h-auto text-sm" onClick={() => navigate(industry.href)}>
|
||||
Learn more →
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -181,12 +181,9 @@ const Home = () => {
|
|||
<p className="text-xl text-section-alt mb-8 max-w-2xl mx-auto">
|
||||
Schedule a free consultation with our communications experts.
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block bg-white text-primary-navy px-8 py-3 rounded-md font-bold text-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Button variant="default" size="lg" onClick={() => navigate('/contact')}>
|
||||
Request Consultation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const Industries = () => {
|
|||
</section>
|
||||
|
||||
{/* 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) => (
|
||||
<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">
|
||||
|
|
@ -28,7 +28,24 @@ const Industries = () => {
|
|||
</h3>
|
||||
</div>
|
||||
<p className="text-soft-text mb-4">{industry.shortDesc}</p>
|
||||
<a href={`/industries/${industry.id}`} className="text-primary-navy font-medium hover:underline inline-flex items-center gap-1">
|
||||
|
||||
<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
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import { industries } from '@/data/industries'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
|
||||
const IndustryDetail = ({ name }) => {
|
||||
const industry = industries.find(i => i.id === name)
|
||||
const IndustryDetail = () => {
|
||||
const { slug } = useParams()
|
||||
const industry = industries.find(i => i.id === slug)
|
||||
|
||||
if (!industry) {
|
||||
return (
|
||||
|
|
@ -39,7 +41,7 @@ const IndustryDetail = ({ name }) => {
|
|||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">Pain Points We Solve</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{industry.painPoints.map((painPoint, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<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">
|
||||
<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) => (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import { services } from '@/data/services'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
|
||||
const ServiceDetail = ({ name }) => {
|
||||
const service = services.find(s => s.id === name)
|
||||
const ServiceDetail = () => {
|
||||
const { slug } = useParams()
|
||||
const service = services.find(s => s.id === slug)
|
||||
|
||||
if (!service) {
|
||||
return (
|
||||
|
|
@ -38,8 +40,8 @@ const ServiceDetail = ({ name }) => {
|
|||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">How Queue North Helps</h2>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold text-primary-navy mb-4">Key Benefits</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{service.benefits.map((benefit, index) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<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">
|
||||
<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) => (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -28,15 +28,19 @@ const Services = () => {
|
|||
</h3>
|
||||
</div>
|
||||
<p className="text-soft-text mb-4">{service.shortDesc}</p>
|
||||
<p className="text-sm text-soft-text mb-4">{service.fullDesc}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{service.benefits.slice(0, 2).map((benefit, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-section-alt rounded text-xs text-soft-text">
|
||||
{benefit}
|
||||
</span>
|
||||
<div className="space-y-2 mb-4">
|
||||
{service.benefits.slice(0, 3).map((benefit, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-sm text-soft-text">
|
||||
<div className="h-4 w-4 rounded-full bg-primary-navy text-white flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-2 w-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>{benefit}</span>
|
||||
</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
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useToast } from 'sonner'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
|
@ -8,7 +8,6 @@ import { Select } from '@/components/ui/Select'
|
|||
import { api } from '@/lib/api'
|
||||
|
||||
const Support = () => {
|
||||
const { toast } = useToast()
|
||||
const [formState, setFormState] = useState({
|
||||
name: '',
|
||||
company: '',
|
||||
|
|
@ -17,6 +16,12 @@ const Support = () => {
|
|||
issue: '',
|
||||
priority: 'medium',
|
||||
})
|
||||
const [errors, setErrors] = useState({
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
issue: '',
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data) => api.post('/support', data),
|
||||
|
|
@ -30,20 +35,68 @@ const Support = () => {
|
|||
issue: '',
|
||||
priority: 'medium',
|
||||
})
|
||||
setErrors({
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
issue: '',
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
if (!validateForm()) return
|
||||
mutation.mutate(formState)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
setFormState(prev => ({ ...prev, [name]: value }))
|
||||
// Clear error for this field as user types
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -104,7 +157,7 @@ const Support = () => {
|
|||
|
||||
{/* Right - Form */}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
|
|
@ -117,7 +170,11 @@ const Support = () => {
|
|||
onChange={handleChange}
|
||||
required
|
||||
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>
|
||||
|
|
@ -132,7 +189,11 @@ const Support = () => {
|
|||
onChange={handleChange}
|
||||
required
|
||||
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>
|
||||
|
|
@ -147,7 +208,11 @@ const Support = () => {
|
|||
onChange={handleChange}
|
||||
required
|
||||
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>
|
||||
|
|
@ -184,7 +249,7 @@ const Support = () => {
|
|||
<label htmlFor="issue" className="block text-sm font-medium text-text mb-2">
|
||||
Describe Your Issue <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
<textarea
|
||||
id="issue"
|
||||
name="issue"
|
||||
value={formState.issue}
|
||||
|
|
@ -192,7 +257,11 @@ const Support = () => {
|
|||
required
|
||||
placeholder="Please describe your issue in detail..."
|
||||
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>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -18,18 +18,9 @@ const router = createBrowserRouter([
|
|||
{ index: true, element: <Home /> },
|
||||
{ path: 'about', element: <About /> },
|
||||
{ path: 'services', element: <Services /> },
|
||||
{ path: 'services/unified-communications', element: <ServiceDetail name="unified-communications" /> },
|
||||
{ path: 'services/contact-center', element: <ServiceDetail name="contact-center" /> },
|
||||
{ path: 'services/managed-support', element: <ServiceDetail name="managed-support" /> },
|
||||
{ path: 'services/consulting-training', element: <ServiceDetail name="consulting-training" /> },
|
||||
{ path: 'services/infrastructure-cabling', element: <ServiceDetail name="infrastructure-cabling" /> },
|
||||
{ path: 'services/wireless-access', element: <ServiceDetail name="wireless-access" /> },
|
||||
{ path: 'services/local-networking', element: <ServiceDetail name="local-networking" /> },
|
||||
{ path: 'services/:slug', element: <ServiceDetail /> },
|
||||
{ path: 'industries', element: <Industries /> },
|
||||
{ path: 'industries/healthcare', element: <IndustryDetail name="healthcare" /> },
|
||||
{ path: 'industries/retail', element: <IndustryDetail name="retail" /> },
|
||||
{ path: 'industries/manufacturing', element: <IndustryDetail name="manufacturing" /> },
|
||||
{ path: 'industries/education-finance', element: <IndustryDetail name="education-finance" /> },
|
||||
{ path: 'industries/:slug', element: <IndustryDetail /> },
|
||||
{ path: '8x8', element: <EightXEight /> },
|
||||
{ path: 'contact', element: <Contact /> },
|
||||
{ path: 'support', element: <Support /> },
|
||||
|
|
|
|||
2629
styles.css
|
|
@ -14,6 +14,7 @@ export default {
|
|||
text: '#0F172A',
|
||||
muted: '#475569',
|
||||
'soft-text': '#64748B',
|
||||
'navy-light': '#68A3B8',
|
||||
primary: {
|
||||
navy: '#0B2A3C',
|
||||
'navy-dark': '#071A2A',
|
||||
|
|
@ -31,7 +32,16 @@ export default {
|
|||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem',
|
||||
'24': '6rem',
|
||||
'26': '6.5rem',
|
||||
'28': '7rem',
|
||||
'32': '8rem',
|
||||
'36': '9rem',
|
||||
'40': '10rem',
|
||||
'48': '12rem',
|
||||
},
|
||||
maxWidth: {
|
||||
'container': '1280px',
|
||||
},
|
||||
boxShadow: {
|
||||
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
|
|
@ -47,5 +57,5 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
}
|
||||
|
|
|
|||