Merge branch 'feature/event-location-maps' into main
This commit is contained in:
@@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.8.0] - 2026-08-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user