Merge branch 'feature/event-location-maps' into main

This commit is contained in:
2026-08-21 11:50:31 +02:00
11 changed files with 100 additions and 6 deletions
+4
View File
@@ -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
@@ -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,
};
+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">
+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`;
}