Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd9e917b62 | ||
|
|
86af7093ac | ||
|
|
2d99b7cafe |
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.7.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Site Settings → Branding now supports a full Primary/Secondary/Accent brand color system (previously a single accent color that only recolored the org name in the navbar) applied site-wide — buttons, nav highlights, hover states, and links — plus a favicon upload alongside the existing logo upload, and a live preview panel (with website/email and desktop/mobile views) that updates as colors are picked, before saving. Selecting a logo now also suggests brand colors extracted from it. Outgoing emails' header and call-to-action buttons now pick up the Primary color too, while semantic alert colors (success/warning/danger) stay fixed. The setup wizard's Branding step got the same preview and color-suggestion treatment.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The setup wizard's logo/favicon upload was silently failing — by the time it ran, the admin account already existed and the upload endpoint required admin auth, but the request never attached the token. It now does, and a failed upload surfaces as a visible error instead of quietly leaving branding unset.
|
||||
- A custom favicon saved via Site Settings never actually showed in the browser tab: the site had a static `favicon.ico` in the special Next.js `app/` convention path, which generates its own `<link rel="icon">` tag that always took priority over the admin's saved one. Moved the static file to `public/` so it's just the default fallback, not a competing tag.
|
||||
- Every unconfigured-organisation default across the app (org name, email subjects, WhatsApp messages, report file metadata, backend status/API-docs pages, `From:` fallback address) said "Hope Events"/"Hope Family Church" instead of a neutral placeholder — now defaults to "Cross Code" (the platform vendor) consistently. Several email subject lines (login/welcome/activation/password-changed/account-closed) were also fully hardcoded regardless of the configured org name; they now interpolate it dynamically like the email body already did.
|
||||
- The optional legal settings (operator name, Information Officer name/email, website URL, effective date, and the Legal pages' own email fallback) defaulted to this project's real founder's name/email/church details — actively misleading if ever shown on another organisation's deployed site before they're configured. These now default to obviously-generic placeholders (e.g. `example.com`, `[not yet configured]`) instead.
|
||||
- Added `SETTINGS.md`, a repo-root reference documenting every setting's default behaviour when left unconfigured.
|
||||
|
||||
## [1.6.0] - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -111,6 +111,7 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
|
||||
- Backend README: ./backend/README.md
|
||||
- Frontend README: ./frontend/README.md
|
||||
- Site Settings — defaults reference: ./SETTINGS.md
|
||||
|
||||
## Recent Changes
|
||||
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Site Settings — Defaults Reference
|
||||
|
||||
Every setting below is stored as a row in the `AppSetting` key-value table and
|
||||
is editable at runtime via **Admin → Site Settings** (or, for the first few,
|
||||
the `/setup` wizard on a fresh install). None of them are required — this
|
||||
page documents exactly what happens when a given key has never been saved.
|
||||
|
||||
Settings changes take effect within 60 seconds everywhere (the backend's
|
||||
in-process settings cache TTL) without a server restart.
|
||||
|
||||
## Organisation
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `org_name` | `"Cross Code"` | Falls back to `ORG_NAME` env var first, then this hardcoded default (the platform vendor's name, used as a neutral placeholder — `org_name` is a required field in the setup wizard, so this fallback is practically unreachable once a site has been set up). Shown in the navbar, email headers/subjects, and WhatsApp messages. |
|
||||
| `org_tagline` | `"Connecting community through events"` | Falls back to `ORG_TAGLINE` env var first. Shown under the org name in the email header. |
|
||||
| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
|
||||
| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
|
||||
| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
|
||||
|
||||
## Branding
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
|
||||
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
|
||||
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
|
||||
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
|
||||
| `favicon_url` | `frontend/public/favicon.ico` (the site's original generic favicon) | Shown in the browser tab. |
|
||||
|
||||
### Two different fallbacks for `primary_color`
|
||||
|
||||
If `primary_color` has *never* been saved (no admin has ever used the new
|
||||
3-color Branding tab) **and** there's no legacy `accent_color` value either,
|
||||
the website and outgoing emails currently disagree on what "no color" means:
|
||||
|
||||
- **Website** (`globals.css` / `tailwind.config.js` static defaults): `#4F46E5`, a bright indigo.
|
||||
- **Emails** (`backend/src/utils/email.js` `getOrg()`): `#1e3a5f`, a dark navy — unless overridden by the `EMAIL_HEADER_COLOR` env var.
|
||||
|
||||
In practice this only matters on a brand-new install before the setup wizard
|
||||
or Branding tab has been saved even once — the wizard always writes
|
||||
`primary_color`, so this gap closes itself the moment setup completes.
|
||||
|
||||
## Notifications
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `reg_notification_emails` | `REGISTRATIONS_EMAIL` env var, else *(blank)* | If truly blank, no internal notification email is sent for new registrations/payments/donations — it's silently skipped, not an error. |
|
||||
|
||||
## Email delivery (SMTP)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `smtp_host` | `SMTP_HOST`/`EMAIL_HOST` env var, else *(blank)* | If blank, the app **does not send real email** — it uses nodemailer's `jsonTransport`, which just logs the would-be email to the console. This is the single biggest "nothing is configured" gotcha — ticket/payment/password-reset emails silently don't go anywhere until SMTP is set up. |
|
||||
| `smtp_port` | `587` | |
|
||||
| `smtp_secure` | `false` | |
|
||||
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
|
||||
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
|
||||
|
||||
## Legal (Terms of Use / Privacy Policy)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `legal_operator_name` | `"[operator details not yet configured]"` | Deliberately a generic, obviously-incomplete placeholder rather than any real person's or organisation's name. Unlike `org_name`, these legal fields are optional and can realistically stay unset on a live site — defaulting them to Cross Code's (or anyone else's) real identity would misrepresent who's legally responsible for that deployment. |
|
||||
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## WhatsApp
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
|
||||
## Setup state
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `setup_complete` | `false` (treated as `"not true"`) | Controls whether `/setup` redirects to `/login` or runs the first-time wizard. Set to `"true"` automatically when the wizard finishes. |
|
||||
@@ -452,6 +452,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
|
||||
|
||||
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -64,8 +64,9 @@ const MAPPINGS = [
|
||||
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
|
||||
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
|
||||
|
||||
// Branding
|
||||
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
|
||||
// Branding — these env vars historically fed the single "brand color" concept,
|
||||
// which is now the Primary color in the 3-color Primary/Secondary/Accent system.
|
||||
{ key: 'primary_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
|
||||
|
||||
// Notifications
|
||||
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
|
||||
@@ -88,7 +89,7 @@ async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(' Hope Events — .env → DB settings migration');
|
||||
console.log(' Settings migration: .env → DB');
|
||||
if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)');
|
||||
if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
|
||||
@@ -250,7 +250,8 @@ async function renderReportPdfToFile(payload) {
|
||||
async function buildReportWorkbook(payload) {
|
||||
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'Hope Family Church Events';
|
||||
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
wb.creator = `${orgName} Events`;
|
||||
wb.created = new Date();
|
||||
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
|
||||
const ws = wb.addWorksheet(sheetName);
|
||||
|
||||
@@ -8,7 +8,13 @@ const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
|
||||
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
'accent_color', 'logo_url', 'setup_complete', 'app_base_url',
|
||||
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
|
||||
// system (accent_color's meaning was repurposed from "the one brand color" to
|
||||
// "tertiary accent color"; primary_color falls back to accent_color's legacy
|
||||
// value wherever it's consumed, so older data still resolves sensibly until
|
||||
// the admin re-saves the Branding tab).
|
||||
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
|
||||
'setup_complete', 'app_base_url',
|
||||
// Legal pages
|
||||
'legal_operator_name', 'legal_io_name', 'legal_io_email',
|
||||
'legal_website_url', 'legal_effective_date',
|
||||
@@ -293,12 +299,13 @@ const testSmtp = async (req, res) => {
|
||||
// Send a real test email to the authenticated user so there's visible proof
|
||||
const adminEmail = req.user?.email;
|
||||
if (adminEmail) {
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
await transporter.sendMail({
|
||||
from: from || user || 'no-reply@hope-events.local',
|
||||
from: from || user || 'no-reply@crosscode.local',
|
||||
to: adminEmail,
|
||||
subject: 'SMTP test — Hope Events',
|
||||
text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
|
||||
html: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
|
||||
subject: `SMTP test — ${orgName}`,
|
||||
text: `This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
|
||||
html: `<p>This is a test email sent from the <strong>${orgName}</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,34 @@ const uploadLogo = multer({
|
||||
}
|
||||
});
|
||||
|
||||
// Favicon storage (same subfolder as the logo — both are "branding" assets)
|
||||
const faviconStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
} catch (error) {
|
||||
cb(new Error(`Cannot access upload directory: ${error.message}`));
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadFavicon = multer({
|
||||
storage: faviconStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.ico', '.png', '.svg'].includes(ext)) {
|
||||
return cb(new Error('Only .ico, .png, or .svg files are allowed for the favicon'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Controller function
|
||||
const uploadEventImage = (req, res) => {
|
||||
// Check for multer errors which would be passed in req.multerError
|
||||
@@ -95,9 +123,21 @@ const uploadLogoImage = (req, res) => {
|
||||
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
|
||||
};
|
||||
|
||||
const uploadFaviconImage = (req, res) => {
|
||||
if (req.multerError) {
|
||||
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No file uploaded' });
|
||||
}
|
||||
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
upload,
|
||||
uploadEventImage,
|
||||
uploadLogo,
|
||||
uploadLogoImage,
|
||||
uploadFavicon,
|
||||
uploadFaviconImage,
|
||||
};
|
||||
|
||||
@@ -3,10 +3,17 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Org name for email subjects — falls back to Cross Code (the platform vendor)
|
||||
// rather than any specific customer's name when unconfigured.
|
||||
function getOrgName() {
|
||||
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
}
|
||||
|
||||
// Resolve a client IP from the request (works behind proxies)
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
@@ -41,7 +48,7 @@ async function sendLoginNotification(user, req) {
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content });
|
||||
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e) {
|
||||
@@ -63,7 +70,7 @@ async function sendWelcomeEmail(user) {
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content });
|
||||
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
@@ -185,7 +192,7 @@ const loginUser = async (req, res) => {
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content })
|
||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
@@ -207,7 +214,7 @@ const loginUser = async (req, res) => {
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
|
||||
const orgName = getOrgName();
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
@@ -394,10 +401,9 @@ const updateUserProfile = async (req, res) => {
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content })
|
||||
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
|
||||
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updatedUser, content.text).catch(() => {});
|
||||
@@ -903,7 +909,7 @@ const closeAccount = async (req, res) => {
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
|
||||
// WhatsApp while we still have phone (send before data wipe completes in-flight)
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
|
||||
}
|
||||
@@ -924,7 +930,7 @@ const closeAccount = async (req, res) => {
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account has been deactivated.' });
|
||||
|
||||
@@ -195,13 +195,14 @@ const handleWebhook = async (req, res) => {
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|
||||
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: 'WhatsApp session is down — action required',
|
||||
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
|
||||
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
|
||||
text: `The ${orgName} WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
|
||||
html: `<p>The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
+16
-16
@@ -244,8 +244,8 @@ app.get('/', async (req, res) => {
|
||||
? `<span class="badge badge-warn">testing</span>`
|
||||
: `<span class="badge badge-warn">development</span>`;
|
||||
|
||||
const html = pageShell('Hope Events API — Status', '#2563eb', `
|
||||
<h1>Hope Events API</h1>
|
||||
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
|
||||
<h1>Cross Code Events API</h1>
|
||||
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
||||
|
||||
<div class="stat-grid">
|
||||
@@ -533,7 +533,7 @@ app.get('/docs', async (req, res) => {
|
||||
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }},
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
|
||||
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
|
||||
@@ -750,7 +750,7 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]},
|
||||
{ method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status',
|
||||
responses:[
|
||||
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }},
|
||||
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Cross Code' }},
|
||||
{ status:200, desc:'Not connected', body:{ status:'close' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account',
|
||||
@@ -773,14 +773,14 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Settings', base: '/api/settings', endpoints: [
|
||||
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)',
|
||||
responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]},
|
||||
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
|
||||
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
|
||||
{ method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.',
|
||||
responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]},
|
||||
{ method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).',
|
||||
responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]},
|
||||
{ method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.',
|
||||
request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
|
||||
request:{ body:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
|
||||
responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]},
|
||||
{ method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.',
|
||||
request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }},
|
||||
@@ -798,7 +798,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.',
|
||||
request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
|
||||
request:{ body:{ settings:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
|
||||
responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]},
|
||||
]},
|
||||
];
|
||||
@@ -964,12 +964,12 @@ app.get('/docs', async (req, res) => {
|
||||
{ trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' },
|
||||
]},
|
||||
{ category: 'Account & security', items: [
|
||||
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to Hope Events!', content: 'Welcome message plus a list of upcoming events.' },
|
||||
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' },
|
||||
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
|
||||
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
|
||||
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to {org}!', content: 'Welcome message plus a list of upcoming events.' },
|
||||
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your {org} account', content: 'One-time activation link; expires after 24 hours.' },
|
||||
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your {org} account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
|
||||
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your {org} password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
|
||||
{ trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' },
|
||||
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
|
||||
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your {org} account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
|
||||
]},
|
||||
{ category: 'Admin-triggered broadcasts', items: [
|
||||
{ trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' },
|
||||
@@ -1019,7 +1019,7 @@ app.get('/docs', async (req, res) => {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hope Events — API Docs</title>
|
||||
<title>Cross Code Events — API Docs</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
|
||||
@@ -1043,7 +1043,7 @@ app.get('/docs', async (req, res) => {
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Hope Events — API Reference</h1>
|
||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Cross Code Events — API Reference</h1>
|
||||
<a href="/" style="font-size:.82rem;color:#6b7280">← Status page</a>
|
||||
</div>
|
||||
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
|
||||
@@ -1070,7 +1070,7 @@ app.get('/docs', async (req, res) => {
|
||||
${notificationsHtml}
|
||||
|
||||
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
|
||||
Hope Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||
Cross Code Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||
</p>
|
||||
</div>
|
||||
<script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController');
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
@@ -17,11 +17,9 @@ router.post('/event-image', protect, supervisor, (req, res, next) => {
|
||||
});
|
||||
}, uploadEventImage);
|
||||
|
||||
// @route POST /api/uploads/logo
|
||||
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
|
||||
async function logoAccess(req, res, next) {
|
||||
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
|
||||
// during first-time setup (no users yet).
|
||||
async function brandingAssetAccess(req, res, next) {
|
||||
try {
|
||||
const count = await prisma.user.count();
|
||||
if (count === 0) return next(); // first-time setup
|
||||
@@ -31,11 +29,24 @@ async function logoAccess(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/logo', logoAccess, (req, res, next) => {
|
||||
// @route POST /api/uploads/logo
|
||||
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
router.post('/logo', brandingAssetAccess, (req, res, next) => {
|
||||
uploadLogo.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadLogoImage);
|
||||
|
||||
// @route POST /api/uploads/favicon
|
||||
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
|
||||
uploadFavicon.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadFaviconImage);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -56,7 +56,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
|
||||
return;
|
||||
}
|
||||
const { transporter, cfg } = _getTransporter();
|
||||
const from = cfg.from || 'no-reply@hope-events.local';
|
||||
const from = cfg.from || 'no-reply@crosscode.local';
|
||||
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
|
||||
|
||||
if (transporter.options && transporter.options.jsonTransport) {
|
||||
@@ -74,12 +74,17 @@ async function sendMail({ to, subject, html, text, attachments }) {
|
||||
|
||||
function getOrg() {
|
||||
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
|
||||
// headerColor prefers the new primary_color setting; falls back to the
|
||||
// legacy accent_color value (which used to double as "the one brand color"
|
||||
// before the 3-color Primary/Secondary/Accent system existed) so emails
|
||||
// stay branded correctly until the admin re-saves the Branding tab.
|
||||
const primary = getSettingSync('primary_color', '') || getSettingSync('accent_color', '');
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
|
||||
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
|
||||
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
|
||||
headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'),
|
||||
headerColor: primary || process.env.EMAIL_HEADER_COLOR || '#1e3a5f',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,8 +142,9 @@ ${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Renders a prominent CTA button. */
|
||||
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
|
||||
/** Renders a prominent CTA button. Defaults to the org's brand color unless a semantic override (e.g. green for "activate", dark neutral for "log in") is passed explicitly. */
|
||||
function ctaButton(label, url, { bg, fg = '#ffffff' } = {}) {
|
||||
bg = bg || getOrg().headerColor;
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
|
||||
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
|
||||
<a href="${url}" target="_blank"
|
||||
@@ -150,8 +156,9 @@ function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
|
||||
|
||||
/** Renders a fallback link below a CTA button. */
|
||||
function fallbackLink(url) {
|
||||
const color = getOrg().headerColor;
|
||||
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
|
||||
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a>
|
||||
Or copy this link: <a href="${url}" style="color:${color}">${url}</a>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
@@ -160,7 +167,12 @@ function divider() {
|
||||
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
||||
}
|
||||
|
||||
/** Coloured callout box. type: info | success | warning | danger | neutral */
|
||||
/**
|
||||
* Coloured callout box. type: info | success | warning | danger | neutral
|
||||
* These 4 semantic colors are deliberately NOT brand-driven — a "this wasn't
|
||||
* you" security warning must always read as urgent/red regardless of the
|
||||
* org's brand color, so don't wire these to getOrg().headerColor.
|
||||
*/
|
||||
function callout(content, type = 'info') {
|
||||
const map = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
|
||||
@@ -177,9 +189,10 @@ function callout(content, type = 'info') {
|
||||
|
||||
/** Numbered payment option row. */
|
||||
function paymentOption(num, title, detail) {
|
||||
const bg = getOrg().headerColor;
|
||||
return `<tr>
|
||||
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
|
||||
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
|
||||
<div style="width:26px;height:26px;border-radius:50%;background:${bg};color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
|
||||
</td>
|
||||
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
|
||||
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
|
||||
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
|
||||
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -7,6 +7,9 @@ import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
||||
|
||||
@@ -97,11 +100,18 @@ function SiteSettingsPageInner() {
|
||||
const [appBaseUrl, setAppBaseUrl] = useState("");
|
||||
|
||||
// ── Branding ──
|
||||
const [accentColor, setAccentColor] = useState("#2563eb");
|
||||
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
|
||||
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
|
||||
const [accentColor, setAccentColor] = useState("#EC4899");
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [faviconUrl, setFaviconUrl] = useState("");
|
||||
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
||||
const faviconRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Notifications ──
|
||||
const [notifEmails, setNotifEmails] = useState("");
|
||||
@@ -135,8 +145,16 @@ function SiteSettingsPageInner() {
|
||||
setOrgPhone(s.org_phone || "");
|
||||
setOrgAddress(s.org_address || "");
|
||||
setAppBaseUrl(s.app_base_url || "");
|
||||
setAccentColor(s.accent_color || "#2563eb");
|
||||
// primary_color falls back to the legacy accent_color value (which used
|
||||
// to be "the one brand color" before this 3-color system existed).
|
||||
// accent_color is only trusted as the NEW tertiary color once
|
||||
// primary_color already exists — otherwise it's still doing legacy-
|
||||
// primary duty and must not also seed the Accent field.
|
||||
setPrimaryColor(s.primary_color || s.accent_color || "#4F46E5");
|
||||
setSecondaryColor(s.secondary_color || "#8B5CF6");
|
||||
setAccentColor(s.primary_color ? (s.accent_color || "#EC4899") : "#EC4899");
|
||||
setLogoUrl(s.logo_url || "");
|
||||
setFaviconUrl(s.favicon_url || "");
|
||||
setNotifEmails(s.reg_notification_emails || "");
|
||||
setSmtpHost(s.smtp_host || "");
|
||||
setSmtpPort(s.smtp_port || "587");
|
||||
@@ -183,31 +201,47 @@ function SiteSettingsPageInner() {
|
||||
app_base_url: appBaseUrl.trim(),
|
||||
});
|
||||
|
||||
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
|
||||
const data = await res.json();
|
||||
return data.url || "";
|
||||
};
|
||||
|
||||
const saveBranding = async () => {
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
let finalLogoUrl = logoUrl;
|
||||
if (logoFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("image", logoFile);
|
||||
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error("Logo upload failed");
|
||||
const data = await res.json();
|
||||
finalLogoUrl = data.url || logoUrl;
|
||||
}
|
||||
if (logoFile) finalLogoUrl = await uploadBrandingAsset(logoFile, "logo") || logoUrl;
|
||||
|
||||
let finalFaviconUrl = faviconUrl;
|
||||
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
|
||||
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT", authToken: token,
|
||||
body: { accent_color: accentColor, logo_url: finalLogoUrl },
|
||||
body: {
|
||||
primary_color: primaryColor,
|
||||
secondary_color: secondaryColor,
|
||||
accent_color: accentColor,
|
||||
logo_url: finalLogoUrl,
|
||||
favicon_url: finalFaviconUrl,
|
||||
},
|
||||
});
|
||||
setLogoUrl(finalLogoUrl);
|
||||
setLogoFile(null);
|
||||
setLogoPreview(null);
|
||||
setFaviconUrl(finalFaviconUrl);
|
||||
setFaviconFile(null);
|
||||
setFaviconPreview(null);
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
@@ -217,6 +251,39 @@ function SiteSettingsPageInner() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetBranding = async () => {
|
||||
if (!token) return;
|
||||
if (!confirm("Reset branding to defaults? This clears your custom colors, logo, and favicon.")) return;
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT", authToken: token,
|
||||
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
|
||||
});
|
||||
setPrimaryColor("#4F46E5");
|
||||
setSecondaryColor("#8B5CF6");
|
||||
setAccentColor("#EC4899");
|
||||
setLogoUrl(""); setLogoFile(null); setLogoPreview(null);
|
||||
setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null);
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Reset to defaults." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to reset" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoFileChange = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setLogoFile(file);
|
||||
const preview = URL.createObjectURL(file);
|
||||
setLogoPreview(preview);
|
||||
setSuggestedColors(await extractDominantColors(preview));
|
||||
};
|
||||
|
||||
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
|
||||
|
||||
const saveSmtp = async () => {
|
||||
@@ -279,9 +346,10 @@ function SiteSettingsPageInner() {
|
||||
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings…</div>;
|
||||
|
||||
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
|
||||
const currentFaviconSrc = faviconPreview || (faviconUrl ? resolveToApiOrigin(faviconUrl) : null);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<div className={activeTab === "branding" ? "max-w-6xl mx-auto w-full" : "max-w-3xl mx-auto w-full"}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -318,13 +386,14 @@ function SiteSettingsPageInner() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<div className={activeTab === "branding" ? "grid lg:grid-cols-5 gap-5 items-start" : ""}>
|
||||
<div className={`border rounded-xl p-5 bg-white shadow-sm ${activeTab === "branding" ? "lg:col-span-3" : ""}`}>
|
||||
{/* ── Organisation ─────────────────────────────────────────────────── */}
|
||||
{activeTab === "organisation" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Organisation details</h2>
|
||||
<Field label="Organisation name" required>
|
||||
<input className={inputCls} placeholder="Hope Family Church"
|
||||
<input className={inputCls} placeholder="Your Organisation"
|
||||
value={orgName} onChange={e => setOrgName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Tagline">
|
||||
@@ -357,15 +426,15 @@ function SiteSettingsPageInner() {
|
||||
{activeTab === "branding" && (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Branding</h2>
|
||||
<Field label="Accent / brand colour" hint="Only recolors the organisation name next to the logo in the top navigation bar — every other button, link, and highlight across the site uses a fixed color scheme.">
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<ColorPickerField label="Primary" hint="Buttons, links, nav highlights, and the email header/CTA."
|
||||
value={primaryColor} onChange={setPrimaryColor} />
|
||||
<ColorPickerField label="Secondary" hint="Subtle backgrounds and outline-button borders/text."
|
||||
value={secondaryColor} onChange={setSecondaryColor} />
|
||||
<ColorPickerField label="Accent" hint="Hover states and highlight chips/badges."
|
||||
value={accentColor} onChange={setAccentColor} />
|
||||
</div>
|
||||
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
|
||||
</Field>
|
||||
|
||||
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
|
||||
{currentLogoSrc && (
|
||||
@@ -375,21 +444,77 @@ function SiteSettingsPageInner() {
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
|
||||
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); setSuggestedColors([]); if (fileRef.current) fileRef.current.value = ""; }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={e => handleLogoFileChange(e.target.files?.[0])} className="text-sm" />
|
||||
{suggestedColors.length > 0 && (
|
||||
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
|
||||
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo — click a swatch to apply it</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedColors.map((hex) => (
|
||||
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
|
||||
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Favicon" hint="ICO, PNG, or SVG, max 2 MB, ideally square. Shown in the browser tab — falls back to the default if not set.">
|
||||
{currentFaviconSrc && (
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={currentFaviconSrc} alt="Current favicon" className="h-8 w-8 object-contain border rounded p-1 bg-gray-50" />
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
onClick={() => { setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null); if (faviconRef.current) faviconRef.current.value = ""; }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={faviconRef} type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setLogoFile(file);
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
setFaviconFile(file);
|
||||
setFaviconPreview(URL.createObjectURL(file));
|
||||
}} className="text-sm" />
|
||||
</Field>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveBranding} result={result} />
|
||||
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={resetBranding}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{result && (
|
||||
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
|
||||
{result.ok ? "✓" : "✗"} {result.message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={saveBranding}
|
||||
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -531,6 +656,20 @@ function SiteSettingsPageInner() {
|
||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
<div className="lg:col-span-2 lg:sticky lg:top-4">
|
||||
<BrandingPreviewPanel
|
||||
primaryColor={primaryColor}
|
||||
secondaryColor={secondaryColor}
|
||||
accentColor={accentColor}
|
||||
logoSrc={currentLogoSrc}
|
||||
orgName={orgName}
|
||||
orgTagline={orgTagline}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
|
||||
/*
|
||||
* Design tokens for shadcn/ui primitives (components.json: baseColor "slate",
|
||||
* cssVariables: true). Base scale is shadcn's stock "slate" theme; --primary
|
||||
* and --ring are overridden to the site's fixed indigo/purple brand color
|
||||
* (matches the `brand` scale in tailwind.config.js, brand-600 #4F46E5).
|
||||
* cssVariables: true). Base scale is shadcn's stock "slate" theme.
|
||||
*
|
||||
* IMPORTANT: the org's admin-configurable accent color
|
||||
* (SiteSettingsContext.settings.accent_color) must NEVER be wired into any
|
||||
* variable here or into any Tailwind color token. It is applied in exactly
|
||||
* one place — an inline `style={{ color }}` on the org name text in
|
||||
* Navbar.tsx — and nowhere else. Every other themed element (buttons, links,
|
||||
* active nav states, icon chips, the help button, etc.) uses the fixed
|
||||
* brand/primary tokens below regardless of what the org sets that setting to.
|
||||
* --primary/--primary-foreground/--ring/--secondary/--secondary-foreground/
|
||||
* --accent/--accent-foreground/--brand-50..900 below are DEFAULTS ONLY — used
|
||||
* before any branding setting has been saved, or if the server-side settings
|
||||
* fetch fails for a request. The real values come from the admin's saved
|
||||
* primary_color/secondary_color/accent_color settings, computed via
|
||||
* lib/colorScale.ts and injected per-request as a `<style id="brand-theme">`
|
||||
* override in layout.tsx (see buildThemeCssVars). This intentionally reverses
|
||||
* an earlier decision (commit 8e6cb542) that kept the accent color scoped to
|
||||
* a single inline style on the navbar's org-name text — branding now flows
|
||||
* through these same tokens site-wide and into outgoing emails.
|
||||
*/
|
||||
@layer base {
|
||||
:root {
|
||||
@@ -47,6 +48,18 @@
|
||||
--ring: 243 75% 59%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Fallback brand scale (matches the pre-branding-feature default indigo, brand-600 #4F46E5) */
|
||||
--brand-50: #EEF2FF;
|
||||
--brand-100: #E0E7FF;
|
||||
--brand-200: #C7D2FE;
|
||||
--brand-300: #A5B4FC;
|
||||
--brand-400: #818CF8;
|
||||
--brand-500: #6366F1;
|
||||
--brand-600: #4F46E5;
|
||||
--brand-700: #4338CA;
|
||||
--brand-800: #3730A3;
|
||||
--brand-900: #312E81;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
|
||||
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { ToastProvider } from "@/components/shared/ToastProvider";
|
||||
import { BannerBar } from "@/components/shared/BannerBar";
|
||||
import { SetupGuard } from "@/components/shared/SetupGuard";
|
||||
import HelpFab from "@/components/shared/HelpFab";
|
||||
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -18,21 +21,54 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events';
|
||||
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
/**
|
||||
* Server-side settings fetch, shared by generateMetadata() and RootLayout()
|
||||
* below. Both call this with the identical URL + options so Next's per-request
|
||||
* fetch memoization dedupes them into a single network call. Degrades to `{}`
|
||||
* (static defaults everywhere downstream) if the backend is unreachable at
|
||||
* request time — intentional, not a bug: branding just isn't critical enough
|
||||
* to fail the whole page render over.
|
||||
*/
|
||||
async function getServerSettings(): Promise<SiteSettings> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getServerSettings();
|
||||
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
||||
|
||||
return {
|
||||
title: appName,
|
||||
description: `Manage and register for events with ${appName}`,
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
icon: faviconUrl || "/favicon.ico",
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const settings = await getServerSettings();
|
||||
const cssVars = buildThemeCssVars({
|
||||
primary: settings.primary_color || settings.accent_color,
|
||||
secondary: settings.secondary_color,
|
||||
accent: settings.primary_color ? settings.accent_color : undefined,
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<SiteSettingsProvider>
|
||||
{cssVars && (
|
||||
<style id="brand-theme" dangerouslySetInnerHTML={{ __html: `:root{${cssVars}}` }} />
|
||||
)}
|
||||
<SiteSettingsProvider initialSettings={settings}>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<SetupGuard />
|
||||
|
||||
@@ -27,12 +27,19 @@ export default function PrivacyPolicyPage() {
|
||||
const activeId = useScrollSpy(sections.map((s) => s.id));
|
||||
const { settings } = useSiteSettings();
|
||||
|
||||
const orgName = settings.org_name || "Hope Family Church";
|
||||
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
|
||||
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
|
||||
const ioName = settings.legal_io_name || "Joshua Oosthuizen";
|
||||
const ioEmail = settings.legal_io_email || "joshua@crosscode.co.za";
|
||||
const effectiveDate = settings.legal_effective_date || "April 2026";
|
||||
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
|
||||
// required field at setup — this fallback is practically unreachable in production.
|
||||
const orgName = settings.org_name || "Cross Code";
|
||||
// The fields below are all OPTIONAL legal-specific settings that can realistically
|
||||
// stay unset on a live site. Defaulting them to a real person/org's identity would be
|
||||
// actively misleading on someone else's deployment, so these are generic placeholders
|
||||
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
|
||||
// silently showing Cross Code's or any other real organisation's details.
|
||||
const orgEmail = settings.org_email || "privacy@example.com";
|
||||
const websiteUrl = settings.legal_website_url || "example.com";
|
||||
const ioName = settings.legal_io_name || "[not yet configured]";
|
||||
const ioEmail = settings.legal_io_email || "io@example.com";
|
||||
const effectiveDate = settings.legal_effective_date || "[not yet set]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||
|
||||
@@ -26,11 +26,18 @@ export default function TermsOfUsePage() {
|
||||
const activeId = useScrollSpy(sections.map((s) => s.id));
|
||||
const { settings } = useSiteSettings();
|
||||
|
||||
const orgName = settings.org_name || "Hope Family Church";
|
||||
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
|
||||
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
|
||||
const operatorName = settings.legal_operator_name || "Joshua Oosthuizen on behalf of Hope Family Church, Henley-on-Klip, South Africa";
|
||||
const effectiveDate = settings.legal_effective_date || "April 2026";
|
||||
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
|
||||
// required field at setup — this fallback is practically unreachable in production.
|
||||
const orgName = settings.org_name || "Cross Code";
|
||||
// The fields below are all OPTIONAL legal-specific settings that can realistically
|
||||
// stay unset on a live site. Defaulting them to a real person/org's identity would be
|
||||
// actively misleading on someone else's deployment, so these are generic placeholders
|
||||
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
|
||||
// silently showing Cross Code's or any other real organisation's details.
|
||||
const orgEmail = settings.org_email || "privacy@example.com";
|
||||
const websiteUrl = settings.legal_website_url || "example.com";
|
||||
const operatorName = settings.legal_operator_name || "[operator details not yet configured]";
|
||||
const effectiveDate = settings.legal_effective_date || "[not yet set]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -68,9 +71,13 @@ export default function SetupPage() {
|
||||
const [adminConfirm, setAdminConfirm] = useState("");
|
||||
|
||||
// Step 3 — Branding
|
||||
const [accentColor, setAccentColor] = useState("#2563eb");
|
||||
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
|
||||
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
|
||||
const [accentColor, setAccentColor] = useState("#EC4899");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
|
||||
const [notifEmail, setNotifEmail] = useState("");
|
||||
|
||||
// Step 4 — Legal
|
||||
@@ -98,11 +105,19 @@ export default function SetupPage() {
|
||||
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
|
||||
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setLogoFile(file);
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
const preview = URL.createObjectURL(file);
|
||||
setLogoPreview(preview);
|
||||
setSuggestedColors(await extractDominantColors(preview));
|
||||
};
|
||||
|
||||
const handleFaviconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setFaviconFile(file);
|
||||
};
|
||||
|
||||
// ── Step validators ───────────────────────────────────────────────────────
|
||||
@@ -171,19 +186,47 @@ export default function SetupPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
// The admin account already exists by this point (created in step 2),
|
||||
// so the branding-asset upload endpoints require admin auth — the
|
||||
// setupToken from /api/setup/register covers that.
|
||||
let logoUrl = "";
|
||||
if (logoFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("image", logoFile);
|
||||
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, { method: "POST", body: fd });
|
||||
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, {
|
||||
method: "POST",
|
||||
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
|
||||
body: fd,
|
||||
});
|
||||
if (uploadRes.ok) {
|
||||
const up = await uploadRes.json();
|
||||
logoUrl = up.url || "";
|
||||
} else {
|
||||
throw new Error("Logo upload failed. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
let faviconUrl = "";
|
||||
if (faviconFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("image", faviconFile);
|
||||
const uploadRes = await fetch(`${API_BASE}/api/uploads/favicon`, {
|
||||
method: "POST",
|
||||
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
|
||||
body: fd,
|
||||
});
|
||||
if (uploadRes.ok) {
|
||||
const up = await uploadRes.json();
|
||||
faviconUrl = up.url || "";
|
||||
} else {
|
||||
throw new Error("Favicon upload failed. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
const settings: Record<string, string> = {
|
||||
org_name: orgName.trim(),
|
||||
primary_color: primaryColor,
|
||||
secondary_color: secondaryColor,
|
||||
accent_color: accentColor,
|
||||
};
|
||||
if (orgTagline.trim()) settings.org_tagline = orgTagline.trim();
|
||||
@@ -193,6 +236,7 @@ export default function SetupPage() {
|
||||
if (appBaseUrl.trim()) settings.app_base_url = appBaseUrl.trim();
|
||||
if (notifEmail.trim()) settings.reg_notification_emails = notifEmail.trim();
|
||||
if (logoUrl) settings.logo_url = logoUrl;
|
||||
if (faviconUrl) settings.favicon_url = faviconUrl;
|
||||
// Legal
|
||||
if (legalOperatorName.trim()) settings.legal_operator_name = legalOperatorName.trim();
|
||||
if (legalWebsiteUrl.trim()) settings.legal_website_url = legalWebsiteUrl.trim();
|
||||
@@ -225,7 +269,7 @@ export default function SetupPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-brand-50 to-brand-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-lg p-8">
|
||||
<div className={`bg-white rounded-2xl shadow-lg w-full p-8 transition-all ${step === 3 ? "max-w-4xl" : "max-w-lg"}`}>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="text-center mb-6">
|
||||
@@ -255,7 +299,7 @@ export default function SetupPage() {
|
||||
</div>
|
||||
|
||||
<Field label="Organisation name" required>
|
||||
<input className={inputCls} placeholder="e.g. Hope Family Church"
|
||||
<input className={inputCls} placeholder="e.g. Your Organisation"
|
||||
value={orgName} onChange={e => setOrgName(e.target.value)} autoFocus />
|
||||
</Field>
|
||||
<Field label="Tagline">
|
||||
@@ -331,21 +375,38 @@ export default function SetupPage() {
|
||||
<p className="text-sm text-gray-500">Customise the look of your site. You can change this later.</p>
|
||||
</div>
|
||||
|
||||
<Field label="Accent / brand colour">
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
<input className={`${inputCls} font-mono`} value={accentColor}
|
||||
onChange={e => setAccentColor(e.target.value)} placeholder="#2563eb" />
|
||||
<div className="grid lg:grid-cols-5 gap-6 items-start">
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<ColorPickerField label="Primary" value={primaryColor} onChange={setPrimaryColor} />
|
||||
<ColorPickerField label="Secondary" value={secondaryColor} onChange={setSecondaryColor} />
|
||||
<ColorPickerField label="Accent" value={accentColor} onChange={setAccentColor} />
|
||||
</div>
|
||||
<div className="mt-2 h-8 rounded-lg" style={{ background: accentColor }} />
|
||||
</Field>
|
||||
|
||||
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
|
||||
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
|
||||
{logoPreview && (
|
||||
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
|
||||
)}
|
||||
{suggestedColors.length > 0 && (
|
||||
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
|
||||
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo — click a swatch to apply it</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedColors.map((hex) => (
|
||||
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
|
||||
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Favicon (optional)" hint="ICO, PNG, or SVG, max 2 MB, ideally square. If skipped, the default favicon is used.">
|
||||
<input type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={handleFaviconChange} className="text-sm" />
|
||||
</Field>
|
||||
|
||||
<Field label="Registration notification email"
|
||||
@@ -353,6 +414,19 @@ export default function SetupPage() {
|
||||
<input type="email" className={inputCls} placeholder="registrations@yourchurch.org"
|
||||
value={notifEmail} onChange={e => setNotifEmail(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<BrandingPreviewPanel
|
||||
primaryColor={primaryColor}
|
||||
secondaryColor={secondaryColor}
|
||||
accentColor={accentColor}
|
||||
logoSrc={logoPreview}
|
||||
orgName={orgName}
|
||||
orgTagline={orgTagline}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-between">
|
||||
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100">← Back</button>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Monitor, Smartphone, RefreshCw } from "lucide-react";
|
||||
import { buildBrandScale, buildSecondaryTokens, buildAccentTokens, contrastForeground, isValidHex } from "@/lib/colorScale";
|
||||
|
||||
type View = "website" | "email";
|
||||
type Viewport = "desktop" | "mobile";
|
||||
|
||||
/**
|
||||
* Hand-built mockup (not an iframe of the real site) so it can reflect
|
||||
* in-progress, unsaved color-picker values instantly — the real site's CSS
|
||||
* variables only update once the admin actually saves (see layout.tsx's SSR
|
||||
* style tag), and this preview must not leak into the surrounding admin
|
||||
* page's own theme while the admin is still experimenting with colors.
|
||||
*/
|
||||
export function BrandingPreviewPanel({
|
||||
primaryColor, secondaryColor, accentColor, logoSrc, orgName, orgTagline,
|
||||
}: {
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
accentColor: string;
|
||||
logoSrc: string | null;
|
||||
orgName: string;
|
||||
orgTagline?: string;
|
||||
}) {
|
||||
const [view, setView] = useState<View>("website");
|
||||
const [viewport, setViewport] = useState<Viewport>("desktop");
|
||||
|
||||
const safePrimary = isValidHex(primaryColor) ? primaryColor : "#4F46E5";
|
||||
const safeSecondary = isValidHex(secondaryColor) ? secondaryColor : "#8B5CF6";
|
||||
const safeAccent = isValidHex(accentColor) ? accentColor : "#EC4899";
|
||||
|
||||
const scale = useMemo(() => buildBrandScale(safePrimary), [safePrimary]);
|
||||
const secondary = useMemo(() => buildSecondaryTokens(safeSecondary), [safeSecondary]);
|
||||
const accent = useMemo(() => buildAccentTokens(safeAccent), [safeAccent]);
|
||||
const primaryFg = useMemo(() => contrastForeground(safePrimary), [safePrimary]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl bg-white shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Live Preview</h3>
|
||||
<p className="text-xs text-gray-500">See how your branding looks across the site.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center rounded-lg border bg-white overflow-hidden">
|
||||
<button type="button" onClick={() => setView("website")}
|
||||
className={`px-2.5 py-1.5 text-xs font-medium ${view === "website" ? "bg-brand-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}>
|
||||
Website
|
||||
</button>
|
||||
<button type="button" onClick={() => setView("email")}
|
||||
className={`px-2.5 py-1.5 text-xs font-medium ${view === "email" ? "bg-brand-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
{view === "website" && (
|
||||
<div className="flex items-center rounded-lg border bg-white overflow-hidden">
|
||||
<button type="button" onClick={() => setViewport("desktop")} title="Desktop"
|
||||
className={`p-1.5 ${viewport === "desktop" ? "bg-gray-200" : "text-gray-500 hover:bg-gray-100"}`}>
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setViewport("mobile")} title="Mobile"
|
||||
className={`p-1.5 ${viewport === "mobile" ? "bg-gray-200" : "text-gray-500 hover:bg-gray-100"}`}>
|
||||
<Smartphone className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-100 flex justify-center overflow-x-auto">
|
||||
{view === "website" ? (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md overflow-hidden transition-all"
|
||||
style={{ width: viewport === "mobile" ? 320 : "100%", maxWidth: viewport === "mobile" ? 320 : 640 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b">
|
||||
{logoSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoSrc} alt="Logo" className="h-6 w-6 object-contain rounded-sm" />
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-sm shrink-0" style={{ background: scale["600"] }} />
|
||||
)}
|
||||
<span className="text-sm font-bold truncate" style={{ color: scale["600"] }}>{orgName || "Your Organisation"}</span>
|
||||
<div className="ml-auto flex items-center gap-2 text-[10px] text-gray-400">
|
||||
<span>Home</span><span>Events</span><span>Contact</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="px-4 py-6 text-center" style={{ background: `hsl(${secondary.bg})` }}>
|
||||
<p className="text-base font-bold text-gray-900">Welcome to {orgName || "Your Organisation"}</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1 mb-4">Experience unforgettable moments.</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg"
|
||||
style={{ background: scale["600"], color: primaryFg }}
|
||||
>
|
||||
View Events
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg border bg-white"
|
||||
style={{ borderColor: `hsl(${secondary.fg})`, color: `hsl(${secondary.fg})` }}
|
||||
>
|
||||
My Dashboard
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming events */}
|
||||
<div className="px-4 py-4">
|
||||
<p className="text-xs font-semibold text-gray-900 mb-2">Upcoming events</p>
|
||||
<div className="space-y-2">
|
||||
{["Sunday Service", "Community Outreach"].map((title) => (
|
||||
<div key={title} className="flex items-center gap-2 border rounded-lg p-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: `hsl(${accent.fg})` }}
|
||||
/>
|
||||
<span className="text-[11px] text-gray-700 truncate">{title}</span>
|
||||
<span
|
||||
className="ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded"
|
||||
style={{ background: `hsl(${accent.bg})`, color: `hsl(${accent.fg})` }}
|
||||
>
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — fixed dark neutral, not brand-driven */}
|
||||
<div className="px-4 py-4 bg-slate-900 text-center">
|
||||
<p className="text-[10px] text-slate-400">© {new Date().getFullYear()} {orgName || "Your Organisation"}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden w-full max-w-md">
|
||||
<div
|
||||
className="px-6 py-8 text-center"
|
||||
style={{ background: `linear-gradient(135deg, ${scale["600"]} 0%, #2d5287 100%)` }}
|
||||
>
|
||||
<p className="text-lg font-extrabold text-white">{orgName || "Your Organisation"}</p>
|
||||
{orgTagline && <p className="text-xs text-white/90 mt-1">{orgTagline}</p>}
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-sm font-bold text-gray-900 mb-1">Sample notification</p>
|
||||
<div className="h-2 bg-gray-100 rounded w-full mb-1.5" />
|
||||
<div className="h-2 bg-gray-100 rounded w-5/6 mb-4" />
|
||||
<div className="flex justify-center">
|
||||
<span
|
||||
className="text-xs font-bold px-6 py-2.5 rounded-lg"
|
||||
style={{ background: scale["600"], color: primaryFg }}
|
||||
>
|
||||
Call to action
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t bg-gray-50 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Updates live as you change colors — save to apply site-wide.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { isValidHex } from "@/lib/colorScale";
|
||||
|
||||
/** A native color swatch + hex text input, shared by the admin Branding tab and the setup wizard's Branding step. */
|
||||
export function ColorPickerField({
|
||||
label, hint, value, onChange,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (hex: string) => void;
|
||||
}) {
|
||||
const valid = isValidHex(value);
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
className="h-10 w-20 border rounded cursor-pointer shrink-0"
|
||||
value={valid ? value : "#000000"}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={`w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-400 ${valid ? "" : "border-red-300"}`}
|
||||
placeholder="#2563eb"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 h-6 rounded-lg transition-colors border" style={{ background: valid ? value : "#f1f5f9" }} />
|
||||
{!valid && <p className="text-xs text-red-500 mt-1">Enter a valid hex color, e.g. #4F46E5</p>}
|
||||
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BRAND_600 } from "@/lib/theme";
|
||||
import { useBrandColorVar } from "@/lib/theme";
|
||||
|
||||
export type TrendDatum = { label: string; value: number };
|
||||
|
||||
@@ -33,6 +33,10 @@ export function AreaTrendChart({
|
||||
}) {
|
||||
const fmt = valueFormatter || ((v: number) => String(v));
|
||||
const axisFmt = axisFormatter || defaultAxisFormatter;
|
||||
// Raw SVG needs a literal hex value — reads the live admin-configurable
|
||||
// primary color off the CSS custom property (see lib/theme.ts). Called
|
||||
// before the early return below so hook order stays stable.
|
||||
const brandColor = useBrandColorVar("--brand-600");
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="text-xs text-gray-400">No data to chart.</div>;
|
||||
@@ -75,8 +79,8 @@ export function AreaTrendChart({
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="w-full h-auto" role="img" aria-label="Trend chart">
|
||||
<defs>
|
||||
<linearGradient id="areaTrendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={BRAND_600} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={BRAND_600} stopOpacity="0" />
|
||||
<stop offset="0%" stopColor={brandColor} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={brandColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{gridlines.map((g, i) => (
|
||||
@@ -88,9 +92,9 @@ export function AreaTrendChart({
|
||||
</g>
|
||||
))}
|
||||
<path d={areaPath} fill="url(#areaTrendFill)" stroke="none" />
|
||||
<path d={linePath} fill="none" stroke={BRAND_600} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
<path d={linePath} fill="none" stroke={brandColor} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={BRAND_600} strokeWidth={2} />
|
||||
<circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={brandColor} strokeWidth={2} />
|
||||
))}
|
||||
</svg>
|
||||
<div className="flex justify-between mt-1 ml-12 text-[10px] text-gray-400">
|
||||
|
||||
@@ -4,14 +4,14 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
|
||||
import { BRAND_600 } from "@/lib/theme";
|
||||
import { useBrandColorVar } from "@/lib/theme";
|
||||
|
||||
export const BottomNav = () => {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const pathname = usePathname();
|
||||
// Fixed brand color — unlike the navbar's org name, nothing here is
|
||||
// admin-configurable (see globals.css for the rule).
|
||||
const accentColor = BRAND_600;
|
||||
// Raw SVG needs a literal hex value — reads the live admin-configurable
|
||||
// primary color off the CSS custom property (see lib/theme.ts).
|
||||
const accentColor = useBrandColorVar("--brand-600");
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
import churchLogo from "@/app/church_logo.jpg";
|
||||
import { appName, brandColor } from "@/lib/siteConfig";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { resolveToApiOrigin } from "@/lib/api";
|
||||
|
||||
type NavLink = {
|
||||
@@ -20,11 +20,7 @@ export const Navbar = () => {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { settings, loading: settingsLoading } = useSiteSettings();
|
||||
const pathname = usePathname();
|
||||
// Admin-configurable — applied ONLY to the org name text below via inline
|
||||
// style. No other element in the navbar (or anywhere else) should read
|
||||
// this; everything else uses the fixed brand-* palette.
|
||||
const displayName = settings.org_name || appName;
|
||||
const displayColor = settings.accent_color || brandColor;
|
||||
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
|
||||
|
||||
const isActive = (href: string) => {
|
||||
@@ -79,7 +75,7 @@ export const Navbar = () => {
|
||||
/>
|
||||
)}
|
||||
{!settingsLoading && (
|
||||
<span className="text-xl font-bold" style={{ color: displayColor }}>{displayName}</span>
|
||||
<span className="text-xl font-bold text-brand-600">{displayName}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -9,8 +9,14 @@ export type SiteSettings = {
|
||||
org_email?: string;
|
||||
org_phone?: string;
|
||||
org_address?: string;
|
||||
// Branding — 3-color system. accent_color is the tertiary/highlight color;
|
||||
// primary_color falls back to accent_color's legacy value if unset (see
|
||||
// settingsController.js PUBLIC_KEYS comment).
|
||||
primary_color?: string;
|
||||
secondary_color?: string;
|
||||
accent_color?: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
setup_complete?: string;
|
||||
// Legal pages
|
||||
legal_operator_name?: string;
|
||||
@@ -32,9 +38,15 @@ const SiteSettingsContext = createContext<SiteSettingsContextValue>({
|
||||
reload: () => {},
|
||||
});
|
||||
|
||||
export function SiteSettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [settings, setSettings] = useState<SiteSettings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
export function SiteSettingsProvider({
|
||||
children, initialSettings,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Server-fetched settings from layout.tsx's generateMetadata/RootLayout — seeds state so there's no loading flash for anything reading useSiteSettings() (e.g. the navbar logo). */
|
||||
initialSettings?: SiteSettings;
|
||||
}) {
|
||||
const [settings, setSettings] = useState<SiteSettings>(initialSettings || {});
|
||||
const [loading, setLoading] = useState(!initialSettings);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -47,6 +59,9 @@ export function SiteSettingsProvider({ children }: { children: React.ReactNode }
|
||||
}
|
||||
};
|
||||
|
||||
// Always refreshes in the background (cheap, 60s-cached server-side) even
|
||||
// when seeded from initialSettings — self-heals if the SSR fetch in
|
||||
// layout.tsx failed, and picks up any change since that request.
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Framework-agnostic color math for the admin-configurable brand system.
|
||||
* No DOM/window/document access — this module runs identically server-side
|
||||
* (the SSR `<style id="brand-theme">` tag in layout.tsx) and client-side
|
||||
* (the live Branding Preview panel, recomputing on every color-picker
|
||||
* keystroke before the admin has saved anything).
|
||||
*/
|
||||
|
||||
export type Hsl = { h: number; s: number; l: number };
|
||||
|
||||
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
||||
|
||||
export function isValidHex(v: string): boolean {
|
||||
return HEX_RE.test((v || "").trim());
|
||||
}
|
||||
|
||||
/** Expands 3-digit hex to 6-digit, lowercases, ensures a leading #. */
|
||||
function normalizeHex(hex: string): string {
|
||||
let h = (hex || "").trim();
|
||||
if (!h.startsWith("#")) h = `#${h}`;
|
||||
if (h.length === 4) {
|
||||
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
|
||||
}
|
||||
return h.toLowerCase();
|
||||
}
|
||||
|
||||
export function hexToHsl(hex: string): Hsl {
|
||||
const h6 = normalizeHex(hex);
|
||||
const r = parseInt(h6.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(h6.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(h6.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) return { h: 0, s: 0, l: l * 100 };
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let hue: number;
|
||||
switch (max) {
|
||||
case r: hue = ((g - b) / d + (g < b ? 6 : 0)); break;
|
||||
case g: hue = (b - r) / d + 2; break;
|
||||
default: hue = (r - g) / d + 4; break;
|
||||
}
|
||||
hue *= 60;
|
||||
|
||||
return { h: hue, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
export function hslToHex(h: number, s: number, l: number): string {
|
||||
const hh = ((h % 360) + 360) % 360;
|
||||
const ss = clamp01(s / 100);
|
||||
const ll = clamp01(l / 100);
|
||||
|
||||
if (ss === 0) {
|
||||
const v = Math.round(ll * 255);
|
||||
return rgbToHex(v, v, v);
|
||||
}
|
||||
|
||||
const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
|
||||
const p = 2 * ll - q;
|
||||
const r = hueToRgb(p, q, hh / 360 + 1 / 3);
|
||||
const g = hueToRgb(p, q, hh / 360);
|
||||
const b = hueToRgb(p, q, hh / 360 - 1 / 3);
|
||||
|
||||
return rgbToHex(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255));
|
||||
}
|
||||
|
||||
function hueToRgb(p: number, q: number, t: number): number {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const toHex = (v: number) => clampByte(v).toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
function clampByte(v: number): number {
|
||||
return Math.min(255, Math.max(0, Math.round(v)));
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return Math.min(1, Math.max(0, v));
|
||||
}
|
||||
|
||||
export function clampL(l: number): number {
|
||||
return Math.min(100, Math.max(0, l));
|
||||
}
|
||||
|
||||
/** Rounded "H S% L%" triple matching this codebase's existing shadcn CSS-variable convention (no hsl() wrapper — see globals.css). */
|
||||
export function hexToHslTriple(hex: string): string {
|
||||
const { h, s, l } = hexToHsl(hex);
|
||||
return `${Math.round(h)} ${Math.round(s)}% ${Math.round(l)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG-style relative luminance → picks readable foreground text/icon color
|
||||
* for a solid fill of `hex`. Used for Primary (a solid button/nav fill needs
|
||||
* real contrast, unlike Secondary/Accent which stay light tints — see
|
||||
* buildSecondaryTokens/buildAccentTokens).
|
||||
*/
|
||||
export function contrastForeground(hex: string): "#ffffff" | "#0a0a0a" {
|
||||
const h6 = normalizeHex(hex);
|
||||
const chan = (v: number) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
const r = chan(parseInt(h6.slice(1, 3), 16));
|
||||
const g = chan(parseInt(h6.slice(3, 5), 16));
|
||||
const b = chan(parseInt(h6.slice(5, 7), 16));
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return luminance > 0.45 ? "#0a0a0a" : "#ffffff";
|
||||
}
|
||||
|
||||
/** Matches the shadcn-token foreground convention used elsewhere in globals.css (near-white / near-black HSL triples, not pure #fff/#000). */
|
||||
export function contrastForegroundTriple(hex: string): string {
|
||||
return contrastForeground(hex) === "#ffffff" ? "210 40% 98%" : "222.2 47.4% 11.2%";
|
||||
}
|
||||
|
||||
const BRAND_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
|
||||
export type BrandScaleStep = (typeof BRAND_SCALE_STEPS)[number];
|
||||
|
||||
/** Lightness offsets (percentage points) applied around the picked color, which is anchored exactly at step 600 — matches the site's pre-existing default (brand-600 #4F46E5). */
|
||||
const BRAND_SCALE_OFFSETS: Record<BrandScaleStep, number> = {
|
||||
"50": 38, "100": 32, "200": 24, "300": 16, "400": 8,
|
||||
"500": 4, "600": 0, "700": -8, "800": -16, "900": -24,
|
||||
};
|
||||
|
||||
/** Generates the full brand-50..900 hex scale from one admin-picked "Primary" hex, anchored at 600. */
|
||||
export function buildBrandScale(primaryHex: string): Record<BrandScaleStep, string> {
|
||||
const { h, s, l } = hexToHsl(primaryHex);
|
||||
const scale = {} as Record<BrandScaleStep, string>;
|
||||
for (const step of BRAND_SCALE_STEPS) {
|
||||
if (step === "600") {
|
||||
scale[step] = normalizeHex(primaryHex);
|
||||
} else {
|
||||
scale[step] = hslToHex(h, s, clampL(l + BRAND_SCALE_OFFSETS[step]));
|
||||
}
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary/Accent both stay light background washes (not bold fills) so
|
||||
* they read the way shadcn's own default `--secondary`/`--accent` tokens do
|
||||
* (subtle hover/outline surfaces, e.g. Button's `secondary`/`outline`/`ghost`
|
||||
* variants) regardless of what hue the admin picks. Lightness is PINNED to a
|
||||
* fixed target rather than offset from the input (like the primary scale is)
|
||||
* — a relative offset would let a pastel input wash out to near-invisible.
|
||||
* Saturation is capped so the tint doesn't read as a neon pastel.
|
||||
*/
|
||||
function buildLightTintTokens(hex: string): { bg: string; fg: string } {
|
||||
const { h, s } = hexToHsl(hex);
|
||||
const cappedS = Math.min(s, 60);
|
||||
const bg = `${Math.round(h)} ${Math.round(cappedS)}% 96%`;
|
||||
const fg = hexToHslTriple(hex);
|
||||
return { bg, fg };
|
||||
}
|
||||
|
||||
export function buildSecondaryTokens(secondaryHex: string): { bg: string; fg: string } {
|
||||
return buildLightTintTokens(secondaryHex);
|
||||
}
|
||||
|
||||
export function buildAccentTokens(accentHex: string): { bg: string; fg: string } {
|
||||
return buildLightTintTokens(accentHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full CSS custom-property declaration block (no `:root{}` wrapper
|
||||
* — callers embed this string themselves) for the 3-color brand system.
|
||||
* Any input left undefined/invalid is simply omitted, so callers can pass a
|
||||
* partial set (e.g. only Primary saved so far) without emitting broken CSS.
|
||||
*/
|
||||
export function buildThemeCssVars(input: { primary?: string; secondary?: string; accent?: string }): string {
|
||||
const decls: string[] = [];
|
||||
|
||||
if (input.primary && isValidHex(input.primary)) {
|
||||
const scale = buildBrandScale(input.primary);
|
||||
for (const step of BRAND_SCALE_STEPS) {
|
||||
decls.push(`--brand-${step}:${scale[step]}`);
|
||||
}
|
||||
decls.push(`--primary:${hexToHslTriple(input.primary)}`);
|
||||
decls.push(`--primary-foreground:${contrastForegroundTriple(input.primary)}`);
|
||||
decls.push(`--ring:${hexToHslTriple(input.primary)}`);
|
||||
}
|
||||
|
||||
if (input.secondary && isValidHex(input.secondary)) {
|
||||
const { bg, fg } = buildSecondaryTokens(input.secondary);
|
||||
decls.push(`--secondary:${bg}`);
|
||||
decls.push(`--secondary-foreground:${fg}`);
|
||||
}
|
||||
|
||||
if (input.accent && isValidHex(input.accent)) {
|
||||
const { bg, fg } = buildAccentTokens(input.accent);
|
||||
decls.push(`--accent:${bg}`);
|
||||
decls.push(`--accent-foreground:${fg}`);
|
||||
}
|
||||
|
||||
return decls.join(";");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Client-only (canvas + Image) — extracts the dominant, non-background colors
|
||||
* from a locally-selected logo file, to surface as "suggested" swatches in
|
||||
* the admin Branding tab. Runs off a local blob URL (URL.createObjectURL),
|
||||
* never a remote URL, so there's no cross-origin/tainted-canvas concern.
|
||||
*/
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Could not load image"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const toHex = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscales the image to a small canvas and buckets pixels by a coarse RGB
|
||||
* quantization (histogram approach — cheap enough at this resolution that a
|
||||
* real k-means isn't needed). Near-white/near-black/low-saturation pixels are
|
||||
* discarded since those are almost always logo whitespace/background, not
|
||||
* the brand color someone would want suggested.
|
||||
*/
|
||||
export async function extractDominantColors(imageSrc: string, maxColors = 5): Promise<string[]> {
|
||||
if (typeof document === "undefined") return [];
|
||||
|
||||
let img: HTMLImageElement;
|
||||
try {
|
||||
img = await loadImage(imageSrc);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const size = 50;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return [];
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
|
||||
let data: Uint8ClampedArray;
|
||||
try {
|
||||
data = ctx.getImageData(0, 0, size, size).data;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const BIN = 24; // quantization step per channel — coarse enough to merge near-duplicate shades, fine enough to keep distinct hues apart
|
||||
const buckets = new Map<string, { count: number; r: number; g: number; b: number }>();
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
|
||||
if (a < 200) continue; // transparent/near-transparent — not a real pixel
|
||||
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const lightness = (max + min) / 2 / 255;
|
||||
const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
|
||||
if (lightness > 0.92 || lightness < 0.08) continue; // near-white / near-black
|
||||
if (sat < 0.15) continue; // low-saturation gray
|
||||
|
||||
const key = `${Math.round(r / BIN)}_${Math.round(g / BIN)}_${Math.round(b / BIN)}`;
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.count++;
|
||||
bucket.r += r; bucket.g += g; bucket.b += b;
|
||||
} else {
|
||||
buckets.set(key, { count: 1, r, g, b });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, maxColors)
|
||||
.map(b => rgbToHex(b.r / b.count, b.g / b.count, b.b / b.count));
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
* Site-wide configuration sourced from environment variables.
|
||||
* Set these in your .env file (NEXT_PUBLIC_ prefix required for browser access).
|
||||
*/
|
||||
export const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events';
|
||||
export const orgName = process.env.NEXT_PUBLIC_ORG_NAME || 'Hope Family Church';
|
||||
export const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'admin@hopehenley.co.za';
|
||||
export const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://events.hopehenley.co.za';
|
||||
export const brandColor = process.env.NEXT_PUBLIC_BRAND_COLOR || '#2563eb';
|
||||
export const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
|
||||
export const orgName = process.env.NEXT_PUBLIC_ORG_NAME || 'Cross Code';
|
||||
export const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'admin@crosscode.co.za';
|
||||
export const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://crosscode.co.za';
|
||||
+28
-18
@@ -1,20 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hex mirrors of the `brand` scale in tailwind.config.js, for the rare spot
|
||||
* that needs a literal color value instead of a Tailwind class (SVG
|
||||
* stroke/fill, canvas, etc.). Keep these in sync with tailwind.config.js by
|
||||
* hand — there are few enough call sites that a build-time sync step isn't
|
||||
* worth it.
|
||||
*
|
||||
* Do NOT use these for the org name/heading text — that stays bound to
|
||||
* SiteSettingsContext.settings.accent_color, not the fixed brand palette.
|
||||
* Static fallback for BRAND_600 — used as the initial value before the
|
||||
* client can read the live CSS custom property (see useBrandColorVar below),
|
||||
* and as the SSR/first-paint value. Keep in sync with globals.css's
|
||||
* `--brand-600` default.
|
||||
*/
|
||||
export const BRAND_50 = "#EEF2FF";
|
||||
export const BRAND_100 = "#E0E7FF";
|
||||
export const BRAND_200 = "#C7D2FE";
|
||||
export const BRAND_300 = "#A5B4FC";
|
||||
export const BRAND_400 = "#818CF8";
|
||||
export const BRAND_500 = "#6366F1";
|
||||
export const BRAND_600 = "#4F46E5";
|
||||
export const BRAND_700 = "#4338CA";
|
||||
export const BRAND_800 = "#3730A3";
|
||||
export const BRAND_900 = "#312E81";
|
||||
const BRAND_600_FALLBACK = "#4F46E5";
|
||||
|
||||
/**
|
||||
* Reads a live brand CSS custom property (e.g. `--brand-600`) off the
|
||||
* document root. Needed by the rare spot that requires a literal color value
|
||||
* instead of a Tailwind class (SVG stroke/fill, canvas, etc.) — Tailwind
|
||||
* classes like `text-brand-600` pick up the admin-configurable primary color
|
||||
* automatically via CSS variables (see tailwind.config.js), but a literal
|
||||
* hex string has to be read explicitly since it can't reference `var()`.
|
||||
*/
|
||||
export function useBrandColorVar(cssVar: string, fallback: string = BRAND_600_FALLBACK): string {
|
||||
const [color, setColor] = useState(fallback);
|
||||
|
||||
useEffect(() => {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
|
||||
if (value) setColor(value);
|
||||
}, [cssVar]);
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
+17
-15
@@ -39,22 +39,24 @@ module.exports = {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
// Fixed indigo/purple brand scale — the site's hard-coded design-system
|
||||
// color. Use this (not `indigo-*`/`blue-*` literals) for anything that
|
||||
// isn't a shadcn primitive: nav active-states, icon chips, the help FAB,
|
||||
// etc. Never bind this scale to the admin-configurable accent_color
|
||||
// setting (see globals.css) — that stays an inline style on the org name only.
|
||||
// Brand scale — the site's primary design-system color. Use this (not
|
||||
// `indigo-*`/`blue-*` literals) for anything that isn't a shadcn
|
||||
// primitive: nav active-states, icon chips, the help FAB, etc.
|
||||
// Values come from CSS custom properties set at request-time in
|
||||
// layout.tsx from the admin's saved `primary_color` setting (see
|
||||
// colorScale.ts's buildBrandScale + globals.css for the static
|
||||
// fallback defaults used when no setting is saved yet).
|
||||
brand: {
|
||||
50: "#EEF2FF",
|
||||
100: "#E0E7FF",
|
||||
200: "#C7D2FE",
|
||||
300: "#A5B4FC",
|
||||
400: "#818CF8",
|
||||
500: "#6366F1",
|
||||
600: "#4F46E5",
|
||||
700: "#4338CA",
|
||||
800: "#3730A3",
|
||||
900: "#312E81",
|
||||
50: "var(--brand-50)",
|
||||
100: "var(--brand-100)",
|
||||
200: "var(--brand-200)",
|
||||
300: "var(--brand-300)",
|
||||
400: "var(--brand-400)",
|
||||
500: "var(--brand-500)",
|
||||
600: "var(--brand-600)",
|
||||
700: "var(--brand-700)",
|
||||
800: "var(--brand-800)",
|
||||
900: "var(--brand-900)",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user