feat: organizers, meetups UI, Plausible analytics, and migration tooling
- Add organizer model/API, admin and public organizer pages, meetup cards - Refresh events/home/contact; add calendar dialog and carousel components - Optional Plausible via NEXT_PUBLIC_PLAUSIBLE_* env vars in root layout - Prisma migration, seed updates, baseline-and-migrate script Made-with: Cursor
This commit is contained in:
126
frontend/components/public/AddToCalendarDialog.tsx
Normal file
126
frontend/components/public/AddToCalendarDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CalendarPlus, Copy, Check, Download, ExternalLink, X } from "lucide-react";
|
||||
|
||||
const siteUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||
|
||||
function Dialog({ onClose }: { onClose: () => void }) {
|
||||
const icsUrl = `${siteUrl}/calendar.ics`;
|
||||
const webcalUrl = icsUrl.replace(/^https?:\/\//, "webcal://");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(icsUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="relative bg-zinc-900 border border-zinc-800 rounded-2xl w-full max-w-md p-6 shadow-2xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-on-surface-variant/50 hover:text-on-surface transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="bg-primary/10 text-primary rounded-lg p-2">
|
||||
<CalendarPlus size={20} />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold">Add to Calendar</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-on-surface-variant leading-relaxed mb-6">
|
||||
Subscribe to this feed to get all public Belgian Bitcoin Embassy
|
||||
meetups in your calendar. New events are added automatically.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-on-surface-variant/60 mb-1.5">
|
||||
Calendar URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={icsUrl}
|
||||
className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-on-surface select-all focus:outline-none focus:border-primary/50"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 flex items-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2 text-xs font-medium transition-all"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<a
|
||||
href={webcalUrl}
|
||||
className="flex items-center justify-center gap-1.5 bg-primary text-on-primary rounded-lg px-3 py-2.5 text-xs font-semibold hover:brightness-110 transition-all"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open in Calendar
|
||||
</a>
|
||||
<a
|
||||
href="/calendar.ics"
|
||||
download="bbe-events.ics"
|
||||
className="flex items-center justify-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2.5 text-xs font-semibold transition-all"
|
||||
>
|
||||
<Download size={14} />
|
||||
Download .ics
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddToCalendarButton({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title="Subscribe to get all future meetups automatically"
|
||||
className={
|
||||
className ??
|
||||
"flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
|
||||
}
|
||||
>
|
||||
<CalendarPlus size={14} />
|
||||
Add to Calendar
|
||||
</button>
|
||||
{open && <Dialog onClose={close} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user