import { useState, useEffect, useCallback, useId, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { X, RefreshCw, Calendar, Power } from 'lucide-react' import { SubscribeLinks } from './SubscribeLinks' import { useIsPhone } from '../../mobile/useIsPhone' import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' interface IcsSubscribeModalProps { /** Token endpoint base, e.g. `/api/trips/123/feed` or `/api/feed/user`. */ endpoint: string title: string description: string onClose: () => void } // A server that has no APP_URL configured hands back a host-relative path; the // webcal:// handoff and Google deep link need an absolute URL, so resolve it // against the current origin as a fallback. function absolutize(url: string): string { if (!url) return '' if (/^https?:\/\//i.test(url)) return url if (url.startsWith('/')) return window.location.origin + url return url } /** * Shared subscribe dialog for the per-trip and all-trips ICS feeds. Opening it * only *reads* the current token — it never mints one silently. The user * explicitly enables the public link, and can rotate or fully turn it off. * * The phone keeps its own markup; the desktop draws the same steps in the * planner's dialog frame, with the link's controls in the bar at the foot. */ export function IcsSubscribeModal({ endpoint, title, description, onClose }: IcsSubscribeModalProps) { const tokenUrl = `${endpoint}/token` const [feedUrl, setFeedUrl] = useState(null) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const isPhone = useIsPhone() const titleId = useId() const httpsUrl = feedUrl ? absolutize(feedUrl) : '' const webcalUrl = httpsUrl ? httpsUrl.replace(/^https?:\/\//, 'webcal://') : '' const load = useCallback(async () => { setLoading(true) try { const res = await fetch(tokenUrl, { credentials: 'include' }) if (res.ok) { const data = await res.json() as { feed_url: string | null } setFeedUrl(data.feed_url) } } catch { /* ignore */ } setLoading(false) }, [tokenUrl]) useEffect(() => { load() }, [load]) const mutate = async (method: 'POST' | 'PUT' | 'DELETE') => { setBusy(true) try { const res = await fetch(tokenUrl, { method, credentials: 'include' }) if (res.ok) { const data = await res.json() as { feed_url: string | null } setFeedUrl(data.feed_url) } } catch { /* ignore */ } setBusy(false) } if (!isPhone) { return ( ) } return createPortal(
{ if (e.target === e.currentTarget) onClose() }} >
{/* Header */}
{title}

{description}

{loading ? (
Loading…
) : !feedUrl ? ( <>

Creates a secret link anyone with it can read without logging in. You can turn it off anytime.

) : ( <>

Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.

)}
, document.body ) } // A secondary footer button in the danger colour, for the switch that takes the link away. const TURN_OFF_BTN = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-danger hover:bg-danger-soft disabled:cursor-default disabled:opacity-50' function DesktopSubscribeDialog({ titleId, title, description, onClose, loading, busy, feedUrl, httpsUrl, webcalUrl, mutate }: { titleId: string title: string description: string onClose: () => void loading: boolean busy: boolean feedUrl: string | null httpsUrl: string webcalUrl: string mutate: (method: 'POST' | 'PUT' | 'DELETE') => Promise }) { const header = ( } tint={NEUTRAL_TINT} labelId={titleId} onClose={onClose} title={title} sub={description} subWraps /> ) const hint = 'm-0 leading-normal text-content-muted' let body: ReactNode let footer: ReactNode = null if (loading) { body =
Loading…
} else if (!feedUrl) { body = (

Creates a secret link anyone with it can read without logging in. You can turn it off anytime.

) footer = ( mutate('POST')} disabled={busy} icon={}> Enable calendar subscription ) } else { body = ( <>

Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.

) footer = ( mutate('PUT')} disabled={busy} icon={}> Regenerate ) } return ( {body} ) }