diff --git a/CHANGELOG.md b/CHANGELOG.md
index dacbfe1..3509576 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### 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
diff --git a/backend/prisma/migrations/20260821090459_add_event_location/migration.sql b/backend/prisma/migrations/20260821090459_add_event_location/migration.sql
new file mode 100644
index 0000000..8d4a4ed
--- /dev/null
+++ b/backend/prisma/migrations/20260821090459_add_event_location/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Event" ADD COLUMN "location" TEXT;
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 95248bd..9c79b88 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -105,6 +105,7 @@ model Event {
contactName String?
contactPhone String?
contactEmail String?
+ location String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js
index af32a59..e0ba75a 100644
--- a/backend/src/controllers/eventController.js
+++ b/backend/src/controllers/eventController.js
@@ -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,
};
diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx
index 575f17c..db5c7c1 100644
--- a/frontend/src/app/contact/page.tsx
+++ b/frontend/src/app/contact/page.tsx
@@ -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() {
)}
+
+ {address && (
+
+
+
+ )}
diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx
index a593bda..0143c0d 100644
--- a/frontend/src/app/dashboard/admin/settings/page.tsx
+++ b/frontend/src/app/dashboard/admin/settings/page.tsx
@@ -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)} />
-
+
setOrgAddress(e.target.value)} />
+ {orgAddress.trim() && (
+
+ View on map ↗
+
+ )}
({
+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(0);
const [pricingSubstep, setPricingSubstep] = useState(0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(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(() => 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(() => {
@@ -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) {
upd({ registrationDeadline: v })} />
upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
+
+
Location
+
upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
+
Defaults to your organisation's address — shown to attendees with a map link.
+
{event.description}
+ {event.location && (
+
+
Location
+
+
+ )}
+
{event.attachments && event.attachments.length > 0 && (
diff --git a/frontend/src/components/events/EventCard.tsx b/frontend/src/components/events/EventCard.tsx
index ec922b0..6affeaa 100644
--- a/frontend/src/components/events/EventCard.tsx
+++ b/frontend/src/components/events/EventCard.tsx
@@ -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 }) => {
{dateRange}
+ {event.location && (
+
+
+ {event.location}
+
+ )}
{event.description}
diff --git a/frontend/src/components/events/LocationMap.tsx b/frontend/src/components/events/LocationMap.tsx
new file mode 100644
index 0000000..eeed969
--- /dev/null
+++ b/frontend/src/components/events/LocationMap.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/lib/maps.ts b/frontend/src/lib/maps.ts
new file mode 100644
index 0000000..e656e90
--- /dev/null
+++ b/frontend/src/lib/maps.ts
@@ -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`;
+}