Compare commits

...
Author SHA1 Message Date
joshuaandClaude Sonnet 5 7eed7a01df Bump version to 1.9.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:24:07 +02:00
joshua a7be2baab2 Merge branch 'fix/contact-only-daily-summary' into main 2026-08-22 21:23:24 +02:00
joshuaandClaude Sonnet 5 5a9137e252 Skip daily summary emails for contact-only events
Contact-only events have no registration flow, so their daily
summary (registration/payment stats) was always empty — exclude
them from the query instead of sending a pointless email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:22:53 +02:00
joshuaandClaude Sonnet 5 4e63a78e8e Bump version to 1.9.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:12:37 +02:00
joshua 2a067a3687 Merge branch 'fix/org-name-branding' into main 2026-08-22 21:11:52 +02:00
joshuaandClaude Sonnet 5 bdae0c6b08 Fix org name not showing in tab title, homepage, and backend pages
The browser tab title, homepage "Welcome to..." heading, and the
backend status/API docs pages all had the "Cross Code" default
hardcoded instead of reading the configured org_name setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:09:35 +02:00
joshuaandClaude Sonnet 5 c84cc257d0 Bump version to 1.9.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:51:27 +02:00
joshua 7cbb147b00 Merge branch 'feature/event-location-maps' into main 2026-08-21 11:50:31 +02:00
joshua b75be18a87 Add event/organisation location with Google Maps links
Events and the organisation profile now have an address, with a
"Directions" link and an embedded Google Maps view (no API key
required) shown on event pages, event cards, and the Contact page.
New events default their location to the org's configured address.
2026-08-21 11:47:23 +02:00
joshua 05840541c2 Merge branch 'feature/early-bird-tranches-and-contact-events' into main 2026-08-21 10:33:19 +02:00
18 changed files with 149 additions and 21 deletions
+19
View File
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.9.2] - 2026-08-22
### Fixed
- Contact-only events (no registration possible) were still getting the daily summary email — it now skips them since there's nothing to summarize.
## [1.9.1] - 2026-08-22
### Fixed
- The browser tab title and homepage "Welcome to..." heading always showed the app's built-in default name instead of the organisation name configured in Site Settings → Organisation.
- The backend's status page (`/`) and API docs page (`/docs`) always showed "Cross Code Events" instead of the configured organisation name.
## [1.9.0] - 2026-08-21
### Added
- Events now have an optional Location field (address), defaulting to the organisation's configured address when creating a new event. Wherever an address is shown — event admin form, public event page, event card listings, the Contact page, and Site Settings → Organisation — there's now a "Directions"/"View on map" link, and the event detail and Contact pages also show an embedded Google Maps view (no API key required).
## [1.8.0] - 2026-08-21
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.8.0",
"version": "1.9.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
+1
View File
@@ -105,6 +105,7 @@ model Event {
contactName String?
contactPhone String?
contactEmail String?
location String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
+4 -2
View File
@@ -41,7 +41,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin
const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
id: uuidv4(),
@@ -62,6 +62,7 @@ const createEvent = async (req, res) => {
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
};
try {
@@ -459,7 +460,7 @@ const updateEvent = async (req, res) => {
// totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res);
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
title: title || event.title,
@@ -477,6 +478,7 @@ const updateEvent = async (req, res) => {
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
location: location !== undefined ? (location || null) : event.location,
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
};
+9 -6
View File
@@ -141,7 +141,8 @@ app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
require('./utils/settingsCache').warmCache().catch(() => {});
const { getSettingSync, warmCache } = require('./utils/settingsCache');
warmCache().catch(() => {});
app.use('/uploads', express.static('public/uploads'));
// ── Shared page helpers ────────────────────────────────────────────────────────
@@ -244,8 +245,9 @@ app.get('/', async (req, res) => {
? `<span class="badge badge-warn">testing</span>`
: `<span class="badge badge-warn">development</span>`;
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
<h1>Cross Code Events API</h1>
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
<h1>${orgName} Events API</h1>
<p class="subtitle">v${API_VERSION} &mdash; ${now}</p>
<div class="stat-grid">
@@ -1030,13 +1032,14 @@ app.get('/docs', async (req, res) => {
}
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cross Code Events API Docs</title>
<title>${orgName} 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}
@@ -1060,7 +1063,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">Cross Code Events API Reference</h1>
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events API Reference</h1>
<a href="/" style="font-size:.82rem;color:#6b7280">&#8592; Status page</a>
</div>
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
@@ -1087,7 +1090,7 @@ app.get('/docs', async (req, res) => {
${notificationsHtml}
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
Cross Code Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
${orgName} Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
</p>
</div>
<script>
+2 -2
View File
@@ -1370,14 +1370,14 @@ async function sendDailyEventSummaries(now = new Date()) {
const today = new Date(now);
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
let events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today } },
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
try {
events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today }, requiresRegistration: true },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.8.0",
"version": "1.9.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+7
View File
@@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { appName } from "@/lib/siteConfig";
import { LocationMap } from "@/components/events/LocationMap";
export default function ContactPage() {
const { settings, loading } = useSiteSettings();
@@ -68,6 +69,12 @@ export default function ContactPage() {
</div>
)}
</div>
{address && (
<div className="mt-8 border rounded-xl p-5 bg-white shadow-sm">
<LocationMap address={address} />
</div>
)}
</main>
<Footer />
</div>
@@ -10,6 +10,7 @@ import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon }
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
@@ -410,9 +411,15 @@ function SiteSettingsPageInner() {
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
</Field>
</div>
<Field label="Address">
<Field label="Address" hint="Used as the default location for new events.">
<input className={inputCls} placeholder="123 Church St, City"
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
{orgAddress.trim() && (
<a href={mapsSearchUrl(orgAddress.trim())} target="_blank" rel="noopener noreferrer"
className="inline-block text-xs text-brand-600 hover:underline mt-1">
View on map
</a>
)}
</Field>
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
<input className={inputCls} placeholder="https://events.yourchurch.org"
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { Calendar } from "lucide-react";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -756,13 +757,15 @@ interface EventDraft {
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
location: string;
}
const blankDraft = (): EventDraft => ({
const blankDraft = (location = ""): EventDraft => ({
title: "", description: "", startDate: "", endDate: "",
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location,
});
const blankOptions = (): OptionDraft[] => [
@@ -781,12 +784,15 @@ interface EventModalProps {
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const { token, user } = useAuth();
const isAdmin = user?.role === "admin";
const { settings } = useSiteSettings();
const [step, setStep] = useState<StepIdx>(0);
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// ── event draft ──
// New events default their location to the organisation's address (settings.org_address);
// editing an existing event always reflects its own saved location instead.
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
title: ev.title || "", description: ev.description || "",
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
@@ -795,7 +801,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
requiresRegistration: ev.requiresRegistration !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
} : blankDraft());
location: ev.location || "",
} : blankDraft(settings.org_address || ""));
// ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => {
@@ -1065,6 +1072,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
contactName: draft.contactName || undefined,
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
location: draft.location.trim(),
};
if (mode === "edit") {
@@ -1209,6 +1217,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Location</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
<p className="text-[10px] text-gray-400 mt-0.5">Defaults to your organisation&apos;s address shown to attendees with a map link.</p>
</div>
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
<input
type="checkbox"
+9
View File
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions";
import { ContactButton } from "@/components/events/ContactButton";
import { LocationMap } from "@/components/events/LocationMap";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -37,6 +38,7 @@ type Event = {
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
};
import { apiFetch, ApiError } from "@/lib/api";
@@ -142,6 +144,13 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{event.location && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
<LocationMap address={event.location} />
</div>
)}
{event.attachments && event.attachments.length > 0 && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
+3 -2
View File
@@ -44,10 +44,11 @@ async function getServerSettings(): Promise<SiteSettings> {
export async function generateMetadata(): Promise<Metadata> {
const settings = await getServerSettings();
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
const displayName = settings.org_name || appName;
return {
title: appName,
description: `Manage and register for events with ${appName}`,
title: displayName,
description: `Manage and register for events with ${displayName}`,
icons: {
icon: faviconUrl || "/favicon.ico",
},
+17 -2
View File
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { JoinUsButton } from "@/components/home/JoinUsButton";
import { appName } from "@/lib/siteConfig";
import { API_BASE } from "@/lib/api";
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
type Event = {
@@ -26,8 +27,22 @@ const FEATURES = [
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
];
async function getOrgName(): Promise<string> {
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return appName;
const settings = await res.json();
return settings?.org_name || appName;
} catch {
return appName;
}
}
export default async function HomePage() {
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const [events, displayName] = await Promise.all([
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
getOrgName(),
]);
const now = Date.now();
const upcoming = (events || []).filter(e => {
const t = new Date(e.startDate).getTime();
@@ -52,7 +67,7 @@ export default async function HomePage() {
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
</div>
)}
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {displayName}</h1>
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
+8 -1
View File
@@ -1,5 +1,5 @@
import { ApiImage } from "@/components/shared/ApiImage";
import { Calendar } from "lucide-react";
import { Calendar, MapPin } from "lucide-react";
import { ContactButton } from "@/components/events/ContactButton";
type Event = {
@@ -17,6 +17,7 @@ import { ContactButton } from "@/components/events/ContactButton";
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
};
import { formatDateTimeRange } from "@/lib/date";
@@ -40,6 +41,12 @@ export const EventCard = ({ event }: { event: Event }) => {
<Calendar className="w-3.5 h-3.5 shrink-0" />
{dateRange}
</p>
{event.location && (
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
<MapPin className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{event.location}</span>
</p>
)}
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
</div>
</a>
@@ -0,0 +1,33 @@
import { MapPin, ExternalLink } from "lucide-react";
import { mapsSearchUrl, mapsEmbedUrl } from "@/lib/maps";
export function LocationMap({ address, className }: { address?: string | null; className?: string }) {
if (!address) return null;
return (
<div className={className}>
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-gray-700 flex items-start gap-1.5">
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-400" />
<span>{address}</span>
</p>
<a
href={mapsSearchUrl(address)}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand-600 hover:underline flex items-center gap-1 shrink-0 whitespace-nowrap"
>
Directions <ExternalLink className="w-3 h-3" />
</a>
</div>
<div className="mt-2 rounded-lg overflow-hidden border">
<iframe
title={`Map showing ${address}`}
src={mapsEmbedUrl(address)}
className="w-full h-48 border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
/** Opens Google Maps with the address pre-filled — no API key required. */
export function mapsSearchUrl(address: string): string {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
}
/** Embeddable Google Maps iframe src for the given address — no API key required. */
export function mapsEmbedUrl(address: string): string {
return `https://maps.google.com/maps?q=${encodeURIComponent(address)}&output=embed`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.8.0",
"version": "1.9.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",