Compare commits

..
Author SHA1 Message Date
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
14 changed files with 105 additions and 9 deletions
+6
View File
@@ -7,6 +7,12 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [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 ## [1.8.0] - 2026-08-21
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.8.0", "version": "1.9.0",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
+1
View File
@@ -105,6 +105,7 @@ model Event {
contactName String? contactName String?
contactPhone String? contactPhone String?
contactEmail String? contactEmail String?
location String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdById String? createdById String?
+4 -2
View File
@@ -41,7 +41,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin // @access Private/Admin
const createEvent = async (req, res) => { const createEvent = async (req, res) => {
try { 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 = { const data = {
id: uuidv4(), id: uuidv4(),
@@ -62,6 +62,7 @@ const createEvent = async (req, res) => {
contactName: contactName || null, contactName: contactName || null,
contactPhone: contactPhone || null, contactPhone: contactPhone || null,
contactEmail: contactEmail || null, contactEmail: contactEmail || null,
location: location || null,
}; };
try { try {
@@ -459,7 +460,7 @@ const updateEvent = async (req, res) => {
// totals — same rule already enforced for payments/costs. Admin can reopen first. // totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res); 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 = { const data = {
title: title || event.title, title: title || event.title,
@@ -477,6 +478,7 @@ const updateEvent = async (req, res) => {
contactName: contactName !== undefined ? (contactName || null) : event.contactName, contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone, contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail, contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
location: location !== undefined ? (location || null) : event.location,
updatedAt: new Date(), updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl, redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.8.0", "version": "1.9.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
+7
View File
@@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { appName } from "@/lib/siteConfig"; import { appName } from "@/lib/siteConfig";
import { LocationMap } from "@/components/events/LocationMap";
export default function ContactPage() { export default function ContactPage() {
const { settings, loading } = useSiteSettings(); const { settings, loading } = useSiteSettings();
@@ -68,6 +69,12 @@ export default function ContactPage() {
</div> </div>
)} )}
</div> </div>
{address && (
<div className="mt-8 border rounded-xl p-5 bg-white shadow-sm">
<LocationMap address={address} />
</div>
)}
</main> </main>
<Footer /> <Footer />
</div> </div>
@@ -10,6 +10,7 @@ import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon }
import { ColorPickerField } from "@/components/admin/ColorPickerField"; import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel"; import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors"; import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp"; type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
@@ -410,9 +411,15 @@ function SiteSettingsPageInner() {
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} /> value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
</Field> </Field>
</div> </div>
<Field label="Address"> <Field label="Address" hint="Used as the default location for new events.">
<input className={inputCls} placeholder="123 Church St, City" <input className={inputCls} placeholder="123 Church St, City"
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} /> 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>
<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"> <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" <input className={inputCls} placeholder="https://events.yourchurch.org"
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState"; import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
// ─── helpers ──────────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────────
@@ -756,13 +757,15 @@ interface EventDraft {
registrationDeadline: string; goLiveAt: string; price: string; picture: string; registrationDeadline: string; goLiveAt: string; price: string; picture: string;
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean; redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string; requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
location: string;
} }
const blankDraft = (): EventDraft => ({ const blankDraft = (location = ""): EventDraft => ({
title: "", description: "", startDate: "", endDate: "", title: "", description: "", startDate: "", endDate: "",
registrationDeadline: "", goLiveAt: "", price: "", picture: "", registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true, redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "", requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location,
}); });
const blankOptions = (): OptionDraft[] => [ const blankOptions = (): OptionDraft[] => [
@@ -781,12 +784,15 @@ interface EventModalProps {
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const { token, user } = useAuth(); const { token, user } = useAuth();
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin";
const { settings } = useSiteSettings();
const [step, setStep] = useState<StepIdx>(0); const [step, setStep] = useState<StepIdx>(0);
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0); const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// ── event draft ── // ── 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 ? { const [draft, setDraft] = useState<EventDraft>(() => ev ? {
title: ev.title || "", description: ev.description || "", title: ev.title || "", description: ev.description || "",
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate), 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, isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
requiresRegistration: ev.requiresRegistration !== false, requiresRegistration: ev.requiresRegistration !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "", contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
} : blankDraft()); location: ev.location || "",
} : blankDraft(settings.org_address || ""));
// ── options (with per-variant tiers) ── // ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => { const [options, setOptions] = useState<OptionDraft[]>(() => {
@@ -1065,6 +1072,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
contactName: draft.contactName || undefined, contactName: draft.contactName || undefined,
contactPhone: draft.contactPhone || undefined, contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined, contactEmail: draft.contactEmail || undefined,
location: draft.location.trim(),
}; };
if (mode === "edit") { 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="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" /> <DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div> </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"> <div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
<input <input
type="checkbox" type="checkbox"
+9
View File
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions"; import ClientActions from "@/app/events/[id]/ClientActions";
import { ContactButton } from "@/components/events/ContactButton"; import { ContactButton } from "@/components/events/ContactButton";
import { LocationMap } from "@/components/events/LocationMap";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react"; import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60; export const revalidate = 60;
@@ -37,6 +38,7 @@ type Event = {
contactName?: string | null; contactName?: string | null;
contactPhone?: string | null; contactPhone?: string | null;
contactEmail?: string | null; contactEmail?: string | null;
location?: string | null;
}; };
import { apiFetch, ApiError } from "@/lib/api"; 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> <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 && ( {event.attachments && event.attachments.length > 0 && (
<div className="border rounded-xl p-5 bg-white shadow-sm"> <div className="border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
+8 -1
View File
@@ -1,5 +1,5 @@
import { ApiImage } from "@/components/shared/ApiImage"; import { ApiImage } from "@/components/shared/ApiImage";
import { Calendar } from "lucide-react"; import { Calendar, MapPin } from "lucide-react";
import { ContactButton } from "@/components/events/ContactButton"; import { ContactButton } from "@/components/events/ContactButton";
type Event = { type Event = {
@@ -17,6 +17,7 @@ import { ContactButton } from "@/components/events/ContactButton";
contactName?: string | null; contactName?: string | null;
contactPhone?: string | null; contactPhone?: string | null;
contactEmail?: string | null; contactEmail?: string | null;
location?: string | null;
}; };
import { formatDateTimeRange } from "@/lib/date"; 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" /> <Calendar className="w-3.5 h-3.5 shrink-0" />
{dateRange} {dateRange}
</p> </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> <p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
</div> </div>
</a> </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", "name": "hope-events",
"version": "1.8.0", "version": "1.9.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",