public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
TypeScript
79%
CSS
19%
SQL
2%
HTML
0%
Create file
Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
Commit
feat: add self-service booking management and free payment durations
456feae
ARCHITECTURE.md | 9 ++
README.md | 18 ++--
src/App.tsx | 251 ++++++++++++++++++++++++++++++++++++++++++++---------
src/styles.css | 83 ++++++++++++++----
worker/index.ts | 154 ++++++++++++++++++++++++++++++--
worker/settings.ts | 4 +
worker/types.ts | 1 +
7 files changed, 449 insertions(+), 71 deletions(-)
Diff
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 55cb3c3..387e1cb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -65,3 +65,12 @@ Every booking also has a separate high-entropy public booking ID (`SLT-...`). It
## Activity history
Slot writes lifecycle events to `activity_events` after successful booking, payment, reschedule, suggestion, refund, cancellation, and payment-hold transitions. Idempotency keys suppress duplicate webhook events. Activity writes are deliberately non-blocking so analytics can never make a booking or refund fail. The Admin Activity endpoint computes aggregate booking metrics from the booking table and reads the latest activity events for the chronological feed. Monetary totals stay grouped by currency.
+
+
+## Optional paid durations
+
+Stripe is optional. When Stripe payments are enabled, `paymentFreeDurations` can mark selected configured meeting lengths as free. Booking creation decides whether Checkout is required from the selected duration. Free bookings take the normal immediate-confirmation path; paid bookings use the Stripe hold/Checkout/fulfillment path.
+
+## Public booking management
+
+Confirmed bookings expose a high-entropy public `SLT-...` reference. The public Manage booking flow uses that bearer reference to load limited booking metadata and lets the attendee cancel or reschedule an upcoming confirmed booking. Rescheduling preserves duration and existing payment state, rechecks live availability, and updates the existing Google Calendar event. Public management endpoints are rate-limited.
diff --git a/README.md b/README.md
index 042330c..600536b 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,12 @@ The visitor flow is intentionally simple:
**date → time → duration → details → confirmed booking**
-When the owner enables paid bookings, Stripe Checkout is inserted before confirmation:
+When the selected meeting length is configured as paid, Stripe Checkout is inserted before confirmation:
**date → time → duration → details → Stripe payment → confirmed booking**
+Meeting lengths marked free skip Stripe and use the normal confirmation flow.
+
There are no public accounts and no Google login for visitors. Google OAuth is private to the calendar owner.
## Architecture
@@ -21,7 +23,7 @@ There are no public accounts and no Google login for visitors. Google OAuth is p
- Google Calendar FreeBusy for live availability
- Google Calendar Events API for invites
- Google Meet generated on the event when enabled
-- Optional Stripe Checkout payments through the Stripe API. Without Stripe, bookings confirm normally; when payment is enabled, payment is required before confirmation
+- Optional Stripe Checkout payments through the Stripe API. Without Stripe, bookings confirm normally; when payment is enabled, selected meeting durations can still be configured as free
No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.
@@ -39,12 +41,12 @@ No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service,
- Creates the Google Calendar event and emails the attendee through Google Calendar
- Private `/admin` UI authenticated through the configured Google account
- Stores the Google refresh token encrypted at rest in D1 using an AES-GCM key kept as a Worker secret
-- When payments are enabled, creates only a private calendar hold until Stripe confirms payment; the attendee invite and Meet link are sent only after payment
+- When a selected meeting length requires payment, creates only a private calendar hold until Stripe confirms payment; free meeting lengths confirm normally
- Verifies Stripe webhook signatures before recording payment or refund state
- Admin rescheduling moves the existing Google Calendar event and sends attendee updates
- Admin can suggest a new time without moving the original booking; the attendee gets a secure accept link through the Google Calendar update
- Cancelling a paid booking issues a full Stripe refund to the original payment method before Slot finalizes the cancellation
-- Every confirmed booking gets a public `SLT-...` booking ID shown on the confirmation screen and Google Calendar invitation; visitors can use it to cancel a future booking from the public booking page
+- Every confirmed booking gets a public `SLT-...` booking ID shown on the confirmation screen and Google Calendar invitation; visitors can use it to manage, reschedule, or cancel a future booking from the public booking page
- Admin Activity dashboard with booking, completion, cancellation, reschedule, payment, revenue, refund, and average-duration metrics plus a chronological lifecycle feed
- Admin Attendees dashboard groups booking history by attendee email, showing repeat bookings, completions, cancellations, reschedules, booked minutes, and payment/refund counts when payments are enabled
- Admin booking search matches public `SLT-...` booking IDs, attendee names, and email addresses across upcoming and past bookings
@@ -163,7 +165,7 @@ Sign in with the email entered during setup. The first authorization requests of
## Stripe payments
-Stripe itself is optional. If payments are disabled, Slot confirms bookings immediately as before. If payments are enabled, payment is mandatory before the booking is confirmed.
+Stripe itself is optional. If payments are disabled, Slot confirms bookings immediately as before. If payments are enabled, Slot can charge only the meeting lengths you want: selected durations can be marked free while the others require Stripe Checkout before confirmation.
This integration uses Stripe-hosted Checkout, so there is no publishable key or card form in the React app. The Stripe secret key stays in the Worker only.
@@ -205,7 +207,7 @@ npm run db:migrate:remote
npm run deploy
```
-6. Open `/admin` → **Settings** → **Stripe payments**. Choose the currency, set a **price per minute** in normal currency units, choose the checkout label, then enable **Require payment before confirmation**.
+6. Open `/admin` → **Settings** → **Stripe payments**. Choose the currency, set a **price per minute** in normal currency units, choose the checkout label, then turn on **Enable Stripe payments**. Under **Free durations**, select any meeting lengths that should bypass Stripe and confirm normally.
The Stripe settings card also shows **TEST MODE** for `sk_test_...` keys and **LIVE MODE** for `sk_live_...` keys. The secret key itself is never sent to the browser.
@@ -232,7 +234,7 @@ Suggestion links contain a high-entropy bearer token. Slot stores only a SHA-256
Paid reschedules keep the original charge because the meeting duration does not change. Slot does not create a second Checkout Session for a pure time change.
-Visitors can also cancel their own future confirmed booking from the public booking page. Each confirmation has a high-entropy `SLT-...` booking ID, also included in the Google Calendar invitation. Entering that ID under **Cancel a booking** uses the same cancellation path as Admin. Paid bookings are refunded through Stripe before the calendar event is cancelled. Public cancellation attempts are rate-limited.
+Visitors can manage their own future confirmed booking from the public booking page. Each confirmation has a high-entropy `SLT-...` booking ID, also included in the Google Calendar invitation. Entering that ID under **Manage a booking** opens the booking and offers **Reschedule** or **Cancel**. Rescheduling keeps the same duration and payment state and sends an updated Google Calendar invitation. Paid cancellations are refunded through Stripe before the calendar event is cancelled. Public management attempts are rate-limited.
## Activity
@@ -298,7 +300,7 @@ The Google refresh token is encrypted with AES-GCM before it is persisted in D1.
This is a focused first version, not a Cal.com clone.
- One owner/admin account
-- No general guest self-service cancel/reschedule portal yet; guests can securely accept organizer-proposed new times
+- Guest self-service management is booking-ID based rather than account based; the booking ID acts as a high-entropy bearer reference
- No recurring availability exceptions UI yet (block time in Google Calendar instead)
- Admin currently accepts calendar IDs as text rather than fetching a calendar picker
- No custom reminder emails; Google Calendar sends the invitation
diff --git a/src/App.tsx b/src/App.tsx
index 3ba0fc8..80b44b8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -42,9 +42,10 @@ type PublicConfig = {
type PaymentOption = {
enabled: true;
- required: true;
+ required: boolean;
ready: boolean;
ratePerMinute: number;
+ freeDurations: number[];
currency: string;
label: string;
};
@@ -86,6 +87,7 @@ type AppSettings = {
inPersonLocation: string;
paymentEnabled: boolean;
paymentRatePerMinute: number;
+ paymentFreeDurations: number[];
paymentCurrency: string;
paymentLabel: string;
availability: Record<string, Array<{ start: string; end: string }>>;
@@ -199,6 +201,23 @@ type RescheduleProposal = {
meetingMode: MeetingMode;
};
+type ManagedBooking = {
+ bookingId: string;
+ name: string;
+ start: string;
+ end: string;
+ duration: number;
+ timezone: string;
+ meetingMode: MeetingMode;
+ status: string;
+ paymentStatus: string;
+ refundStatus: string;
+ canCancel: boolean;
+ canReschedule: boolean;
+};
+
+type ManageAction = 'none' | 'cancel' | 'reschedule';
+
type BookingScheduleAction = 'reschedule' | 'suggest';
type RecurringUnavailableRule = {
@@ -500,9 +519,15 @@ function PublicBooking() {
const [availabilityNotice, setAvailabilityNotice] = useState('');
const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
const [paymentReturnState, setPaymentReturnState] = useState<{ kind: 'checking' | 'cancelled' | 'error'; message: string } | null>(null);
- const [cancelOpen, setCancelOpen] = useState(false);
- const [cancelBookingId, setCancelBookingId] = useState('');
- const [cancelState, setCancelState] = useState<{ kind: 'idle' | 'loading' | 'success' | 'error'; message: string }>({ kind: 'idle', message: '' });
+ const [manageOpen, setManageOpen] = useState(false);
+ const [manageBookingId, setManageBookingId] = useState('');
+ const [managedBooking, setManagedBooking] = useState<ManagedBooking | null>(null);
+ const [manageAction, setManageAction] = useState<ManageAction>('none');
+ const [manageDate, setManageDate] = useState('');
+ const [manageSlots, setManageSlots] = useState<Array<{ start: string; time: string }>>([]);
+ const [manageStart, setManageStart] = useState('');
+ const [manageSlotsLoading, setManageSlotsLoading] = useState(false);
+ const [manageState, setManageState] = useState<{ kind: 'idle' | 'loading' | 'success' | 'error'; message: string }>({ kind: 'idle', message: '' });
useEffect(() => {
api<PublicConfig>('/api/config')
@@ -670,19 +695,59 @@ function PublicBooking() {
}
}
- async function submitPublicCancellation(event: FormEvent<HTMLFormElement>) {
+ async function submitManageLookup(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
- const bookingId = cancelBookingId.trim();
+ const bookingId = manageBookingId.trim();
if (!bookingId) {
- setCancelState({ kind: 'error', message: 'Enter the booking ID from your confirmation or calendar invitation.' });
+ setManageState({ kind: 'error', message: 'Enter the booking ID from your confirmation or calendar invitation.' });
return;
}
+ setManageState({ kind: 'loading', message: 'Loading booking…' });
+ setManagedBooking(null);
+ setManageAction('none');
+ setManageSlots([]);
+ setManageStart('');
+ try {
+ const result = await api<{ booking: ManagedBooking }>('/api/bookings/manage', {
+ method: 'POST',
+ body: JSON.stringify({ bookingId }),
+ });
+ setManagedBooking(result.booking);
+ setManageBookingId(result.booking.bookingId);
+ setManageDate(dateKeyForInstant(result.booking.start, result.booking.timezone));
+ setManageState({ kind: 'idle', message: '' });
+ } catch (err) {
+ setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not find this booking.' });
+ }
+ }
- setCancelState({ kind: 'loading', message: 'Cancelling booking…' });
+ async function loadManageAvailability(date: string) {
+ if (!managedBooking || !date) return;
+ setManageDate(date);
+ setManageStart('');
+ setManageSlots([]);
+ setManageSlotsLoading(true);
+ setManageState({ kind: 'idle', message: '' });
+ try {
+ const result = await api<{ slots: Array<{ start: string; time: string }> }>('/api/bookings/manage/availability', {
+ method: 'POST',
+ body: JSON.stringify({ bookingId: managedBooking.bookingId, date }),
+ });
+ setManageSlots(result.slots);
+ } catch (err) {
+ setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not load available times.' });
+ } finally {
+ setManageSlotsLoading(false);
+ }
+ }
+
+ async function cancelManagedBooking() {
+ if (!managedBooking) return;
+ setManageState({ kind: 'loading', message: 'Cancelling booking…' });
try {
const result = await api<{ ok: boolean; refunded?: boolean; refundStatus?: string; alreadyCancelled?: boolean; warning?: string }>('/api/bookings/cancel', {
method: 'POST',
- body: JSON.stringify({ bookingId }),
+ body: JSON.stringify({ bookingId: managedBooking.bookingId }),
});
let message = result.alreadyCancelled
? 'This booking was already cancelled.'
@@ -692,13 +757,33 @@ function PublicBooking() {
: 'Booking cancelled. The Stripe payment has been refunded.'
: 'Booking cancelled. The calendar invitation has been cancelled.';
if (result.warning) message += ` ${result.warning}`;
- setCancelState({ kind: 'success', message });
- if (confirmation?.bookingId && confirmation.bookingId.toUpperCase() === bookingId.toUpperCase()) {
- setConfirmation(null);
- }
+ setManagedBooking({ ...managedBooking, status: 'cancelled', canCancel: false, canReschedule: false, refundStatus: result.refundStatus ?? managedBooking.refundStatus });
+ setManageAction('none');
+ setManageState({ kind: 'success', message });
+ if (confirmation?.bookingId && confirmation.bookingId.toUpperCase() === managedBooking.bookingId.toUpperCase()) setConfirmation(null);
+ if (selectedDate) await chooseDate(selectedDate);
+ } catch (err) {
+ setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel this booking.' });
+ }
+ }
+
+ async function rescheduleManagedBooking() {
+ if (!managedBooking || !manageStart) return;
+ setManageState({ kind: 'loading', message: 'Rescheduling booking…' });
+ try {
+ const result = await api<{ ok: boolean; booking: ManagedBooking }>('/api/bookings/reschedule', {
+ method: 'POST',
+ body: JSON.stringify({ bookingId: managedBooking.bookingId, start: manageStart }),
+ });
+ setManagedBooking(result.booking);
+ setManageDate(dateKeyForInstant(result.booking.start, result.booking.timezone));
+ setManageStart('');
+ setManageSlots([]);
+ setManageAction('none');
+ setManageState({ kind: 'success', message: 'Booking rescheduled. Google Calendar sent an updated invitation.' });
if (selectedDate) await chooseDate(selectedDate);
} catch (err) {
- setCancelState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel this booking.' });
+ setManageState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not reschedule this booking.' });
}
}
@@ -847,6 +932,7 @@ function PublicBooking() {
function renderDetailsView() {
if (!selectedSlot || !duration) return null;
+ const paymentRequiredForSelection = Boolean(activeConfig.payment && !activeConfig.payment.freeDurations.includes(duration));
return (
<div className="panel-view panel-view-scroll" key="details">
<button className="panel-back" type="button" onClick={changeDuration}>
@@ -916,7 +1002,7 @@ function PublicBooking() {
<label>Anything I should know? <span className="optional">Optional</span>
<textarea name="message" rows={3} placeholder="Context, topic, links..." />
</label>
- {activeConfig.payment && (
+ {activeConfig.payment && paymentRequiredForSelection && (
<div className={`payment-note ${activeConfig.payment.ready ? '' : 'payment-unavailable'}`}>
<CreditCard size={17} />
<div>
@@ -924,18 +1010,24 @@ function PublicBooking() {
<span>
{activeConfig.payment.ready
? <>{formatMoneyMajor(activeConfig.payment.ratePerMinute, activeConfig.payment.currency)} per minute · {duration} min = <strong>{formatMoneyMajor(activeConfig.payment.ratePerMinute * duration, activeConfig.payment.currency)}</strong>. Your calendar invitation is sent only after Stripe confirms payment.</>
- : <>This calendar requires payment, but its Stripe connection is not ready. Please contact the owner.</>}
+ : <>This session requires payment, but its Stripe connection is not ready. Please contact the owner.</>}
</span>
</div>
</div>
)}
+ {activeConfig.payment && !paymentRequiredForSelection && (
+ <div className="payment-note free-session-note">
+ <Check size={17} />
+ <div><strong>No payment required</strong><span>{duration}-minute sessions are configured as free.</span></div>
+ </div>
+ )}
<label className="honeypot" aria-hidden="true">Website<input name="website" tabIndex={-1} autoComplete="off" /></label>
{error && <div className="error-banner" role="alert">{error}</div>}
- <button className="primary-button" type="submit" disabled={booking || Boolean(activeConfig.payment && !activeConfig.payment.ready)}>
+ <button className="primary-button" type="submit" disabled={booking || Boolean(paymentRequiredForSelection && activeConfig.payment && !activeConfig.payment.ready)}>
{booking
- ? <><Loader2 className="spin" size={18} /> {activeConfig.payment ? 'Opening payment…' : 'Booking…'}</>
- : <>{activeConfig.payment ? (activeConfig.payment.ready ? 'Continue to secure payment' : 'Payment unavailable') : 'Book this slot'} <ArrowRight size={18} /></>}
+ ? <><Loader2 className="spin" size={18} /> {paymentRequiredForSelection ? 'Opening payment…' : 'Booking…'}</>
+ : <>{paymentRequiredForSelection ? (activeConfig.payment?.ready ? 'Continue to secure payment' : 'Payment unavailable') : 'Book this slot'} <ArrowRight size={18} /></>}
</button>
</form>
</div>
@@ -1004,29 +1096,29 @@ function PublicBooking() {
<span>Choose the day first. Everything else follows.</span>
</div>
</div>
- <div className={`public-cancel ${cancelOpen ? 'open' : ''}`}>
- {!cancelOpen ? (
+ <div className={`public-manage ${manageOpen ? 'open' : ''}`}>
+ {!manageOpen ? (
<button
- className="public-cancel-link"
+ className="public-manage-link"
type="button"
onClick={() => {
- setCancelOpen(true);
- setCancelState({ kind: 'idle', message: '' });
+ setManageOpen(true);
+ setManageState({ kind: 'idle', message: '' });
}}
- >Cancel a booking</button>
- ) : (
- <form className="public-cancel-form" onSubmit={submitPublicCancellation}>
- <div className="public-cancel-heading">
- <strong>Cancel a booking</strong>
- <button className="text-button" type="button" onClick={() => setCancelOpen(false)}>Close</button>
+ >Manage a booking</button>
+ ) : !managedBooking ? (
+ <form className="public-manage-form" onSubmit={submitManageLookup}>
+ <div className="public-manage-heading">
+ <strong>Manage booking</strong>
+ <button className="text-button" type="button" onClick={() => setManageOpen(false)}>Close</button>
</div>
<label>
Booking ID
<input
- value={cancelBookingId}
+ value={manageBookingId}
onChange={(event) => {
- setCancelBookingId(event.target.value.toUpperCase());
- if (cancelState.kind !== 'loading') setCancelState({ kind: 'idle', message: '' });
+ setManageBookingId(event.target.value.toUpperCase());
+ if (manageState.kind !== 'loading') setManageState({ kind: 'idle', message: '' });
}}
autoComplete="off"
spellCheck={false}
@@ -1034,14 +1126,69 @@ function PublicBooking() {
aria-label="Booking ID"
/>
</label>
- <button className="danger-button public-cancel-submit" type="submit" disabled={cancelState.kind === 'loading'}>
- {cancelState.kind === 'loading' ? <><Loader2 className="spin" size={15} /> Cancelling…</> : 'Cancel booking'}
+ <button className="secondary-button public-manage-submit" type="submit" disabled={manageState.kind === 'loading'}>
+ {manageState.kind === 'loading' ? <><Loader2 className="spin" size={15} /> Loading…</> : 'Manage booking'}
</button>
- <small>Paid bookings are refunded to the original payment method before Slot cancels the meeting.</small>
- {cancelState.kind !== 'idle' && cancelState.kind !== 'loading' && (
- <p className={`public-cancel-message ${cancelState.kind}`} role={cancelState.kind === 'error' ? 'alert' : 'status'}>{cancelState.message}</p>
- )}
+ <small>Your booking ID is shown on the confirmation screen and in the calendar invitation.</small>
+ {manageState.kind === 'error' && <p className="public-manage-message error" role="alert">{manageState.message}</p>}
</form>
+ ) : (
+ <div className="public-manage-form">
+ <div className="public-manage-heading">
+ <strong>Manage booking</strong>
+ <button className="text-button" type="button" onClick={() => {
+ setManagedBooking(null);
+ setManageAction('none');
+ setManageState({ kind: 'idle', message: '' });
+ }}>Another ID</button>
+ </div>
+ <div className="public-manage-summary">
+ <code>{managedBooking.bookingId}</code>
+ <strong>{managedBooking.name}</strong>
+ <span>{formatDateShort(managedBooking.start, managedBooking.timezone)} · {formatClock(managedBooking.start, managedBooking.timezone)} · {managedBooking.duration} min</span>
+ {managedBooking.status !== 'confirmed' && <span className="public-manage-status">{managedBooking.status === 'cancelled' ? 'Cancelled' : managedBooking.status}</span>}
+ </div>
+
+ {manageAction === 'none' && managedBooking.status === 'confirmed' && (
+ <div className="public-manage-actions">
+ <button type="button" className="secondary-button" disabled={!managedBooking.canReschedule} onClick={() => {
+ setManageAction('reschedule');
+ const date = dateKeyForInstant(managedBooking.start, managedBooking.timezone);
+ void loadManageAvailability(date);
+ }}>Reschedule</button>
+ <button type="button" className="danger-button" disabled={!managedBooking.canCancel} onClick={() => setManageAction('cancel')}>Cancel</button>
+ </div>
+ )}
+
+ {manageAction === 'cancel' && (
+ <div className="public-manage-action-panel">
+ <strong>Cancel this booking?</strong>
+ <small>{managedBooking.paymentStatus === 'paid' ? 'The Stripe payment will be refunded to the original payment method before the meeting is cancelled.' : 'The calendar invitation will be cancelled and the time will become available again.'}</small>
+ <div className="public-manage-actions">
+ <button type="button" className="secondary-button" onClick={() => setManageAction('none')} disabled={manageState.kind === 'loading'}>Keep booking</button>
+ <button type="button" className="danger-button" onClick={cancelManagedBooking} disabled={manageState.kind === 'loading'}>{manageState.kind === 'loading' ? 'Cancelling…' : managedBooking.paymentStatus === 'paid' ? 'Cancel & refund' : 'Confirm cancellation'}</button>
+ </div>
+ </div>
+ )}
+
+ {manageAction === 'reschedule' && (
+ <div className="public-manage-action-panel">
+ <label>New date<input type="date" value={manageDate} onChange={(event) => void loadManageAvailability(event.target.value)} /></label>
+ {manageSlotsLoading ? <div className="public-manage-loading"><Loader2 className="spin" size={14} /> Checking times…</div> : manageSlots.length ? (
+ <div className="public-manage-times">
+ {manageSlots.map((slot) => <button type="button" key={slot.start} className={manageStart === slot.start ? 'active' : ''} onClick={() => setManageStart(slot.start)}>{slot.time}</button>)}
+ </div>
+ ) : <small>No matching {managedBooking.duration}-minute times are open on this date.</small>}
+ <div className="public-manage-actions">
+ <button type="button" className="secondary-button" onClick={() => { setManageAction('none'); setManageStart(''); }} disabled={manageState.kind === 'loading'}>Back</button>
+ <button type="button" className="primary-button" onClick={rescheduleManagedBooking} disabled={!manageStart || manageState.kind === 'loading'}>{manageState.kind === 'loading' ? 'Rescheduling…' : 'Reschedule booking'}</button>
+ </div>
+ </div>
+ )}
+
+ {manageState.kind === 'success' && <p className="public-manage-message success" role="status">{manageState.message}</p>}
+ {manageState.kind === 'error' && <p className="public-manage-message error" role="alert">{manageState.message}</p>}
+ </div>
)}
</div>
</div>
@@ -1842,6 +1989,9 @@ function Admin() {
durations: e.target.checked
? [...activeSettings.durations, minutes].sort((a, b) => a - b)
: activeSettings.durations.filter((value) => value !== minutes),
+ paymentFreeDurations: e.target.checked
+ ? activeSettings.paymentFreeDurations
+ : activeSettings.paymentFreeDurations.filter((value) => value !== minutes),
})}
/>
{minutes} min
@@ -1870,18 +2020,37 @@ function Admin() {
checked={activeSettings.paymentEnabled}
onChange={(e) => setSettings({ ...activeSettings, paymentEnabled: e.target.checked })}
/>
- Require payment before confirmation
+ Enable Stripe payments
</label>
</div>
<div className="two-col-fields">
<label>Currency<input maxLength={3} value={activeSettings.paymentCurrency} onChange={(e) => setSettings({ ...activeSettings, paymentCurrency: e.target.value.toLowerCase() })} /><small>ISO code</small></label>
<label>Price per minute<input type="number" min="0" step="0.01" value={activeSettings.paymentRatePerMinute} onChange={(e) => setSettings({ ...activeSettings, paymentRatePerMinute: Number(e.target.value) })} /><small>{activeSettings.paymentCurrency.toUpperCase()} / minute</small></label>
</div>
+ <span className="field-label">Free durations <span className="optional">Optional</span></span>
+ <div className="checkbox-chips">
+ {activeSettings.durations.map((minutes) => (
+ <label key={minutes} className={activeSettings.paymentFreeDurations.includes(minutes) ? 'active' : ''}>
+ <input
+ type="checkbox"
+ checked={activeSettings.paymentFreeDurations.includes(minutes)}
+ onChange={(e) => setSettings({
+ ...activeSettings,
+ paymentFreeDurations: e.target.checked
+ ? [...activeSettings.paymentFreeDurations, minutes].sort((a, b) => a - b)
+ : activeSettings.paymentFreeDurations.filter((value) => value !== minutes),
+ })}
+ />
+ {minutes} min free
+ </label>
+ ))}
+ </div>
+ <p className="settings-note">Stripe can stay enabled while selected meeting lengths remain free. For example, make 15 minutes free and charge normally for 30, 45, and 60 minutes.</p>
<label>Checkout label<input value={activeSettings.paymentLabel} onChange={(e) => setSettings({ ...activeSettings, paymentLabel: e.target.value })} placeholder="Booking payment" /></label>
<p className="theme-footnote">
Enter normal currency, not Stripe minor units. Example: <strong>50</strong> with SEK means <strong>SEK 50 per minute</strong>, so a 15-minute booking costs <strong>{formatMoneyMajor(50 * 15, 'sek')}</strong>.
</p>
- {activeSettings.paymentEnabled && !stripeConfigured && <div className="error-banner" role="alert">Payment is required but Stripe secrets are missing. Visitors will not be allowed to create unpaid bookings.</div>}
+ {activeSettings.paymentEnabled && !stripeConfigured && <div className="error-banner" role="alert">Stripe secrets are missing. Durations marked free can still be booked, but paid durations will be unavailable.</div>}
{stripeConfigured && stripeMode === 'unknown' && <div className="error-banner" role="alert">The Stripe secret key is configured, but its mode could not be identified. Slot expects a key beginning with sk_test_ or sk_live_.</div>}
{stripeConfigured && stripeMode === 'test' && <p className="settings-note">Stripe test mode only emails test receipts to verified Stripe account team-member addresses. Every successful payment still gets a hosted Stripe receipt link on Slot's confirmation screen.</p>}
</section>
diff --git a/src/styles.css b/src/styles.css
index c5b4734..1267e7b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1677,16 +1677,16 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
.activity-feed-heading { align-items: flex-start; flex-direction: column; }
}
-/* Public self-service cancellation */
+/* Public self-service booking management */
.intro-bottom {
display: grid;
gap: 13px;
}
-.public-cancel {
+.public-manage {
display: grid;
justify-items: start;
}
-.public-cancel-link {
+.public-manage-link {
appearance: none;
border: 0;
background: transparent;
@@ -1698,8 +1698,8 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
text-underline-offset: 3px;
cursor: pointer;
}
-.public-cancel-link:hover { color: var(--text); }
-.public-cancel-form {
+.public-manage-link:hover { color: var(--text); }
+.public-manage-form {
width: 100%;
display: grid;
gap: 9px;
@@ -1708,22 +1708,22 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
border-radius: var(--radius-md);
background: var(--surface-soft);
}
-.public-cancel-heading {
+.public-manage-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
-.public-cancel-heading strong { font-size: 12px; }
-.public-cancel-heading .text-button { padding: 0; min-height: auto; font-size: 10px; }
-.public-cancel-form label {
+.public-manage-heading strong { font-size: 12px; }
+.public-manage-heading .text-button { padding: 0; min-height: auto; font-size: 10px; }
+.public-manage-form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 10px;
font-weight: 650;
}
-.public-cancel-form input {
+.public-manage-form input {
width: 100%;
min-width: 0;
border: 1px solid var(--border);
@@ -1736,18 +1736,18 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
letter-spacing: .02em;
text-transform: uppercase;
}
-.public-cancel-submit {
+.public-manage-submit {
width: 100%;
min-height: 36px;
padding: 8px 11px;
font-size: 11px;
}
-.public-cancel-form small {
+.public-manage-form small {
color: var(--faint);
font-size: 9.5px;
line-height: 1.45;
}
-.public-cancel-message {
+.public-manage-message {
margin: 0;
padding: 9px 10px;
border: 1px solid var(--border);
@@ -1755,14 +1755,67 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
font-size: 10px;
line-height: 1.45;
}
-.public-cancel-message.success {
+.public-manage-message.success {
color: var(--success);
background: color-mix(in srgb, var(--success) 7%, var(--surface));
}
-.public-cancel-message.error {
+.public-manage-message.error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
}
+
+.public-manage-summary {
+ display: grid;
+ gap: 4px;
+ padding: 9px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+}
+.public-manage-summary code {
+ color: var(--faint);
+ font-size: 9px;
+ word-break: break-all;
+}
+.public-manage-summary strong { font-size: 12px; }
+.public-manage-summary span { color: var(--muted); font-size: 10px; line-height: 1.4; }
+.public-manage-status { text-transform: capitalize; }
+.public-manage-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+.public-manage-actions > * { width: 100%; min-width: 0; min-height: 34px; padding: 7px 9px; font-size: 10px; }
+.public-manage-action-panel {
+ display: grid;
+ gap: 9px;
+ padding-top: 2px;
+}
+.public-manage-action-panel > strong { font-size: 11px; }
+.public-manage-loading { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; }
+.public-manage-times {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ max-height: 150px;
+ overflow: auto;
+}
+.public-manage-times button {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--text);
+ padding: 8px 5px;
+ font: inherit;
+ font-size: 10px;
+ cursor: pointer;
+}
+.public-manage-times button.active {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, var(--surface));
+}
+.free-session-note { border-color: color-mix(in srgb, var(--success) 30%, var(--border)); }
+
.booking-id-row dd {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
diff --git a/worker/index.ts b/worker/index.ts
index df1dbbb..5bc921b 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -135,6 +135,29 @@ async function enforceCancellationRateLimit(request: Request, env: Env): Promise
return (row?.count ?? 1) <= 10;
}
+
+async function enforceManageRateLimit(request: Request, env: Env): Promise<boolean> {
+ const ip = request.headers.get('CF-Connecting-IP');
+ if (!ip) return true;
+
+ const hourBucket = Math.floor(Date.now() / 3_600_000);
+ const material = new TextEncoder().encode(`manage:${ip}:${hourBucket}:${env.SESSION_SECRET}`);
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', material));
+ const key = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
+ const resetAt = (hourBucket + 1) * 3_600_000;
+
+ await env.DB.prepare('DELETE FROM rate_limits WHERE reset_at < ?1').bind(Date.now()).run();
+ await env.DB.prepare(`
+ INSERT INTO rate_limits (key, count, reset_at)
+ VALUES (?1, 1, ?2)
+ ON CONFLICT(key) DO UPDATE SET count = count + 1
+ `).bind(key, resetAt).run();
+ const row = await env.DB.prepare('SELECT count FROM rate_limits WHERE key = ?1')
+ .bind(key)
+ .first<{ count: number }>();
+ return (row?.count ?? 1) <= 30;
+}
+
function newPublicBookingId(): string {
const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
const bytes = crypto.getRandomValues(new Uint8Array(20));
@@ -188,8 +211,12 @@ function validateAppSettings(input: unknown): AppSettings {
1_000_000,
Math.max(0, Math.round(Number(raw.paymentRatePerMinute ?? DEFAULT_SETTINGS.paymentRatePerMinute) * 10000) / 10000),
);
- if (paymentEnabled && paymentRatePerMinute <= 0) {
- throw new Error('Set a price per minute greater than zero before requiring payment.');
+ const paymentFreeDurations = Array.isArray(raw.paymentFreeDurations)
+ ? [...new Set(raw.paymentFreeDurations.map(Number).filter((value) => durations.includes(value)))].sort((a, b) => a - b)
+ : DEFAULT_SETTINGS.paymentFreeDurations;
+ const hasPaidDuration = durations.some((duration) => !paymentFreeDurations.includes(duration));
+ if (paymentEnabled && hasPaidDuration && paymentRatePerMinute <= 0) {
+ throw new Error('Set a price per minute greater than zero for paid meeting lengths.');
}
return {
@@ -213,6 +240,7 @@ function validateAppSettings(input: unknown): AppSettings {
inPersonLocation: String(raw.inPersonLocation ?? '').trim().slice(0, 240),
paymentEnabled,
paymentRatePerMinute,
+ paymentFreeDurations,
paymentCurrency: /^[a-zA-Z]{3}$/.test(String(raw.paymentCurrency ?? ''))
? String(raw.paymentCurrency).toLowerCase()
: DEFAULT_SETTINGS.paymentCurrency,
@@ -456,6 +484,35 @@ function publicConfirmation(booking: PaymentBookingRow, receiptUrl: string | nul
};
}
+
+function publicManagePayload(booking: PaymentBookingRow) {
+ const future = Date.parse(booking.end_time) > Date.now();
+ const confirmed = booking.status === 'confirmed';
+ return {
+ bookingId: booking.public_booking_id,
+ name: booking.name,
+ start: booking.start_time,
+ end: booking.end_time,
+ duration: booking.duration_minutes,
+ timezone: booking.timezone,
+ meetingMode: booking.meeting_mode,
+ status: booking.status,
+ paymentStatus: booking.payment_status,
+ refundStatus: booking.refund_status,
+ canCancel: confirmed && future,
+ canReschedule: confirmed && future,
+ };
+}
+
+async function bookingByPublicId(env: Env, value: unknown): Promise<PaymentBookingRow | null> {
+ const publicBookingId = normalizePublicBookingId(value);
+ if (!publicBookingId) return null;
+ const row = await env.DB.prepare('SELECT id FROM bookings WHERE public_booking_id = ?1')
+ .bind(publicBookingId)
+ .first<{ id: string }>();
+ return row ? paymentBookingById(env, row.id) : null;
+}
+
type ActivityMetadata = Record<string, string | number | boolean | null | undefined>;
async function logActivity(
@@ -975,7 +1032,7 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
return json({ error: 'A phone number is required for a phone meeting.' }, 400);
}
- const paymentRequired = settings.paymentEnabled;
+ const paymentRequired = settings.paymentEnabled && !settings.paymentFreeDurations.includes(duration);
if (paymentRequired && settings.paymentRatePerMinute <= 0) {
return json({ error: 'Payment is required, but the owner has not configured a per-minute price.' }, 503);
}
@@ -1423,7 +1480,7 @@ async function rescheduleConfirmedBooking(
env: Env,
booking: PaymentBookingRow,
startIso: string,
- source: 'admin' | 'guest' = 'admin',
+ source: 'admin' | 'guest_suggestion' | 'guest_self' = 'admin',
): Promise<PaymentBookingRow> {
if (booking.status !== 'confirmed') throw new Error('Only confirmed bookings can be rescheduled.');
if (!booking.google_event_id) throw new Error('This booking is missing its Google Calendar event.');
@@ -1498,7 +1555,7 @@ async function rescheduleConfirmedBooking(
const result = await paymentBookingById(env, booking.id);
if (!result) throw new Error('Rescheduled booking record was not found.');
- await logActivity(env, booking.id, source === 'guest' ? 'time_suggestion_accepted' : 'booking_rescheduled', {
+ await logActivity(env, booking.id, source === 'guest_suggestion' ? 'time_suggestion_accepted' : 'booking_rescheduled', {
source,
oldStart: booking.start_time,
oldEnd: booking.end_time,
@@ -1627,7 +1684,7 @@ async function acceptRescheduleProposal(env: Env, token: string): Promise<Respon
return json({ error: 'This reschedule suggestion is invalid or no longer active.' }, 404);
}
try {
- const updated = await rescheduleConfirmedBooking(env, booking, booking.proposed_start_time, 'guest');
+ const updated = await rescheduleConfirmedBooking(env, booking, booking.proposed_start_time, 'guest_suggestion');
return json({ ok: true, booking: publicConfirmation(updated) });
} catch (error) {
if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
@@ -1809,6 +1866,73 @@ async function publicCancelBooking(request: Request, env: Env, value: unknown):
return cancelBooking(env, row.id);
}
+
+async function publicManageBooking(request: Request, env: Env, value: unknown): Promise<Response> {
+ if (!(await enforceManageRateLimit(request, env))) {
+ return json({ error: 'Too many booking management attempts. Try again later.' }, 429);
+ }
+ const publicBookingId = normalizePublicBookingId(value);
+ if (!publicBookingId) return json({ error: 'Enter a valid Slot booking ID.' }, 400);
+ const booking = await bookingByPublicId(env, publicBookingId);
+ if (!booking) return json({ error: 'Booking ID not found.' }, 404);
+ return json({ booking: publicManagePayload(booking) });
+}
+
+async function publicManageAvailability(
+ request: Request,
+ env: Env,
+ value: unknown,
+ date: unknown,
+): Promise<Response> {
+ if (!(await enforceManageRateLimit(request, env))) {
+ return json({ error: 'Too many booking management attempts. Try again later.' }, 429);
+ }
+ const publicBookingId = normalizePublicBookingId(value);
+ if (!publicBookingId) return json({ error: 'Enter a valid Slot booking ID.' }, 400);
+ const booking = await bookingByPublicId(env, publicBookingId);
+ if (!booking) return json({ error: 'Booking ID not found.' }, 404);
+ if (booking.status !== 'confirmed' || Date.parse(booking.end_time) <= Date.now()) {
+ return json({ error: 'Only upcoming confirmed bookings can be rescheduled.' }, 409);
+ }
+ const dateKey = String(date ?? '').trim();
+ try {
+ const availability = await computeAvailability(env, dateKey);
+ const slots = availability.slots
+ .filter((slot) => slot.durations.includes(booking.duration_minutes))
+ .map((slot) => ({ start: slot.start, time: slot.time }));
+ return json({ date: dateKey, timezone: availability.settings.timezone, duration: booking.duration_minutes, slots });
+ } catch (error) {
+ if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
+ return json({ error: errorMessage(error) }, 400);
+ }
+}
+
+async function publicRescheduleBooking(
+ request: Request,
+ env: Env,
+ value: unknown,
+ start: unknown,
+): Promise<Response> {
+ if (!(await enforceManageRateLimit(request, env))) {
+ return json({ error: 'Too many booking management attempts. Try again later.' }, 429);
+ }
+ const publicBookingId = normalizePublicBookingId(value);
+ if (!publicBookingId) return json({ error: 'Enter a valid Slot booking ID.' }, 400);
+ const booking = await bookingByPublicId(env, publicBookingId);
+ if (!booking) return json({ error: 'Booking ID not found.' }, 404);
+ if (booking.status !== 'confirmed' || Date.parse(booking.end_time) <= Date.now()) {
+ return json({ error: 'Only upcoming confirmed bookings can be rescheduled.' }, 409);
+ }
+ try {
+ const updated = await rescheduleConfirmedBooking(env, booking, String(start ?? ''), 'guest_self');
+ return json({ ok: true, booking: publicManagePayload(updated) });
+ } catch (error) {
+ if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
+ const message = errorMessage(error);
+ return json({ error: message }, /available|busy|future|took/i.test(message) ? 409 : 400);
+ }
+}
+
function cleanLabel(value: unknown): string | null {
const label = String(value ?? '').trim().slice(0, 80);
return label || null;
@@ -1916,9 +2040,10 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
connected,
payment: paymentRequired ? {
enabled: true,
- required: true,
+ required: settings.paymentFreeDurations.length === 0,
ready: stripeConfigured(env) && settings.paymentRatePerMinute > 0,
ratePerMinute: settings.paymentRatePerMinute,
+ freeDurations: settings.paymentFreeDurations,
currency: settings.paymentCurrency,
label: settings.paymentLabel,
} : undefined,
@@ -1960,6 +2085,21 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
return publicCancelBooking(request, env, body.bookingId);
}
+ if (request.method === 'POST' && url.pathname === '/api/bookings/manage') {
+ const body = await request.json().catch(() => ({})) as { bookingId?: string };
+ return publicManageBooking(request, env, body.bookingId);
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/bookings/manage/availability') {
+ const body = await request.json().catch(() => ({})) as { bookingId?: string; date?: string };
+ return publicManageAvailability(request, env, body.bookingId, body.date);
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/bookings/reschedule') {
+ const body = await request.json().catch(() => ({})) as { bookingId?: string; start?: string };
+ return publicRescheduleBooking(request, env, body.bookingId, body.start);
+ }
+
if (request.method === 'POST' && url.pathname === '/api/payments/checkout') {
const body = (await request.json()) as { bookingId?: string };
return createPaymentCheckout(env, String(body.bookingId ?? ''));
diff --git a/worker/settings.ts b/worker/settings.ts
index 5b9450a..fce95b3 100644
--- a/worker/settings.ts
+++ b/worker/settings.ts
@@ -17,6 +17,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
inPersonLocation: '',
paymentEnabled: false,
paymentRatePerMinute: 0,
+ paymentFreeDurations: [],
paymentCurrency: 'sek',
paymentLabel: 'Booking payment',
availability: {
@@ -56,6 +57,9 @@ export async function getSettings(env: Env): Promise<AppSettings> {
paymentEnabled: hasPerMinuteRate ? Boolean(parsed.paymentEnabled) : false,
paymentCurrency: currency,
paymentRatePerMinute,
+ paymentFreeDurations: Array.isArray(parsed.paymentFreeDurations)
+ ? parsed.paymentFreeDurations.map(Number).filter((value) => Number.isFinite(value) && value > 0)
+ : DEFAULT_SETTINGS.paymentFreeDurations,
durations: Array.isArray(parsed.durations) ? parsed.durations : DEFAULT_SETTINGS.durations,
busyCalendarIds: Array.isArray(parsed.busyCalendarIds) ? parsed.busyCalendarIds : DEFAULT_SETTINGS.busyCalendarIds,
allowMeetingModes: Array.isArray(parsed.allowMeetingModes)
diff --git a/worker/types.ts b/worker/types.ts
index e9ccb47..586f387 100644
--- a/worker/types.ts
+++ b/worker/types.ts
@@ -28,6 +28,7 @@ export interface AppSettings {
inPersonLocation: string;
paymentEnabled: boolean;
paymentRatePerMinute: number;
+ paymentFreeDurations: number[];
paymentCurrency: string;
paymentLabel: string;
availability: WeeklyAvailability;