diff --git a/src/components/CreatePoll.tsx b/src/components/CreatePoll.tsx index f6a0b21..08da5da 100644 --- a/src/components/CreatePoll.tsx +++ b/src/components/CreatePoll.tsx @@ -3,7 +3,7 @@ import { useAppFreeToken, useOrg, useOrgBranding, useSubscription, useUniversal, import type { NewPoll, PollBranding, PollMode, Slot, Theme } from '../lib/types' import { isHexTheme, THEMES } from '../lib/types' import { hexOfTheme, themeAttr, themeVars } from '../lib/theme' -import { createPoll, createPollGated, currentUser, sendHostCode, setNotifyOnResponse as apiSetNotify, shortId, uploadPollLogo, verifyHostCode } from '../lib/api' +import { createPoll, createPollGated, currentUser, sendHostCode, setNotifyOnResponse as apiSetNotify, setPollLocation as apiSetLocation, shortId, uploadPollLogo, verifyHostCode } from '../lib/api' import { SUPABASE_CONFIGURED, supabase } from '../lib/supabase' import { listTimezones, localTimezone, tzAbbrev } from '../lib/time' import SlotPicker from './SlotPicker' @@ -33,6 +33,9 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { const [slots, setSlots] = useState([]) const [theme, setTheme] = useState('orange') const [timezone, setTimezone] = useState(localTimezone()) + // Optional EVENT location — a meeting link or a physical place — for the whole + // poll (not per-slot). Shown to respondents and carried into the export. + const [location, setLocation] = useState('') const [validityDays, setValidityDays] = useState(30) const [notifyOnResponse, setNotifyOnResponse] = useState(false) const [email, setEmail] = useState('') @@ -183,7 +186,7 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { function draft(branding: PollBranding | null): NewPoll { const expires_at = validityDays == null ? null : new Date(Date.now() + validityDays * 86_400_000).toISOString() - return { id: shortId(), title, timezone, mode, slots, theme, branding, expires_at } + return { id: shortId(), title, timezone, mode, slots, theme, branding, location: location.trim() || null, expires_at } } // `client` must be signed in as `hostUserId` (suite client for any Universal @@ -199,6 +202,12 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { const poll = freeGated ? await createPollGated(client, pollDraft, hostEmail) : await createPoll(client, pollDraft, hostUserId, hostEmail) + // The gated create RPC doesn't take a location, so set it as a follow-up + // (the direct insert above already carries it). Non-fatal — the poll is + // already created. + if (freeGated && pollDraft.location) { + try { await apiSetLocation(client, poll.id, pollDraft.location) } catch { /* poll still created */ } + } // Response alerts are a follow-up update (keeps the create RPC/insert // untouched); non-fatal, since the poll itself is already created. if (notifyOnResponse) { @@ -328,6 +337,22 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { + {/* Location / meeting link (whole-event, optional) */} +
+ +

Shown to everyone on the poll and added to the calendar invite.

+
+ {/* More options */}
{isHost && ( @@ -434,6 +436,33 @@ function ConfirmedBanner({ poll, slot, pollUrl, viewerTz, activeTz, dayMode, isH ) } +/** Whether a location string is a clickable http(s) link (a Teams / Zoom / Meet + * URL) rather than a physical place ("Meeting room 5"). */ +function isUrlLike(s: string): boolean { + return /^https?:\/\/\S+$/i.test(s.trim()) +} + +/** The poll's event location: a link icon + the value, rendered as an anchor for + * a meeting URL, or plain text for a physical place. */ +function PollLocation({ location, className = '' }: { location: string; className?: string }) { + const isLink = isUrlLike(location) + return ( +
+ + {isLink ? ( + + {location} + + ) : ( + {location} + )} +
+ ) +} + /** Tells the viewer which timezone the poll's times are in, and lets them * re-render every time on the page in their own zone (one click) or any other * zone (searchable picker). */ diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 2bbbe78..a6b7008 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -12,6 +12,7 @@ const fakePoll = (id: string): Poll => ({ slots: [], theme: 'orange', branding: null, + location: null, final_slot_id: null, notify_on_response: false, created_at: '2026-07-24T10:00:00.000Z', diff --git a/src/lib/api.ts b/src/lib/api.ts index f0e0476..7e0a2b8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -64,6 +64,10 @@ export async function createPoll( theme: p.theme, branding: p.branding, expires_at: p.expires_at, + // Only send `location` when set: omitting the key when it's null keeps the + // insert from referencing the column at all, so a build that shipped before + // migration 0060 added `polls.location` still creates location-less polls. + ...(p.location ? { location: p.location } : {}), } const { data, error } = await client.from('polls').insert(row).select().single() if (error) throw error @@ -130,6 +134,18 @@ export async function setFinalSlot( if (error) throw error } +/** Host-only: set (or clear, with `null`) the poll's event location. `client` + * must be signed in as the host — RLS (`polls_owner_update`) gates it. Used as a + * follow-up write for the gated create path, whose RPC doesn't take a location. */ +export async function setPollLocation( + client: SupabaseClient, + pollId: string, + location: string | null, +): Promise { + const { error } = await client.from('polls').update({ location }).eq('id', pollId) + if (error) throw error +} + /** Host-only: turn per-response email alerts on/off for a poll. `client` must be * signed in as the host — RLS (`polls_owner_update`) gates it. */ export async function setNotifyOnResponse( diff --git a/src/lib/calendar.test.ts b/src/lib/calendar.test.ts index b5663e2..3a71236 100644 --- a/src/lib/calendar.test.ts +++ b/src/lib/calendar.test.ts @@ -16,6 +16,7 @@ function timedPoll(overrides: Partial = {}): Poll { slots: [], theme: 'orange', branding: null, + location: null, final_slot_id: null, notify_on_response: false, created_at: NOW.toISOString(), @@ -82,6 +83,16 @@ describe('buildIcs', () => { expect(ics).toContain('SUMMARY:Lunch\\, drinks\\; then talk') }) + it('emits a LOCATION line when the poll has a location, escaped', () => { + const ics = buildIcs(timedPoll({ location: 'Meeting room 5, floor 2' }), timedSlot, POLL_URL, NOW) + expect(ics).toContain('LOCATION:Meeting room 5\\, floor 2') + }) + + it('omits LOCATION when the poll has none', () => { + const ics = buildIcs(timedPoll({ location: null }), timedSlot, POLL_URL, NOW) + expect(ics).not.toContain('LOCATION:') + }) + // RFC 5545 folding is a 75-OCTET limit and must never split a code point. // The poll title is unconstrained Unicode, so emoji/CJK have to survive. it('folds long lines at 75 octets without splitting surrogate pairs', () => { @@ -127,6 +138,13 @@ describe('googleCalendarUrl', () => { const url = googleCalendarUrl(poll, { id: 'd1', start: '2026-06-10T00:00', durationMins: 0 }, POLL_URL) expect(new URL(url).searchParams.get('dates')).toBe('20260610/20260611') }) + + it('passes the location through as a query param, else omits it', () => { + const withLoc = googleCalendarUrl(timedPoll({ location: 'https://zoom.us/j/123' }), timedSlot, POLL_URL) + expect(new URL(withLoc).searchParams.get('location')).toBe('https://zoom.us/j/123') + const without = googleCalendarUrl(timedPoll({ location: null }), timedSlot, POLL_URL) + expect(new URL(without).searchParams.has('location')).toBe(false) + }) }) describe('outlookCalendarUrl', () => { diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts index 8008200..604a606 100644 --- a/src/lib/calendar.ts +++ b/src/lib/calendar.ts @@ -18,6 +18,9 @@ interface CalendarEventBase { description: string /** Poll page URL, surfaced as the event URL / in the body. */ url: string + /** Event location — a meeting link or physical place — or '' when the poll has + * none. Carried into ICS LOCATION and the Google/Outlook deep-links. */ + location: string } /** A calendar event is EITHER timed (absolute start/end instants) or all-day @@ -43,19 +46,20 @@ export type CalendarEvent = * poll page link, woven into the event body so an attendee can get back to it. */ export function eventForSlot(poll: Poll, slot: Slot, pollUrl: string): CalendarEvent { const title = poll.title.trim() || 'Meeting' + const location = poll.location?.trim() || '' const description = `Scheduled with Universal Date Polling.${pollUrl ? `\n\nView or update the poll: ${pollUrl}` : ''}` if (poll.mode === 'days') { const startDay = slotDayKey(slot) return { - title, description, url: pollUrl, allDay: true, + title, description, url: pollUrl, location, allDay: true, startDay, endDay: addCalendarDays(startDay, 1), } } const start = slotInstant(slot.start, poll.timezone) const end = slotEnd(start, slot.durationMins) - return { title, description, url: pollUrl, allDay: false, start, end } + return { title, description, url: pollUrl, location, allDay: false, start, end } } // ── ICS ───────────────────────────────────────────────────────────────────── @@ -78,6 +82,7 @@ export function buildIcs(poll: Poll, slot: Slot, pollUrl: string, now: Date = ne `SUMMARY:${escapeIcs(ev.title)}`, `DESCRIPTION:${escapeIcs(ev.description)}`, ] + if (ev.location) lines.push(`LOCATION:${escapeIcs(ev.location)}`) if (pollUrl) lines.push(`URL:${escapeIcs(pollUrl)}`) if (ev.allDay) { @@ -121,6 +126,7 @@ export function googleCalendarUrl(poll: Poll, slot: Slot, pollUrl: string): stri dates, details: ev.description, }) + if (ev.location) params.set('location', ev.location) return `https://calendar.google.com/calendar/render?${params.toString()}` } @@ -135,6 +141,7 @@ export function outlookCalendarUrl(poll: Poll, slot: Slot, pollUrl: string): str body: ev.description, allday: String(ev.allDay), }) + if (ev.location) params.set('location', ev.location) if (ev.allDay) { params.set('startdt', ev.startDay) params.set('enddt', ev.endDay) diff --git a/src/lib/types.ts b/src/lib/types.ts index 8f54465..087bcf1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -48,6 +48,10 @@ export interface Poll { slots: Slot[] theme: Theme branding: PollBranding | null + /** Optional EVENT location — a meeting link (Teams / Zoom / Google Meet) or a + * physical place ("Meeting room 5"). One value for the whole poll (not + * per-slot); shown to respondents and carried into the calendar export. */ + location: string | null /** The slot the host has confirmed as the final chosen time (a `Slot.id`), or * null while undecided. Only the host can set it. */ final_slot_id: string | null @@ -79,5 +83,6 @@ export interface NewPoll { slots: Slot[] theme: Theme branding: PollBranding | null + location: string | null expires_at: string | null }