chnages
chnages
This commit is contained in:
1 parent
3e22355510
commit
e22a8dc248
71 files changed
+24775
-1
No files matched your search
@@ -0,0 +1,60 @@
|
||||
# ================================================================
|
||||
# .env.example — Single config file for the ENTIRE project
|
||||
# ================================================================
|
||||
# HOW TO USE:
|
||||
# 1. Copy this file: cp .env.example .env
|
||||
# 2. NEVER commit .env to Git (it is already in .gitignore)
|
||||
# ================================================================
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# BACKEND — Server
|
||||
# ----------------------------------------------------------------
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# BACKEND — PostgreSQL database
|
||||
# ----------------------------------------------------------------
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=auth_db
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=database
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# BACKEND — JWT secret
|
||||
# ----------------------------------------------------------------
|
||||
JWT_SECRET=376ea99080f848fec7c056dcce75df9f052d6d3c0a35da467c89cd7aae7b3a9f
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# BACKEND — Cloudflare Turnstile secret key (backend only)
|
||||
# ----------------------------------------------------------------
|
||||
TURNSTILE_SECRET_KEY=0x4AAAAAACtzRelpHW67jqU-Xo-PrBU6ss4
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# BACKEND — Frontend origin for CORS
|
||||
# ----------------------------------------------------------------
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# FRONTEND (React) — Cloudflare Turnstile site key
|
||||
# ----------------------------------------------------------------
|
||||
REACT_APP_TURNSTILE_SITE_KEY=0x4AAAAAACtzRYcinMFJ4f1g
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# FRONTEND (React) — Backend API base URL
|
||||
# ----------------------------------------------------------------
|
||||
REACT_APP_API_URL=http://localhost:5000
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# EMAIL — For sending OTP via email (nodemailer)
|
||||
# ----------------------------------------------------------------
|
||||
EMAIL_USER=adityakhakurel69@gmail.com
|
||||
EMAIL_PASS=hbxrnzfjnkijsoea
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# BACKEND — Google Gemini (AI chatbot proxy). Free key from
|
||||
# https://aistudio.google.com/app/apikey — keep this server-side only.
|
||||
# ----------------------------------------------------------------
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.5-flash
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# backend dependencies
|
||||
/backend/node_modules
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -1 +1,120 @@
|
||||
# ProductDevelopment
|
||||
# AI-Solutions — Website System (CET333 Product Development)
|
||||
|
||||
A multi-page website for **AI-Solutions Ltd**, a fictional Sunderland start-up that uses AI to
|
||||
improve the digital employee experience. The public site markets the company's services, past
|
||||
projects, feedback, events and gallery, and lets visitors submit an enquiry through a Contact Us
|
||||
form. A password-protected admin area lets staff review those enquiries.
|
||||
|
||||
- **Frontend:** React (Create React App) + React Router
|
||||
- **Backend:** Node.js + Express (REST API)
|
||||
- **Database:** PostgreSQL
|
||||
- **AI chatbot:** Google Gemini (free tier), proxied through the backend
|
||||
- **Design system:** see [`DESIGN.md`](DESIGN.md) — warm cream surfaces, dark terminal panels,
|
||||
a single violet accent, Inter + JetBrains Mono. All UI is derived from those tokens.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Public website
|
||||
- **Home** — mission, value proposition and featured services/feedback
|
||||
- **Services** — the software solutions offered
|
||||
- **Past Solutions** — case studies of completed industry projects
|
||||
- **Customer Feedback** — testimonials with star ratings
|
||||
- **Photo Gallery** — past & upcoming event photos with a lightbox
|
||||
- **Events** — upcoming events (plus a past-events archive)
|
||||
- **Contact Us** — 7-field enquiry form (Full Name, Email, Phone, Company, Country, Job Title,
|
||||
Job Details). No customer account required. Validated on both client and server and protected by
|
||||
Cloudflare Turnstile.
|
||||
- **AI chatbot ("Ava")** — a floating widget on every page. Messages are sent to the backend Gemini
|
||||
proxy (the API key never reaches the browser) and the bot gracefully offers a human hand-off when a
|
||||
query should be escalated.
|
||||
|
||||
### Admin area (password-protected)
|
||||
- Sign in with the **existing** secure flow: email + password + Turnstile, then a 6-digit email **OTP**.
|
||||
- A back-office dashboard whose sole job is to review Contact Us enquiries: headline stats, a
|
||||
**searchable / filterable / paginated** table, and a full-detail view of any single enquiry
|
||||
(mark read/unread, reply by email, delete).
|
||||
- Only the **admin** role exists — there is no public sign-up and no role-management UI.
|
||||
|
||||
> The authentication/security backend (JWT sessions, OTP, Turnstile, hashing, CSRF, rate limiting,
|
||||
> Helmet, account lockout, audit logs) is reused as-is. The front end integrates with it; it does
|
||||
> not reimplement or weaken any of it.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/ Shared UI: Navbar, Footer, Field, Modal, StarRating, Chatbot, Turnstile, States…
|
||||
pages/ Public pages (Home, Services, PastSolutions, Feedback, Gallery, Events, Contact)
|
||||
pages/admin/ AdminLogin (login + OTP), AdminLayout, AdminDashboard (enquiries)
|
||||
lib/ useFetch data hook
|
||||
index.css Design system (DESIGN.md tokens + components)
|
||||
api.js fetch wrapper (CSRF + token refresh) + JSON helpers
|
||||
backend/
|
||||
controllers/ auth, admin (inquiries), contact, publicContent, chat (Gemini proxy)
|
||||
routes/ authRoutes, adminRoutes, publicRoutes
|
||||
middleware/ authMiddleware, validate
|
||||
utils/ hash, otp, email, logger, validator, turnstile
|
||||
server.js Express app (Helmet, CORS, CSRF, rate limit)
|
||||
seed.js Creates the admin + sample public content
|
||||
database/
|
||||
schema.sql Tables: users(admin), sessions, password_history, logs,
|
||||
inquiries, solutions, case_studies, feedback, articles, events, gallery_images
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup & run locally
|
||||
|
||||
### 1. Configure environment
|
||||
Copy `.env.example` to `.env` and fill in the values (DB credentials, JWT secret, Turnstile keys,
|
||||
email/OTP credentials). To enable the AI chatbot, add a free Gemini key from
|
||||
<https://aistudio.google.com/app/apikey>:
|
||||
|
||||
```
|
||||
GEMINI_API_KEY=your_key_here
|
||||
GEMINI_MODEL=gemini-2.0-flash
|
||||
```
|
||||
|
||||
If `GEMINI_API_KEY` is left blank, the site still runs — the chatbot replies with a friendly
|
||||
"offline" message and points visitors to the Contact page.
|
||||
|
||||
### 2. Install dependencies
|
||||
```bash
|
||||
npm install # frontend (repo root)
|
||||
cd backend && npm install
|
||||
```
|
||||
|
||||
### 3. Create the database
|
||||
```bash
|
||||
# create the database once (psql), then load the schema:
|
||||
psql -U postgres -d auth_db -f database/schema.sql
|
||||
|
||||
# seed the admin account + sample website content:
|
||||
cd backend && node seed.js
|
||||
```
|
||||
The seed prints the default admin credentials:
|
||||
|
||||
```
|
||||
Admin login: admin@ai-solutions.com
|
||||
Password: Admin@2026!
|
||||
```
|
||||
(Change this after the first login. You can override via `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD`.)
|
||||
|
||||
### 4. Start the apps
|
||||
```bash
|
||||
cd backend && npm run dev # API → http://localhost:5000
|
||||
npm start # site → http://localhost:3000 (run from repo root)
|
||||
```
|
||||
|
||||
The public site is at `/`; the admin portal is at `/admin`.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- The Contact form and admin login both require a valid Cloudflare Turnstile challenge, which only
|
||||
completes in a real browser — automated/curl submissions are intentionally rejected server-side.
|
||||
- OTP codes are emailed; in development they are also logged to the backend console.
|
||||
@@ -0,0 +1,158 @@
|
||||
// ============================================================
|
||||
// adminController.js — Admin-only operations
|
||||
// ============================================================
|
||||
// The admin area has a single purpose: review the customer
|
||||
// inquiries submitted through the public "Contact Us" form.
|
||||
// (No user/role management — there is only the admin role.)
|
||||
// ============================================================
|
||||
|
||||
const pool = require('../db');
|
||||
const { log } = require('../utils/logger');
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// GET /api/admin/stats
|
||||
// Dashboard headline figures + the distinct country list used to
|
||||
// populate the table's country filter.
|
||||
// ------------------------------------------------------------
|
||||
async function getStats(req, res, next) {
|
||||
try {
|
||||
const [total, unread, last7, today, countries] = await Promise.all([
|
||||
pool.query('SELECT COUNT(*) FROM inquiries'),
|
||||
pool.query('SELECT COUNT(*) FROM inquiries WHERE is_read = false'),
|
||||
pool.query("SELECT COUNT(*) FROM inquiries WHERE created_at >= NOW() - INTERVAL '7 days'"),
|
||||
pool.query('SELECT COUNT(*) FROM inquiries WHERE created_at::date = CURRENT_DATE'),
|
||||
pool.query("SELECT DISTINCT country FROM inquiries WHERE country IS NOT NULL AND country <> '' ORDER BY country ASC"),
|
||||
]);
|
||||
return res.json({
|
||||
totalInquiries: parseInt(total.rows[0].count, 10),
|
||||
unreadInquiries: parseInt(unread.rows[0].count, 10),
|
||||
last7Days: parseInt(last7.rows[0].count, 10),
|
||||
today: parseInt(today.rows[0].count, 10),
|
||||
countries: countries.rows.map(r => r.country),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// GET /api/admin/inquiries?page=&limit=&search=&country=&status=
|
||||
// Paginated, searchable, filterable list for the dashboard table.
|
||||
// ------------------------------------------------------------
|
||||
async function getInquiries(req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 10));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { search, country, status } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
const p = `$${params.length}`;
|
||||
conditions.push(
|
||||
`(full_name ILIKE ${p} OR email ILIKE ${p} OR company ILIKE ${p} OR job_title ILIKE ${p} OR job_details ILIKE ${p})`
|
||||
);
|
||||
}
|
||||
if (country) {
|
||||
params.push(country);
|
||||
conditions.push(`country = $${params.length}`);
|
||||
}
|
||||
if (status === 'read') conditions.push('is_read = true');
|
||||
if (status === 'unread') conditions.push('is_read = false');
|
||||
|
||||
const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
|
||||
const countResult = await pool.query(`SELECT COUNT(*) FROM inquiries ${whereClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
const dataResult = await pool.query(
|
||||
`SELECT id, full_name, email, phone, company, country, job_title, job_details, is_read, created_at
|
||||
FROM inquiries
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
inquiries: dataResult.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.max(1, Math.ceil(totalCount / limit)),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// GET /api/admin/inquiries/:id — full detail of one inquiry
|
||||
// Viewing an inquiry marks it as read.
|
||||
// ------------------------------------------------------------
|
||||
async function getInquiry(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query('SELECT * FROM inquiries WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Inquiry not found.' });
|
||||
}
|
||||
// Mark as read on open (idempotent).
|
||||
await pool.query('UPDATE inquiries SET is_read = true WHERE id = $1 AND is_read = false', [id]);
|
||||
const inquiry = { ...result.rows[0], is_read: true };
|
||||
return res.json({ inquiry });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// PATCH /api/admin/inquiries/:id/read body: { is_read }
|
||||
// ------------------------------------------------------------
|
||||
async function setInquiryRead(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const isRead = req.body.is_read !== false; // default true
|
||||
const result = await pool.query(
|
||||
'UPDATE inquiries SET is_read = $1 WHERE id = $2 RETURNING id',
|
||||
[isRead, id]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Inquiry not found.' });
|
||||
}
|
||||
return res.json({ message: isRead ? 'Marked as read.' : 'Marked as unread.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// DELETE /api/admin/inquiries/:id
|
||||
// ------------------------------------------------------------
|
||||
async function deleteInquiry(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query('DELETE FROM inquiries WHERE id = $1 RETURNING email', [id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Inquiry not found.' });
|
||||
}
|
||||
await log(req.user.id, 'ADMIN_DELETE_INQUIRY', 'success',
|
||||
`Admin "${req.user.username}" deleted inquiry from ${result.rows[0].email}`, req);
|
||||
return res.json({ message: 'Inquiry deleted.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStats,
|
||||
getInquiries,
|
||||
getInquiry,
|
||||
setInquiryRead,
|
||||
deleteInquiry,
|
||||
};
|
||||
@@ -0,0 +1,839 @@
|
||||
// ============================================================
|
||||
// authController.js — All authentication logic
|
||||
// ============================================================
|
||||
|
||||
const axios = require('axios');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { hashPassword, verifyPassword, generateSalt } = require('../utils/hash');
|
||||
const { generateOTP, getOTPExpiry, hashOTP, verifyOTPHash } = require('../utils/otp');
|
||||
const { sendOTPEmail, sendChangePasswordLink, sendNewDeviceAlert } = require('../utils/email');
|
||||
const { log, ACTIONS } = require('../utils/logger');
|
||||
const { parseDevice } = require('../utils/deviceParser');
|
||||
const {
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
getPasswordErrors,
|
||||
isLeakedPassword,
|
||||
sanitize,
|
||||
} = require('../utils/validator');
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
// Max OTP verification attempts before OTP is invalidated
|
||||
const MAX_OTP_ATTEMPTS = 3;
|
||||
|
||||
// Minimum seconds between OTP resend requests per user
|
||||
const OTP_RESEND_COOLDOWN_SEC = 60;
|
||||
|
||||
// Password expires after 90 days — user must change it
|
||||
const PASSWORD_MAX_AGE_DAYS = 90;
|
||||
|
||||
// Helper: get client IP
|
||||
function getIP(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER: Verify Cloudflare Turnstile CAPTCHA token
|
||||
// ============================================================
|
||||
async function verifyTurnstile(token) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
new URLSearchParams({
|
||||
secret: process.env.TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||
);
|
||||
return response.data.success === true;
|
||||
} catch (err) {
|
||||
console.error('Turnstile verification error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: password validation error message
|
||||
function passwordErrorMessage(password) {
|
||||
const errors = getPasswordErrors(password);
|
||||
if (errors.length === 0) return null;
|
||||
return `Password must contain ${errors.join(', ')}.`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/login
|
||||
// ============================================================
|
||||
async function login(req, res, next) {
|
||||
try {
|
||||
let { email, password, turnstileToken } = req.body;
|
||||
email = sanitize(email || '').toLowerCase().trim();
|
||||
|
||||
// Verify CAPTCHA
|
||||
const captchaOk = await verifyTurnstile(turnstileToken);
|
||||
if (!captchaOk) {
|
||||
await log(null, ACTIONS.CAPTCHA_FAILED, 'failed', `CAPTCHA failed on login for: "${email}"`, req);
|
||||
return res.status(400).json({ message: 'CAPTCHA verification failed. Please try again.' });
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
|
||||
if (result.rows.length === 0) {
|
||||
await log(null, ACTIONS.LOGIN_FAILED, 'failed', `Login attempt for unknown email: "${email}"`, req);
|
||||
return res.status(401).json({ message: 'Invalid email or password.' });
|
||||
}
|
||||
const user = result.rows[0];
|
||||
|
||||
// Block login if email is not verified
|
||||
if (user.email_verified === false) {
|
||||
await log(user.id, ACTIONS.LOGIN_FAILED, 'failed', `Login blocked — email not verified for "${email}"`, req);
|
||||
return res.status(403).json({
|
||||
message: 'Email not verified. Please verify your email with the OTP sent during registration.',
|
||||
requireEmailVerification: true,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Check account lock
|
||||
if (user.locked_until && new Date() < new Date(user.locked_until)) {
|
||||
const minsLeft = Math.ceil((new Date(user.locked_until) - new Date()) / 60000);
|
||||
await log(user.id, ACTIONS.ACCOUNT_LOCKED, 'warning', `Login blocked — account locked for ${minsLeft} more min(s)`, req);
|
||||
return res.status(423).json({ message: `Account is locked. Try again in ${minsLeft} minute(s).` });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordOk = verifyPassword(password, user.password_hash);
|
||||
if (!passwordOk) {
|
||||
const newAttempts = user.failed_attempts + 1;
|
||||
let lockUntil = null;
|
||||
|
||||
if (newAttempts >= 5) {
|
||||
lockUntil = new Date(Date.now() + 15 * 60 * 1000);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET failed_attempts = $1, locked_until = $2 WHERE id = $3',
|
||||
[newAttempts, lockUntil, user.id]
|
||||
);
|
||||
|
||||
if (lockUntil) {
|
||||
await log(user.id, ACTIONS.ACCOUNT_LOCKED, 'warning', `Account locked after 5 failed attempts`, req);
|
||||
return res.status(423).json({ message: 'Account locked for 15 minutes after 5 failed attempts.' });
|
||||
}
|
||||
|
||||
const remaining = 5 - newAttempts;
|
||||
await log(user.id, ACTIONS.LOGIN_FAILED, 'failed', `Wrong password — attempt ${newAttempts}/5 (${remaining} left)`, req);
|
||||
return res.status(401).json({ message: `Invalid password. ${remaining} attempt(s) remaining before lockout.` });
|
||||
}
|
||||
|
||||
// Password correct — reset failed attempts
|
||||
await pool.query(
|
||||
'UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Check password expiry (90 days)
|
||||
if (user.password_changed_at) {
|
||||
const daysSinceChange = (Date.now() - new Date(user.password_changed_at).getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceChange >= PASSWORD_MAX_AGE_DAYS) {
|
||||
await log(user.id, ACTIONS.PASSWORD_EXPIRED, 'warning',
|
||||
`Password expired (${Math.floor(daysSinceChange)} days old) for "${user.email}"`, req);
|
||||
return res.status(403).json({
|
||||
message: `Your password has expired (last changed ${Math.floor(daysSinceChange)} days ago). Please reset your password.`,
|
||||
passwordExpired: true,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate OTP, hash it, and store the hash in DB
|
||||
const otp = generateOTP();
|
||||
const otpExpiry = getOTPExpiry();
|
||||
const otpHash = hashOTP(otp);
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_code = $1, otp_expires_at = $2, otp_attempts = 0 WHERE id = $3',
|
||||
[otpHash, otpExpiry, user.id]
|
||||
);
|
||||
|
||||
// Send OTP via email
|
||||
try {
|
||||
await sendOTPEmail(user.email, user.username, otp);
|
||||
if (isDev) console.log(`[OTP] Sent to ${user.email} — Code: ${otp}`);
|
||||
} catch (emailErr) {
|
||||
console.error('[OTP] Email failed:', emailErr.message);
|
||||
if (isDev) console.log(`[OTP] Fallback — User: ${user.username} | Code: ${otp}`);
|
||||
}
|
||||
|
||||
await log(user.id, ACTIONS.OTP_SENT, 'success', `OTP sent to ${user.email} after password verified`, req);
|
||||
|
||||
return res.json({
|
||||
message: `OTP sent to your email (${user.email}).`,
|
||||
userId: user.id,
|
||||
requireOTP: true,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/request-otp (resend)
|
||||
// ============================================================
|
||||
async function requestOTP(req, res, next) {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, email, otp_expires_at FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
const user = result.rows[0];
|
||||
|
||||
// Rate-limit: enforce cooldown between OTP resends
|
||||
// OTP expiry is 5 min from issue. If last OTP was issued less than
|
||||
// OTP_RESEND_COOLDOWN_SEC ago, reject the request.
|
||||
if (user.otp_expires_at) {
|
||||
const issuedAt = new Date(user.otp_expires_at).getTime() - 5 * 60 * 1000; // when OTP was issued
|
||||
const elapsed = (Date.now() - issuedAt) / 1000;
|
||||
if (elapsed < OTP_RESEND_COOLDOWN_SEC) {
|
||||
const wait = Math.ceil(OTP_RESEND_COOLDOWN_SEC - elapsed);
|
||||
return res.status(429).json({
|
||||
message: `Please wait ${wait} second(s) before requesting a new OTP.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new OTP, hash it, reset attempts
|
||||
const otp = generateOTP();
|
||||
const otpExpiry = getOTPExpiry();
|
||||
const otpHash = hashOTP(otp);
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_code = $1, otp_expires_at = $2, otp_attempts = 0 WHERE id = $3',
|
||||
[otpHash, otpExpiry, userId]
|
||||
);
|
||||
|
||||
try {
|
||||
await sendOTPEmail(user.email, user.username, otp);
|
||||
if (isDev) console.log(`[OTP] Resent to ${user.email} — Code: ${otp}`);
|
||||
} catch (emailErr) {
|
||||
console.error('[OTP] Email failed:', emailErr.message);
|
||||
if (isDev) console.log(`[OTP] Fallback — User: ${user.username} | Code: ${otp}`);
|
||||
}
|
||||
|
||||
await log(user.id, ACTIONS.OTP_RESENT, 'success', `OTP resent to ${user.email}`, req);
|
||||
return res.json({ message: `New OTP sent to ${user.email}.` });
|
||||
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/verify-otp
|
||||
// ============================================================
|
||||
async function verifyOTP(req, res, next) {
|
||||
try {
|
||||
const { userId, otp } = req.body;
|
||||
|
||||
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
const user = result.rows[0];
|
||||
|
||||
// Check OTP expiry first
|
||||
if (!user.otp_expires_at || new Date() > new Date(user.otp_expires_at)) {
|
||||
await log(user.id, ACTIONS.OTP_EXPIRED, 'failed', `OTP expired`, req);
|
||||
return res.status(401).json({ message: 'OTP has expired. Please request a new one.' });
|
||||
}
|
||||
|
||||
// Brute-force protection: check attempt count
|
||||
const attempts = user.otp_attempts || 0;
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
// Invalidate the OTP
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0 WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
await log(user.id, ACTIONS.OTP_FAILED, 'failed', `OTP invalidated after ${MAX_OTP_ATTEMPTS} failed attempts`, req);
|
||||
return res.status(401).json({
|
||||
message: `Too many wrong attempts. OTP has been invalidated. Please request a new one.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify OTP hash
|
||||
let otpValid = false;
|
||||
try {
|
||||
otpValid = user.otp_code && verifyOTPHash(otp, user.otp_code);
|
||||
} catch {
|
||||
otpValid = false;
|
||||
}
|
||||
|
||||
if (!otpValid) {
|
||||
// Increment attempt counter
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_attempts = otp_attempts + 1 WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
const remaining = MAX_OTP_ATTEMPTS - (attempts + 1);
|
||||
await log(user.id, ACTIONS.OTP_FAILED, 'failed', `Wrong OTP entered (${remaining} attempt(s) remaining)`, req);
|
||||
return res.status(401).json({
|
||||
message: `Invalid OTP. ${remaining} attempt(s) remaining.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear OTP
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0 WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Generate a unique session ID (jti = JWT ID)
|
||||
const jti = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Issue JWT — includes jti so we can match it to a session row
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role, jti },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Issue refresh token (longer lived)
|
||||
const refreshJti = crypto.randomBytes(16).toString('hex');
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, jti: refreshJti, type: 'refresh' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// Save session to DB with device + IP info, including refresh token jti
|
||||
const deviceInfo = parseDevice(req.headers['user-agent']);
|
||||
const clientIP = getIP(req);
|
||||
|
||||
// Check if this device + IP combo has been seen before for this user
|
||||
const knownDevice = await pool.query(
|
||||
`SELECT id FROM sessions
|
||||
WHERE user_id = $1 AND device_info = $2 AND ip_address = $3
|
||||
LIMIT 1`,
|
||||
[user.id, deviceInfo, clientIP]
|
||||
);
|
||||
const isNewDevice = knownDevice.rows.length === 0;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO sessions (user_id, jti, refresh_jti, ip_address, device_info)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[user.id, jti, refreshJti, clientIP, deviceInfo]
|
||||
);
|
||||
|
||||
// Send new device alert email (non-blocking — don't fail login if email fails)
|
||||
if (isNewDevice) {
|
||||
sendNewDeviceAlert(user.email, user.username, deviceInfo, clientIP, new Date())
|
||||
.then(() => {
|
||||
if (isDev) console.log(`[NEW DEVICE] Alert sent to ${user.email} for "${deviceInfo}"`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[NEW DEVICE] Alert email failed:', err.message);
|
||||
});
|
||||
await log(user.id, ACTIONS.NEW_DEVICE_LOGIN, 'warning',
|
||||
`New device login: ${deviceInfo} from ${clientIP}`, req);
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
maxAge: 60 * 60 * 1000, // 1 hour
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
});
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
path: '/api/auth/refresh-token', // only sent to refresh endpoint
|
||||
});
|
||||
|
||||
await log(user.id, ACTIONS.LOGIN_SUCCESS, 'success',
|
||||
`Login completed — role: ${user.role} — device: ${deviceInfo}`, req);
|
||||
return res.json({
|
||||
message: 'Login successful! Welcome.',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/refresh-token (public, requires refresh cookie)
|
||||
// ============================================================
|
||||
async function refreshToken(req, res, next) {
|
||||
try {
|
||||
const token = req.cookies.refreshToken;
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'No refresh token.' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch {
|
||||
return res.status(401).json({ message: 'Invalid or expired refresh token. Please login again.' });
|
||||
}
|
||||
|
||||
if (decoded.type !== 'refresh') {
|
||||
return res.status(401).json({ message: 'Invalid token type.' });
|
||||
}
|
||||
|
||||
// Check session still exists by refresh_jti
|
||||
const session = await pool.query(
|
||||
'SELECT s.*, u.username, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.refresh_jti = $1',
|
||||
[decoded.jti]
|
||||
);
|
||||
if (session.rows.length === 0) {
|
||||
return res.status(401).json({ message: 'Session revoked. Please login again.' });
|
||||
}
|
||||
|
||||
const sess = session.rows[0];
|
||||
|
||||
// Issue a new access token
|
||||
const newJti = crypto.randomBytes(16).toString('hex');
|
||||
const newToken = jwt.sign(
|
||||
{ id: sess.user_id, username: sess.username, role: sess.role, jti: newJti },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Update session jti
|
||||
await pool.query(
|
||||
'UPDATE sessions SET jti = $1, last_used_at = NOW() WHERE id = $2',
|
||||
[newJti, sess.id]
|
||||
);
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
res.cookie('token', newToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
maxAge: 60 * 60 * 1000,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
});
|
||||
|
||||
return res.json({ message: 'Token refreshed.', user: { id: sess.user_id, username: sess.username, role: sess.role } });
|
||||
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/change-password (protected)
|
||||
// ============================================================
|
||||
async function changePassword(req, res, next) {
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!validatePassword(newPassword)) {
|
||||
const msg = passwordErrorMessage(newPassword) || 'Password does not meet requirements.';
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
if (isLeakedPassword(newPassword)) {
|
||||
await log(userId, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Leaked password used for change password`, req);
|
||||
return res.status(400).json({ message: 'This password is commonly used and easy to crack. Please choose a stronger password.' });
|
||||
}
|
||||
|
||||
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
const user = result.rows[0];
|
||||
|
||||
// Verify old password
|
||||
const oldPasswordOk = verifyPassword(oldPassword, user.password_hash);
|
||||
if (!oldPasswordOk) {
|
||||
await log(userId, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Wrong current password provided`, req);
|
||||
return res.status(401).json({ message: 'Current password is incorrect.' });
|
||||
}
|
||||
|
||||
// Check password history (last 5)
|
||||
const history = await pool.query(
|
||||
`SELECT password_hash FROM password_history
|
||||
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5`,
|
||||
[userId]
|
||||
);
|
||||
for (const record of history.rows) {
|
||||
if (verifyPassword(newPassword, record.password_hash)) {
|
||||
await log(userId, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Attempted to reuse a recent password`, req);
|
||||
return res.status(400).json({ message: 'Cannot reuse a recent password. Please choose a different one.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Hash and save new password + update password_changed_at
|
||||
const newSalt = generateSalt();
|
||||
const newPasswordHash = hashPassword(newPassword, newSalt);
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2',
|
||||
[newPasswordHash, userId]
|
||||
);
|
||||
await pool.query(
|
||||
'INSERT INTO password_history (user_id, password_hash) VALUES ($1, $2)',
|
||||
[userId, newPasswordHash]
|
||||
);
|
||||
|
||||
// Invalidate ALL sessions (forces re-login on all devices)
|
||||
const deleted = await pool.query(
|
||||
'DELETE FROM sessions WHERE user_id = $1 RETURNING id',
|
||||
[userId]
|
||||
);
|
||||
|
||||
await log(userId, ACTIONS.CHANGE_PASSWORD, 'success',
|
||||
`Password changed — ${deleted.rowCount} session(s) invalidated`, req);
|
||||
res.clearCookie('token');
|
||||
res.clearCookie('refreshToken', { path: '/api/auth/refresh-token' });
|
||||
return res.json({
|
||||
message: 'Password changed successfully! You have been logged out of all devices for security.',
|
||||
loggedOut: true,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/forgot-password (public)
|
||||
// ============================================================
|
||||
async function forgotPassword(req, res, next) {
|
||||
try {
|
||||
let { email } = req.body;
|
||||
email = sanitize(email || '').toLowerCase().trim();
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
return res.status(400).json({ message: 'Please enter a valid email address.' });
|
||||
}
|
||||
|
||||
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
await log(null, ACTIONS.FORGOT_PASSWORD_FAILED, 'failed',
|
||||
`Reset requested for unknown email: "${email}"`, req);
|
||||
return res.json({ message: 'If that email is registered, an OTP has been sent.' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Rate-limit: enforce cooldown between OTP resends
|
||||
if (user.otp_expires_at) {
|
||||
const issuedAt = new Date(user.otp_expires_at).getTime() - 5 * 60 * 1000;
|
||||
const elapsed = (Date.now() - issuedAt) / 1000;
|
||||
if (elapsed < OTP_RESEND_COOLDOWN_SEC) {
|
||||
return res.json({ message: 'If that email is registered, an OTP has been sent.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate OTP, hash it, and store
|
||||
const otp = generateOTP();
|
||||
const otpExpiry = getOTPExpiry();
|
||||
const otpHash = hashOTP(otp);
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_code = $1, otp_expires_at = $2, otp_attempts = 0 WHERE id = $3',
|
||||
[otpHash, otpExpiry, user.id]
|
||||
);
|
||||
|
||||
try {
|
||||
await sendOTPEmail(user.email, user.username, otp);
|
||||
if (isDev) console.log(`[RESET OTP] Sent to ${user.email} — Code: ${otp}`);
|
||||
} catch (emailErr) {
|
||||
console.error('[RESET OTP] Email failed:', emailErr.message);
|
||||
if (isDev) console.log(`[RESET OTP] Fallback — User: ${user.username} | Code: ${otp}`);
|
||||
}
|
||||
|
||||
await log(user.id, ACTIONS.FORGOT_PASSWORD, 'success',
|
||||
`Password reset OTP sent to ${user.email}`, req);
|
||||
|
||||
return res.json({
|
||||
message: 'OTP sent to your email. Enter it below to reset your password.',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/reset-password (public)
|
||||
// ============================================================
|
||||
async function resetPassword(req, res, next) {
|
||||
try {
|
||||
const { userId, otp, newPassword } = req.body;
|
||||
|
||||
if (!validatePassword(newPassword)) {
|
||||
const msg = passwordErrorMessage(newPassword) || 'Password does not meet requirements.';
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
if (isLeakedPassword(newPassword)) {
|
||||
await log(userId, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `Leaked password used for reset password`, req);
|
||||
return res.status(400).json({ message: 'This password is commonly used and easy to crack. Please choose a stronger password.' });
|
||||
}
|
||||
|
||||
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
const user = result.rows[0];
|
||||
|
||||
// Check OTP expiry
|
||||
if (!user.otp_expires_at || new Date() > new Date(user.otp_expires_at)) {
|
||||
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `Expired OTP on password reset`, req);
|
||||
return res.status(401).json({ message: 'OTP has expired. Please request a new one.' });
|
||||
}
|
||||
|
||||
// Brute-force protection
|
||||
const attempts = user.otp_attempts || 0;
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0 WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `OTP invalidated after ${MAX_OTP_ATTEMPTS} failed attempts`, req);
|
||||
return res.status(401).json({
|
||||
message: `Too many wrong attempts. OTP has been invalidated. Please request a new one.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify OTP hash
|
||||
let otpValid = false;
|
||||
try {
|
||||
otpValid = user.otp_code && verifyOTPHash(otp, user.otp_code);
|
||||
} catch {
|
||||
otpValid = false;
|
||||
}
|
||||
|
||||
if (!otpValid) {
|
||||
await pool.query(
|
||||
'UPDATE users SET otp_attempts = otp_attempts + 1 WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
const remaining = MAX_OTP_ATTEMPTS - (attempts + 1);
|
||||
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed', `Wrong OTP on password reset (${remaining} attempt(s) remaining)`, req);
|
||||
return res.status(401).json({ message: `Invalid OTP. ${remaining} attempt(s) remaining.` });
|
||||
}
|
||||
|
||||
// Check password history
|
||||
const history = await pool.query(
|
||||
`SELECT password_hash FROM password_history
|
||||
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5`,
|
||||
[userId]
|
||||
);
|
||||
for (const record of history.rows) {
|
||||
if (verifyPassword(newPassword, record.password_hash)) {
|
||||
await log(user.id, ACTIONS.RESET_PASSWORD_FAILED, 'failed',
|
||||
`Attempted to reuse a recent password during reset`, req);
|
||||
return res.status(400).json({
|
||||
message: 'Cannot reuse a recent password. Please choose a different one.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hash and save the new password
|
||||
const newSalt = generateSalt();
|
||||
const newPasswordHash = hashPassword(newPassword, newSalt);
|
||||
|
||||
await pool.query('UPDATE users SET password_hash = $1, otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0, password_changed_at = NOW() WHERE id = $2',
|
||||
[newPasswordHash, userId]);
|
||||
await pool.query(
|
||||
'INSERT INTO password_history (user_id, password_hash) VALUES ($1, $2)',
|
||||
[userId, newPasswordHash]
|
||||
);
|
||||
|
||||
await log(user.id, ACTIONS.RESET_PASSWORD, 'success',
|
||||
`Password reset successfully via OTP for "${user.username}"`, req);
|
||||
|
||||
return res.json({ message: 'Password reset successful! You can now login with your new password.' });
|
||||
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/logout (protected)
|
||||
// ============================================================
|
||||
async function logout(req, res, next) {
|
||||
try {
|
||||
await pool.query('DELETE FROM sessions WHERE jti = $1', [req.userJti]);
|
||||
await log(req.user.id, ACTIONS.LOGOUT, 'success', `User logged out from one device`, req);
|
||||
res.clearCookie('token');
|
||||
res.clearCookie('refreshToken', { path: '/api/auth/refresh-token' });
|
||||
return res.json({ message: 'Logged out successfully.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/logout-all (protected)
|
||||
// ============================================================
|
||||
async function logoutAll(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM sessions WHERE user_id = $1 RETURNING id',
|
||||
[req.user.id]
|
||||
);
|
||||
const count = result.rowCount;
|
||||
await log(req.user.id, ACTIONS.LOGOUT_ALL, 'success',
|
||||
`Logged out from all ${count} device(s)`, req);
|
||||
res.clearCookie('token');
|
||||
res.clearCookie('refreshToken', { path: '/api/auth/refresh-token' });
|
||||
return res.json({ message: `Logged out from all ${count} device(s).` });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /api/auth/sessions (protected)
|
||||
// ============================================================
|
||||
async function getSessions(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, jti, ip_address, device_info, created_at, last_used_at
|
||||
FROM sessions
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_used_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
const sessions = result.rows.map(s => ({
|
||||
...s,
|
||||
isCurrent: s.jti === req.userJti,
|
||||
}));
|
||||
return res.json({ sessions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /api/auth/me (protected)
|
||||
// ============================================================
|
||||
async function getMe(req, res) {
|
||||
return res.json({ user: req.user });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/request-change-password-link (protected)
|
||||
// ============================================================
|
||||
async function requestChangePasswordLink(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const result = await pool.query('SELECT username, email, password_hash FROM users WHERE id = $1', [userId]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ message: 'User not found.' });
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
const secret = process.env.JWT_SECRET + user.password_hash;
|
||||
const token = jwt.sign({ id: userId, type: 'change-password-link' }, secret, { expiresIn: '15m' });
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5002';
|
||||
const link = `${frontendUrl}/set-new-password?token=${token}&id=${userId}`;
|
||||
|
||||
try {
|
||||
await sendChangePasswordLink(user.email, user.username, link);
|
||||
if (isDev) console.log(`[CHANGE PWD LINK] Sent to ${user.email} — Link: ${link}`);
|
||||
} catch (emailErr) {
|
||||
console.error('[CHANGE PWD LINK] Email failed:', emailErr.message);
|
||||
if (isDev) console.log(`[CHANGE PWD LINK] Fallback — User: ${user.username} | Link: ${link}`);
|
||||
}
|
||||
|
||||
await log(userId, ACTIONS.CHANGE_PASSWORD, 'success', `Change password link sent to ${user.email}`, req);
|
||||
|
||||
return res.json({ message: 'A secure password change link has been sent to your email. Please check your inbox.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/auth/verify-change-password-link (public, but requires token)
|
||||
// ============================================================
|
||||
async function verifyChangePasswordLink(req, res, next) {
|
||||
try {
|
||||
const { id, token, newPassword } = req.body;
|
||||
|
||||
if (!validatePassword(newPassword)) {
|
||||
const msg = passwordErrorMessage(newPassword) || 'Password does not meet requirements.';
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
if (isLeakedPassword(newPassword)) {
|
||||
await log(id, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Leaked password used for email link reset`, req);
|
||||
return res.status(400).json({ message: 'This password is commonly used and easy to crack. Please choose a stronger password.' });
|
||||
}
|
||||
|
||||
const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ message: 'User not found.' });
|
||||
|
||||
const user = result.rows[0];
|
||||
const secret = process.env.JWT_SECRET + user.password_hash;
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret);
|
||||
if (decoded.id !== parseInt(id) || decoded.type !== 'change-password-link') {
|
||||
throw new Error('Invalid token payload');
|
||||
}
|
||||
} catch (tokenErr) {
|
||||
await log(id, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Invalid or expired password change link`, req);
|
||||
return res.status(400).json({ message: 'The secure link is invalid or has expired. Please request a new one.' });
|
||||
}
|
||||
|
||||
// Check password history (last 5)
|
||||
const history = await pool.query(
|
||||
`SELECT password_hash FROM password_history
|
||||
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5`,
|
||||
[id]
|
||||
);
|
||||
for (const record of history.rows) {
|
||||
if (verifyPassword(newPassword, record.password_hash)) {
|
||||
await log(user.id, ACTIONS.CHANGE_PASSWORD_FAILED, 'failed', `Attempted to reuse a recent password during link reset`, req);
|
||||
return res.status(400).json({ message: 'Cannot reuse a recent password. Please choose a different one.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Hash and save new password
|
||||
const newSalt = generateSalt();
|
||||
const newPasswordHash = hashPassword(newPassword, newSalt);
|
||||
|
||||
await pool.query('UPDATE users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2', [newPasswordHash, id]);
|
||||
await pool.query(
|
||||
'INSERT INTO password_history (user_id, password_hash) VALUES ($1, $2)',
|
||||
[id, newPasswordHash]
|
||||
);
|
||||
|
||||
// Logout all active sessions for security
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [id]);
|
||||
|
||||
await log(id, ACTIONS.CHANGE_PASSWORD, 'success', `Password changed successfully via email link`, req);
|
||||
|
||||
return res.json({ message: 'Password changed successfully! You will now be logged out of all devices.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
login, requestOTP, verifyOTP, refreshToken,
|
||||
changePassword, forgotPassword, resetPassword,
|
||||
requestChangePasswordLink, verifyChangePasswordLink,
|
||||
logout, logoutAll, getSessions, getMe,
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
// ============================================================
|
||||
// chatController.js — AI assistant proxy (Google Gemini)
|
||||
// ============================================================
|
||||
// The browser NEVER calls Gemini directly and never sees the API
|
||||
// key. The widget posts a message here; this controller forwards
|
||||
// it to the Gemini free-tier API using the server-side
|
||||
// GEMINI_API_KEY and returns the assistant's reply.
|
||||
//
|
||||
// Escalation: the model is instructed to append the marker
|
||||
// [[ESCALATE]] when a human representative should take over
|
||||
// (pricing negotiation, complaints, account/legal issues, or
|
||||
// anything it cannot confidently answer). We strip the marker and
|
||||
// surface an `escalate` flag so the UI can offer the Contact page.
|
||||
// ============================================================
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash';
|
||||
const GEMINI_URL =
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
||||
|
||||
// Persona / knowledge for the assistant.
|
||||
const SYSTEM_PROMPT = `You are "Ava", the AI virtual assistant for AI-Solutions, a Sunderland-based
|
||||
start-up that uses artificial intelligence to improve the digital employee experience. AI-Solutions
|
||||
offers AI-powered virtual assistants, rapid affordable prototyping, and bespoke software solutions
|
||||
that speed up design, engineering and innovation for industry clients.
|
||||
|
||||
Your job: answer visitor questions about the company, its services, past projects, events and how to
|
||||
get in touch. Be concise (2-4 sentences), warm, and professional. Never invent specific prices,
|
||||
contracts, or personal data.
|
||||
|
||||
Escalate to a human by ending your reply with the exact marker [[ESCALATE]] when: the visitor asks
|
||||
for pricing/quotes, wants to book or schedule a demo, raises a complaint, asks something you cannot
|
||||
answer confidently, or explicitly asks to speak to a person. When you escalate, briefly tell them a
|
||||
representative will help and suggest the Contact Us form.`;
|
||||
|
||||
// Keyword fallback so escalation still works if the model forgets the marker.
|
||||
const ESCALATION_HINTS = [
|
||||
'human', 'representative', 'agent', 'real person', 'speak to someone',
|
||||
'price', 'pricing', 'quote', 'cost', 'complaint', 'refund', 'book a demo',
|
||||
'schedule a demo', 'sales',
|
||||
];
|
||||
|
||||
function looksLikeEscalation(text) {
|
||||
const t = (text || '').toLowerCase();
|
||||
return ESCALATION_HINTS.some(k => t.includes(k));
|
||||
}
|
||||
|
||||
// POST /api/chat body: { message, history?: [{role, text}] }
|
||||
async function chat(req, res, next) {
|
||||
try {
|
||||
const message = (req.body.message || '').toString().trim();
|
||||
if (!message) {
|
||||
return res.status(400).json({ message: 'Please enter a message.' });
|
||||
}
|
||||
if (message.length > 1000) {
|
||||
return res.status(400).json({ message: 'Message is too long (max 1000 characters).' });
|
||||
}
|
||||
|
||||
// Graceful degradation when the key isn't configured.
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
return res.json({
|
||||
reply:
|
||||
"I'm not fully online right now, but our team would love to help. " +
|
||||
'Please use the Contact Us page and someone will get back to you shortly.',
|
||||
escalate: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Build conversation history (last 8 turns) in Gemini's format.
|
||||
const history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
|
||||
const contents = history
|
||||
.filter(h => h && h.text)
|
||||
.map(h => ({
|
||||
role: h.role === 'assistant' || h.role === 'model' ? 'model' : 'user',
|
||||
parts: [{ text: String(h.text).slice(0, 1000) }],
|
||||
}));
|
||||
contents.push({ role: 'user', parts: [{ text: message }] });
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${GEMINI_URL}?key=${process.env.GEMINI_API_KEY}`,
|
||||
{
|
||||
system_instruction: { parts: [{ text: SYSTEM_PROMPT }] },
|
||||
contents,
|
||||
generationConfig: { temperature: 0.6, maxOutputTokens: 400, topP: 0.9 },
|
||||
},
|
||||
{ headers: { 'Content-Type': 'application/json' }, timeout: 20000 }
|
||||
);
|
||||
|
||||
let reply =
|
||||
data?.candidates?.[0]?.content?.parts?.map(p => p.text).join(' ').trim() || '';
|
||||
|
||||
let escalate = false;
|
||||
if (reply.includes('[[ESCALATE]]')) {
|
||||
escalate = true;
|
||||
reply = reply.replace(/\[\[ESCALATE\]\]/g, '').trim();
|
||||
}
|
||||
// Fallback signal from the user's own words.
|
||||
if (!escalate && looksLikeEscalation(message)) escalate = true;
|
||||
|
||||
if (!reply) {
|
||||
reply =
|
||||
"I'm sorry, I didn't quite catch that. Could you rephrase, or reach our team via the Contact Us page?";
|
||||
escalate = true;
|
||||
}
|
||||
|
||||
return res.json({ reply, escalate });
|
||||
} catch (err) {
|
||||
// Don't leak provider errors to the client — degrade gracefully.
|
||||
console.error('[Chat] Gemini error:', err.response?.data?.error?.message || err.message);
|
||||
return res.json({
|
||||
reply:
|
||||
"I'm having trouble reaching my AI service right now. Our team can help directly — " +
|
||||
'please use the Contact Us page and we will be in touch.',
|
||||
escalate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { chat };
|
||||
@@ -0,0 +1,56 @@
|
||||
// ============================================================
|
||||
// contactController.js — Public "Contact Us" inquiry submission
|
||||
// ============================================================
|
||||
// Customers do NOT need an account or password to submit an
|
||||
// inquiry. The 7 CSE fields are validated (see validate.js),
|
||||
// sanitised, stored in the `inquiries` table, and logged.
|
||||
// ============================================================
|
||||
|
||||
const pool = require('../db');
|
||||
const { log } = require('../utils/logger');
|
||||
const { sanitize } = require('../utils/validator');
|
||||
const { verifyTurnstile } = require('../utils/turnstile');
|
||||
|
||||
// POST /api/contact
|
||||
async function submitInquiry(req, res, next) {
|
||||
try {
|
||||
// Protect the public form with the existing Cloudflare Turnstile.
|
||||
const captchaOk = await verifyTurnstile(req.body.turnstileToken);
|
||||
if (!captchaOk) {
|
||||
await log(null, 'CAPTCHA_FAILED', 'failed', 'Turnstile failed on Contact Us submission', req);
|
||||
return res.status(400).json({ message: 'CAPTCHA verification failed. Please try again.' });
|
||||
}
|
||||
|
||||
const full_name = sanitize(req.body.full_name || '');
|
||||
const email = sanitize(req.body.email || '').toLowerCase().trim();
|
||||
const phone = sanitize(req.body.phone || '');
|
||||
const company = sanitize(req.body.company || '');
|
||||
const country = sanitize(req.body.country || '');
|
||||
const job_title = sanitize(req.body.job_title || '');
|
||||
const job_details = sanitize(req.body.job_details || '');
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO inquiries (full_name, email, phone, company, country, job_title, job_details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at`,
|
||||
[full_name, email, phone || null, company || null, country || null, job_title || null, job_details]
|
||||
);
|
||||
|
||||
await log(
|
||||
null,
|
||||
'INQUIRY_RECEIVED',
|
||||
'success',
|
||||
`New customer inquiry from "${full_name}" (${email})${company ? ` @ ${company}` : ''}`,
|
||||
req
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Thank you! Your inquiry has been received. Our team will be in touch shortly.',
|
||||
inquiryId: result.rows[0].id,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { submitInquiry };
|
||||
@@ -0,0 +1,136 @@
|
||||
// ============================================================
|
||||
// publicContentController.js — Public, read-only website content
|
||||
// ============================================================
|
||||
// These endpoints power the public marketing pages. They expose
|
||||
// only ACTIVE / PUBLISHED rows. No authentication is required.
|
||||
// ============================================================
|
||||
|
||||
const pool = require('../db');
|
||||
|
||||
// GET /api/content/solutions
|
||||
async function getSolutions(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, title, summary, description, icon
|
||||
FROM solutions
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order ASC, id ASC`
|
||||
);
|
||||
return res.json({ solutions: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/content/case-studies
|
||||
async function getCaseStudies(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, title, client, industry, summary, outcome, image_url, created_at
|
||||
FROM case_studies
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
return res.json({ caseStudies: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/content/feedback
|
||||
async function getFeedback(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, customer_name, company, rating, comment, created_at
|
||||
FROM feedback
|
||||
WHERE is_published = true
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
// Average rating for a quick "social proof" headline figure
|
||||
const avg = result.rows.length
|
||||
? result.rows.reduce((sum, r) => sum + r.rating, 0) / result.rows.length
|
||||
: 0;
|
||||
return res.json({
|
||||
feedback: result.rows,
|
||||
averageRating: Math.round(avg * 10) / 10,
|
||||
count: result.rows.length,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/content/articles
|
||||
async function getArticles(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, slug, title, excerpt, cover_image, author, published_at
|
||||
FROM articles
|
||||
WHERE is_published = true
|
||||
ORDER BY published_at DESC, id DESC`
|
||||
);
|
||||
return res.json({ articles: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/content/articles/:slug
|
||||
async function getArticleBySlug(req, res, next) {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const result = await pool.query(
|
||||
`SELECT id, slug, title, excerpt, body, cover_image, author, published_at
|
||||
FROM articles
|
||||
WHERE slug = $1 AND is_published = true`,
|
||||
[slug]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Article not found.' });
|
||||
}
|
||||
return res.json({ article: result.rows[0] });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/content/events
|
||||
async function getEvents(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, title, description, location, event_date, is_upcoming, cover_image
|
||||
FROM events
|
||||
ORDER BY event_date DESC NULLS LAST, id DESC`
|
||||
);
|
||||
return res.json({
|
||||
upcoming: result.rows.filter(e => e.is_upcoming),
|
||||
past: result.rows.filter(e => !e.is_upcoming),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/content/gallery
|
||||
async function getGallery(req, res, next) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT g.id, g.image_url, g.caption, g.created_at, e.title AS event_title
|
||||
FROM gallery_images g
|
||||
LEFT JOIN events e ON e.id = g.event_id
|
||||
ORDER BY g.created_at DESC, g.id DESC`
|
||||
);
|
||||
return res.json({ gallery: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSolutions,
|
||||
getCaseStudies,
|
||||
getFeedback,
|
||||
getArticles,
|
||||
getArticleBySlug,
|
||||
getEvents,
|
||||
getGallery,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// ============================================================
|
||||
// db.js — PostgreSQL database connection
|
||||
// ============================================================
|
||||
// We use the 'pg' package to talk to PostgreSQL.
|
||||
// A "Pool" keeps multiple connections open so requests don't
|
||||
// have to wait for a single connection to free up.
|
||||
// ============================================================
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
// Read database settings from .env file
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST, // e.g. 'localhost'
|
||||
port: process.env.DB_PORT, // e.g. 5432
|
||||
database: process.env.DB_NAME, // e.g. 'auth_db'
|
||||
user: process.env.DB_USER, // e.g. 'postgres'
|
||||
password: process.env.DB_PASSWORD, // your postgres password
|
||||
});
|
||||
|
||||
// Test the connection when the server starts
|
||||
pool.connect((err) => {
|
||||
if (err) {
|
||||
console.error('Database connection error:', err.message);
|
||||
} else {
|
||||
console.log('Connected to PostgreSQL database!');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
@@ -0,0 +1,15 @@
|
||||
123456
|
||||
password
|
||||
123456789
|
||||
qwerty
|
||||
12345678
|
||||
abc123
|
||||
password123
|
||||
111111
|
||||
123123
|
||||
admin
|
||||
letmein
|
||||
welcome
|
||||
iloveyou
|
||||
000000
|
||||
1234
|
||||
@@ -0,0 +1,62 @@
|
||||
// ============================================================
|
||||
// authMiddleware.js — JWT + session validation
|
||||
// ============================================================
|
||||
// Every protected route runs this middleware first.
|
||||
// It checks two things:
|
||||
// 1. Is the JWT signature valid and not expired?
|
||||
// 2. Does this session (jti) still exist in the sessions table?
|
||||
// → If "logout all devices" was called, all sessions are
|
||||
// deleted, so even a valid JWT gets rejected here.
|
||||
// ============================================================
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../db');
|
||||
|
||||
async function authenticate(req, res, next) {
|
||||
const token = req.cookies.token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Not authenticated. Please login.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Verify JWT signature and expiry
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Step 2: Check this session still exists in the DB
|
||||
// This is what makes "logout all devices" work —
|
||||
// deleting sessions from DB invalidates all existing tokens
|
||||
const session = await pool.query(
|
||||
'SELECT id FROM sessions WHERE jti = $1',
|
||||
[decoded.jti]
|
||||
);
|
||||
|
||||
if (session.rows.length === 0) {
|
||||
return res.status(401).json({ message: 'Session expired or signed out. Please login again.' });
|
||||
}
|
||||
|
||||
// Step 3: Update last_used_at to track device activity
|
||||
await pool.query(
|
||||
'UPDATE sessions SET last_used_at = NOW() WHERE jti = $1',
|
||||
[decoded.jti]
|
||||
);
|
||||
|
||||
req.user = decoded; // { id, username, role, jti }
|
||||
req.userJti = decoded.jti; // shortcut for logout
|
||||
next();
|
||||
|
||||
} catch (err) {
|
||||
return res.status(401).json({ message: 'Session expired. Please login again.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware: Check if the user has admin role
|
||||
// Must be used AFTER 'authenticate'
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Admin access required.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authenticate, requireAdmin };
|
||||
@@ -0,0 +1,155 @@
|
||||
// ============================================================
|
||||
// validate.js — Express-validator validation rules + middleware
|
||||
// ============================================================
|
||||
// Centralizes request validation so controllers stay clean.
|
||||
// Each route can pick the validation chain it needs.
|
||||
// ============================================================
|
||||
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
|
||||
// Middleware: run after validation chains — returns 400 if any field is invalid
|
||||
function handleValidationErrors(req, res, next) {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
const messages = errors.array().map(e => e.msg);
|
||||
return res.status(400).json({ message: messages[0], errors: messages });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ---- Auth validation chains --------------------------------
|
||||
|
||||
const loginRules = [
|
||||
body('email')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Email is required.')
|
||||
.isEmail().withMessage('Invalid email address.'),
|
||||
body('password')
|
||||
.notEmpty().withMessage('Password is required.'),
|
||||
body('turnstileToken')
|
||||
.notEmpty().withMessage('CAPTCHA token is required.'),
|
||||
];
|
||||
|
||||
const verifyOtpRules = [
|
||||
body('userId')
|
||||
.notEmpty().withMessage('User ID is required.')
|
||||
.isInt({ min: 1 }).withMessage('Invalid user ID.'),
|
||||
body('otp')
|
||||
.notEmpty().withMessage('OTP is required.')
|
||||
.isLength({ min: 6, max: 6 }).withMessage('OTP must be 6 digits.')
|
||||
.isNumeric().withMessage('OTP must be numeric.'),
|
||||
];
|
||||
|
||||
const requestOtpRules = [
|
||||
body('userId')
|
||||
.notEmpty().withMessage('User ID is required.')
|
||||
.isInt({ min: 1 }).withMessage('Invalid user ID.'),
|
||||
];
|
||||
|
||||
const changePasswordRules = [
|
||||
body('oldPassword')
|
||||
.notEmpty().withMessage('Current password is required.'),
|
||||
body('newPassword')
|
||||
.notEmpty().withMessage('New password is required.')
|
||||
.isLength({ min: 8 }).withMessage('New password must be at least 8 characters.')
|
||||
.matches(/[A-Z]/).withMessage('New password must contain an uppercase letter.')
|
||||
.matches(/[a-z]/).withMessage('New password must contain a lowercase letter.')
|
||||
.matches(/[0-9]/).withMessage('New password must contain a number.')
|
||||
.matches(/[^a-zA-Z0-9]/).withMessage('New password must contain a special character.'),
|
||||
];
|
||||
|
||||
const forgotPasswordRules = [
|
||||
body('email')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Email is required.')
|
||||
.isEmail().withMessage('Invalid email address.'),
|
||||
];
|
||||
|
||||
const resetPasswordRules = [
|
||||
body('userId')
|
||||
.notEmpty().withMessage('User ID is required.')
|
||||
.isInt({ min: 1 }).withMessage('Invalid user ID.'),
|
||||
body('otp')
|
||||
.notEmpty().withMessage('OTP is required.')
|
||||
.isLength({ min: 6, max: 6 }).withMessage('OTP must be 6 digits.')
|
||||
.isNumeric().withMessage('OTP must be numeric.'),
|
||||
body('newPassword')
|
||||
.notEmpty().withMessage('New password is required.')
|
||||
.isLength({ min: 8 }).withMessage('New password must be at least 8 characters.')
|
||||
.matches(/[A-Z]/).withMessage('New password must contain an uppercase letter.')
|
||||
.matches(/[a-z]/).withMessage('New password must contain a lowercase letter.')
|
||||
.matches(/[0-9]/).withMessage('New password must contain a number.')
|
||||
.matches(/[^a-zA-Z0-9]/).withMessage('New password must contain a special character.'),
|
||||
];
|
||||
|
||||
const verifyChangePwdLinkRules = [
|
||||
body('id')
|
||||
.notEmpty().withMessage('User ID is required.'),
|
||||
body('token')
|
||||
.notEmpty().withMessage('Token is required.'),
|
||||
body('newPassword')
|
||||
.notEmpty().withMessage('New password is required.')
|
||||
.isLength({ min: 8 }).withMessage('New password must be at least 8 characters.')
|
||||
.matches(/[A-Z]/).withMessage('New password must contain an uppercase letter.')
|
||||
.matches(/[a-z]/).withMessage('New password must contain a lowercase letter.')
|
||||
.matches(/[0-9]/).withMessage('New password must contain a number.')
|
||||
.matches(/[^a-zA-Z0-9]/).withMessage('New password must contain a special character.'),
|
||||
];
|
||||
|
||||
// ---- Public "Contact Us" validation chain ------------------
|
||||
|
||||
const contactRules = [
|
||||
body('full_name')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Full name is required.')
|
||||
.isLength({ max: 120 }).withMessage('Full name is too long.'),
|
||||
body('email')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Email is required.')
|
||||
.isEmail().withMessage('Please enter a valid email address.'),
|
||||
body('phone')
|
||||
.optional({ checkFalsy: true })
|
||||
.trim()
|
||||
.isLength({ max: 40 }).withMessage('Phone number is too long.'),
|
||||
body('company')
|
||||
.optional({ checkFalsy: true })
|
||||
.trim()
|
||||
.isLength({ max: 160 }).withMessage('Company name is too long.'),
|
||||
body('country')
|
||||
.optional({ checkFalsy: true })
|
||||
.trim()
|
||||
.isLength({ max: 80 }).withMessage('Country is too long.'),
|
||||
body('job_title')
|
||||
.optional({ checkFalsy: true })
|
||||
.trim()
|
||||
.isLength({ max: 120 }).withMessage('Job title is too long.'),
|
||||
body('job_details')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Please tell us about your requirements.')
|
||||
.isLength({ min: 5, max: 5000 }).withMessage('Job details must be 5-5000 characters.'),
|
||||
body('turnstileToken')
|
||||
.notEmpty().withMessage('Please complete the CAPTCHA challenge.'),
|
||||
];
|
||||
|
||||
// ---- Admin validation chains --------------------------------
|
||||
|
||||
const adminLogsRules = [
|
||||
query('page').optional().isInt({ min: 1 }).withMessage('Page must be a positive integer.'),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit must be 1-100.'),
|
||||
query('action').optional().isString().trim(),
|
||||
query('status').optional().isIn(['success', 'failed', 'warning']).withMessage('Status must be success, failed, or warning.'),
|
||||
query('search').optional().isString().trim(),
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
handleValidationErrors,
|
||||
loginRules,
|
||||
verifyOtpRules,
|
||||
requestOtpRules,
|
||||
changePasswordRules,
|
||||
forgotPasswordRules,
|
||||
resetPasswordRules,
|
||||
verifyChangePwdLinkRules,
|
||||
contactRules,
|
||||
adminLogsRules,
|
||||
};
|
||||
Generated
+1697
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "auth-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Secure authentication backend using Node.js, Express, and PostgreSQL",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.3.2",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^8.0.3",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// ============================================================
|
||||
// adminRoutes.js — Admin-only API routes
|
||||
// Every route requires: logged in (JWT + active session) + admin.
|
||||
// Sole purpose: review Contact Us inquiries.
|
||||
// ============================================================
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authenticate, requireAdmin } = require('../middleware/authMiddleware');
|
||||
const {
|
||||
getStats,
|
||||
getInquiries,
|
||||
getInquiry,
|
||||
setInquiryRead,
|
||||
deleteInquiry,
|
||||
} = require('../controllers/adminController');
|
||||
|
||||
// Apply auth + admin guard to every route in this file.
|
||||
router.use(authenticate, requireAdmin);
|
||||
|
||||
router.get('/stats', getStats);
|
||||
router.get('/inquiries', getInquiries);
|
||||
router.get('/inquiries/:id', getInquiry);
|
||||
router.patch('/inquiries/:id/read', setInquiryRead);
|
||||
router.delete('/inquiries/:id', deleteInquiry);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,76 @@
|
||||
// ============================================================
|
||||
// authRoutes.js — All authentication API routes
|
||||
// ============================================================
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const {
|
||||
login,
|
||||
requestOTP,
|
||||
verifyOTP,
|
||||
refreshToken,
|
||||
changePassword,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
requestChangePasswordLink,
|
||||
verifyChangePasswordLink,
|
||||
logout,
|
||||
logoutAll,
|
||||
getSessions,
|
||||
getMe,
|
||||
} = require('../controllers/authController');
|
||||
|
||||
const { authenticate } = require('../middleware/authMiddleware');
|
||||
|
||||
const {
|
||||
handleValidationErrors,
|
||||
loginRules,
|
||||
verifyOtpRules,
|
||||
requestOtpRules,
|
||||
changePasswordRules,
|
||||
forgotPasswordRules,
|
||||
resetPasswordRules,
|
||||
verifyChangePwdLinkRules,
|
||||
} = require('../middleware/validate');
|
||||
|
||||
// POST /api/auth/login — Check email + password, send OTP
|
||||
router.post('/login', loginRules, handleValidationErrors, login);
|
||||
|
||||
// POST /api/auth/request-otp — Resend OTP to user (for "resend" button)
|
||||
router.post('/request-otp', requestOtpRules, handleValidationErrors, requestOTP);
|
||||
|
||||
// POST /api/auth/verify-otp — Submit OTP to complete login
|
||||
router.post('/verify-otp', verifyOtpRules, handleValidationErrors, verifyOTP);
|
||||
|
||||
// POST /api/auth/refresh-token — Get a new access token using refresh token
|
||||
router.post('/refresh-token', refreshToken);
|
||||
|
||||
// POST /api/auth/change-password — Change password (must be logged in)
|
||||
router.post('/change-password', authenticate, changePasswordRules, handleValidationErrors, changePassword);
|
||||
|
||||
// POST /api/auth/request-change-password-link — Send email link
|
||||
router.post('/request-change-password-link', authenticate, requestChangePasswordLink);
|
||||
|
||||
// POST /api/auth/verify-change-password-link — Set new password from link
|
||||
router.post('/verify-change-password-link', verifyChangePwdLinkRules, handleValidationErrors, verifyChangePasswordLink);
|
||||
|
||||
// POST /api/auth/forgot-password — Send OTP to email for password reset
|
||||
router.post('/forgot-password', forgotPasswordRules, handleValidationErrors, forgotPassword);
|
||||
|
||||
// POST /api/auth/reset-password — Verify OTP and set new password
|
||||
router.post('/reset-password', resetPasswordRules, handleValidationErrors, resetPassword);
|
||||
|
||||
// GET /api/auth/sessions — List all active sessions for this user
|
||||
router.get('/sessions', authenticate, getSessions);
|
||||
|
||||
// POST /api/auth/logout — Logout this device only
|
||||
router.post('/logout', authenticate, logout);
|
||||
|
||||
// POST /api/auth/logout-all — Logout ALL devices
|
||||
router.post('/logout-all', authenticate, logoutAll);
|
||||
|
||||
// GET /api/auth/me — Get current user info (must be logged in)
|
||||
router.get('/me', authenticate, getMe);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,39 @@
|
||||
// ============================================================
|
||||
// publicRoutes.js — Public website API (no authentication)
|
||||
// ============================================================
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { submitInquiry } = require('../controllers/contactController');
|
||||
const { chat } = require('../controllers/chatController');
|
||||
const {
|
||||
getSolutions,
|
||||
getCaseStudies,
|
||||
getFeedback,
|
||||
getArticles,
|
||||
getArticleBySlug,
|
||||
getEvents,
|
||||
getGallery,
|
||||
} = require('../controllers/publicContentController');
|
||||
|
||||
const { handleValidationErrors, contactRules } = require('../middleware/validate');
|
||||
|
||||
// ---- Contact Us form -------------------------------------
|
||||
// POST /api/contact — submit a customer inquiry (7 CSE fields, Turnstile-protected)
|
||||
router.post('/contact', contactRules, handleValidationErrors, submitInquiry);
|
||||
|
||||
// ---- AI assistant chat proxy (Google Gemini) -------------
|
||||
// POST /api/chat — forwards to Gemini server-side; key never leaves the backend
|
||||
router.post('/chat', chat);
|
||||
|
||||
// ---- Public content (read-only) --------------------------
|
||||
router.get('/content/solutions', getSolutions);
|
||||
router.get('/content/case-studies', getCaseStudies);
|
||||
router.get('/content/feedback', getFeedback);
|
||||
router.get('/content/articles', getArticles);
|
||||
router.get('/content/articles/:slug', getArticleBySlug);
|
||||
router.get('/content/events', getEvents);
|
||||
router.get('/content/gallery', getGallery);
|
||||
|
||||
module.exports = router;
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
// ============================================================
|
||||
// seed.js — Seed the AI-Solutions database
|
||||
// ============================================================
|
||||
// Creates the single admin account (there is no public sign-up)
|
||||
// and loads sample public website content so every page renders.
|
||||
//
|
||||
// Run from the backend folder: node seed.js
|
||||
// Idempotent: re-running resets sample content and upserts admin.
|
||||
// ============================================================
|
||||
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
|
||||
const pool = require('./db');
|
||||
const { generateSalt, hashPassword } = require('./utils/hash');
|
||||
|
||||
// ---- Default admin credentials (change after first login) ----
|
||||
const ADMIN = {
|
||||
username: 'admin',
|
||||
email: process.env.SEED_ADMIN_EMAIL || 'admin@ai-solutions.com',
|
||||
password: process.env.SEED_ADMIN_PASSWORD || 'Admin@2026!',
|
||||
};
|
||||
|
||||
const solutions = [
|
||||
{ title: 'AI Virtual Assistants & Chatbots', icon: 'robot', sort_order: 1,
|
||||
summary: 'Conversational AI that handles routine customer and employee queries 24/7 and escalates to a human when needed.',
|
||||
description: 'We design and deploy AI assistants — built on large language models with retrieval-augmented generation — that answer questions from your own knowledge base, automate common requests such as order tracking or IT password resets, and hand over to a human agent with full context when a query is complex or sensitive.' },
|
||||
{ title: 'Intelligent Document Processing', icon: 'code', sort_order: 2,
|
||||
summary: 'Extract, classify and validate data from invoices, forms and contracts automatically using OCR and NLP.',
|
||||
description: 'Manual data entry is slow and error-prone. Our document-processing pipelines combine optical character recognition with natural-language models to read structured and unstructured documents, extract the fields that matter, and push clean data straight into your finance or back-office systems.' },
|
||||
{ title: 'Predictive Analytics & Forecasting', icon: 'chart', sort_order: 3,
|
||||
summary: 'Machine-learning models for demand forecasting, churn prediction and predictive maintenance.',
|
||||
description: 'We build and validate models on your historical data to forecast demand, anticipate equipment failures before they happen, and flag customers at risk of churning — turning the data you already collect into decisions you can act on.' },
|
||||
{ title: 'Process Automation (RPA + AI)', icon: 'bolt', sort_order: 4,
|
||||
summary: 'Automate repetitive, rules-based workflows and augment them with AI judgement.',
|
||||
description: 'By combining robotic process automation with AI decisioning, we remove the repetitive digital tasks that slow teams down — from onboarding and approvals to reconciliation — freeing people to focus on work that needs human judgement.' },
|
||||
{ title: 'Computer Vision & Quality Inspection', icon: 'spark', sort_order: 5,
|
||||
summary: 'Automated visual inspection and image recognition for manufacturing and operations.',
|
||||
description: 'Our computer-vision solutions detect defects on production lines, read labels and serial numbers, and monitor sites in real time, delivering consistent quality checks at a speed and scale that manual inspection cannot match.' },
|
||||
];
|
||||
|
||||
const caseStudies = [
|
||||
{ title: 'AI service-desk assistant deflecting routine IT tickets', client: 'A national logistics operator', industry: 'Logistics',
|
||||
summary: 'An AI virtual assistant was integrated into the internal IT service desk to handle password resets, system access requests, status updates and "how-to" questions for depot and driver staff.',
|
||||
outcome: 'A large share of tier-1 requests were resolved automatically without an agent, with anything complex escalated to the support team with full context — reducing wait times for staff and freeing agents for higher-value work.',
|
||||
image_url: 'https://picsum.photos/seed/logistics/800/500' },
|
||||
{ title: 'Predictive maintenance reducing unplanned downtime', client: 'A precision manufacturer', industry: 'Manufacturing',
|
||||
summary: 'Machine-learning models were trained on sensor and maintenance data to predict equipment failures on a production line before they occurred.',
|
||||
outcome: 'Early-warning alerts allowed maintenance to be scheduled proactively rather than reactively, cutting unplanned downtime and extending the working life of critical machinery.',
|
||||
image_url: 'https://picsum.photos/seed/factory/800/500' },
|
||||
{ title: 'Automated claims intake with intelligent document processing', client: 'A regional insurer', industry: 'Insurance',
|
||||
summary: 'OCR and natural-language models were used to read incoming claim forms and supporting documents and extract the data needed to open and triage a case.',
|
||||
outcome: 'Claims that previously required manual keying were processed in minutes, improving turnaround times while handlers focused on assessment rather than data entry.',
|
||||
image_url: 'https://picsum.photos/seed/insurance/800/500' },
|
||||
{ title: 'Demand forecasting to improve stock availability', client: 'A multi-site retailer', industry: 'Retail',
|
||||
summary: 'A forecasting model combined sales history, seasonality and promotional calendars to predict demand at store and product level.',
|
||||
outcome: 'More accurate forecasts reduced both stockouts and overstock, improving on-shelf availability while lowering the working capital tied up in inventory.',
|
||||
image_url: 'https://picsum.photos/seed/retail/800/500' },
|
||||
];
|
||||
|
||||
const feedback = [
|
||||
{ customer_name: 'Sarah Whitfield', company: 'National logistics operator', rating: 5,
|
||||
comment: 'Their AI service-desk assistant handles the routine questions our team used to drown in, and the hand-off to a human is genuinely seamless. Delivered faster than we expected.' },
|
||||
{ customer_name: 'David Olsen', company: 'Precision manufacturer', rating: 5,
|
||||
comment: 'The predictive-maintenance models have already flagged issues before they became failures. Pragmatic, expert and easy to work with.' },
|
||||
{ customer_name: 'Priya Nair', company: 'Regional insurer', rating: 4,
|
||||
comment: 'Automating our claims intake removed a huge amount of manual data entry. A few tweaks were needed but support was excellent throughout.' },
|
||||
{ customer_name: 'Marcus Lee', company: 'Multi-site retailer', rating: 5,
|
||||
comment: 'Demand forecasting finally gave us a real handle on stock. On-shelf availability is up and we are holding far less dead inventory.' },
|
||||
{ customer_name: 'Elena Costa', company: 'Customer services team', rating: 4,
|
||||
comment: 'Professional, responsive and genuinely invested in our success. The chatbot has noticeably cut our call volume.' },
|
||||
];
|
||||
|
||||
const articles = [
|
||||
{ slug: 'ai-assistants-it-service-desks', title: 'How AI Virtual Assistants Are Transforming IT Service Desks',
|
||||
excerpt: 'A look at how conversational AI is deflecting routine tickets and improving employee support in real organisations.',
|
||||
cover_image: 'https://picsum.photos/seed/servicedesk/800/450',
|
||||
body: 'The IT service desk is one of the most common — and most successful — places organisations deploy AI today. A large proportion of the requests that reach a service desk are repetitive: password resets, software access, "how do I…" questions and status updates.\n\nModern AI assistants, built on large language models connected to a company\'s own knowledge base, can resolve many of these requests instantly and around the clock. Crucially, a well-designed assistant recognises the limits of what it can safely answer and escalates to a human agent — with the full conversation history attached — whenever a request is complex, sensitive or unusual.\n\nThe result is not the removal of human agents but the redirection of their time: routine volume is absorbed by the assistant, while skilled staff focus on the incidents that genuinely need them. This is the model AI-Solutions builds towards for every assistant we deliver.' },
|
||||
{ slug: 'predictive-maintenance-machine-learning', title: 'Predictive Maintenance: Using Machine Learning to Cut Downtime',
|
||||
excerpt: 'How manufacturers use sensor data and machine learning to fix equipment before it breaks.',
|
||||
cover_image: 'https://picsum.photos/seed/maintenance/800/450',
|
||||
body: 'Unplanned downtime is one of the largest hidden costs in manufacturing. Traditional maintenance is either reactive — fixing machines after they fail — or calendar-based, replacing parts on a fixed schedule whether they need it or not.\n\nPredictive maintenance takes a different approach. By training machine-learning models on the data that equipment already produces — vibration, temperature, current draw and error logs — it becomes possible to detect the subtle patterns that precede a failure and to act before the breakdown happens.\n\nIn practice this means maintenance can be scheduled at the right moment: late enough to get full value from a component, early enough to avoid an unplanned stoppage. For the manufacturers we work with, that balance translates directly into higher output and lower cost.' },
|
||||
{ slug: 'intelligent-document-processing-finance', title: 'Intelligent Document Processing in Financial Services',
|
||||
excerpt: 'Turning invoices, forms and contracts into clean data with OCR and natural-language AI.',
|
||||
cover_image: 'https://picsum.photos/seed/documents/800/450',
|
||||
body: 'Financial services run on documents — invoices, claims, statements, contracts and forms — and historically much of that information has been moved into systems by hand. Manual keying is slow, expensive and prone to error.\n\nIntelligent document processing combines optical character recognition, which converts images of text into machine-readable characters, with natural-language models that understand context. Together they can locate and extract the specific fields a process needs, validate them against business rules, and route anything uncertain to a person for review.\n\nThe pattern is widely applicable: accounts-payable automation, claims intake, customer onboarding and compliance checks all benefit. The goal is straightforward — let software handle the predictable extraction work, and let people handle the judgement.' },
|
||||
{ slug: 'rag-enterprise-knowledge', title: 'Retrieval-Augmented Generation: Grounding AI in Your Own Data',
|
||||
excerpt: 'Why RAG is the practical way to make AI assistants accurate and trustworthy for business use.',
|
||||
cover_image: 'https://picsum.photos/seed/rag/800/450',
|
||||
body: 'Large language models are powerful, but on their own they can answer confidently from general knowledge that may be out of date or simply wrong for your business. Retrieval-augmented generation, or RAG, is the technique that addresses this.\n\nInstead of relying only on the model\'s memory, a RAG system first retrieves the most relevant passages from a trusted source — your documentation, policies or product data — and gives them to the model as context when it answers. The response is therefore grounded in your own, current information, and can cite where it came from.\n\nFor organisations, this is the difference between an interesting demo and a system they can rely on. It is the foundation of the AI assistants AI-Solutions deploys, because accuracy and trust matter more than novelty.' },
|
||||
];
|
||||
|
||||
// event_date stored as YYYY-MM-DD; is_upcoming controls the page it appears on.
|
||||
const events = [
|
||||
{ title: 'AI for the Workplace — Sunderland Summit', location: 'Sunderland Software Centre',
|
||||
event_date: '2026-09-18', is_upcoming: true,
|
||||
description: 'A half-day summit exploring practical AI adoption for regional employers, featuring live demos of our virtual assistant.',
|
||||
cover_image: 'https://picsum.photos/seed/summit/800/500' },
|
||||
{ title: 'Hands-on Prototyping Workshop', location: 'Online (Webinar)',
|
||||
event_date: '2026-07-30', is_upcoming: true,
|
||||
description: 'A free interactive workshop showing how to take an idea to a working prototype using AI-assisted tooling.',
|
||||
cover_image: 'https://picsum.photos/seed/workshop/800/500' },
|
||||
{ title: 'North East Tech Expo 2026', location: 'Newcastle Exhibition Centre',
|
||||
event_date: '2026-03-12', is_upcoming: false,
|
||||
description: 'We showcased our latest digital employee experience tooling to over 400 visitors at the region’s flagship technology expo.',
|
||||
cover_image: 'https://picsum.photos/seed/expo/800/500' },
|
||||
{ title: 'Innovation Showcase Evening', location: 'Sunderland City Hall',
|
||||
event_date: '2025-11-20', is_upcoming: false,
|
||||
description: 'An evening demonstrating real industry solutions delivered by AI-Solutions, with talks from our client partners.',
|
||||
cover_image: 'https://picsum.photos/seed/showcase/800/500' },
|
||||
];
|
||||
|
||||
// Gallery images keyed to the past events by index after insert.
|
||||
const galleryFor = (pastEventIds) => ([
|
||||
{ event_idx: 0, image_url: 'https://picsum.photos/seed/g1/600/400', caption: 'Our stand at the North East Tech Expo' },
|
||||
{ event_idx: 0, image_url: 'https://picsum.photos/seed/g2/600/400', caption: 'Live virtual assistant demo' },
|
||||
{ event_idx: 1, image_url: 'https://picsum.photos/seed/g3/600/400', caption: 'Client partner talk at the Innovation Showcase' },
|
||||
{ event_idx: 1, image_url: 'https://picsum.photos/seed/g4/600/400', caption: 'Networking at Sunderland City Hall' },
|
||||
{ event_idx: 0, image_url: 'https://picsum.photos/seed/g5/600/400', caption: 'The AI-Solutions team' },
|
||||
{ event_idx: 1, image_url: 'https://picsum.photos/seed/g6/600/400', caption: 'Prototyping in action' },
|
||||
].map(g => ({ ...g, event_id: pastEventIds[g.event_idx] || null })));
|
||||
|
||||
async function seed() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// ---- Admin (upsert) ----
|
||||
const hash = hashPassword(ADMIN.password, generateSalt());
|
||||
await client.query(
|
||||
`INSERT INTO users (username, email, password_hash, role, email_verified, password_changed_at)
|
||||
VALUES ($1, $2, $3, 'admin', true, NOW())
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET password_hash = EXCLUDED.password_hash,
|
||||
role = 'admin',
|
||||
email_verified = true,
|
||||
failed_attempts = 0,
|
||||
locked_until = NULL`,
|
||||
[ADMIN.username, ADMIN.email, hash]
|
||||
);
|
||||
|
||||
// ---- Reset + load sample content ----
|
||||
await client.query('TRUNCATE solutions, case_studies, feedback, articles, gallery_images, events RESTART IDENTITY CASCADE');
|
||||
|
||||
for (const s of solutions) {
|
||||
await client.query(
|
||||
`INSERT INTO solutions (title, summary, description, icon, sort_order, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,true)`,
|
||||
[s.title, s.summary, s.description, s.icon, s.sort_order]
|
||||
);
|
||||
}
|
||||
|
||||
for (const c of caseStudies) {
|
||||
await client.query(
|
||||
`INSERT INTO case_studies (title, client, industry, summary, outcome, image_url)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[c.title, c.client, c.industry, c.summary, c.outcome, c.image_url]
|
||||
);
|
||||
}
|
||||
|
||||
for (const f of feedback) {
|
||||
await client.query(
|
||||
`INSERT INTO feedback (customer_name, company, rating, comment, is_published)
|
||||
VALUES ($1,$2,$3,$4,true)`,
|
||||
[f.customer_name, f.company, f.rating, f.comment]
|
||||
);
|
||||
}
|
||||
|
||||
for (const a of articles) {
|
||||
await client.query(
|
||||
`INSERT INTO articles (slug, title, excerpt, body, cover_image, is_published, published_at)
|
||||
VALUES ($1,$2,$3,$4,$5,true,NOW())`,
|
||||
[a.slug, a.title, a.excerpt, a.body, a.cover_image]
|
||||
);
|
||||
}
|
||||
|
||||
const pastEventIds = [];
|
||||
for (const e of events) {
|
||||
const r = await client.query(
|
||||
`INSERT INTO events (title, description, location, event_date, is_upcoming, cover_image)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, is_upcoming`,
|
||||
[e.title, e.description, e.location, e.event_date, e.is_upcoming, e.cover_image]
|
||||
);
|
||||
if (!r.rows[0].is_upcoming) pastEventIds.push(r.rows[0].id);
|
||||
}
|
||||
|
||||
for (const g of galleryFor(pastEventIds)) {
|
||||
await client.query(
|
||||
`INSERT INTO gallery_images (event_id, image_url, caption) VALUES ($1,$2,$3)`,
|
||||
[g.event_id, g.image_url, g.caption]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
console.log('\n AI-Solutions database seeded successfully.');
|
||||
console.log(' --------------------------------------------');
|
||||
console.log(` Admin login: ${ADMIN.email}`);
|
||||
console.log(` Password: ${ADMIN.password}`);
|
||||
console.log(' (Change this after your first login.)\n');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('\n Seed failed:', err.message, '\n');
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
@@ -0,0 +1,152 @@
|
||||
// ============================================================
|
||||
// server.js — Main Express server entry point
|
||||
// ============================================================
|
||||
|
||||
// Load environment variables from the single root .env file
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
|
||||
// ---- Validate required environment variables at startup -----
|
||||
const REQUIRED_ENV = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET', 'TURNSTILE_SECRET_KEY'];
|
||||
const missing = REQUIRED_ENV.filter(key => !process.env[key]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`FATAL: Missing required environment variables: ${missing.join(', ')}`);
|
||||
console.error('Please check your .env file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const crypto = require('crypto');
|
||||
const pool = require('./db');
|
||||
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const publicRoutes = require('./routes/publicRoutes');
|
||||
|
||||
const app = express();
|
||||
|
||||
// ---- Security: HTTP headers --------------------------------
|
||||
app.use(helmet());
|
||||
|
||||
// ---- CORS: Only allow requests from our React frontend -----
|
||||
// FRONTEND_URL may be a comma-separated list of allowed origins.
|
||||
// In development we also allow any localhost / 127.0.0.1 port so the
|
||||
// dev server can run on 3000, 5001, etc. without breaking fetches.
|
||||
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.use(cors({
|
||||
origin(origin, callback) {
|
||||
// Allow non-browser clients (curl, server-to-server) with no Origin header.
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
if (process.env.NODE_ENV !== 'production' &&
|
||||
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// ---- Body Parser: Read JSON from request body --------------
|
||||
app.use(express.json());
|
||||
|
||||
// ---- Cookie Parser: Read cookies from requests -------------
|
||||
app.use(cookieParser());
|
||||
|
||||
// ---- CSRF Protection (Double Submit Cookie) ----------------
|
||||
// Generate a CSRF token and set it as a readable cookie.
|
||||
// The frontend must send it back in the X-CSRF-Token header.
|
||||
// This protects state-changing endpoints from cross-site attacks.
|
||||
app.use((req, res, next) => {
|
||||
// Only enforce CSRF on state-changing methods
|
||||
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
|
||||
|
||||
if (safeMethods.includes(req.method)) {
|
||||
// For safe methods, issue/refresh the CSRF cookie if missing
|
||||
if (!req.cookies.csrfToken) {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
res.cookie('csrfToken', csrfToken, {
|
||||
httpOnly: false, // frontend JS needs to read this
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// For POST/PUT/PATCH/DELETE: validate the CSRF token
|
||||
const cookieToken = req.cookies.csrfToken;
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||||
return res.status(403).json({ message: 'CSRF token validation failed.' });
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// ---- Rate Limiting: Max 100 requests per 15 minutes --------
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
message: { message: 'Too many requests. Please wait and try again.' },
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// ---- Routes -----------------------------------------------
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api', publicRoutes); // /api/contact, /api/chat, /api/content/*
|
||||
|
||||
// ---- Health check route ------------------------------------
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ message: 'AI-Solutions API is running!' });
|
||||
});
|
||||
|
||||
// ---- Centralized Error Handler -----------------------------
|
||||
// All controllers call next(err) instead of handling errors inline.
|
||||
// This middleware catches them all in one place.
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} — Error:`, err.message);
|
||||
|
||||
// Don't leak internal details in production
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
res.status(err.status || 500).json({
|
||||
message: isProduction ? 'Server error. Please try again.' : err.message || 'Server error.',
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Session Cleanup: Remove expired sessions --------------
|
||||
// Runs every hour. Deletes sessions older than 1 hour (matching JWT expiry)
|
||||
// and sessions whose refresh tokens have expired (7 days).
|
||||
function cleanupExpiredSessions() {
|
||||
pool.query(
|
||||
`DELETE FROM sessions WHERE created_at < NOW() - INTERVAL '7 days'`
|
||||
).then(result => {
|
||||
if (result.rowCount > 0) {
|
||||
console.log(`[Session Cleanup] Removed ${result.rowCount} expired session(s).`);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Session Cleanup] Error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Run cleanup on startup and then every hour
|
||||
cleanupExpiredSessions();
|
||||
const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
|
||||
setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL);
|
||||
|
||||
// ---- Start Server ------------------------------------------
|
||||
const PORT = process.env.PORT || 5000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Backend server is running at http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// ============================================================
|
||||
// deviceParser.js — Parse User-Agent into readable device info
|
||||
// ============================================================
|
||||
|
||||
function parseDevice(userAgent) {
|
||||
if (!userAgent) return 'Unknown Device';
|
||||
|
||||
// Detect OS
|
||||
let os = 'Unknown OS';
|
||||
if (/windows nt 10/i.test(userAgent)) os = 'Windows 10/11';
|
||||
else if (/windows nt/i.test(userAgent)) os = 'Windows';
|
||||
else if (/android/i.test(userAgent)) os = 'Android';
|
||||
else if (/iphone/i.test(userAgent)) os = 'iPhone';
|
||||
else if (/ipad/i.test(userAgent)) os = 'iPad';
|
||||
else if (/mac os/i.test(userAgent)) os = 'macOS';
|
||||
else if (/linux/i.test(userAgent)) os = 'Linux';
|
||||
|
||||
// Detect browser
|
||||
let browser = 'Unknown Browser';
|
||||
if (/edg\//i.test(userAgent)) browser = 'Edge';
|
||||
else if (/opr\//i.test(userAgent)) browser = 'Opera';
|
||||
else if (/chrome/i.test(userAgent)) browser = 'Chrome';
|
||||
else if (/firefox/i.test(userAgent)) browser = 'Firefox';
|
||||
else if (/safari/i.test(userAgent)) browser = 'Safari';
|
||||
|
||||
return `${browser} on ${os}`;
|
||||
}
|
||||
|
||||
module.exports = { parseDevice };
|
||||
@@ -0,0 +1,114 @@
|
||||
// ============================================================
|
||||
// email.js — Send emails via Gmail using Nodemailer
|
||||
// ============================================================
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
// Send the OTP email to the user
|
||||
async function sendOTPEmail(toEmail, username, otp) {
|
||||
const mailOptions = {
|
||||
from: `"Auth System" <${process.env.EMAIL_USER}>`,
|
||||
to: toEmail,
|
||||
subject: 'Your OTP Code',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 400px; margin: auto;
|
||||
border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px;">
|
||||
<h2 style="color: #16a34a; margin-top: 0;">One-Time Password</h2>
|
||||
<p>Hello <strong>${username}</strong>,</p>
|
||||
<p>Your login OTP code is:</p>
|
||||
<div style="font-size: 2rem; font-weight: bold; letter-spacing: 8px;
|
||||
color: #16a34a; text-align: center; padding: 16px 0;">
|
||||
${otp}
|
||||
</div>
|
||||
<p style="color: #6b7280; font-size: 0.9rem;">
|
||||
This code expires in <strong>5 minutes</strong>.<br/>
|
||||
If you did not request this, please ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
}
|
||||
|
||||
// Send the Change Password Link email
|
||||
async function sendChangePasswordLink(toEmail, username, link) {
|
||||
const mailOptions = {
|
||||
from: `"Auth System" <${process.env.EMAIL_USER}>`,
|
||||
to: toEmail,
|
||||
subject: 'Password Change Request',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 400px; margin: auto;
|
||||
border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px;">
|
||||
<h2 style="color: #1d4ed8; margin-top: 0;">Change Your Password</h2>
|
||||
<p>Hello <strong>${username}</strong>,</p>
|
||||
<p>You requested to change your password. Click the secure link below to set a new password:</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${link}" style="background-color: #1d4ed8; color: white; padding: 12px 24px;
|
||||
text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||
Set New Password
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #6b7280; font-size: 0.9rem;">
|
||||
This link expires in <strong>15 minutes</strong>.<br/>
|
||||
If you did not request this, please ignore this email and your password will remain unchanged.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
}
|
||||
|
||||
// Send a new device login alert email
|
||||
async function sendNewDeviceAlert(toEmail, username, deviceInfo, ipAddress, loginTime) {
|
||||
const timeStr = new Date(loginTime).toLocaleString('en-US', {
|
||||
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
|
||||
});
|
||||
|
||||
const mailOptions = {
|
||||
from: `"Auth System" <${process.env.EMAIL_USER}>`,
|
||||
to: toEmail,
|
||||
subject: 'New Device Login Detected',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 440px; margin: auto;
|
||||
border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px;">
|
||||
<h2 style="color: #ea580c; margin-top: 0;">New Device Login</h2>
|
||||
<p>Hello <strong>${username}</strong>,</p>
|
||||
<p>We detected a new login to your account from a device we haven't seen before:</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 0.9rem;">
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; background: #f9fafb; border: 1px solid #e5e7eb; font-weight: 600; width: 100px;">Device</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${deviceInfo}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; background: #f9fafb; border: 1px solid #e5e7eb; font-weight: 600;">IP Address</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-family: monospace;">${ipAddress}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; background: #f9fafb; border: 1px solid #e5e7eb; font-weight: 600;">Time</td>
|
||||
<td style="padding: 8px 12px; border: 1px solid #e5e7eb;">${timeStr}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #6b7280; font-size: 0.9rem;">
|
||||
If this was you, no action is needed.<br/>
|
||||
If you don't recognize this login, please <strong>change your password immediately</strong>
|
||||
and log out of all devices from your dashboard.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
}
|
||||
|
||||
module.exports = { sendOTPEmail, sendChangePasswordLink, sendNewDeviceAlert };
|
||||
@@ -0,0 +1,19 @@
|
||||
// ============================================================
|
||||
// hash.js — Password hashing using bcrypt
|
||||
// ============================================================
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
function generateSalt() {
|
||||
return bcrypt.genSaltSync(10);
|
||||
}
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
return bcrypt.hashSync(password, salt);
|
||||
}
|
||||
|
||||
function verifyPassword(inputPassword, storedHash) {
|
||||
return bcrypt.compareSync(inputPassword, storedHash);
|
||||
}
|
||||
|
||||
module.exports = { generateSalt, hashPassword, verifyPassword };
|
||||
@@ -0,0 +1,66 @@
|
||||
// ============================================================
|
||||
// logger.js — Save activity logs to the database
|
||||
// ============================================================
|
||||
// Every important action (login, register, OTP, etc.) calls
|
||||
// this function to leave a record in the logs table.
|
||||
// ============================================================
|
||||
|
||||
const pool = require('../db');
|
||||
|
||||
// Actions — use these constants everywhere so spelling is consistent
|
||||
const ACTIONS = {
|
||||
REGISTER_SUCCESS: 'REGISTER_SUCCESS',
|
||||
REGISTER_FAILED: 'REGISTER_FAILED',
|
||||
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
|
||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||
ACCOUNT_LOCKED: 'ACCOUNT_LOCKED',
|
||||
OTP_SENT: 'OTP_SENT',
|
||||
OTP_RESENT: 'OTP_RESENT',
|
||||
OTP_VERIFIED: 'OTP_VERIFIED',
|
||||
OTP_FAILED: 'OTP_FAILED',
|
||||
OTP_EXPIRED: 'OTP_EXPIRED',
|
||||
LOGOUT: 'LOGOUT',
|
||||
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
|
||||
CHANGE_PASSWORD_FAILED:'CHANGE_PASSWORD_FAILED',
|
||||
ADMIN_UNLOCK_USER: 'ADMIN_UNLOCK_USER',
|
||||
ADMIN_DELETE_USER: 'ADMIN_DELETE_USER',
|
||||
ADMIN_CHANGE_ROLE: 'ADMIN_CHANGE_ROLE',
|
||||
CAPTCHA_FAILED: 'CAPTCHA_FAILED',
|
||||
LOGOUT_ALL: 'LOGOUT_ALL',
|
||||
FORGOT_PASSWORD: 'FORGOT_PASSWORD',
|
||||
FORGOT_PASSWORD_FAILED:'FORGOT_PASSWORD_FAILED',
|
||||
RESET_PASSWORD: 'RESET_PASSWORD',
|
||||
RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED',
|
||||
PASSWORD_EXPIRED: 'PASSWORD_EXPIRED',
|
||||
NEW_DEVICE_LOGIN: 'NEW_DEVICE_LOGIN',
|
||||
};
|
||||
|
||||
// Get IP address from the request object
|
||||
function getIP(req) {
|
||||
return (
|
||||
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.socket?.remoteAddress ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
// Write a log entry to the database
|
||||
// userId — can be null (e.g. failed login where user doesn't exist)
|
||||
// action — use ACTIONS constants above
|
||||
// status — 'success' | 'failed' | 'warning'
|
||||
// details — any extra human-readable info
|
||||
async function log(userId, action, status, details, req) {
|
||||
try {
|
||||
const ip = req ? getIP(req) : 'system';
|
||||
await pool.query(
|
||||
`INSERT INTO logs (user_id, action, status, details, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[userId || null, action, status, details || null, ip]
|
||||
);
|
||||
} catch (err) {
|
||||
// Never crash the app because logging failed
|
||||
console.error('[Logger] Failed to write log:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { log, ACTIONS };
|
||||
@@ -0,0 +1,30 @@
|
||||
// ============================================================
|
||||
// otp.js — One-Time Password (OTP) generator
|
||||
// ============================================================
|
||||
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// Generate a random 6-digit OTP using crypto.randomInt()
|
||||
function generateOTP() {
|
||||
return crypto.randomInt(100000, 1000000).toString();
|
||||
}
|
||||
|
||||
// Get the expiry time: 5 minutes from now
|
||||
function getOTPExpiry() {
|
||||
const expiry = new Date();
|
||||
expiry.setMinutes(expiry.getMinutes() + 5);
|
||||
return expiry;
|
||||
}
|
||||
|
||||
// Hash an OTP with bcrypt before storing in DB
|
||||
function hashOTP(otp) {
|
||||
return bcrypt.hashSync(String(otp), 10);
|
||||
}
|
||||
|
||||
// Verify an OTP against a stored bcrypt hash
|
||||
function verifyOTPHash(inputOTP, storedHash) {
|
||||
return bcrypt.compareSync(String(inputOTP), storedHash);
|
||||
}
|
||||
|
||||
module.exports = { generateOTP, getOTPExpiry, hashOTP, verifyOTPHash };
|
||||
@@ -0,0 +1,30 @@
|
||||
// ============================================================
|
||||
// turnstile.js — Cloudflare Turnstile CAPTCHA verification
|
||||
// ============================================================
|
||||
// Shared helper so both the auth flow and the public Contact Us
|
||||
// form can validate Turnstile tokens server-side against the
|
||||
// existing TURNSTILE_SECRET_KEY. The browser never bypasses this.
|
||||
// ============================================================
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
async function verifyTurnstile(token) {
|
||||
// If no token supplied, fail closed.
|
||||
if (!token) return false;
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
new URLSearchParams({
|
||||
secret: process.env.TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||
);
|
||||
return response.data.success === true;
|
||||
} catch (err) {
|
||||
console.error('Turnstile verification error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyTurnstile };
|
||||
@@ -0,0 +1,102 @@
|
||||
// ============================================================
|
||||
// validator.js — Input validation and sanitization
|
||||
// ============================================================
|
||||
// Always validate user input before storing it in the database.
|
||||
// This protects against bad data and injection attacks.
|
||||
// ============================================================
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Load simple leaked passwords dictionary into memory
|
||||
let leakedPasswords = new Set();
|
||||
try {
|
||||
const filePath = path.join(__dirname, '..', 'leaked_passwords.txt');
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
data.split(/\r?\n/).forEach(line => {
|
||||
const pwd = line.trim();
|
||||
if (pwd) leakedPasswords.add(pwd);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Could not load leaked_passwords.txt. Leaked password checking is disabled.');
|
||||
}
|
||||
|
||||
// Check if email format is valid (e.g. user@example.com)
|
||||
function validateEmail(email) {
|
||||
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return regex.test(email);
|
||||
}
|
||||
|
||||
// Username: only letters, numbers, underscores. Length 3-30.
|
||||
function validateUsername(username) {
|
||||
const regex = /^[a-zA-Z0-9_]{3,30}$/;
|
||||
return regex.test(username);
|
||||
}
|
||||
|
||||
// Check if password exists in our leaked passwords file
|
||||
function isLeakedPassword(password) {
|
||||
return leakedPasswords.has(password);
|
||||
}
|
||||
|
||||
// Password must be at least 8 characters and contain:
|
||||
// - at least 1 uppercase letter
|
||||
// - at least 1 lowercase letter
|
||||
// - at least 1 number
|
||||
// - at least 1 special character
|
||||
function validatePassword(password) {
|
||||
if (typeof password !== 'string' || password.length < 8) return false;
|
||||
if (!/[A-Z]/.test(password)) return false; // uppercase
|
||||
if (!/[a-z]/.test(password)) return false; // lowercase
|
||||
if (!/[0-9]/.test(password)) return false; // digit
|
||||
if (!/[^a-zA-Z0-9]/.test(password)) return false; // special char
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns a user-friendly message about what's missing from the password
|
||||
function getPasswordErrors(password) {
|
||||
const errors = [];
|
||||
if (typeof password !== 'string') return ['Password is required.'];
|
||||
if (password.length < 8) errors.push('at least 8 characters');
|
||||
if (!/[A-Z]/.test(password)) errors.push('an uppercase letter');
|
||||
if (!/[a-z]/.test(password)) errors.push('a lowercase letter');
|
||||
if (!/[0-9]/.test(password)) errors.push('a number');
|
||||
if (!/[^a-zA-Z0-9]/.test(password)) errors.push('a special character');
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Sanitize input to prevent XSS (cross-site scripting)
|
||||
// This escapes HTML special characters like < > & " '
|
||||
function sanitize(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Password strength checker — returns 'weak', 'medium', or 'strong'
|
||||
// Used on the frontend to show a live strength indicator
|
||||
function checkPasswordStrength(password) {
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++; // basic length
|
||||
if (password.length >= 12) score++; // longer = better
|
||||
if (/[A-Z]/.test(password)) score++; // has uppercase
|
||||
if (/[0-9]/.test(password)) score++; // has number
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++; // has special char
|
||||
|
||||
if (score <= 2) return 'weak';
|
||||
if (score <= 3) return 'medium';
|
||||
return 'strong';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateEmail,
|
||||
validateUsername,
|
||||
validatePassword,
|
||||
getPasswordErrors,
|
||||
isLeakedPassword,
|
||||
sanitize,
|
||||
checkPasswordStrength,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
-- ============================================================
|
||||
-- migration.sql — Run this on an existing auth_db to add new columns
|
||||
-- ============================================================
|
||||
|
||||
-- Add OTP brute-force attempt counter to users
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_attempts INTEGER DEFAULT 0;
|
||||
|
||||
-- Add email verification flag (default true for existing users who already logged in)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT true;
|
||||
|
||||
-- Add password expiry tracking (default NOW so existing users get 90 days from migration)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMP DEFAULT NOW();
|
||||
|
||||
-- Add refresh token JTI to sessions
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS refresh_jti VARCHAR(64);
|
||||
|
||||
-- Add indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_jti ON sessions(jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_refresh_jti ON sessions(refresh_jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_user_id ON logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_action ON logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_history_user ON password_history(user_id);
|
||||
@@ -0,0 +1,174 @@
|
||||
-- ============================================================
|
||||
-- schema.sql — Database tables for the AI-Solutions website
|
||||
-- ============================================================
|
||||
-- Database: ai_solutions (or reuse the existing auth_db).
|
||||
-- Apply with:
|
||||
-- psql -U postgres -d auth_db -f database/schema.sql
|
||||
-- Then seed sample data + admin with:
|
||||
-- node backend/seed.js
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- AUTH / ADMIN TABLES
|
||||
-- ============================================================
|
||||
-- There are NO public user accounts. The only accounts are
|
||||
-- administrators, created by the seed script (not self-service
|
||||
-- registration). RBAC is therefore unnecessary — every row is
|
||||
-- an admin — but the role column is kept for defense-in-depth.
|
||||
-- ============================================================
|
||||
|
||||
-- Admin accounts
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(30) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role VARCHAR(10) DEFAULT 'admin' CHECK (role IN ('admin')),
|
||||
failed_attempts INTEGER DEFAULT 0,
|
||||
locked_until TIMESTAMP,
|
||||
email_verified BOOLEAN DEFAULT true, -- admins are pre-verified by the seed script
|
||||
password_changed_at TIMESTAMP DEFAULT NOW(), -- tracks when password was last changed (expires after 90 days)
|
||||
otp_code TEXT, -- stores bcrypt hash of the login OTP
|
||||
otp_expires_at TIMESTAMP,
|
||||
otp_attempts INTEGER DEFAULT 0, -- tracks wrong OTP guesses (max 3)
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Password history — prevents reuse of the last 5 passwords
|
||||
CREATE TABLE IF NOT EXISTS password_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Sessions — tracks every logged-in admin device
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
jti VARCHAR(64) NOT NULL, -- JWT ID for access token
|
||||
refresh_jti VARCHAR(64), -- JWT ID for refresh token
|
||||
ip_address VARCHAR(45),
|
||||
device_info TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_used_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Activity logs — full audit trail
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(10) NOT NULL CHECK (status IN ('success', 'failed', 'warning')),
|
||||
details TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- PUBLIC WEBSITE CONTENT TABLES
|
||||
-- ============================================================
|
||||
|
||||
-- Customer inquiries — submitted via the public "Contact Us" form.
|
||||
-- Captures the 7 CSE fields. No account/password is required to submit.
|
||||
CREATE TABLE IF NOT EXISTS inquiries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
full_name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(40),
|
||||
company VARCHAR(160),
|
||||
country VARCHAR(80),
|
||||
job_title VARCHAR(120),
|
||||
job_details TEXT NOT NULL,
|
||||
is_read BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Software solutions / services offered to customers
|
||||
CREATE TABLE IF NOT EXISTS solutions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(160) NOT NULL,
|
||||
summary VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
icon VARCHAR(40) DEFAULT 'robot', -- icon key used by the frontend
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Case studies — "highlights of past industry solutions"
|
||||
CREATE TABLE IF NOT EXISTS case_studies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(180) NOT NULL,
|
||||
client VARCHAR(160),
|
||||
industry VARCHAR(120),
|
||||
summary VARCHAR(400) NOT NULL,
|
||||
outcome TEXT,
|
||||
image_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Customer feedback with star ratings
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_name VARCHAR(120) NOT NULL,
|
||||
company VARCHAR(160),
|
||||
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
comment TEXT NOT NULL,
|
||||
is_published BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Promotional articles
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(180) UNIQUE NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
excerpt VARCHAR(400),
|
||||
body TEXT NOT NULL,
|
||||
cover_image TEXT,
|
||||
author VARCHAR(120) DEFAULT 'AI-Solutions Team',
|
||||
is_published BOOLEAN DEFAULT true,
|
||||
published_at TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Promotional + upcoming events
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
location VARCHAR(180),
|
||||
event_date DATE,
|
||||
is_upcoming BOOLEAN DEFAULT false,
|
||||
cover_image TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Photo gallery — images optionally tied to an event
|
||||
CREATE TABLE IF NOT EXISTS gallery_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
caption VARCHAR(200),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- INDEXES
|
||||
-- ============================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_jti ON sessions(jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_refresh_jti ON sessions(refresh_jti);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_user_id ON logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_action ON logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_history_user ON password_history(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inquiries_created_at ON inquiries(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_solutions_active ON solutions(is_active, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_published ON feedback(is_published);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(is_published, published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_date ON events(event_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_gallery_event ON gallery_images(event_id);
|
||||
Generated
+17324
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "acs_assigment",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#1f1813" />
|
||||
<meta
|
||||
name="description"
|
||||
content="AI-Solutions — AI-powered virtual assistants and rapid prototyping for the digital employee experience."
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>AI-Solutions — The Future of the Digital Employee Experience</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// App — top-level routing.
|
||||
// Public site (cream theme, navbar/footer/chatbot) under PublicLayout.
|
||||
// Admin area (dark back-office) under /admin, guarded by RequireAuth,
|
||||
// using the existing login + OTP backend flow.
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
|
||||
import PublicLayout from './components/PublicLayout';
|
||||
import RequireAuth from './components/RequireAuth';
|
||||
|
||||
import Home from './pages/Home';
|
||||
import Services from './pages/Services';
|
||||
import PastSolutions from './pages/PastSolutions';
|
||||
import Feedback from './pages/Feedback';
|
||||
import Articles from './pages/Articles';
|
||||
import ArticleDetail from './pages/ArticleDetail';
|
||||
import Gallery from './pages/Gallery';
|
||||
import Events from './pages/Events';
|
||||
import Contact from './pages/Contact';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
import AdminLogin from './pages/admin/AdminLogin';
|
||||
import AdminLayout from './pages/admin/AdminLayout';
|
||||
import AdminDashboard from './pages/admin/AdminDashboard';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public website */}
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/services" element={<Services />} />
|
||||
<Route path="/past-solutions" element={<PastSolutions />} />
|
||||
<Route path="/feedback" element={<Feedback />} />
|
||||
<Route path="/articles" element={<Articles />} />
|
||||
<Route path="/articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin area */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<RequireAuth><AdminLayout /></RequireAuth>}
|
||||
>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
</Route>
|
||||
<Route path="/admin/*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
// Smoke test: the public site shell renders with the AI-Solutions brand.
|
||||
test('renders the AI-Solutions brand in the navbar', () => {
|
||||
render(<App />);
|
||||
const brand = screen.getAllByText(/solutions/i)[0];
|
||||
expect(brand).toBeInTheDocument();
|
||||
});
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Shared fetch wrapper that handles CSRF tokens and auto-refreshes expired sessions
|
||||
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:5000';
|
||||
|
||||
function getCookie(name) {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
return match ? match[2] : null;
|
||||
}
|
||||
|
||||
async function ensureCsrfToken() {
|
||||
if (!getCookie('csrfToken')) {
|
||||
await fetch(`${API_BASE}/`, { credentials: 'include' });
|
||||
}
|
||||
return getCookie('csrfToken');
|
||||
}
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
|
||||
const headers = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
if (options.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401 && !path.includes('/refresh-token')) {
|
||||
const refreshed = await tryRefreshToken();
|
||||
if (refreshed) {
|
||||
const retryCsrf = getCookie('csrfToken');
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && retryCsrf) {
|
||||
headers['X-CSRF-Token'] = retryCsrf;
|
||||
}
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function tryRefreshToken() {
|
||||
try {
|
||||
const csrfToken = getCookie('csrfToken');
|
||||
const headers = {};
|
||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/auth/refresh-token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Convenience JSON helpers ------------------------------
|
||||
// Resolve with parsed JSON on success; throw an Error whose
|
||||
// message is the backend's message on failure, so callers can
|
||||
// show clean error states.
|
||||
async function getJSON(path) {
|
||||
const res = await apiFetch(path);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message || 'Something went wrong.');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function postJSON(path, body, method = 'POST') {
|
||||
const res = await apiFetch(path, { method, body: JSON.stringify(body || {}) });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.message || 'Something went wrong.');
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export { apiFetch, API_BASE, getJSON, postJSON };
|
||||
@@ -0,0 +1,21 @@
|
||||
// Brand mark — mono wordmark + dark rounded "AI" square (DESIGN.md §Brand).
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function Brand({ to = '/', onDark = false }) {
|
||||
const inner = (
|
||||
<span className="brand">
|
||||
<span className="brand__mark">AI</span>
|
||||
<span className="brand__word" style={onDark ? { color: 'var(--dark-fg)' } : undefined}>
|
||||
ai-<b>solutions</b>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
if (!to) return inner;
|
||||
return (
|
||||
<Link to={to} aria-label="AI-Solutions home" style={{ textDecoration: 'none' }}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default Brand;
|
||||
@@ -0,0 +1,135 @@
|
||||
// Floating AI assistant widget ("Ava"). Present on every page via
|
||||
// PublicLayout. Talks ONLY to our backend Gemini proxy (/api/chat) —
|
||||
// the API key never reaches the browser. Surfaces a graceful
|
||||
// "escalate to a human" prompt when the backend flags it.
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { postJSON } from '../api';
|
||||
import Icon from './Icon';
|
||||
|
||||
const GREETING = {
|
||||
role: 'assistant',
|
||||
text: "Hi, I'm Ava — the AI-Solutions virtual assistant. Ask me about our services, past projects or events, and I'll point you in the right direction.",
|
||||
};
|
||||
|
||||
function Chatbot() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState([GREETING]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [escalated, setEscalated] = useState(false);
|
||||
const bodyRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Auto-scroll to the latest message.
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
}, [messages, sending]);
|
||||
|
||||
// Focus the input when the panel opens.
|
||||
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
|
||||
|
||||
const send = async (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
const history = messages.map((m) => ({ role: m.role, text: m.text }));
|
||||
setMessages((m) => [...m, { role: 'user', text }]);
|
||||
setInput('');
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const data = await postJSON('/api/chat', { message: text, history });
|
||||
setMessages((m) => [...m, { role: 'assistant', text: data.reply, escalate: data.escalate }]);
|
||||
if (data.escalate) setEscalated(true);
|
||||
} catch {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
role: 'assistant',
|
||||
escalate: true,
|
||||
text: "I couldn't reach my AI service just now. Our team can help directly — please use the Contact Us page.",
|
||||
},
|
||||
]);
|
||||
setEscalated(true);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-fab"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open AI assistant chat"
|
||||
>
|
||||
<Icon name="chat" size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<section className="chat-panel" role="dialog" aria-label="AI assistant" aria-modal="false">
|
||||
<header className="chat-head">
|
||||
<div className="chat-head__title">
|
||||
<span className="chat-head__dot" aria-hidden="true" />
|
||||
<span>
|
||||
<b>Ava</b><br />
|
||||
<small>AI VIRTUAL ASSISTANT</small>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="modal__close" onClick={() => setOpen(false)} aria-label="Close chat">
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="chat-body" ref={bodyRef}>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`msg msg--${m.role === 'user' ? 'user' : 'bot'}${m.escalate ? ' msg--escalate' : ''}`}
|
||||
>
|
||||
{m.text}
|
||||
{m.escalate && (
|
||||
<div className="mt-8">
|
||||
<Link to="/contact" className="btn btn--primary" onClick={() => setOpen(false)}>
|
||||
Talk to a human <Icon name="arrow" size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sending && (
|
||||
<div className="msg msg--bot" aria-live="polite" aria-label="Ava is typing">
|
||||
<span className="chat-typing"><span /><span /><span /></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="chat-foot" onSubmit={send}>
|
||||
<label className="sr-only" htmlFor="chat-input">Message</label>
|
||||
<input
|
||||
id="chat-input"
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
placeholder={escalated ? 'Ask another question…' : 'Type your message…'}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
maxLength={1000}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit" className="btn btn--primary" disabled={sending || !input.trim()} aria-label="Send message">
|
||||
<Icon name="send" size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chatbot;
|
||||
@@ -0,0 +1,97 @@
|
||||
// Accessible form field wrappers: label + control + error/hint, all
|
||||
// wired with htmlFor/id and aria-invalid/aria-describedby.
|
||||
import { useId, useState } from 'react';
|
||||
import Icon from './Icon';
|
||||
|
||||
export function TextField({
|
||||
label, name, value, onChange, error, hint, required, type = 'text',
|
||||
autoComplete, placeholder, icon,
|
||||
}) {
|
||||
const id = useId();
|
||||
const errId = `${id}-err`;
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={id}>
|
||||
{label}{required && <span className="field__req" aria-hidden="true"> *</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
className={`input${error ? ' input--error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errId : undefined}
|
||||
/>
|
||||
{error && <span className="field__error" id={errId}>{error}</span>}
|
||||
{!error && hint && <span className="field__hint">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextArea({ label, name, value, onChange, error, hint, required, rows = 5, placeholder }) {
|
||||
const id = useId();
|
||||
const errId = `${id}-err`;
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={id}>
|
||||
{label}{required && <span className="field__req" aria-hidden="true"> *</span>}
|
||||
</label>
|
||||
<textarea
|
||||
id={id}
|
||||
name={name}
|
||||
rows={rows}
|
||||
className={`textarea${error ? ' textarea--error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errId : undefined}
|
||||
/>
|
||||
{error && <span className="field__error" id={errId}>{error}</span>}
|
||||
{!error && hint && <span className="field__hint">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordField({ label, value, onChange, error, required, autoComplete = 'current-password', placeholder }) {
|
||||
const id = useId();
|
||||
const errId = `${id}-err`;
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={id}>
|
||||
{label}{required && <span className="field__req" aria-hidden="true"> *</span>}
|
||||
</label>
|
||||
<div className="password-wrap">
|
||||
<input
|
||||
id={id}
|
||||
type={show ? 'text' : 'password'}
|
||||
className={`input${error ? ' input--error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errId : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
aria-label={show ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Icon name={show ? 'eyeOff' : 'eye'} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{error && <span className="field__error" id={errId}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Public site footer (dark).
|
||||
import { Link } from 'react-router-dom';
|
||||
import Brand from './Brand';
|
||||
|
||||
function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container footer__inner">
|
||||
<div className="footer__col">
|
||||
<Brand to="/" onDark />
|
||||
<p style={{ color: 'var(--dark-dim)', marginTop: 16, fontSize: 15, maxWidth: 320 }}>
|
||||
Innovating, promoting and delivering the future of the digital employee
|
||||
experience — with a strong focus on supporting people at work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="footer__col">
|
||||
<h4>Explore</h4>
|
||||
<Link to="/services">Services</Link>
|
||||
<Link to="/past-solutions">Past Solutions</Link>
|
||||
<Link to="/feedback">Customer Feedback</Link>
|
||||
<Link to="/articles">Articles</Link>
|
||||
</div>
|
||||
|
||||
<div className="footer__col">
|
||||
<h4>Company</h4>
|
||||
<Link to="/gallery">Photo Gallery</Link>
|
||||
<Link to="/events">Upcoming Events</Link>
|
||||
<Link to="/contact">Contact Us</Link>
|
||||
<Link to="/admin">Admin Portal</Link>
|
||||
</div>
|
||||
|
||||
<div className="footer__col">
|
||||
<h4>Get in touch</h4>
|
||||
<a href="mailto:hello@ai-solutions.com">hello@ai-solutions.com</a>
|
||||
<span style={{ color: 'var(--dark-dim)', fontSize: 15, display: 'block', padding: '5px 0' }}>Sunderland, United Kingdom</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer__bottom">
|
||||
© {year} AI-Solutions Ltd · Built for CET333 Product Development
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,124 @@
|
||||
// Inline SVG icon set. Per DESIGN.md: utility SVG only (1.75 stroke,
|
||||
// currentColor, ~13-15px), no decorative illustration, no emoji.
|
||||
// Usage: <Icon name="bolt" size={16} />
|
||||
|
||||
const PATHS = {
|
||||
robot: (
|
||||
<>
|
||||
<rect x="4" y="8" width="16" height="11" rx="2" />
|
||||
<path d="M12 8V4M9 4h6" />
|
||||
<circle cx="9" cy="13" r="1" />
|
||||
<circle cx="15" cy="13" r="1" />
|
||||
<path d="M2 13v2M22 13v2" />
|
||||
</>
|
||||
),
|
||||
bolt: <path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" />,
|
||||
code: <path d="m9 18-6-6 6-6M15 6l6 6-6 6" />,
|
||||
chart: (
|
||||
<>
|
||||
<path d="M3 3v18h18" />
|
||||
<rect x="7" y="12" width="3" height="6" />
|
||||
<rect x="12" y="8" width="3" height="10" />
|
||||
<rect x="17" y="5" width="3" height="13" />
|
||||
</>
|
||||
),
|
||||
spark: <path d="M12 3v6M12 15v6M3 12h6M15 12h6M5.6 5.6l4.2 4.2M14.2 14.2l4.2 4.2M18.4 5.6l-4.2 4.2M9.8 14.2l-4.2 4.2" />,
|
||||
shield: <path d="M12 3 5 6v5c0 4 3 7 7 9 4-2 7-5 7-9V6l-7-3Z" />,
|
||||
star: <path d="M12 3.5l2.6 5.3 5.9.9-4.2 4.1 1 5.8L12 17l-5.3 2.6 1-5.8L3.5 9.7l5.9-.9L12 3.5Z" />,
|
||||
mail: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</>
|
||||
),
|
||||
phone: <path d="M5 4h4l2 5-3 2a14 14 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z" />,
|
||||
building: (
|
||||
<>
|
||||
<rect x="5" y="3" width="14" height="18" rx="1" />
|
||||
<path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2" />
|
||||
</>
|
||||
),
|
||||
globe: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" />
|
||||
</>
|
||||
),
|
||||
briefcase: (
|
||||
<>
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</>
|
||||
),
|
||||
user: (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" />
|
||||
</>
|
||||
),
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 9h18M8 3v4M16 3v4" />
|
||||
</>
|
||||
),
|
||||
pin: (
|
||||
<>
|
||||
<path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11Z" />
|
||||
<circle cx="12" cy="10" r="2.5" />
|
||||
</>
|
||||
),
|
||||
inbox: (
|
||||
<>
|
||||
<path d="M3 12h5l2 3h4l2-3h5" />
|
||||
<path d="M5 5h14l2 7v7H3v-7l2-7Z" />
|
||||
</>
|
||||
),
|
||||
chat: <path d="M21 12a8 8 0 0 1-8 8H7l-4 3v-4a8 8 0 1 1 18-7Z" />,
|
||||
send: <path d="M4 12 20 4l-6 16-2-7-8-1Z" />,
|
||||
close: <path d="M6 6l12 12M18 6 6 18" />,
|
||||
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
|
||||
arrow: <path d="M5 12h14M13 6l6 6-6 6" />,
|
||||
eye: (
|
||||
<>
|
||||
<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
),
|
||||
eyeOff: <path d="M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M9.9 5.2A9.5 9.5 0 0 1 12 5c6 0 10 7 10 7a17 17 0 0 1-3.2 3.8M6.2 6.2A17 17 0 0 0 2 12s4 7 10 7a9.4 9.4 0 0 0 3-.5" />,
|
||||
logout: <path d="M14 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-2M9 12h12M18 9l3 3-3 3" />,
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</>
|
||||
),
|
||||
trash: <path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13h10l1-13" />,
|
||||
check: <path d="m5 12 5 5 9-11" />,
|
||||
refresh: <path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 4v4h-4" />,
|
||||
};
|
||||
|
||||
function Icon({ name, size = 16, strokeWidth = 1.75, className = '', ...rest }) {
|
||||
const content = PATHS[name];
|
||||
if (!content) return null;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Icon;
|
||||
@@ -0,0 +1,43 @@
|
||||
// Accessible modal dialog: focus trap-lite, Esc to close, backdrop
|
||||
// click to close, scroll lock. Used for the admin enquiry detail.
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Icon from './Icon';
|
||||
|
||||
function Modal({ title, onClose, children, labelledById = 'modal-title' }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Move focus into the dialog for screen-reader + keyboard users.
|
||||
ref.current?.focus();
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div
|
||||
className="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledById}
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="modal__head">
|
||||
<h2 className="modal__title" id={labelledById}>{title}</h2>
|
||||
<button type="button" className="modal__close" onClick={onClose} aria-label="Close dialog">
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal__body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,62 @@
|
||||
// Public site navigation. Sticky, responsive (collapses < 760px),
|
||||
// keyboard accessible, marks the active route.
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import Brand from './Brand';
|
||||
import Icon from './Icon';
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/', label: 'Home', end: true },
|
||||
{ to: '/services', label: 'Services' },
|
||||
{ to: '/past-solutions', label: 'Past Solutions' },
|
||||
{ to: '/feedback', label: 'Feedback' },
|
||||
{ to: '/articles', label: 'Articles' },
|
||||
{ to: '/gallery', label: 'Gallery' },
|
||||
{ to: '/events', label: 'Events' },
|
||||
];
|
||||
|
||||
function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<header className="nav">
|
||||
<div className="container nav__inner">
|
||||
<Brand />
|
||||
|
||||
<nav className={`nav__links${open ? ' is-open' : ''}`} aria-label="Primary">
|
||||
{LINKS.map((l) => (
|
||||
<NavLink
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
end={l.end}
|
||||
className={({ isActive }) => `nav__link${isActive ? ' is-active' : ''}`}
|
||||
onClick={close}
|
||||
>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{/* Shown only inside the collapsed mobile menu */}
|
||||
<NavLink to="/contact" className="nav__link nav__link--mobile" onClick={close}>Contact</NavLink>
|
||||
<NavLink to="/admin" className="nav__link nav__link--mobile" onClick={close}>Admin</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="nav__actions">
|
||||
<NavLink to="/admin" className="nav__admin">Admin</NavLink>
|
||||
<NavLink to="/contact" className="btn btn--primary">Contact Us</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
className="nav__toggle"
|
||||
aria-expanded={open}
|
||||
aria-label="Toggle menu"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Icon name={open ? 'close' : 'menu'} size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,26 @@
|
||||
// Public site shell: skip-link, navbar, routed page, footer, and the
|
||||
// always-available AI chatbot. Scrolls to top on route change.
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import Navbar from './Navbar';
|
||||
import Footer from './Footer';
|
||||
import Chatbot from './Chatbot';
|
||||
|
||||
function PublicLayout() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="skip-link" href="#main">Skip to content</a>
|
||||
<Navbar />
|
||||
<main id="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
<Chatbot />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicLayout;
|
||||
@@ -0,0 +1,31 @@
|
||||
// Route guard for the admin area. Verifies the session against the
|
||||
// existing backend (GET /api/auth/me) — it never inspects tokens
|
||||
// itself, so the server stays the single source of truth. Redirects
|
||||
// to the admin login when unauthenticated.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getJSON } from '../api';
|
||||
import { Loading } from './States';
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const [status, setStatus] = useState('checking'); // checking | ok | denied
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getJSON('/api/auth/me')
|
||||
.then(() => active && setStatus('ok'))
|
||||
.catch(() => active && setStatus('denied'));
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
if (status === 'checking') {
|
||||
return <div className="auth-screen"><Loading label="Checking your session…" /></div>;
|
||||
}
|
||||
if (status === 'denied') {
|
||||
return <Navigate to="/admin/login" state={{ from: location.pathname }} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export default RequireAuth;
|
||||
@@ -0,0 +1,35 @@
|
||||
// Image with a graceful branded fallback. If the remote image fails
|
||||
// to load, we render a tinted placeholder with an icon instead of a
|
||||
// broken-image glyph — keeping every card looking intentional.
|
||||
import { useState } from 'react';
|
||||
import Icon from './Icon';
|
||||
|
||||
function SmartImage({ src, alt, icon = 'spark', className = '', style }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed || !src) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ ...style, display: 'grid', placeItems: 'center', background: 'var(--accent-soft)', color: 'var(--accent)' }}
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
>
|
||||
<Icon name={icon} size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={style}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmartImage;
|
||||
@@ -0,0 +1,21 @@
|
||||
// Accessible star rating (read-only). Renders 5 stars, fills `value`.
|
||||
import Icon from './Icon';
|
||||
|
||||
function StarRating({ value = 0, max = 5 }) {
|
||||
const rounded = Math.round(value);
|
||||
return (
|
||||
<span className="stars" role="img" aria-label={`${value} out of ${max} stars`}>
|
||||
{Array.from({ length: max }).map((_, i) => (
|
||||
<Icon
|
||||
key={i}
|
||||
name="star"
|
||||
size={16}
|
||||
className={i < rounded ? '' : 'star--empty'}
|
||||
style={{ fill: i < rounded ? 'currentColor' : 'none' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default StarRating;
|
||||
@@ -0,0 +1,38 @@
|
||||
// Shared async UI states: Loading, Empty, Error.
|
||||
// Every asynchronous view uses these for consistent UX.
|
||||
import Icon from './Icon';
|
||||
|
||||
export function Loading({ label = 'Loading…' }) {
|
||||
return (
|
||||
<div className="state" role="status" aria-live="polite">
|
||||
<div className="spinner" />
|
||||
<p className="mt-16 muted">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title = 'Nothing here yet', message = '', icon = 'inbox', children }) {
|
||||
return (
|
||||
<div className="state">
|
||||
<Icon name={icon} size={28} />
|
||||
<p className="state__title">{title}</p>
|
||||
{message && <p className="muted">{message}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message = 'Something went wrong.', onRetry }) {
|
||||
return (
|
||||
<div className="state" role="alert">
|
||||
<Icon name="refresh" size={28} />
|
||||
<p className="state__title">We hit a problem</p>
|
||||
<p className="muted">{message}</p>
|
||||
{onRetry && (
|
||||
<button type="button" className="btn mt-16" onClick={onRetry}>
|
||||
<Icon name="refresh" size={14} /> Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Reusable Cloudflare Turnstile widget. Loads the CF script on demand,
|
||||
// renders the widget, and reports the token up via onToken. Exposes a
|
||||
// reset() through a ref so forms can re-challenge after a failure.
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
const SITE_KEY = process.env.REACT_APP_TURNSTILE_SITE_KEY;
|
||||
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
let scriptPromise = null;
|
||||
function loadTurnstileScript() {
|
||||
if (window.turnstile) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
scriptPromise = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = SCRIPT_SRC;
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('Failed to load CAPTCHA.'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
const Turnstile = forwardRef(function Turnstile({ onToken }, ref) {
|
||||
const containerRef = useRef(null);
|
||||
const widgetId = useRef(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset() {
|
||||
if (widgetId.current !== null && window.turnstile) {
|
||||
window.turnstile.reset(widgetId.current);
|
||||
onToken?.('');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadTurnstileScript()
|
||||
.then(() => {
|
||||
if (cancelled || !containerRef.current || widgetId.current !== null) return;
|
||||
widgetId.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: SITE_KEY,
|
||||
callback: (token) => onToken?.(token),
|
||||
'expired-callback': () => onToken?.(''),
|
||||
'error-callback': () => onToken?.(''),
|
||||
});
|
||||
})
|
||||
.catch(() => {/* surfaced via missing token on submit */});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (widgetId.current !== null && window.turnstile) {
|
||||
window.turnstile.remove(widgetId.current);
|
||||
widgetId.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} className="mt-8" />;
|
||||
});
|
||||
|
||||
export default Turnstile;
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
/* ============================================================
|
||||
AI-Solutions — Design System (v2, modern professional)
|
||||
Warm cream surfaces + a single orange accent. Clean, spacious,
|
||||
image-led marketing look. Inter throughout, mono for small
|
||||
technical accents only.
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
/* Surfaces (warm) */
|
||||
--bg: #f7f3ee;
|
||||
--bg-soft: #efe8df;
|
||||
--card: #ffffff;
|
||||
--border: #e7ddd0;
|
||||
--border-soft: #f0e8dd;
|
||||
--ink: #211a13;
|
||||
--ink-dim: #6c6052;
|
||||
|
||||
/* Accent (single warm orange hue) */
|
||||
--accent: #e8590c;
|
||||
--accent-hover: #cf4e06;
|
||||
--accent-soft: #fdeadb;
|
||||
--accent-2: #f59e0b; /* amber — used only in gradients */
|
||||
|
||||
/* Dark surface */
|
||||
--dark: #1c1610;
|
||||
--dark-2: #241d15;
|
||||
--dark-panel: #2c2319;
|
||||
--dark-border: #463829;
|
||||
--dark-accent: #ff9e57;
|
||||
--dark-fg: #f4ede4;
|
||||
--dark-dim: #b9a892;
|
||||
|
||||
/* Status */
|
||||
--danger: #c94a4a;
|
||||
--danger-soft: #fbeaea;
|
||||
--success: #2f8f5b;
|
||||
--success-soft: #e6f4ec;
|
||||
--warn: #b8761f;
|
||||
|
||||
/* Radii — larger, modern */
|
||||
--r-chip: 999px;
|
||||
--r-sm: 10px;
|
||||
--r-card: 18px;
|
||||
--r-panel: 26px;
|
||||
|
||||
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
--t: 0.18s cubic-bezier(.4,0,.2,1);
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(33, 26, 19, 0.06);
|
||||
--shadow-sm: 0 2px 8px rgba(33, 26, 19, 0.06);
|
||||
--shadow-md: 0 12px 28px rgba(33, 26, 19, 0.08);
|
||||
--shadow-lg: 0 24px 60px rgba(33, 26, 19, 0.12);
|
||||
--shadow-accent: 0 12px 30px rgba(232, 89, 12, 0.28);
|
||||
|
||||
--grad-accent: linear-gradient(120deg, #f97a2b 0%, #e8590c 55%, #d24a05 100%);
|
||||
--grad-warm: radial-gradient(900px 500px at 85% -5%, rgba(245,158,11,.18), transparent 60%),
|
||||
radial-gradient(700px 500px at 5% 0%, rgba(232,89,12,.10), transparent 55%);
|
||||
}
|
||||
|
||||
/* ---- Reset & base ---- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
h1, h2, h3, h4, p { margin: 0; }
|
||||
h1, h2, h3, h4 { line-height: 1.15; letter-spacing: -0.02em; font-weight: 800; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
img { max-width: 100%; display: block; }
|
||||
button { font-family: inherit; }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
.skip-link { position: absolute; left: 16px; top: -52px; z-index: 200; background: var(--ink); color: #fff; padding: 10px 16px; border-radius: 10px; transition: top var(--t); }
|
||||
.skip-link:focus { top: 16px; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #d9ccba; border-radius: 6px; border: 2px solid var(--bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: #c3b29a; }
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 40px; }
|
||||
.section { padding: 80px 0; }
|
||||
.section--tight { padding: 56px 0; }
|
||||
.section--tint { background: var(--bg-soft); }
|
||||
.section--last { padding-bottom: 96px; }
|
||||
|
||||
.section-head { max-width: 760px; margin: 0 auto 48px; text-align: center; }
|
||||
.section-head--left { margin-left: 0; text-align: left; }
|
||||
.section-head h2 { font-size: clamp(28px, 3.4vw, 40px); }
|
||||
.section-head p { color: var(--ink-dim); margin-top: 16px; font-size: 18px; }
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-size: 13px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--accent); margin-bottom: 16px;
|
||||
}
|
||||
.eyebrow::before { content: ''; width: 22px; height: 2px; background: var(--accent); border-radius: 2px; }
|
||||
.section-head:not(.section-head--left) .eyebrow { justify-content: center; }
|
||||
.eyebrow--on-dark { color: var(--dark-accent); }
|
||||
.eyebrow--on-dark::before { background: var(--dark-accent); }
|
||||
|
||||
.grid { display: grid; gap: 24px; }
|
||||
.grid--2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid--3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid--4 { grid-template-columns: repeat(4, 1fr); }
|
||||
|
||||
/* ---- Brand ---- */
|
||||
.brand { display: inline-flex; align-items: center; gap: 11px; flex: none; }
|
||||
.brand__mark {
|
||||
width: 34px; height: 34px; border-radius: 11px; background: var(--grad-accent);
|
||||
color: #fff; display: grid; place-items: center; font-weight: 800; font-size: 14px;
|
||||
letter-spacing: -0.5px; flex: none; box-shadow: var(--shadow-accent);
|
||||
}
|
||||
.brand__word { font-weight: 800; font-size: 18px; letter-spacing: -0.02em; color: var(--ink); white-space: nowrap; }
|
||||
.brand__word b { color: var(--accent); }
|
||||
.tag { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); background: var(--accent-soft); padding: 3px 9px; border-radius: 999px; }
|
||||
|
||||
/* ---- Navbar ---- */
|
||||
.nav { position: sticky; top: 0; z-index: 100; background: rgba(247,243,238,.8); backdrop-filter: saturate(160%) blur(12px); border-bottom: 1px solid var(--border); }
|
||||
.nav__inner { display: flex; align-items: center; justify-content: space-between; height: 72px; gap: 18px; }
|
||||
.nav__links { display: flex; align-items: center; gap: 2px; }
|
||||
.nav__link { font-size: 14.5px; font-weight: 600; color: var(--ink-dim); padding: 8px 11px; border-radius: 10px; white-space: nowrap; transition: color var(--t), background var(--t); }
|
||||
.nav__link:hover { color: var(--ink); background: var(--bg-soft); }
|
||||
.nav__link.is-active { color: var(--accent); background: var(--accent-soft); }
|
||||
.nav__link--mobile { display: none; }
|
||||
.nav__actions { display: flex; align-items: center; gap: 8px; flex: none; }
|
||||
.nav__admin { font-size: 14.5px; font-weight: 600; color: var(--ink-dim); padding: 9px 12px; border-radius: 10px; white-space: nowrap; }
|
||||
.nav__admin:hover { color: var(--ink); background: var(--bg-soft); }
|
||||
.nav__toggle { display: none; background: none; border: 1px solid var(--border); border-radius: 12px; width: 44px; height: 44px; cursor: pointer; color: var(--ink); align-items: center; justify-content: center; }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
font-size: 15px; font-weight: 700; letter-spacing: -0.01em;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 12px 20px; border-radius: 12px; cursor: pointer; white-space: nowrap;
|
||||
background: var(--card); color: var(--ink); border: 1px solid var(--border);
|
||||
transition: transform var(--t), box-shadow var(--t), background var(--t), border-color var(--t), color var(--t);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.btn:hover { transform: translateY(-2px); box-shadow: var(--shadow-sm); color: var(--ink); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
.btn--primary { background: var(--grad-accent); color: #fff; border-color: transparent; box-shadow: var(--shadow-accent); }
|
||||
.btn--primary:hover { color: #fff; box-shadow: 0 16px 36px rgba(232,89,12,.36); }
|
||||
.btn--ghost { background: transparent; box-shadow: none; border-color: transparent; }
|
||||
.btn--ghost:hover { background: var(--bg-soft); box-shadow: none; }
|
||||
.btn--dark { background: rgba(255,255,255,.08); color: var(--dark-fg); border-color: var(--dark-border); box-shadow: none; }
|
||||
.btn--dark:hover { color: #fff; background: rgba(255,255,255,.16); border-color: var(--dark-accent); }
|
||||
.btn--white { background: #fff; color: var(--accent); border-color: transparent; }
|
||||
.btn--big { padding: 15px 26px; font-size: 16px; border-radius: 14px; }
|
||||
.btn--block { width: 100%; }
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: var(--r-card); padding: 28px; transition: transform var(--t), box-shadow var(--t), border-color var(--t); box-shadow: var(--shadow-xs); }
|
||||
.card--hover:hover { transform: translateY(-5px); box-shadow: var(--shadow-lg); border-color: #ecd9c5; }
|
||||
.card--rule::before { content: ''; position: absolute; left: 0; top: 22px; bottom: 22px; width: 4px; border-radius: 4px; background: var(--grad-accent); }
|
||||
.card--rule { padding-left: 32px; }
|
||||
|
||||
.icon-badge, .card__icon { width: 52px; height: 52px; border-radius: 15px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; margin-bottom: 18px; }
|
||||
.card__title { font-size: 20px; font-weight: 700; margin-bottom: 9px; letter-spacing: -0.02em; }
|
||||
.card__text { color: var(--ink-dim); font-size: 15.5px; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 14px; }
|
||||
.chip { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: var(--accent); background: var(--accent-soft); padding: 5px 11px; border-radius: 999px; }
|
||||
.chip--muted { color: var(--ink-dim); background: var(--bg-soft); }
|
||||
|
||||
/* ---- Hero ---- */
|
||||
.hero { position: relative; padding: 64px 0 24px; background: var(--grad-warm); }
|
||||
.hero__grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 48px; align-items: center; }
|
||||
.hero__pill { display: inline-flex; align-items: center; gap: 8px; background: var(--card); border: 1px solid var(--border); padding: 7px 14px 7px 8px; border-radius: 999px; font-size: 13px; font-weight: 600; color: var(--ink-dim); box-shadow: var(--shadow-xs); margin-bottom: 22px; }
|
||||
.hero__pill b { background: var(--accent-soft); color: var(--accent); padding: 3px 9px; border-radius: 999px; font-size: 12px; }
|
||||
.hero h1 { font-size: clamp(38px, 5.2vw, 60px); letter-spacing: -0.03em; }
|
||||
.hero h1 .accent { background: var(--grad-accent); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero__lead { font-size: 19px; color: var(--ink-dim); max-width: 56ch; margin: 22px 0 28px; }
|
||||
.hero__actions { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
.hero__proof { display: flex; align-items: center; gap: 14px; margin-top: 30px; color: var(--ink-dim); font-size: 14px; }
|
||||
.hero__avatars { display: flex; }
|
||||
.hero__avatars span { width: 34px; height: 34px; border-radius: 999px; border: 2px solid var(--bg); background: var(--grad-accent); color: #fff; display: grid; place-items: center; font-size: 12px; font-weight: 700; margin-left: -10px; }
|
||||
.hero__avatars span:first-child { margin-left: 0; }
|
||||
|
||||
.hero__media { position: relative; }
|
||||
.hero__media img { width: 100%; border-radius: var(--r-panel); box-shadow: var(--shadow-lg); aspect-ratio: 5/5.4; object-fit: cover; }
|
||||
.hero__float { position: absolute; background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 14px 16px; box-shadow: var(--shadow-md); display: flex; align-items: center; gap: 12px; }
|
||||
.hero__float--tl { top: 22px; left: -22px; }
|
||||
.hero__float--br { bottom: 22px; right: -22px; }
|
||||
.hero__float .icon-badge { width: 40px; height: 40px; margin: 0; border-radius: 12px; }
|
||||
.hero__float b { display: block; font-size: 15px; }
|
||||
.hero__float small { color: var(--ink-dim); font-size: 12.5px; }
|
||||
|
||||
/* logo / trust strip */
|
||||
.logo-strip { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 14px 36px; padding: 28px 0; }
|
||||
.logo-strip__label { width: 100%; text-align: center; font-size: 13px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-dim); margin-bottom: 4px; }
|
||||
.logo-strip__item { font-size: 18px; font-weight: 800; letter-spacing: -.02em; color: #b6a892; transition: color var(--t); }
|
||||
.logo-strip__item:hover { color: var(--ink); }
|
||||
|
||||
/* ---- Stats band ---- */
|
||||
.stats-band { background: var(--dark); background-image: var(--grad-warm); border-radius: var(--r-panel); padding: 44px; color: var(--dark-fg); display: grid; grid-template-columns: repeat(4,1fr); gap: 24px; box-shadow: var(--shadow-lg); }
|
||||
.stats-band__item { text-align: center; }
|
||||
.stats-band__num { font-size: clamp(30px, 4vw, 44px); font-weight: 800; letter-spacing: -.03em; background: linear-gradient(120deg,#ffb774,#ff7a2b); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stats-band__label { color: var(--dark-dim); font-size: 14px; margin-top: 6px; }
|
||||
|
||||
.stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.stat__num { font-size: 30px; font-weight: 800; color: var(--accent); letter-spacing: -.02em; }
|
||||
.stat__label { font-size: 13px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-dim); }
|
||||
|
||||
/* ---- Feature / showcase rows ---- */
|
||||
.showcase { display: grid; grid-template-columns: 1fr 1fr; gap: 56px; align-items: center; }
|
||||
.showcase--reverse .showcase__media { order: 2; }
|
||||
.showcase__media img, .showcase__media > div { width: 100%; border-radius: var(--r-panel); box-shadow: var(--shadow-lg); aspect-ratio: 4/3; object-fit: cover; }
|
||||
.showcase__list { list-style: none; padding: 0; margin: 22px 0 0; display: grid; gap: 14px; }
|
||||
.showcase__list li { display: flex; gap: 12px; align-items: flex-start; color: var(--ink); font-size: 16px; }
|
||||
.showcase__list li .tick { flex: none; width: 24px; height: 24px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; margin-top: 1px; }
|
||||
|
||||
/* ---- Testimonials ---- */
|
||||
.testimonial { background: var(--card); border: 1px solid var(--border); border-radius: var(--r-card); padding: 28px; box-shadow: var(--shadow-xs); height: 100%; }
|
||||
.testimonial__quote { font-size: 16.5px; color: var(--ink); margin: 16px 0 20px; }
|
||||
.testimonial__who { display: flex; align-items: center; gap: 12px; }
|
||||
.avatar { width: 44px; height: 44px; border-radius: 999px; background: var(--grad-accent); color: #fff; display: grid; place-items: center; font-weight: 700; font-size: 15px; flex: none; }
|
||||
.testimonial__name { font-weight: 700; font-size: 15px; }
|
||||
.testimonial__role { color: var(--ink-dim); font-size: 13px; }
|
||||
|
||||
/* ---- CTA band ---- */
|
||||
.cta-band { position: relative; overflow: hidden; background: var(--grad-accent); border-radius: var(--r-panel); padding: 56px; color: #fff; text-align: center; box-shadow: var(--shadow-accent); }
|
||||
.cta-band::after { content: ''; position: absolute; inset: 0; background: radial-gradient(600px 300px at 80% -20%, rgba(255,255,255,.25), transparent 60%); pointer-events: none; }
|
||||
.cta-band h2 { font-size: clamp(26px, 3.4vw, 38px); color: #fff; position: relative; }
|
||||
.cta-band p { color: rgba(255,255,255,.92); font-size: 18px; margin: 14px 0 26px; position: relative; }
|
||||
.cta-band .btn { position: relative; }
|
||||
|
||||
/* ---- Page header ---- */
|
||||
.page-head { padding: 64px 0 8px; background: var(--grad-warm); text-align: center; }
|
||||
.page-head .container { max-width: 820px; }
|
||||
.page-head h1 { font-size: clamp(34px, 4.6vw, 52px); letter-spacing: -.03em; }
|
||||
.page-head p { color: var(--ink-dim); font-size: 19px; margin: 18px auto 0; }
|
||||
|
||||
/* media object (case studies / events / articles) */
|
||||
.media-card { overflow: hidden; padding: 0; }
|
||||
.media-card__img { aspect-ratio: 16/10; object-fit: cover; width: 100%; }
|
||||
.media-card__body { padding: 24px 26px 28px; }
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.field { margin-bottom: 20px; }
|
||||
.field__label { display: block; font-size: 14px; font-weight: 600; color: var(--ink); margin-bottom: 8px; }
|
||||
.field__req { color: var(--accent); }
|
||||
.input, .textarea, .select {
|
||||
width: 100%; font-family: inherit; font-size: 16px; color: var(--ink);
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 13px 15px;
|
||||
transition: border-color var(--t), box-shadow var(--t);
|
||||
}
|
||||
.input::placeholder, .textarea::placeholder { color: #b3a795; }
|
||||
.input:focus, .textarea:focus, .select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
|
||||
.input--error, .textarea--error { border-color: var(--danger); }
|
||||
.textarea { resize: vertical; min-height: 130px; line-height: 1.55; }
|
||||
.field__error { display: block; color: var(--danger); font-size: 13px; margin-top: 7px; }
|
||||
.field__hint { display: block; color: var(--ink-dim); font-size: 13px; margin-top: 7px; }
|
||||
.password-wrap { position: relative; }
|
||||
.password-wrap .input { padding-right: 48px; }
|
||||
.password-toggle { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--ink-dim); width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; }
|
||||
.password-toggle:hover { color: var(--accent); }
|
||||
|
||||
.notice { border-radius: 12px; padding: 13px 16px; font-size: 14.5px; margin-bottom: 18px; border: 1px solid transparent; }
|
||||
.notice--error { background: var(--danger-soft); color: #8f2f2f; border-color: #eccdcd; }
|
||||
.notice--success { background: var(--success-soft); color: #1f6b43; border-color: #c7e6d4; }
|
||||
.notice--info { background: var(--accent-soft); color: #9a3d06; border-color: #f6cba3; }
|
||||
|
||||
/* ---- Footer ---- */
|
||||
.footer { margin-top: 0; background: var(--dark); color: var(--dark-dim); }
|
||||
.footer__inner { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: 40px; padding: 64px 0 40px; }
|
||||
.footer__col h4 { font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--dark-fg); margin-bottom: 16px; }
|
||||
.footer__col a { display: block; color: var(--dark-dim); font-size: 15px; padding: 5px 0; }
|
||||
.footer__col a:hover { color: var(--dark-accent); }
|
||||
.footer .brand__word { color: var(--dark-fg); }
|
||||
.footer__bottom { border-top: 1px solid var(--dark-border); padding: 20px 0; text-align: center; font-size: 14px; color: var(--dark-dim); }
|
||||
|
||||
/* ---- Modal ---- */
|
||||
.modal-overlay { position: fixed; inset: 0; z-index: 300; background: rgba(28,22,16,.6); backdrop-filter: blur(4px); display: grid; place-items: center; padding: 20px; animation: fadeIn .2s ease; }
|
||||
.modal { background: var(--card); border: 1px solid var(--border); border-radius: var(--r-panel); width: 100%; max-width: 660px; max-height: 86vh; overflow: auto; box-shadow: var(--shadow-lg); animation: slideUp .24s ease; }
|
||||
.modal__head { display: flex; align-items: center; justify-content: space-between; padding: 22px 26px; border-bottom: 1px solid var(--border-soft); position: sticky; top: 0; background: var(--card); }
|
||||
.modal__title { font-size: 20px; font-weight: 700; }
|
||||
.modal__close { background: none; border: 1px solid var(--border); border-radius: 10px; width: 38px; height: 38px; cursor: pointer; color: var(--ink-dim); display: grid; place-items: center; }
|
||||
.modal__close:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.modal__body { padding: 26px; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* ---- Stars ---- */
|
||||
.stars { display: inline-flex; gap: 2px; color: var(--accent); }
|
||||
.stars svg { width: 18px; height: 18px; }
|
||||
.stars .star--empty { color: #e0d4c4; }
|
||||
|
||||
/* ---- States ---- */
|
||||
.state { text-align: center; padding: 72px 24px; color: var(--ink-dim); }
|
||||
.state__title { font-size: 18px; font-weight: 700; color: var(--ink); margin: 14px 0 6px; }
|
||||
.spinner { width: 30px; height: 30px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; margin: 0 auto; }
|
||||
.spinner--sm { width: 17px; height: 17px; border-width: 2px; margin: 0; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.skeleton { background: linear-gradient(90deg, var(--bg-soft) 25%, #f4ece1 37%, var(--bg-soft) 63%); background-size: 400% 100%; animation: shimmer 1.3s ease infinite; border-radius: 14px; }
|
||||
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
||||
|
||||
/* ---- Gallery ---- */
|
||||
.gallery { columns: 3; column-gap: 20px; }
|
||||
.gallery__item { break-inside: avoid; margin-bottom: 20px; border-radius: var(--r-card); overflow: hidden; border: 1px solid var(--border); background: var(--card); box-shadow: var(--shadow-xs); transition: box-shadow var(--t), transform var(--t); }
|
||||
.gallery__item:hover { box-shadow: var(--shadow-md); transform: translateY(-3px); }
|
||||
.gallery__item img { width: 100%; transition: transform .4s ease; }
|
||||
.gallery__item:hover img { transform: scale(1.05); }
|
||||
.gallery__cap { padding: 12px 14px; font-size: 13.5px; color: var(--ink-dim); }
|
||||
|
||||
/* ---- Chatbot ---- */
|
||||
.chat-fab { position: fixed; right: 24px; bottom: 24px; z-index: 250; width: 60px; height: 60px; border-radius: 999px; cursor: pointer; background: var(--grad-accent); color: #fff; border: none; display: grid; place-items: center; box-shadow: var(--shadow-accent); transition: transform var(--t); }
|
||||
.chat-fab:hover { transform: scale(1.08) translateY(-2px); }
|
||||
.chat-panel { position: fixed; right: 24px; bottom: 24px; z-index: 251; width: 372px; max-width: calc(100vw - 32px); height: 540px; max-height: calc(100vh - 48px); background: var(--card); border: 1px solid var(--border); border-radius: var(--r-panel); box-shadow: var(--shadow-lg); display: flex; flex-direction: column; overflow: hidden; animation: slideUp .2s ease; }
|
||||
.chat-head { background: var(--dark); background-image: var(--grad-warm); color: var(--dark-fg); padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.chat-head__title { display: flex; align-items: center; gap: 11px; }
|
||||
.chat-head__title b { font-size: 15px; }
|
||||
.chat-head__title small { color: var(--dark-dim); font-size: 11px; letter-spacing: .06em; }
|
||||
.chat-head__avatar { width: 36px; height: 36px; border-radius: 999px; background: var(--grad-accent); display: grid; place-items: center; color: #fff; }
|
||||
.chat-head .modal__close { color: var(--dark-dim); border-color: var(--dark-border); }
|
||||
.chat-head .modal__close:hover { color: #fff; }
|
||||
.chat-body { flex: 1; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; background: var(--bg); }
|
||||
.msg { max-width: 84%; padding: 11px 14px; border-radius: 16px; font-size: 14.5px; line-height: 1.5; white-space: pre-wrap; }
|
||||
.msg--bot { background: var(--card); border: 1px solid var(--border); border-bottom-left-radius: 5px; align-self: flex-start; }
|
||||
.msg--user { background: var(--grad-accent); color: #fff; border-bottom-right-radius: 5px; align-self: flex-end; }
|
||||
.msg--escalate { border-color: var(--accent); background: var(--accent-soft); color: var(--ink); }
|
||||
.chat-typing { display: inline-flex; gap: 4px; align-items: center; }
|
||||
.chat-typing span { width: 7px; height: 7px; border-radius: 999px; background: var(--ink-dim); animation: blink 1s infinite; }
|
||||
.chat-typing span:nth-child(2) { animation-delay: .2s; } .chat-typing span:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes blink { 0%,80%,100% { opacity: .3; } 40% { opacity: 1; } }
|
||||
.chat-foot { border-top: 1px solid var(--border); padding: 12px; display: flex; gap: 8px; background: var(--card); }
|
||||
.chat-foot .input { padding: 10px 13px; border-radius: 12px; }
|
||||
|
||||
/* ---- Admin back-office ---- */
|
||||
.admin { min-height: 100vh; display: grid; grid-template-columns: 256px 1fr; background: var(--bg); }
|
||||
.admin__side { background: var(--dark); color: var(--dark-fg); padding: 24px 18px; display: flex; flex-direction: column; }
|
||||
.admin__brand { display: flex; align-items: center; gap: 11px; margin-bottom: 30px; }
|
||||
.admin__brand .brand__word { color: var(--dark-fg); }
|
||||
.admin__navlink { display: flex; align-items: center; gap: 11px; padding: 11px 13px; border-radius: 12px; color: var(--dark-dim); font-size: 14px; font-weight: 600; cursor: pointer; border: none; background: none; width: 100%; text-align: left; transition: background var(--t), color var(--t); }
|
||||
.admin__navlink:hover { background: var(--dark-panel); color: var(--dark-fg); }
|
||||
.admin__navlink.is-active { background: var(--grad-accent); color: #fff; }
|
||||
.admin__spacer { flex: 1; }
|
||||
.admin__main { padding: 32px 36px; overflow-x: auto; }
|
||||
.admin__topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
|
||||
.admin__title h1 { font-size: 26px; }
|
||||
.admin__title p { color: var(--ink-dim); font-size: 15px; margin-top: 4px; }
|
||||
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--r-card); padding: 22px; box-shadow: var(--shadow-xs); }
|
||||
.stat-card .stat__num { font-size: 36px; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 18px; }
|
||||
.toolbar .input, .toolbar .select { width: auto; min-width: 190px; }
|
||||
.toolbar__grow { flex: 1; }
|
||||
|
||||
.table-wrap { background: var(--card); border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; box-shadow: var(--shadow-xs); }
|
||||
table.data { width: 100%; border-collapse: collapse; font-size: 15px; }
|
||||
table.data th { text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-dim); padding: 16px 18px; border-bottom: 1px solid var(--border); background: var(--bg-soft); }
|
||||
table.data td { padding: 16px 18px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
||||
table.data tr:last-child td { border-bottom: none; }
|
||||
table.data tbody tr { cursor: pointer; transition: background var(--t); }
|
||||
table.data tbody tr:hover { background: var(--bg-soft); }
|
||||
.row-unread td:first-child { box-shadow: inset 4px 0 0 var(--accent); }
|
||||
.badge { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 4px 9px; border-radius: 999px; }
|
||||
.badge--new { background: var(--accent-soft); color: var(--accent); }
|
||||
.badge--read { background: var(--bg-soft); color: var(--ink-dim); }
|
||||
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; flex-wrap: wrap; }
|
||||
.pagination__info { font-size: 13.5px; color: var(--ink-dim); }
|
||||
.dl { display: grid; grid-template-columns: 160px 1fr; gap: 0; }
|
||||
.dl dt { font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-dim); padding: 13px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.dl dd { padding: 13px 0; margin: 0; border-bottom: 1px solid var(--border-soft); word-break: break-word; }
|
||||
|
||||
/* ---- Auth card ---- */
|
||||
.auth-screen { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--bg); background-image: var(--grad-warm); }
|
||||
.auth-card { width: 100%; max-width: 440px; background: var(--card); border: 1px solid var(--border); border-radius: var(--r-panel); padding: 38px; box-shadow: var(--shadow-lg); }
|
||||
.auth-card__head { text-align: center; margin-bottom: 26px; }
|
||||
.auth-card__head .brand { justify-content: center; margin-bottom: 18px; }
|
||||
.auth-card__head h1 { font-size: 24px; }
|
||||
.auth-card__head p { color: var(--ink-dim); font-size: 15px; margin-top: 8px; }
|
||||
.auth-card__foot { text-align: center; margin-top: 20px; font-size: 14px; color: var(--ink-dim); }
|
||||
|
||||
/* ---- utilities ---- */
|
||||
.text-center { text-align: center; }
|
||||
.mt-8 { margin-top: 8px; } .mt-16 { margin-top: 16px; } .mt-24 { margin-top: 24px; } .mt-32 { margin-top: 32px; }
|
||||
.muted { color: var(--ink-dim); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.flex { display: flex; } .between { justify-content: space-between; } .items-center { align-items: center; }
|
||||
.gap-8 { gap: 8px; } .gap-12 { gap: 12px; } .gap-16 { gap: 16px; } .wrap { flex-wrap: wrap; }
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 1000px) {
|
||||
.grid--4 { grid-template-columns: repeat(2,1fr); }
|
||||
.grid--3 { grid-template-columns: repeat(2,1fr); }
|
||||
.hero__grid { grid-template-columns: 1fr; gap: 36px; }
|
||||
.hero__media { max-width: 460px; }
|
||||
.hero__float--tl { left: 0; } .hero__float--br { right: 0; }
|
||||
.showcase { grid-template-columns: 1fr; gap: 32px; }
|
||||
.showcase--reverse .showcase__media { order: 0; }
|
||||
.stats-band { grid-template-columns: repeat(2,1fr); padding: 32px; }
|
||||
.footer__inner { grid-template-columns: 1fr 1fr; }
|
||||
.gallery { columns: 2; }
|
||||
.admin { grid-template-columns: 1fr; }
|
||||
.admin__side { flex-direction: row; align-items: center; gap: 6px; overflow-x: auto; padding: 12px 16px; }
|
||||
.admin__brand { margin-bottom: 0; margin-right: 8px; } .admin__navlink { width: auto; white-space: nowrap; } .admin__spacer { display: none; }
|
||||
}
|
||||
/* Collapse the nav into a menu on laptops/tablets so links never overflow */
|
||||
@media (max-width: 900px) {
|
||||
.nav__links { display: none; }
|
||||
.nav__links.is-open {
|
||||
display: flex; flex-direction: column; align-items: stretch;
|
||||
position: absolute; top: 72px; left: 0; right: 0; background: var(--bg);
|
||||
border-bottom: 1px solid var(--border); padding: 14px 20px; gap: 4px; box-shadow: var(--shadow-md);
|
||||
}
|
||||
.nav__link { font-size: 15.5px; padding: 11px 12px; }
|
||||
.nav__link--mobile { display: block; }
|
||||
.nav__admin { display: none; }
|
||||
.nav__toggle { display: inline-flex; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.container { padding: 0 20px; }
|
||||
.section { padding: 56px 0; }
|
||||
.grid--2, .grid--3, .grid--4 { grid-template-columns: 1fr; }
|
||||
.stats-band { grid-template-columns: 1fr 1fr; }
|
||||
.footer__inner { grid-template-columns: 1fr 1fr; }
|
||||
.dl { grid-template-columns: 1fr; } .dl dt { padding-bottom: 0; border-bottom: none; } .dl dd { padding-top: 4px; }
|
||||
.gallery { columns: 1; }
|
||||
.cta-band, .stats-band { padding: 32px 24px; }
|
||||
.hero__float { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; scroll-behavior: auto !important; } }
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -0,0 +1,23 @@
|
||||
// Curated imagery for the marketing pages. Uses the Unsplash CDN
|
||||
// (stable photo IDs). Every <SmartImage> falls back to a branded
|
||||
// placeholder if a URL fails, so the layout never breaks.
|
||||
const U = (id, w = 900) => `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&q=80`;
|
||||
|
||||
export const IMAGES = {
|
||||
heroTeam: U('1522071820081-009f0129c71c', 1100), // team collaborating
|
||||
showcaseAssistant: U('1485827404703-89b55fcc595e', 1000), // AI / robot
|
||||
showcasePrototype: U('1542744173-8e7e53415bb0', 1000), // planning / whiteboard
|
||||
};
|
||||
|
||||
// Map a solution's `icon` key to a themed image.
|
||||
const SERVICE_IMAGES = {
|
||||
robot: U('1485827404703-89b55fcc595e'), // AI / robot
|
||||
bolt: U('1517180102446-f3ece451e9d8'), // fast / tech
|
||||
code: U('1461749280684-dccba630e2f6'), // code on screen
|
||||
chart: U('1551288049-bebda4e38f71'), // analytics dashboard
|
||||
};
|
||||
const SERVICE_FALLBACK = U('1498050108023-c5249f4df085'); // workspace / code
|
||||
|
||||
export function serviceImage(icon) {
|
||||
return SERVICE_IMAGES[icon] || SERVICE_FALLBACK;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Tiny data-fetching hook → { data, loading, error, reload }.
|
||||
// Wraps the shared getJSON helper so every page gets consistent
|
||||
// loading / error / empty handling.
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getJSON } from '../api';
|
||||
|
||||
export default function useFetch(path) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getJSON(path)
|
||||
.then((d) => { if (active) setData(d); })
|
||||
.catch((e) => { if (active) setError(e.message || 'Failed to load.'); })
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => load(), [load]);
|
||||
|
||||
return { data, loading, error, reload: load };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ArticleDetail — a single promotional article.
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import useFetch from '../lib/useFetch';
|
||||
import Icon from '../components/Icon';
|
||||
import SmartImage from '../components/SmartImage';
|
||||
import { Loading, ErrorState } from '../components/States';
|
||||
|
||||
function formatDate(d) {
|
||||
return d ? new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
}
|
||||
|
||||
function ArticleDetail() {
|
||||
const { slug } = useParams();
|
||||
const { data, loading, error, reload } = useFetch(`/api/content/articles/${slug}`);
|
||||
const a = data?.article;
|
||||
|
||||
return (
|
||||
<article className="section section--last">
|
||||
<div className="container" style={{ maxWidth: 820 }}>
|
||||
<Link to="/articles" className="btn btn--ghost" style={{ marginBottom: 24 }}>
|
||||
<Icon name="arrow" size={15} style={{ transform: 'rotate(180deg)' }} /> All articles
|
||||
</Link>
|
||||
|
||||
{loading && <Loading label="Loading article…" />}
|
||||
{error && <ErrorState message={error} onRetry={reload} />}
|
||||
|
||||
{a && (
|
||||
<>
|
||||
<p className="eyebrow">{formatDate(a.published_at)}{a.author ? ` · ${a.author}` : ''}</p>
|
||||
<h1 style={{ fontSize: 'clamp(30px,4.4vw,46px)', letterSpacing: '-.03em' }}>{a.title}</h1>
|
||||
{a.excerpt && <p className="muted mt-16" style={{ fontSize: 19 }}>{a.excerpt}</p>}
|
||||
{a.cover_image && (
|
||||
<SmartImage src={a.cover_image} alt={a.title} icon="chat"
|
||||
style={{ width: '100%', borderRadius: 'var(--r-panel)', margin: '28px 0', boxShadow: 'var(--shadow-md)', aspectRatio: '16/8', objectFit: 'cover' }} />
|
||||
)}
|
||||
<div style={{ fontSize: 17.5, lineHeight: 1.75, color: 'var(--ink)' }}>
|
||||
{a.body.split('\n').filter(Boolean).map((para, i) => (
|
||||
<p key={i} style={{ marginBottom: 20 }}>{para}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cta-band mt-32" style={{ padding: 36 }}>
|
||||
<h2 style={{ fontSize: 26 }}>Want to know more?</h2>
|
||||
<p>Our team is happy to talk through how this applies to your organisation.</p>
|
||||
<Link to="/contact" className="btn btn--white">Contact us <Icon name="arrow" size={15} /></Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArticleDetail;
|
||||
@@ -0,0 +1,59 @@
|
||||
// Articles — promotional articles / news about the company (CSE requirement).
|
||||
import { Link } from 'react-router-dom';
|
||||
import useFetch from '../lib/useFetch';
|
||||
import Icon from '../components/Icon';
|
||||
import SmartImage from '../components/SmartImage';
|
||||
import { Loading, ErrorState, EmptyState } from '../components/States';
|
||||
|
||||
function formatDate(d) {
|
||||
return d ? new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
}
|
||||
|
||||
function Articles() {
|
||||
const { data, loading, error, reload } = useFetch('/api/content/articles');
|
||||
const articles = data?.articles || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div className="container">
|
||||
<span className="eyebrow">Articles</span>
|
||||
<h1>News & insights from AI-Solutions</h1>
|
||||
<p>Ideas on AI, the digital employee experience and how we help teams innovate faster.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section section--last">
|
||||
<div className="container">
|
||||
{loading && <Loading label="Loading articles…" />}
|
||||
{error && <ErrorState message={error} onRetry={reload} />}
|
||||
{!loading && !error && articles.length === 0 && (
|
||||
<EmptyState title="No articles yet" message="Our latest articles will appear here." icon="chat" />
|
||||
)}
|
||||
|
||||
{!loading && !error && articles.length > 0 && (
|
||||
<div className="grid grid--3">
|
||||
{articles.map((a) => (
|
||||
<Link to={`/articles/${a.slug}`} className="card media-card card--hover" key={a.id} style={{ color: 'inherit' }}>
|
||||
<SmartImage src={a.cover_image} alt={a.title} icon="chat" className="media-card__img" />
|
||||
<div className="media-card__body">
|
||||
<p className="muted" style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
{formatDate(a.published_at)}{a.author ? ` · ${a.author}` : ''}
|
||||
</p>
|
||||
<h2 className="card__title">{a.title}</h2>
|
||||
<p className="card__text">{a.excerpt}</p>
|
||||
<span className="mt-16" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--accent)', fontWeight: 700, fontSize: 14 }}>
|
||||
Read article <Icon name="arrow" size={14} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Articles;
|
||||
@@ -0,0 +1,144 @@
|
||||
// Contact Us — the 7-field enquiry form. No customer account required.
|
||||
// Client-side validation mirrors the server rules; submission is
|
||||
// protected by Cloudflare Turnstile and posts to /api/contact.
|
||||
import { useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { postJSON } from '../api';
|
||||
import { TextField, TextArea } from '../components/Field';
|
||||
import Turnstile from '../components/Turnstile';
|
||||
import Icon from '../components/Icon';
|
||||
|
||||
const EMPTY = {
|
||||
full_name: '', email: '', phone: '', company: '',
|
||||
country: '', job_title: '', job_details: '',
|
||||
};
|
||||
|
||||
function validate(values) {
|
||||
const e = {};
|
||||
if (!values.full_name.trim()) e.full_name = 'Please enter your full name.';
|
||||
if (!values.email.trim()) e.email = 'Please enter your email address.';
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email.trim())) e.email = 'Please enter a valid email address.';
|
||||
if (values.phone && values.phone.length > 40) e.phone = 'Phone number is too long.';
|
||||
if (!values.job_details.trim()) e.job_details = 'Please tell us about your requirements.';
|
||||
else if (values.job_details.trim().length < 5) e.job_details = 'Please add a little more detail (5+ characters).';
|
||||
return e;
|
||||
}
|
||||
|
||||
function Contact() {
|
||||
const [values, setValues] = useState(EMPTY);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [token, setToken] = useState('');
|
||||
const [status, setStatus] = useState('idle'); // idle | submitting | success
|
||||
const [serverError, setServerError] = useState('');
|
||||
const turnstileRef = useRef(null);
|
||||
|
||||
const set = (key) => (val) => {
|
||||
setValues((v) => ({ ...v, [key]: val }));
|
||||
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setServerError('');
|
||||
const v = validate(values);
|
||||
setErrors(v);
|
||||
if (Object.keys(v).length > 0) return;
|
||||
|
||||
if (!token) {
|
||||
setServerError('Please complete the CAPTCHA challenge below.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('submitting');
|
||||
try {
|
||||
await postJSON('/api/contact', { ...values, turnstileToken: token });
|
||||
setStatus('success');
|
||||
} catch (err) {
|
||||
setStatus('idle');
|
||||
setServerError(err.message || 'Could not send your message. Please try again.');
|
||||
turnstileRef.current?.reset();
|
||||
setToken('');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<section className="page-head">
|
||||
<div className="container" style={{ maxWidth: 640 }}>
|
||||
<div className="card card--rule" role="status">
|
||||
<div className="card__icon"><Icon name="check" size={20} /></div>
|
||||
<h1 style={{ fontSize: 26 }}>Thank you — your enquiry is in.</h1>
|
||||
<p className="muted mt-8">
|
||||
Our team will review your requirements and get back to you shortly. We have not created
|
||||
an account for you — there is nothing else you need to do.
|
||||
</p>
|
||||
<div className="flex gap-12 mt-24 wrap">
|
||||
<Link to="/" className="btn btn--primary">Back to home</Link>
|
||||
<button type="button" className="btn" onClick={() => { setValues(EMPTY); setToken(''); setStatus('idle'); }}>
|
||||
Send another enquiry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div className="container">
|
||||
<span className="eyebrow">Contact Us</span>
|
||||
<h1>Tell us about your project</h1>
|
||||
<p>
|
||||
Share your requirements and our team will be in touch. No account or password needed —
|
||||
just the details below.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section section--last">
|
||||
<div className="container" style={{ maxWidth: 720 }}>
|
||||
<form className="card" onSubmit={handleSubmit} noValidate>
|
||||
{serverError && <div className="notice notice--error" role="alert">{serverError}</div>}
|
||||
|
||||
<div className="grid grid--2" style={{ gap: '0 20px' }}>
|
||||
<TextField label="Full Name" required value={values.full_name} onChange={set('full_name')}
|
||||
error={errors.full_name} autoComplete="name" />
|
||||
<TextField label="Email Address" required type="email" value={values.email} onChange={set('email')}
|
||||
error={errors.email} autoComplete="email" />
|
||||
<TextField label="Phone Number" type="tel" value={values.phone} onChange={set('phone')}
|
||||
error={errors.phone} autoComplete="tel" />
|
||||
<TextField label="Company Name" value={values.company} onChange={set('company')}
|
||||
autoComplete="organization" />
|
||||
<TextField label="Country" value={values.country} onChange={set('country')}
|
||||
autoComplete="country-name" />
|
||||
<TextField label="Job Title" value={values.job_title} onChange={set('job_title')}
|
||||
autoComplete="organization-title" />
|
||||
</div>
|
||||
|
||||
<TextArea label="Job Details" required rows={6} value={values.job_details} onChange={set('job_details')}
|
||||
error={errors.job_details}
|
||||
placeholder="Describe what you would like help with — your goals, timescales and anything else we should know." />
|
||||
|
||||
<div className="field">
|
||||
<span className="field__label">Verification</span>
|
||||
<Turnstile ref={turnstileRef} onToken={setToken} />
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn--primary btn--big btn--block" disabled={status === 'submitting'}>
|
||||
{status === 'submitting'
|
||||
? (<><span className="spinner spinner--sm" /> Sending…</>)
|
||||
: (<>Send enquiry <Icon name="send" size={15} /></>)}
|
||||
</button>
|
||||
<p className="field__hint text-center mt-16">
|
||||
By submitting you agree we may contact you about your enquiry.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Contact;
|
||||
@@ -0,0 +1,96 @@
|
||||
// Upcoming Events — plus a section for past events for context.
|
||||
import { useState } from 'react';
|
||||
import useFetch from '../lib/useFetch';
|
||||
import Icon from '../components/Icon';
|
||||
import SmartImage from '../components/SmartImage';
|
||||
import { Loading, ErrorState, EmptyState } from '../components/States';
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return 'Date to be confirmed';
|
||||
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function EventCard({ e, upcoming }) {
|
||||
// Demo-only registration: no backend call, just visual confirmation.
|
||||
const [registered, setRegistered] = useState(false);
|
||||
|
||||
return (
|
||||
<article className="card media-card card--hover" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{e.cover_image && (
|
||||
<SmartImage src={e.cover_image} alt={e.title} icon="calendar" className="media-card__img" />
|
||||
)}
|
||||
<div className="media-card__body" style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<div className="chips">
|
||||
<span className={`chip${upcoming ? '' : ' chip--muted'}`}>{upcoming ? 'Upcoming' : 'Past event'}</span>
|
||||
</div>
|
||||
<h2 className="card__title">{e.title}</h2>
|
||||
<p className="mono" style={{ fontSize: 12.5, color: 'var(--ink-dim)', display: 'flex', gap: 14, flexWrap: 'wrap', margin: '4px 0 12px' }}>
|
||||
<span className="flex items-center gap-8"><Icon name="calendar" size={13} /> {formatDate(e.event_date)}</span>
|
||||
{e.location && <span className="flex items-center gap-8"><Icon name="pin" size={13} /> {e.location}</span>}
|
||||
</p>
|
||||
{e.description && <p className="card__text">{e.description}</p>}
|
||||
|
||||
{upcoming && (
|
||||
<div style={{ marginTop: 'auto', paddingTop: 18 }}>
|
||||
{registered ? (
|
||||
<button type="button" className="btn btn--block" disabled style={{ color: 'var(--success)', borderColor: 'var(--success)' }}>
|
||||
<Icon name="check" size={16} /> You're registered
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn--primary btn--block" onClick={() => setRegistered(true)}>
|
||||
Register for this event <Icon name="arrow" size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Events() {
|
||||
const { data, loading, error, reload } = useFetch('/api/content/events');
|
||||
const upcoming = data?.upcoming || [];
|
||||
const past = data?.past || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div className="container">
|
||||
<span className="eyebrow">Events</span>
|
||||
<h1>Where to find AI-Solutions</h1>
|
||||
<p>Join us at upcoming demos and workshops, register your interest, or see where we have been.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="section-head section-head--left"><span className="eyebrow">Upcoming</span><h2>Upcoming events</h2></div>
|
||||
{loading && <Loading label="Loading events…" />}
|
||||
{error && <ErrorState message={error} onRetry={reload} />}
|
||||
{!loading && !error && upcoming.length === 0 && (
|
||||
<EmptyState title="No upcoming events" message="Check back soon for new dates." icon="calendar" />
|
||||
)}
|
||||
{!loading && !error && upcoming.length > 0 && (
|
||||
<div className="grid grid--3">
|
||||
{upcoming.map((e) => <EventCard key={e.id} e={e} upcoming />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!loading && !error && past.length > 0 && (
|
||||
<section className="section section--last">
|
||||
<div className="container">
|
||||
<div className="section-head section-head--left"><span className="eyebrow">Archive</span><h2>Past events</h2></div>
|
||||
<div className="grid grid--3">
|
||||
{past.map((e) => <EventCard key={e.id} e={e} upcoming={false} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Events;
|
||||
@@ -0,0 +1,63 @@
|
||||
// Customer Feedback — testimonials with star ratings.
|
||||
import useFetch from '../lib/useFetch';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { Loading, ErrorState, EmptyState } from '../components/States';
|
||||
|
||||
function Feedback() {
|
||||
const { data, loading, error, reload } = useFetch('/api/content/feedback');
|
||||
const items = data?.feedback || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div className="container">
|
||||
<span className="eyebrow">Customer Feedback</span>
|
||||
<h1>What our clients think</h1>
|
||||
<p>Honest feedback and ratings from the organisations we have worked with.</p>
|
||||
|
||||
{!loading && !error && items.length > 0 && (
|
||||
<div className="flex gap-12 items-center mt-16" style={{ flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div className="stat">
|
||||
<span className="stat__num">{data.averageRating?.toFixed(1)}</span>
|
||||
<span className="stat__label">Average rating</span>
|
||||
</div>
|
||||
<StarRating value={data.averageRating} />
|
||||
<span className="muted">· based on {data.count} review{data.count === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section section--last">
|
||||
<div className="container">
|
||||
{loading && <Loading label="Loading feedback…" />}
|
||||
{error && <ErrorState message={error} onRetry={reload} />}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<EmptyState title="No feedback yet" message="Reviews from our clients will appear here." icon="star" />
|
||||
)}
|
||||
|
||||
{!loading && !error && items.length > 0 && (
|
||||
<div className="grid grid--2">
|
||||
{items.map((f) => (
|
||||
<article className="card card--rule" key={f.id}>
|
||||
<div className="flex between items-center">
|
||||
<StarRating value={f.rating} />
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-dim)' }}>
|
||||
{new Date(f.created_at).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="card__text mt-16" style={{ fontSize: 16, color: 'var(--ink)' }}>“{f.comment}”</p>
|
||||
<p className="mono mt-16" style={{ fontSize: 12, color: 'var(--ink-dim)' }}>
|
||||
{f.customer_name}{f.company ? ` · ${f.company}` : ''}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Feedback;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Photo Gallery — images from past & upcoming promotional events,
|
||||
// with an accessible lightbox.
|
||||
import { useState } from 'react';
|
||||
import useFetch from '../lib/useFetch';
|
||||
import Modal from '../components/Modal';
|
||||
import { Loading, ErrorState, EmptyState } from '../components/States';
|
||||
|
||||
function Gallery() {
|
||||
const { data, loading, error, reload } = useFetch('/api/content/gallery');
|
||||
const images = data?.gallery || [];
|
||||
const [active, setActive] = useState(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div className="container">
|
||||
<span className="eyebrow">Photo Gallery</span>
|
||||
<h1>Moments from our events</h1>
|
||||
<p>A look back at our promotional events, demos and the people behind AI-Solutions.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section section--last">
|
||||
<div className="container">
|
||||
{loading && <Loading label="Loading gallery…" />}
|
||||
{error && <ErrorState message={error} onRetry={reload} />}
|
||||
{!loading && !error && images.length === 0 && (
|
||||
<EmptyState title="No photos yet" message="Event photos will appear here." icon="globe" />
|
||||
)}
|
||||
|
||||
{!loading && !error && images.length > 0 && (
|
||||
<div className="gallery">
|
||||
{images.map((img) => (
|
||||
<button
|
||||
type="button"
|
||||
className="gallery__item"
|
||||
key={img.id}
|
||||
onClick={() => setActive(img)}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: 0, border: '1px solid var(--border)', cursor: 'zoom-in', background: 'var(--card)' }}
|
||||
aria-label={`View photo: ${img.caption || 'event photo'}`}
|
||||
>
|
||||
<img src={img.image_url} alt={img.caption || 'Event photo'} loading="lazy" />
|
||||
{(img.caption || img.event_title) && (
|
||||
<span className="gallery__cap" style={{ display: 'block' }}>
|
||||
{img.caption}{img.event_title ? ` · ${img.event_title}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{active && (
|
||||
<Modal title={active.caption || 'Event photo'} onClose={() => setActive(null)}>
|
||||
<img src={active.image_url} alt={active.caption || 'Event photo'} style={{ borderRadius: 8, width: '100%' }} />
|
||||
{active.event_title && <p className="muted mt-16 mono" style={{ fontSize: 12 }}>{active.event_title}</p>}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Gallery;
|
||||
@@ -0,0 +1,212 @@
|
||||
// Home — company mission, core services and value proposition.
|
||||
import { Link } from 'react-router-dom';
|
||||
import useFetch from '../lib/useFetch';
|
||||
import { IMAGES, serviceImage } from '../lib/images';
|
||||
import Icon from '../components/Icon';
|
||||
import StarRating from '../components/StarRating';
|
||||
import SmartImage from '../components/SmartImage';
|
||||
|
||||
const VALUES = [
|
||||
{ icon: 'spark', title: 'AI-first by design',
|
||||
text: 'An AI-powered virtual assistant sits at the core of everything we build, answering inquiries and accelerating work.' },
|
||||
{ icon: 'bolt', title: 'Affordable & fast',
|
||||
text: 'Rapid, low-cost prototyping turns ideas into testable solutions in days — validate before you invest.' },
|
||||
{ icon: 'globe', title: 'Built to scale globally',
|
||||
text: 'From a Sunderland start-up to worldwide impact: solutions engineered to grow across teams and industries.' },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ num: '24/7', label: 'AI assistant availability' },
|
||||
{ num: '62%', label: 'Avg. faster resolution' },
|
||||
{ num: 'Days', label: 'From idea to prototype' },
|
||||
{ num: '4.8/5', label: 'Average client rating' },
|
||||
];
|
||||
|
||||
const ASSISTANT_POINTS = [
|
||||
'Understands natural language and resolves routine requests automatically',
|
||||
'Knows when to hand a conversation to a human — gracefully',
|
||||
'Plugs into your existing tools to cut response times',
|
||||
];
|
||||
|
||||
function Home() {
|
||||
const { data } = useFetch('/api/content/solutions');
|
||||
const { data: fb } = useFetch('/api/content/feedback');
|
||||
const solutions = (data?.solutions || []).slice(0, 3);
|
||||
const featured = (fb?.feedback || []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="hero">
|
||||
<div className="container hero__grid">
|
||||
<div>
|
||||
<span className="hero__pill"><b>New</b> AI virtual assistant for industry teams</span>
|
||||
<h1>The future of the <span className="accent">digital employee experience</span></h1>
|
||||
<p className="hero__lead">
|
||||
AI-Solutions helps industry teams work better. We build AI-powered virtual assistants
|
||||
and affordable prototypes that speed up design, engineering and innovation — with a
|
||||
strong focus on supporting people at work.
|
||||
</p>
|
||||
<div className="hero__actions">
|
||||
<Link to="/contact" className="btn btn--primary btn--big">Get in touch <Icon name="arrow" size={16} /></Link>
|
||||
<Link to="/services" className="btn btn--big">Explore services</Link>
|
||||
</div>
|
||||
<div className="hero__proof">
|
||||
<div className="hero__avatars" aria-hidden="true">
|
||||
<span>NL</span><span>TM</span><span>WC</span><span>BR</span>
|
||||
</div>
|
||||
<span>Trusted by teams in logistics, manufacturing, healthcare & more</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero__media">
|
||||
<SmartImage src={IMAGES.heroTeam} alt="The AI-Solutions team collaborating" icon="robot" />
|
||||
<div className="hero__float hero__float--tl">
|
||||
<span className="icon-badge"><Icon name="spark" size={18} /></span>
|
||||
<span><b>AI Assistant</b><small>Always on, always learning</small></span>
|
||||
</div>
|
||||
<div className="hero__float hero__float--br">
|
||||
<span className="icon-badge"><Icon name="bolt" size={18} /></span>
|
||||
<span><b>Rapid prototypes</b><small>Idea → demo in days</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="logo-strip">
|
||||
<span className="logo-strip__label">Supporting people at work across industries</span>
|
||||
{['Logistics', 'Manufacturing', 'Healthcare', 'Retail', 'Insurance'].map((s) => (
|
||||
<span className="logo-strip__item" key={s}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission / values */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="section-head">
|
||||
<span className="eyebrow">Our mission</span>
|
||||
<h2>Innovate, promote & deliver — for people at work</h2>
|
||||
<p>We leverage AI to rapidly and proactively address the issues that impact the digital
|
||||
employee experience, so organisations can design, engineer and innovate faster.</p>
|
||||
</div>
|
||||
<div className="grid grid--3">
|
||||
{VALUES.map((v) => (
|
||||
<article className="card card--hover" key={v.title}>
|
||||
<div className="icon-badge"><Icon name={v.icon} size={22} /></div>
|
||||
<h3 className="card__title">{v.title}</h3>
|
||||
<p className="card__text">{v.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats band */}
|
||||
<section className="section--tight">
|
||||
<div className="container">
|
||||
<div className="stats-band">
|
||||
{STATS.map((s) => (
|
||||
<div className="stats-band__item" key={s.label}>
|
||||
<div className="stats-band__num">{s.num}</div>
|
||||
<div className="stats-band__label">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Showcase: AI assistant */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="showcase">
|
||||
<div className="showcase__media">
|
||||
<SmartImage src={IMAGES.showcaseAssistant} alt="AI-powered virtual assistant" icon="robot" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="eyebrow">Our core product</span>
|
||||
<h2 style={{ fontSize: 'clamp(26px,3.2vw,36px)' }}>An AI virtual assistant that actually helps</h2>
|
||||
<p className="muted mt-16" style={{ fontSize: 17 }}>
|
||||
Our flagship assistant responds to inquiries instantly and provides affordable,
|
||||
AI-based prototyping — the unique selling point that sets AI-Solutions apart.
|
||||
</p>
|
||||
<ul className="showcase__list">
|
||||
{ASSISTANT_POINTS.map((p) => (
|
||||
<li key={p}><span className="tick"><Icon name="check" size={14} /></span> <span>{p}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-32"><Link to="/services" className="btn btn--primary">See how it works <Icon name="arrow" size={15} /></Link></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured services */}
|
||||
<section className="section section--tint">
|
||||
<div className="container">
|
||||
<div className="section-head">
|
||||
<span className="eyebrow">What we do</span>
|
||||
<h2>Software solutions, built around you</h2>
|
||||
<p>From AI assistants to bespoke engineering — services grounded in what your team actually needs.</p>
|
||||
</div>
|
||||
<div className="grid grid--3">
|
||||
{solutions.length === 0
|
||||
? Array.from({ length: 3 }).map((_, i) => <div className="card skeleton" key={i} style={{ height: 320 }} />)
|
||||
: solutions.map((s) => (
|
||||
<article className="card media-card card--hover" key={s.id}>
|
||||
<SmartImage src={serviceImage(s.icon)} alt={s.title} icon={s.icon || 'robot'} className="media-card__img" />
|
||||
<div className="media-card__body">
|
||||
<h3 className="card__title">{s.title}</h3>
|
||||
<p className="card__text">{s.summary}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-32"><Link to="/services" className="btn">View all services <Icon name="arrow" size={15} /></Link></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials */}
|
||||
{featured.length > 0 && (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="section-head">
|
||||
<span className="eyebrow">Customer feedback</span>
|
||||
<h2>Trusted by the teams we work with</h2>
|
||||
</div>
|
||||
<div className="grid grid--3">
|
||||
{featured.map((f) => (
|
||||
<article className="testimonial" key={f.id}>
|
||||
<StarRating value={f.rating} />
|
||||
<p className="testimonial__quote">“{f.comment}”</p>
|
||||
<div className="testimonial__who">
|
||||
<span className="avatar">{f.customer_name.split(' ').map(w => w[0]).slice(0, 2).join('')}</span>
|
||||
<span>
|
||||
<span className="testimonial__name" style={{ display: 'block' }}>{f.customer_name}</span>
|
||||
<span className="testimonial__role">{f.company}</span>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-32"><Link to="/feedback" className="btn">Read all feedback <Icon name="arrow" size={15} /></Link></div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<section className="section section--last">
|
||||
<div className="container">
|
||||
<div className="cta-band">
|
||||
<h2>Ready to support your people with AI?</h2>
|
||||
<p>Tell us about your project and our team will be in touch — no account needed.</p>
|
||||
<Link to="/contact" className="btn btn--white btn--big">Contact us <Icon name="arrow" size={16} /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,19 @@
|
||||
// 404 — unknown public route.
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<section className="page-head section--last">
|
||||
<div className="container text-center">
|
||||
<span className="eyebrow">Error 404</span>
|
||||
<h1>Page not found</h1>
|
||||
<p className="muted" style={{ margin: '0 auto' }}>
|
||||
The page you are looking for doesn’t exist or has moved.
|
||||
</p>
|
||||
<div className="mt-24"><Link to="/" className="btn btn--primary">Back to home</Link></div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,57 @@
|
||||
// Past Solutions — case studies of completed industry projects.
|
||||
import useFetch from '../lib/useFetch';
|
||||
import { Loading, ErrorState, EmptyState } from '../components/States';
|
||||
|
||||
function PastSolutions() {
|
||||
const { data, loading, error, reload } = useFetch('/api/content/case-studies');
|
||||
const items = data?.caseStudies || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div className="container">
|
||||
<span className="eyebrow">Past Solutions</span>
|
||||
<h1>Real results, delivered for industry</h1>
|
||||
<p>Highlights of past solutions we have provided — and the measurable outcomes they produced.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section section--last">
|
||||
<div className="container">
|
||||
{loading && <Loading label="Loading case studies…" />}
|
||||
{error && <ErrorState message={error} onRetry={reload} />}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<EmptyState title="No case studies yet" message="Our completed projects will appear here." icon="briefcase" />
|
||||
)}
|
||||
|
||||
{!loading && !error && items.length > 0 && (
|
||||
<div className="grid grid--3">
|
||||
{items.map((c) => (
|
||||
<article className="card media-card card--hover" key={c.id}>
|
||||
{c.image_url && (
|
||||
<img className="media-card__img" src={c.image_url} alt={`${c.client || c.title} project`} loading="lazy" />
|
||||
)}
|
||||
<div className="media-card__body">
|
||||
<div className="chips">
|
||||
{c.industry && <span className="chip">{c.industry}</span>}
|
||||
{c.client && <span className="chip chip--muted">{c.client}</span>}
|
||||
</div>
|
||||
<h2 className="card__title">{c.title}</h2>
|
||||
<p className="card__text" style={{ marginBottom: 10 }}>{c.summary}</p>
|
||||
{c.outcome && (
|
||||
<p className="card__text" style={{ color: 'var(--ink)' }}>
|
||||
<strong>Outcome:</strong> {c.outcome}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PastSolutions;
|
||||
@@ -0,0 +1,105 @@
|
||||
// Services — the software solutions AI-Solutions offers.
|
||||
import { Link } from 'react-router-dom';
|
||||
import useFetch from '../lib/useFetch';
|
||||
import { IMAGES, serviceImage } from '../lib/images';
|
||||
import Icon from '../components/Icon';
|
||||
import SmartImage from '../components/SmartImage';
|
||||
import { Loading, ErrorState, EmptyState } from '../components/States';
|
||||
|
||||
const STEPS = [
|
||||
{ n: '01', title: 'Listen', text: 'We start with your needs — your workflows, your people, your goals.' },
|
||||
{ n: '02', title: 'Prototype', text: 'We build an affordable, working prototype so you can test ideas fast.' },
|
||||
{ n: '03', title: 'Deliver & scale', text: 'We refine, deploy and support the solution as it grows with you.' },
|
||||
];
|
||||
|
||||
function Services() {
|
||||
const { data, loading, error, reload } = useFetch('/api/content/solutions');
|
||||
const solutions = data?.solutions || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header with image */}
|
||||
<section className="hero">
|
||||
<div className="container">
|
||||
<div className="showcase">
|
||||
<div>
|
||||
<span className="eyebrow">Services</span>
|
||||
<h1 style={{ fontSize: 36, letterSpacing: '-0.6px', lineHeight: 1.12 }}>
|
||||
Software solutions that support people at work
|
||||
</h1>
|
||||
<p className="muted mt-16" style={{ fontSize: 17 }}>
|
||||
Each engagement is grounded in what you actually need. From AI virtual assistants to
|
||||
bespoke engineering, here is how we help teams design, build and innovate faster.
|
||||
</p>
|
||||
<div className="hero__actions mt-24">
|
||||
<Link to="/contact" className="btn btn--primary btn--big">Get a free consultation <Icon name="arrow" size={14} /></Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="showcase__media">
|
||||
<SmartImage src={IMAGES.showcasePrototype} alt="Planning a software solution" icon="code" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Service cards */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
{loading && <Loading label="Loading services…" />}
|
||||
{error && <ErrorState message={error} onRetry={reload} />}
|
||||
{!loading && !error && solutions.length === 0 && (
|
||||
<EmptyState title="No services published yet" message="Please check back soon." icon="briefcase" />
|
||||
)}
|
||||
|
||||
{!loading && !error && solutions.length > 0 && (
|
||||
<div className="grid grid--2">
|
||||
{solutions.map((s) => (
|
||||
<article className="card media-card card--hover" key={s.id}>
|
||||
<SmartImage src={serviceImage(s.icon)} alt={s.title} icon={s.icon || 'robot'} className="media-card__img" />
|
||||
<div className="media-card__body">
|
||||
<div className="card__icon" style={{ marginBottom: 12 }}><Icon name={s.icon || 'robot'} size={20} /></div>
|
||||
<h2 className="card__title">{s.title}</h2>
|
||||
<p className="card__text" style={{ marginBottom: 12 }}>{s.summary}</p>
|
||||
{s.description && <p className="card__text">{s.description}</p>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How we work */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="section-head">
|
||||
<span className="eyebrow">How we work</span>
|
||||
<h2>A simple path from idea to impact</h2>
|
||||
</div>
|
||||
<div className="grid grid--3">
|
||||
{STEPS.map((st) => (
|
||||
<article className="card card--rule" key={st.n}>
|
||||
<span className="mono" style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)' }}>{st.n}</span>
|
||||
<h3 className="card__title mt-8">{st.title}</h3>
|
||||
<p className="card__text">{st.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="section section--last">
|
||||
<div className="container">
|
||||
<div className="cta-band">
|
||||
<h2>Not sure which solution fits?</h2>
|
||||
<p>Tell us your goals and we will recommend the right approach — no obligation.</p>
|
||||
<Link to="/contact" className="btn btn--white btn--big">Get in touch <Icon name="arrow" size={16} /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Services;
|
||||
@@ -0,0 +1,267 @@
|
||||
// Admin dashboard — the sole back-office job: review Contact Us
|
||||
// enquiries. Headline stats + a searchable, filterable, paginated
|
||||
// table, with a full-detail modal per enquiry.
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { getJSON, postJSON, apiFetch } from '../../api';
|
||||
import Modal from '../../components/Modal';
|
||||
import Icon from '../../components/Icon';
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/States';
|
||||
|
||||
function StatCard({ num, label }) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat">
|
||||
<span className="stat__num">{num}</span>
|
||||
<span className="stat__label">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDateTime(d) {
|
||||
return new Date(d).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function AdminDashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [country, setCountry] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [list, setList] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [active, setActive] = useState(null); // open enquiry detail
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const limit = 10;
|
||||
|
||||
// Debounce the free-text search.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setDebounced(search); setPage(1); }, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const loadStats = useCallback(() => {
|
||||
getJSON('/api/admin/stats').then(setStats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (debounced) params.set('search', debounced);
|
||||
if (country) params.set('country', country);
|
||||
if (status) params.set('status', status);
|
||||
getJSON(`/api/admin/inquiries?${params.toString()}`)
|
||||
.then(setList)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, debounced, country, status]);
|
||||
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
|
||||
const openDetail = async (id) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const data = await getJSON(`/api/admin/inquiries/${id}`);
|
||||
setActive(data.inquiry);
|
||||
loadStats(); // unread count may change (viewing marks read)
|
||||
loadList();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeInquiry = async (id) => {
|
||||
if (!window.confirm('Delete this enquiry? This cannot be undone.')) return;
|
||||
try {
|
||||
await apiFetch(`/api/admin/inquiries/${id}`, { method: 'DELETE' });
|
||||
setActive(null);
|
||||
loadStats();
|
||||
loadList();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRead = async (inq) => {
|
||||
try {
|
||||
await postJSON(`/api/admin/inquiries/${inq.id}/read`, { is_read: !inq.is_read }, 'PATCH');
|
||||
loadStats();
|
||||
loadList();
|
||||
setActive((a) => (a && a.id === inq.id ? { ...a, is_read: !inq.is_read } : a));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const inquiries = list?.inquiries || [];
|
||||
const pg = list?.pagination;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="admin__topbar">
|
||||
<div className="admin__title">
|
||||
<h1>Customer Enquiries</h1>
|
||||
<p>Every submission from the public Contact Us form.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid--4" style={{ marginBottom: 28 }}>
|
||||
<StatCard num={stats ? stats.totalInquiries : '—'} label="Total enquiries" />
|
||||
<StatCard num={stats ? stats.unreadInquiries : '—'} label="Unread" />
|
||||
<StatCard num={stats ? stats.last7Days : '—'} label="Last 7 days" />
|
||||
<StatCard num={stats ? stats.today : '—'} label="Today" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="toolbar">
|
||||
<div className="toolbar__grow" style={{ position: 'relative', minWidth: 220 }}>
|
||||
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--ink-dim)' }}>
|
||||
<Icon name="search" size={16} />
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
style={{ paddingLeft: 38, width: '100%' }}
|
||||
placeholder="Search name, email, company, details…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Search enquiries"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="sr-only" htmlFor="f-country">Filter by country</label>
|
||||
<select id="f-country" className="select" value={country} onChange={(e) => { setCountry(e.target.value); setPage(1); }}>
|
||||
<option value="">All countries</option>
|
||||
{(stats?.countries || []).map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
|
||||
<label className="sr-only" htmlFor="f-status">Filter by status</label>
|
||||
<select id="f-status" className="select" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="read">Read</option>
|
||||
</select>
|
||||
|
||||
{(search || country || status) && (
|
||||
<button type="button" className="btn btn--ghost" onClick={() => { setSearch(''); setCountry(''); setStatus(''); setPage(1); }}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading && <Loading label="Loading enquiries…" />}
|
||||
{error && !loading && <ErrorState message={error} onRetry={loadList} />}
|
||||
{!loading && !error && inquiries.length === 0 && (
|
||||
<EmptyState
|
||||
title="No enquiries found"
|
||||
message={search || country || status ? 'Try clearing your filters.' : 'New Contact Us submissions will appear here.'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && inquiries.length > 0 && (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<caption className="sr-only">Customer enquiries</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Company</th>
|
||||
<th scope="col">Country</th>
|
||||
<th scope="col">Received</th>
|
||||
<th scope="col"><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inquiries.map((inq) => (
|
||||
<tr
|
||||
key={inq.id}
|
||||
className={inq.is_read ? '' : 'row-unread'}
|
||||
onClick={() => openDetail(inq.id)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') openDetail(inq.id); }}
|
||||
>
|
||||
<td><span className={`badge ${inq.is_read ? 'badge--read' : 'badge--new'}`}>{inq.is_read ? 'Read' : 'New'}</span></td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{inq.full_name}</div>
|
||||
<div className="muted" style={{ fontSize: 12.5 }}>{inq.email}</div>
|
||||
</td>
|
||||
<td>{inq.company || <span className="muted">—</span>}</td>
|
||||
<td>{inq.country || <span className="muted">—</span>}</td>
|
||||
<td className="mono" style={{ fontSize: 12, color: 'var(--ink-dim)' }}>{fmtDateTime(inq.created_at)}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="btn btn--ghost" onClick={() => openDetail(inq.id)} aria-label={`View enquiry from ${inq.full_name}`}>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pg && (
|
||||
<div className="pagination">
|
||||
<span className="pagination__info">
|
||||
Page {pg.page} of {pg.totalPages} · {pg.totalCount} enquir{pg.totalCount === 1 ? 'y' : 'ies'}
|
||||
</span>
|
||||
<div className="flex gap-8">
|
||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
|
||||
<button type="button" className="btn" disabled={page >= pg.totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Detail modal */}
|
||||
{(active || detailLoading) && (
|
||||
<Modal title={active ? `Enquiry from ${active.full_name}` : 'Loading…'} onClose={() => setActive(null)}>
|
||||
{!active ? (
|
||||
<Loading label="Loading enquiry…" />
|
||||
) : (
|
||||
<>
|
||||
<dl className="dl">
|
||||
<dt>Full name</dt><dd>{active.full_name}</dd>
|
||||
<dt>Email</dt><dd><a href={`mailto:${active.email}`}>{active.email}</a></dd>
|
||||
<dt>Phone</dt><dd>{active.phone || <span className="muted">Not provided</span>}</dd>
|
||||
<dt>Company</dt><dd>{active.company || <span className="muted">Not provided</span>}</dd>
|
||||
<dt>Country</dt><dd>{active.country || <span className="muted">Not provided</span>}</dd>
|
||||
<dt>Job title</dt><dd>{active.job_title || <span className="muted">Not provided</span>}</dd>
|
||||
<dt>Received</dt><dd>{fmtDateTime(active.created_at)}</dd>
|
||||
<dt>Job details</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{active.job_details}</dd>
|
||||
</dl>
|
||||
|
||||
<div className="flex between wrap gap-12 mt-24">
|
||||
<div className="flex gap-8 wrap">
|
||||
<a className="btn btn--primary" href={`mailto:${active.email}?subject=Re: your enquiry to AI-Solutions`}>
|
||||
<Icon name="mail" size={15} /> Reply by email
|
||||
</a>
|
||||
<button type="button" className="btn" onClick={() => toggleRead(active)}>
|
||||
Mark as {active.is_read ? 'unread' : 'read'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn--ghost" onClick={() => removeInquiry(active.id)} style={{ color: 'var(--danger)' }}>
|
||||
<Icon name="trash" size={15} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -0,0 +1,58 @@
|
||||
// Admin back-office shell: dark sidebar (visually distinct from the
|
||||
// public cream site) + routed content. Loads the current admin via
|
||||
// the existing /api/auth/me and logs out via /api/auth/logout.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useNavigate, Link } from 'react-router-dom';
|
||||
import { getJSON, postJSON } from '../../api';
|
||||
import Brand from '../../components/Brand';
|
||||
import Icon from '../../components/Icon';
|
||||
|
||||
function AdminLayout() {
|
||||
const [me, setMe] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getJSON('/api/auth/me').then((d) => setMe(d.user)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
try { await postJSON('/api/auth/logout', {}); } catch {/* ignore */}
|
||||
navigate('/admin/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
<aside className="admin__side">
|
||||
<div className="admin__brand"><Brand to={null} onDark /></div>
|
||||
|
||||
<nav aria-label="Admin">
|
||||
<Link to="/admin" className="admin__navlink is-active">
|
||||
<Icon name="inbox" size={16} /> Enquiries
|
||||
</Link>
|
||||
<Link to="/" className="admin__navlink">
|
||||
<Icon name="globe" size={16} /> View website
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="admin__spacer" />
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--dark-border)', paddingTop: 14 }}>
|
||||
{me && (
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--dark-dim)', marginBottom: 10, wordBreak: 'break-all' }}>
|
||||
{me.username}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className="admin__navlink" onClick={logout}>
|
||||
<Icon name="logout" size={16} /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="admin__main">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -0,0 +1,118 @@
|
||||
// Admin login — integrates with the EXISTING secure backend:
|
||||
// 1) POST /api/auth/login (email + password + Turnstile) → sends OTP
|
||||
// 2) POST /api/auth/verify-otp (userId + 6-digit OTP) → sets session
|
||||
// No auth/security logic is reimplemented here; this is just the UI.
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { postJSON } from '../../api';
|
||||
import { TextField, PasswordField } from '../../components/Field';
|
||||
import Turnstile from '../../components/Turnstile';
|
||||
import Brand from '../../components/Brand';
|
||||
import Icon from '../../components/Icon';
|
||||
|
||||
function AdminLogin() {
|
||||
const [step, setStep] = useState('credentials'); // credentials | otp
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [userId, setUserId] = useState(null);
|
||||
const [token, setToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const turnstileRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dest = location.state?.from || '/admin';
|
||||
|
||||
const resetCaptcha = () => { turnstileRef.current?.reset(); setToken(''); };
|
||||
|
||||
const submitCredentials = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!token) { setError('Please complete the CAPTCHA challenge.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await postJSON('/api/auth/login', { email, password, turnstileToken: token });
|
||||
if (data.requireOTP) {
|
||||
setUserId(data.userId);
|
||||
setStep('otp');
|
||||
setInfo(data.message || 'We sent a 6-digit code to your email.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Login failed.');
|
||||
resetCaptcha();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitOtp = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await postJSON('/api/auth/verify-otp', { userId, otp });
|
||||
navigate(dest, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message || 'Invalid code.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resendOtp = async () => {
|
||||
setError(''); setInfo('');
|
||||
try {
|
||||
const data = await postJSON('/api/auth/request-otp', { userId });
|
||||
setInfo(data.message || 'A new code has been sent.');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not resend the code.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__head">
|
||||
<Brand to={null} />
|
||||
<h1 className="mt-16">Admin Portal</h1>
|
||||
<p>{step === 'credentials' ? 'Sign in to review customer enquiries.' : 'Enter the verification code we emailed you.'}</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="notice notice--error" role="alert">{error}</div>}
|
||||
{info && step === 'otp' && <div className="notice notice--info">{info}</div>}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={submitCredentials} noValidate>
|
||||
<TextField label="Email" required type="email" value={email} onChange={setEmail} autoComplete="username" />
|
||||
<PasswordField label="Password" required value={password} onChange={setPassword} />
|
||||
<div className="field">
|
||||
<span className="field__label">Verification</span>
|
||||
<Turnstile ref={turnstileRef} onToken={setToken} />
|
||||
</div>
|
||||
<button type="submit" className="btn btn--primary btn--big btn--block" disabled={busy}>
|
||||
{busy ? (<><span className="spinner spinner--sm" /> Signing in…</>) : (<>Continue <Icon name="arrow" size={14} /></>)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submitOtp} noValidate>
|
||||
<TextField label="6-digit code" required value={otp} onChange={(v) => setOtp(v.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="••••••" autoComplete="one-time-code" />
|
||||
<button type="submit" className="btn btn--primary btn--big btn--block" disabled={busy || otp.length !== 6}>
|
||||
{busy ? (<><span className="spinner spinner--sm" /> Verifying…</>) : 'Verify & sign in'}
|
||||
</button>
|
||||
<div className="flex between mt-16">
|
||||
<button type="button" className="btn btn--ghost" onClick={() => { setStep('credentials'); setOtp(''); resetCaptcha(); }}>Back</button>
|
||||
<button type="button" className="btn btn--ghost" onClick={resendOtp}>Resend code</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="auth-card__foot"><Link to="/">← Back to website</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLogin;
|
||||
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in new issue
Block a user