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.
This commit is contained in:
@@ -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'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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user