Blocks

Availability Pricing Calendar Block

Reviewed

A working host calendar with API-backed inventory, date drafts, partner holds, validation, and multi-channel publishing.

Commerce / Booking

Availability pricing calendar

Copy the responsive calendar, focused day inspector, and repository-local API contract into booking marketplaces, rental host tools, appointment inventory apps, or any product where teams manage availability and revenue by date.

1200px

vue
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import {
	DomAlert,
	DomBadge,
	DomButton,
	DomDialog,
	DomEmptyState,
	DomIconButton,
	DomMoneyInput,
	DomMonthCalendar,
	DomNumberInput,
	DomSelect,
	DomStatusPill,
	DomTagCombobox,
	DomToggleButtonGroup,
} from '@getdom/studio/vue';

const modes = [
	{ label: 'Price', value: 'price' },
	{ label: 'Availability', value: 'availability' },
	{ label: 'Rules', value: 'rules' },
];

const mobileViews = [
	{ label: 'Calendar', value: 'calendar' },
	{ label: 'Day', value: 'day' },
	{ label: 'Publish', value: 'publish' },
];

const availabilityOptions = [
	{ label: 'Available', value: 'available', description: 'Open for instant booking' },
	{ label: 'On hold', value: 'hold', description: 'Reserved by a partner or operator' },
	{ label: 'Blocked', value: 'blocked', description: 'Unavailable across every channel' },
];

const restrictionOptions = [
	{ label: 'No check-in', value: 'no-check-in', description: 'Guests cannot arrive on this date' },
	{ label: 'No check-out', value: 'no-check-out', description: 'Guests cannot leave on this date' },
	{ label: 'Manual review', value: 'manual-review', description: 'Require approval before confirmation' },
	{ label: 'Event pricing', value: 'event-pricing', description: 'Keep event rate guardrails active' },
	{ label: 'Owner stay', value: 'owner-stay', description: 'Personal use or maintenance window' },
];

const weekdayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const previousIcon = 'M15 18l-6-6 6-6';
const nextIcon = 'M9 18l6-6-6-6';

const listings = ref([]);
const selectedListingId = ref('river-loft');
const calendar = ref(null);
const selectedMonth = ref('2026-08');
const selectedDate = ref('2026-08-01');
const mode = ref('price');
const mobileView = ref('calendar');
const nightlyPrice = ref(0);
const availability = ref('available');
const minimumStay = ref(1);
const restrictions = ref([]);
const feedback = ref(null);
const loadError = ref('');
const publishDialogOpen = ref(false);
const isLoading = ref(true);
const isSaving = ref(false);
const isImporting = ref(false);
const isPublishing = ref(false);
const isDiscarding = ref(false);
let calendarRequestSequence = 0;

const listingOptions = computed(() => listings.value.map((listing) => ({
	value: listing.id,
	label: listing.name,
	description: `${listing.description} · ${listing.occupancy}% occupied`,
	status: listing.status,
	pendingChangeCount: listing.pendingChangeCount,
})));
const calendarDays = computed(() => calendar.value?.days || []);
const dayByDate = computed(() => Object.fromEntries(calendarDays.value.map((day) => [day.date, day])));
const visibleDays = computed(() => calendarDays.value.filter((day) => day.date.startsWith(`${selectedMonth.value}-`)));
const selectedDay = computed(() => dayByDate.value[selectedDate.value] || visibleDays.value[0] || null);
const activeListing = computed(() => calendar.value?.listing || listings.value.find((listing) => listing.id === selectedListingId.value) || null);
const pendingChanges = computed(() => calendar.value?.pendingChanges || []);
const pendingDateSet = computed(() => new Set(pendingChanges.value.map((change) => change.date)));
const selectedDayPending = computed(() => pendingDateSet.value.has(selectedDate.value));
const currentMonthIndex = computed(() => Math.max(0, (calendar.value?.months || []).indexOf(selectedMonth.value)));
const monthLabel = computed(() => formatMonth(selectedMonth.value));
const selectedDateLabel = computed(() => formatDate(selectedDate.value));
const selectedRestrictionLabels = computed(() => restrictions.value.map(restrictionLabel).join(', ') || 'No restrictions');
const changedFields = computed(() => {
	if (!selectedDay.value) return [];
	const changes = [];
	if (Number(nightlyPrice.value) !== selectedDay.value.price) changes.push('price');
	if (availability.value !== selectedDay.value.status) changes.push('availability');
	if (Number(minimumStay.value) !== selectedDay.value.minimumStay) changes.push('minimum stay');
	if (restrictions.value.join('|') !== selectedDay.value.restrictions.join('|')) changes.push('restrictions');
	return changes;
});
const availableNightCount = computed(() => visibleDays.value.filter((day) => day.status === 'available').length);
const averageNightlyRate = computed(() => {
	if (!visibleDays.value.length) return 0;
	return Math.round(visibleDays.value.reduce((total, day) => total + day.price, 0) / visibleDays.value.length);
});
const projectedRevenue = computed(() => Math.round(availableNightCount.value * averageNightlyRate.value * 0.72));
const mobileGridCells = computed(() => {
	const firstDay = new Date(`${selectedMonth.value}-01T12:00:00`).getDay();
	const mondayOffset = (firstDay + 6) % 7;
	return [
		...Array.from({ length: mondayOffset }, (_, index) => ({ empty: true, key: `empty-${index}` })),
		...visibleDays.value.map((day) => ({ ...day, key: day.date })),
	];
});

watch(selectedListingId, (listingId, previousListingId) => {
	if (listingId && previousListingId && listingId !== previousListingId) loadCalendar(listingId);
});

onMounted(loadResources);

/**
 * Requests JSON from the availability calendar API and converts non-success
 * responses into ordinary JavaScript errors for consistent recovery UI.
 *
 * @param {string} url API route.
 * @param {RequestInit} [options] Fetch options.
 * @returns {Promise<Record<string, unknown>>} Parsed response payload.
 */
async function requestJson(url, options = {}) {
	const response = await fetch(url, {
		...options,
		headers: {
			Accept: 'application/json',
			...(options.body ? { 'Content-Type': 'application/json' } : {}),
			...(options.headers || {}),
		},
	});
	const payload = await response.json().catch(() => ({}));
	if (!response.ok) throw new Error(payload.error || `Request failed with status ${response.status}.`);
	return payload;
}

/**
 * Loads the listing catalog before hydrating the initially selected calendar.
 *
 * @returns {Promise<void>}
 */
async function loadResources() {
	isLoading.value = true;
	loadError.value = '';
	try {
		const payload = await requestJson('/api/block-demos/availability-pricing-calendar/listings');
		listings.value = Array.isArray(payload.listings) ? payload.listings : [];
		if (!listings.value.length) throw new Error('No booking listings are available.');
		if (!listings.value.some((listing) => listing.id === selectedListingId.value)) {
			selectedListingId.value = listings.value[0].id;
		}
		await loadCalendar(selectedListingId.value);
	} catch (error) {
		loadError.value = error instanceof Error ? error.message : 'Could not load booking inventory.';
		isLoading.value = false;
	}
}

/**
 * Loads one listing while ignoring stale responses after a quick selection.
 *
 * @param {string} listingId Canonical listing identifier.
 * @returns {Promise<void>}
 */
async function loadCalendar(listingId) {
	const requestSequence = ++calendarRequestSequence;
	isLoading.value = true;
	loadError.value = '';
	feedback.value = null;
	try {
		const payload = await requestJson(`/api/block-demos/availability-pricing-calendar/${encodeURIComponent(listingId)}`);
		if (requestSequence !== calendarRequestSequence) return;
		applyCalendar(payload.calendar, { resetMonth: true });
	} catch (error) {
		if (requestSequence !== calendarRequestSequence) return;
		loadError.value = error instanceof Error ? error.message : 'Could not load this listing.';
	} finally {
		if (requestSequence === calendarRequestSequence) isLoading.value = false;
	}
}

/**
 * Applies a complete API calendar payload and refreshes the active day editor.
 *
 * @param {Record<string, unknown>} nextCalendar API calendar payload.
 * @param {{ resetMonth?: boolean, preferredDate?: string }} [options] Selection behavior.
 * @returns {void}
 */
function applyCalendar(nextCalendar, options = {}) {
	calendar.value = nextCalendar;
	syncListingSummary(nextCalendar.listing);
	if (options.resetMonth || !nextCalendar.months.includes(selectedMonth.value)) {
		selectedMonth.value = nextCalendar.months[0];
	}
	const preferredDate = options.preferredDate || selectedDate.value;
	const nextDate = nextCalendar.days.some((day) => day.date === preferredDate)
		? preferredDate
		: nextCalendar.days.find((day) => day.date.startsWith(`${selectedMonth.value}-`))?.date;
	selectDay(nextDate || nextCalendar.days[0]?.date, false);
}

/**
 * Refreshes selector metadata after draft or publish mutations.
 *
 * @param {Record<string, unknown>} listing Updated listing summary.
 * @returns {void}
 */
function syncListingSummary(listing) {
	listings.value = listings.value.map((item) => (item.id === listing.id ? { ...item, ...listing } : item));
}

/**
 * Selects a date, populates its exact rule editor, and optionally opens the
 * focused mobile day workflow.
 *
 * @param {string} date Date-only inventory identifier.
 * @param {boolean} [openMobileEditor] Whether to reveal the mobile day panel.
 * @returns {void}
 */
function selectDay(date, openMobileEditor = true) {
	const day = dayByDate.value[date];
	if (!day) return;
	selectedDate.value = date;
	nightlyPrice.value = day.price;
	availability.value = day.status;
	minimumStay.value = day.minimumStay;
	restrictions.value = [...day.restrictions];
	feedback.value = null;
	if (openMobileEditor) mobileView.value = 'day';
}

/**
 * Handles a DOM Studio calendar day activation.
 *
 * @param {{ cell: { value: string } }} payload Calendar click payload.
 * @returns {void}
 */
function onDayClick({ cell }) {
	selectDay(cell.value);
}

/**
 * Moves to the adjacent loaded month without requesting unsupported inventory.
 *
 * @param {number} direction Negative for previous and positive for next.
 * @returns {void}
 */
function navigateMonth(direction) {
	const months = calendar.value?.months || [];
	const nextIndex = Math.max(0, Math.min(months.length - 1, currentMonthIndex.value + direction));
	if (!months[nextIndex] || months[nextIndex] === selectedMonth.value) return;
	selectedMonth.value = months[nextIndex];
	const nextDate = calendarDays.value.find((day) => day.date.startsWith(`${selectedMonth.value}-`))?.date;
	if (nextDate) selectDay(nextDate, false);
}

/**
 * Saves the active date editor as an unpublished server-owned draft.
 *
 * @returns {Promise<void>}
 */
async function saveDay() {
	if (!selectedDay.value || !changedFields.value.length) return;
	isSaving.value = true;
	feedback.value = null;
	try {
		const payload = await requestJson(
			`/api/block-demos/availability-pricing-calendar/${encodeURIComponent(selectedListingId.value)}/days/${encodeURIComponent(selectedDate.value)}`,
			{
				method: 'PATCH',
				body: JSON.stringify({
					price: Number(nightlyPrice.value),
					status: availability.value,
					minimumStay: Number(minimumStay.value),
					restrictions: restrictions.value,
				}),
			},
		);
		applyCalendar(payload.calendar, { preferredDate: selectedDate.value });
		feedback.value = {
			tone: 'success',
			title: 'Draft saved',
			description: `${selectedDateLabel.value} is ready for review and publishing.`,
		};
	} catch (error) {
		feedback.value = {
			tone: 'danger',
			title: 'Draft not saved',
			description: error instanceof Error ? error.message : 'Check the date rule and try again.',
		};
	} finally {
		isSaving.value = false;
	}
}

/**
 * Imports deterministic partner holds into the visible month through the API.
 *
 * @returns {Promise<void>}
 */
async function importHolds() {
	isImporting.value = true;
	feedback.value = null;
	try {
		const payload = await requestJson(
			`/api/block-demos/availability-pricing-calendar/${encodeURIComponent(selectedListingId.value)}/holds/import`,
			{
				method: 'POST',
				body: JSON.stringify({ month: selectedMonth.value }),
			},
		);
		applyCalendar(payload.calendar, { preferredDate: payload.importedDates[0] || selectedDate.value });
		feedback.value = {
			tone: payload.importedDates.length ? 'success' : 'info',
			title: payload.importedDates.length ? 'Partner holds imported' : 'Channels already up to date',
			description: payload.importedDates.length
				? `${payload.importedDates.length} ${payload.importedDates.length === 1 ? 'date was' : 'dates were'} saved as drafts for ${monthLabel.value}.`
				: `No new holds were found for ${monthLabel.value}.`,
		};
	} catch (error) {
		feedback.value = {
			tone: 'danger',
			title: 'Holds not imported',
			description: error instanceof Error ? error.message : 'Try the import again.',
		};
	} finally {
		isImporting.value = false;
	}
}

/**
 * Publishes every saved draft to the configured channels with revision checks.
 *
 * @returns {Promise<void>}
 */
async function publishChanges() {
	if (!pendingChanges.value.length) return;
	isPublishing.value = true;
	feedback.value = null;
	try {
		const payload = await requestJson(
			`/api/block-demos/availability-pricing-calendar/${encodeURIComponent(selectedListingId.value)}/publish`,
			{
				method: 'POST',
				body: JSON.stringify({ revision: calendar.value.revision }),
			},
		);
		applyCalendar(payload.calendar, { preferredDate: selectedDate.value });
		publishDialogOpen.value = false;
		mobileView.value = 'calendar';
		feedback.value = {
			tone: 'success',
			title: 'Calendar published',
			description: `${payload.publishedDates.length} ${payload.publishedDates.length === 1 ? 'date is' : 'dates are'} synced across all three channels.`,
		};
	} catch (error) {
		feedback.value = {
			tone: 'danger',
			title: 'Calendar not published',
			description: error instanceof Error ? error.message : 'Reload the inventory and try again.',
		};
	} finally {
		isPublishing.value = false;
	}
}

/**
 * Discards all saved drafts and restores the last published calendar state.
 *
 * @returns {Promise<void>}
 */
async function discardChanges() {
	if (!pendingChanges.value.length) return;
	isDiscarding.value = true;
	feedback.value = null;
	try {
		const payload = await requestJson(
			`/api/block-demos/availability-pricing-calendar/${encodeURIComponent(selectedListingId.value)}/discard`,
			{
				method: 'POST',
				body: JSON.stringify({ revision: calendar.value.revision }),
			},
		);
		applyCalendar(payload.calendar, { preferredDate: selectedDate.value });
		publishDialogOpen.value = false;
		feedback.value = {
			tone: 'info',
			title: 'Drafts discarded',
			description: `${payload.discardedDates.length} ${payload.discardedDates.length === 1 ? 'date was' : 'dates were'} restored to the published state.`,
		};
	} catch (error) {
		feedback.value = {
			tone: 'danger',
			title: 'Drafts not discarded',
			description: error instanceof Error ? error.message : 'Reload the inventory and try again.',
		};
	} finally {
		isDiscarding.value = false;
	}
}

/**
 * Returns a day record for the DOM Studio month-calendar slot.
 *
 * @param {string} date Date-only cell value.
 * @returns {Record<string, unknown>|undefined} Matching inventory day.
 */
function dayFor(date) {
	return dayByDate.value[date];
}

/**
 * Builds calendar cell classes from selection, draft, and availability state.
 *
 * @param {{ value: string }} cell DOM Studio calendar cell.
 * @returns {string} Tailwind class list.
 */
function cellClass(cell) {
	const day = dayFor(cell.value);
	if (!day) return '!min-h-24 md:!min-h-24 lg:!min-h-28';
	const classes = ['!min-h-24 md:!min-h-24 lg:!min-h-28'];
	if (cell.value === selectedDate.value) classes.push('bg-primary/10 ring-2 ring-inset ring-primary/45');
	else if (day.status === 'blocked') classes.push('bg-destructive/5');
	else if (day.status === 'hold') classes.push('bg-warning/10');
	if (pendingDateSet.value.has(cell.value)) classes.push('after:absolute after:right-2 after:top-2 after:size-2 after:rounded-full after:bg-primary');
	return classes.join(' ');
}

/**
 * Resolves a semantic status tone for an availability state.
 *
 * @param {string} status Availability state.
 * @returns {string} DOM Studio tone.
 */
function statusTone(status) {
	return {
		available: 'success',
		hold: 'warning',
		blocked: 'danger',
	}[status] || 'neutral';
}

/**
 * Resolves a concise label for an availability state.
 *
 * @param {string} status Availability state.
 * @returns {string} Human-readable status.
 */
function statusLabel(status) {
	return {
		available: 'Open',
		hold: 'Hold',
		blocked: 'Blocked',
	}[status] || status;
}

/**
 * Resolves a human-readable restriction label.
 *
 * @param {string} value Restriction identifier.
 * @returns {string} Restriction label.
 */
function restrictionLabel(value) {
	return restrictionOptions.find((option) => option.value === value)?.label || value;
}

/**
 * Formats an API month identifier for the workspace heading.
 *
 * @param {string} value Month in YYYY-MM format.
 * @returns {string} Localized month label.
 */
function formatMonth(value) {
	return new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' })
		.format(new Date(`${value}-01T12:00:00`));
}

/**
 * Formats an API date identifier for the day inspector.
 *
 * @param {string} value Date in YYYY-MM-DD format.
 * @returns {string} Localized date label.
 */
function formatDate(value) {
	return new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'long' })
		.format(new Date(`${value}T12:00:00`));
}

/**
 * Formats a whole-value price in the listing currency.
 *
 * @param {number} value Monetary value.
 * @returns {string} Compact localized currency.
 */
function formatCurrency(value) {
	return new Intl.NumberFormat('en-GB', {
		style: 'currency',
		currency: activeListing.value?.currency || 'GBP',
		maximumFractionDigits: 0,
	}).format(value || 0);
}

/**
 * Builds an accessible mobile calendar day label.
 *
 * @param {Record<string, unknown>} day Inventory day.
 * @returns {string} Button label.
 */
function mobileDayLabel(day) {
	return `${formatDate(day.date)}, ${formatCurrency(day.price)}, ${statusLabel(day.status)}`;
}
</script>

<template>
	<section class="min-h-screen bg-canvas text-canvas-fg" data-testid="availability-pricing-calendar-block">
		<header class="border-b border-border bg-canvas px-4 py-4 sm:px-6 lg:px-8">
			<div class="mx-auto flex max-w-[96rem] flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
				<div class="min-w-0">
					<div class="flex flex-wrap items-center gap-2 text-xs font-medium text-muted-fg">
						<span>Bookings</span>
						<span aria-hidden="true">/</span>
						<span>Calendar</span>
						<DomStatusPill v-if="calendar" tone="success" size="sm">Inventory live</DomStatusPill>
					</div>
					<h1 class="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">Rates &amp; availability</h1>
					<p class="mt-1 text-sm text-muted-fg">Manage resolved nightly inventory, then publish saved drafts to every booking channel.</p>
				</div>

				<div v-if="calendar" class="flex flex-wrap items-end gap-x-5 gap-y-3 text-sm">
					<div>
						<p class="text-xs text-muted-fg">Projected revenue</p>
						<p class="font-semibold">{{ formatCurrency(projectedRevenue) }}</p>
					</div>
					<div class="hidden h-8 w-px bg-border sm:block"></div>
					<div>
						<p class="text-xs text-muted-fg">Open nights</p>
						<p class="font-semibold">{{ availableNightCount }} of {{ visibleDays.length }}</p>
					</div>
					<div class="hidden h-8 w-px bg-border sm:block"></div>
					<div>
						<p class="text-xs text-muted-fg">Occupancy</p>
						<p class="font-semibold">{{ activeListing.occupancy }}%</p>
					</div>
				</div>
			</div>
		</header>

		<main class="mx-auto max-w-[96rem] px-4 py-4 sm:px-6 lg:px-8">
			<DomAlert
				v-if="loadError"
				tone="danger"
				title="Calendar unavailable"
				:description="loadError"
			>
				<template #actions>
					<DomButton size="sm" variant="secondary" @click="loadResources">Try again</DomButton>
				</template>
			</DomAlert>

			<DomEmptyState
				v-else-if="isLoading && !calendar"
				title="Loading booking inventory"
				description="Resolving prices, holds, restrictions, and channel state from the demo API."
				size="sm"
			/>

			<template v-else-if="calendar">
				<div class="flex flex-col gap-3 border-b border-border pb-4 xl:flex-row xl:items-end xl:justify-between">
					<div class="grid min-w-0 gap-3 sm:grid-cols-[minmax(15rem,22rem)_auto] sm:items-end">
						<DomSelect
							v-model="selectedListingId"
							label="Listing"
							:options="listingOptions"
							width="min-w-[21rem]"
						>
							<template #value="{ option }">
								<span class="flex min-w-0 items-center justify-between gap-3">
									<span class="min-w-0 truncate font-semibold">{{ option?.label || activeListing.name }}</span>
									<DomBadge v-if="activeListing.pendingChangeCount" tone="primary" size="sm">{{ activeListing.pendingChangeCount }} drafts</DomBadge>
								</span>
							</template>
							<template #option="{ option }">
								<span class="flex min-w-0 items-center justify-between gap-3">
									<span class="min-w-0">
										<span class="block truncate font-semibold">{{ option.label }}</span>
										<span class="block truncate text-xs opacity-75">{{ option.description }}</span>
									</span>
									<DomBadge v-if="option.pendingChangeCount" tone="primary" size="sm">{{ option.pendingChangeCount }}</DomBadge>
								</span>
							</template>
						</DomSelect>

						<DomToggleButtonGroup
							v-model="mode"
							label="Calendar display"
							:options="modes"
							size="sm"
						/>
					</div>

					<div class="flex flex-wrap items-center gap-2">
						<DomButton variant="secondary" size="sm" :loading="isImporting" @click="importHolds">Import holds</DomButton>
						<DomButton size="sm" :disabled="!pendingChanges.length" @click="publishDialogOpen = true">
							Review &amp; publish
							<span v-if="pendingChanges.length">({{ pendingChanges.length }})</span>
						</DomButton>
					</div>
				</div>

				<DomAlert
					v-if="feedback"
					class="mt-4"
					:tone="feedback.tone"
					:title="feedback.title"
					:description="feedback.description"
					dismissible
					@dismiss="feedback = null"
				/>

				<div class="mt-4 md:hidden">
					<DomToggleButtonGroup
						v-model="mobileView"
						label="Mobile calendar section"
						:options="mobileViews"
						size="sm"
					/>
				</div>

				<div class="mt-4 hidden gap-0 overflow-hidden rounded-2xl border border-border bg-canvas shadow-sm md:grid md:grid-cols-[minmax(0,1fr)_20rem] xl:grid-cols-[minmax(0,1fr)_22rem]">
					<section class="min-w-0 border-r border-border" aria-label="Availability calendar">
						<div class="flex items-center justify-between border-b border-border px-4 py-3">
							<div class="flex items-center gap-2">
								<DomIconButton
									label="Previous month"
									:icon="previousIcon"
									size="sm"
									:disabled="currentMonthIndex === 0"
									@click="navigateMonth(-1)"
								/>
								<h2 class="min-w-40 text-center text-base font-semibold">{{ monthLabel }}</h2>
								<DomIconButton
									label="Next month"
									:icon="nextIcon"
									size="sm"
									:disabled="currentMonthIndex === calendar.months.length - 1"
									@click="navigateMonth(1)"
								/>
							</div>
							<div class="flex items-center gap-2 text-xs text-muted-fg">
								<span class="size-2 rounded-full bg-primary"></span>
								Saved draft
							</div>
						</div>

						<div class="p-3">
							<DomMonthCalendar
								:start-date="`${selectedMonth}-01`"
								:months="1"
								:clickable="true"
								:fixed-weeks="true"
								:show-adjacent-days="false"
								:day-class="cellClass"
								@day-click="onDayClick"
							>
								<template #month-header>
									<span>{{ activeListing.name }} · {{ mode === 'price' ? 'Nightly rate' : mode === 'availability' ? 'Booking status' : 'Stay rules' }}</span>
								</template>

								<template #default="{ cell }">
									<div v-if="dayFor(cell.value)" class="flex h-full min-h-0 flex-col gap-1">
										<template v-if="mode === 'price'">
											<strong class="text-sm">{{ formatCurrency(dayFor(cell.value).price) }}</strong>
											<span class="mt-auto text-[11px] text-muted-fg">{{ dayFor(cell.value).demand }}% demand</span>
										</template>
										<template v-else-if="mode === 'availability'">
											<DomStatusPill :tone="statusTone(dayFor(cell.value).status)" size="sm">{{ statusLabel(dayFor(cell.value).status) }}</DomStatusPill>
											<span class="mt-auto truncate text-[11px] text-muted-fg">{{ dayFor(cell.value).source }}</span>
										</template>
										<template v-else>
											<strong class="text-sm">{{ dayFor(cell.value).minimumStay }} night min</strong>
											<span class="mt-auto text-[11px] text-muted-fg">{{ dayFor(cell.value).restrictions.length ? `${dayFor(cell.value).restrictions.length} restrictions` : 'Standard rules' }}</span>
										</template>
									</div>
								</template>
							</DomMonthCalendar>
						</div>
					</section>

					<aside class="min-w-0 bg-secondary/20" aria-label="Selected date editor">
						<div class="border-b border-border px-5 py-4">
							<div class="flex items-start justify-between gap-3">
								<div>
									<p class="text-xs font-medium uppercase tracking-wide text-muted-fg">Selected date</p>
									<h2 class="mt-1 text-lg font-semibold">{{ selectedDateLabel }}</h2>
								</div>
								<DomBadge v-if="selectedDayPending" tone="primary" variant="outline">Draft</DomBadge>
							</div>
							<p class="mt-2 text-xs leading-5 text-muted-fg">{{ selectedDay?.demand }}% demand · Recommended {{ formatCurrency(selectedDay?.recommendedPrice) }}</p>
						</div>

						<form class="space-y-4 p-5" @submit.prevent="saveDay">
							<DomMoneyInput v-model="nightlyPrice" label="Nightly price" currency="GBP" :min="80" :max="1200" :step="1" />
							<DomSelect v-model="availability" label="Availability" :options="availabilityOptions" width="min-w-[18rem]" />
							<DomNumberInput v-model="minimumStay" label="Minimum stay" description="Nights before checkout" :min="1" :max="14" :step="1" />
							<DomTagCombobox v-model="restrictions" label="Restrictions" placeholder="Add a rule" :options="restrictionOptions" :clearable="true" />

							<div class="border-t border-border pt-4">
								<DomButton class="w-full" type="submit" :loading="isSaving" :disabled="!changedFields.length">
									Save day draft
								</DomButton>
								<p class="mt-2 text-center text-xs leading-5 text-muted-fg">
									{{ changedFields.length ? `${changedFields.length} unsaved ${changedFields.length === 1 ? 'field' : 'fields'}` : selectedDayPending ? 'Saved, not yet published' : 'Matches published inventory' }}
								</p>
							</div>
						</form>
					</aside>
				</div>

				<section v-if="mobileView === 'calendar'" class="mt-4 md:hidden" aria-label="Compact availability calendar">
					<div class="flex items-center justify-between">
						<DomIconButton label="Previous month" :icon="previousIcon" :disabled="currentMonthIndex === 0" @click="navigateMonth(-1)" />
						<div class="text-center">
							<h2 class="font-semibold">{{ monthLabel }}</h2>
							<p class="text-xs text-muted-fg">{{ activeListing.name }}</p>
						</div>
						<DomIconButton label="Next month" :icon="nextIcon" :disabled="currentMonthIndex === calendar.months.length - 1" @click="navigateMonth(1)" />
					</div>

					<div class="mt-4 grid grid-cols-7 border-b border-l border-border text-center">
						<div v-for="weekday in weekdayLabels" :key="weekday" class="border-r border-t border-border bg-secondary/50 px-1 py-2 text-[10px] font-semibold uppercase text-muted-fg">
							{{ weekday.slice(0, 1) }}
						</div>
						<template v-for="cell in mobileGridCells" :key="cell.key">
							<div v-if="cell.empty" class="min-h-16 border-r border-t border-border bg-secondary/20"></div>
							<button
								v-else
								type="button"
								class="relative min-h-16 border-r border-t border-border p-1 text-left outline-none transition focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
								:class="{
									'bg-primary/10 ring-2 ring-inset ring-primary/45': cell.date === selectedDate,
									'bg-warning/10': cell.status === 'hold' && cell.date !== selectedDate,
									'bg-destructive/5': cell.status === 'blocked' && cell.date !== selectedDate,
								}"
								:aria-label="mobileDayLabel(cell)"
								@click="selectDay(cell.date)"
							>
								<span class="text-xs font-semibold">{{ Number(cell.date.slice(-2)) }}</span>
								<span v-if="pendingDateSet.has(cell.date)" class="absolute right-1 top-1 size-1.5 rounded-full bg-primary" title="Saved draft"></span>
								<span class="mt-2 block truncate text-[10px] font-medium">{{ mode === 'price' ? formatCurrency(cell.price) : mode === 'availability' ? statusLabel(cell.status) : `${cell.minimumStay}n min` }}</span>
							</button>
						</template>
					</div>
					<p class="mt-3 text-center text-xs text-muted-fg">Choose a date to open its full rate and availability controls.</p>
				</section>

				<section v-else-if="mobileView === 'day'" class="mt-4 md:hidden" aria-label="Selected day editor">
					<div class="flex items-start justify-between gap-3 border-b border-border pb-4">
						<div>
							<p class="text-xs font-medium uppercase tracking-wide text-muted-fg">Selected date</p>
							<h2 class="mt-1 text-xl font-semibold">{{ selectedDateLabel }}</h2>
							<p class="mt-1 text-xs text-muted-fg">{{ selectedDay?.demand }}% demand · Recommended {{ formatCurrency(selectedDay?.recommendedPrice) }}</p>
						</div>
						<DomBadge v-if="selectedDayPending" tone="primary" variant="outline">Draft</DomBadge>
					</div>

					<form class="mt-4 space-y-4" @submit.prevent="saveDay">
						<DomMoneyInput v-model="nightlyPrice" label="Nightly price" currency="GBP" :min="80" :max="1200" :step="1" />
						<DomSelect v-model="availability" label="Availability" :options="availabilityOptions" width="min-w-[18rem]" />
						<DomNumberInput v-model="minimumStay" label="Minimum stay" description="Nights before checkout" :min="1" :max="14" :step="1" />
						<DomTagCombobox v-model="restrictions" label="Restrictions" placeholder="Add a rule" :options="restrictionOptions" :clearable="true" />
						<DomButton class="w-full" type="submit" :loading="isSaving" :disabled="!changedFields.length">Save day draft</DomButton>
						<p class="text-center text-xs text-muted-fg">{{ selectedRestrictionLabels }}</p>
					</form>
				</section>

				<section v-else class="mt-4 space-y-5 md:hidden" aria-label="Publish calendar changes">
					<div>
						<p class="text-xs font-medium uppercase tracking-wide text-muted-fg">Saved drafts</p>
						<h2 class="mt-1 text-xl font-semibold">{{ pendingChanges.length }} {{ pendingChanges.length === 1 ? 'date' : 'dates' }} ready</h2>
						<p class="mt-1 text-sm leading-6 text-muted-fg">Publishing distributes resolved inventory to Direct booking, Airbnb, and Booking.com.</p>
					</div>

					<div v-if="pendingChanges.length" class="divide-y divide-border border-y border-border">
						<div v-for="change in pendingChanges" :key="change.date" class="flex items-start justify-between gap-3 py-3 text-sm">
							<div>
								<p class="font-semibold">{{ formatDate(change.date) }}</p>
								<p class="mt-1 text-xs text-muted-fg">{{ change.fields.join(', ') }}</p>
							</div>
							<DomBadge tone="primary" variant="outline">Draft</DomBadge>
						</div>
					</div>
					<DomEmptyState v-else title="Everything is published" description="Edit a day or import partner holds to create a new draft." size="sm" />

					<div class="space-y-2">
						<DomButton class="w-full" :disabled="!pendingChanges.length" :loading="isPublishing" @click="publishChanges">Publish to 3 channels</DomButton>
						<DomButton class="w-full" variant="ghost" :disabled="!pendingChanges.length" :loading="isDiscarding" @click="discardChanges">Discard drafts</DomButton>
					</div>
				</section>

				<footer class="mt-4 flex flex-col gap-2 border-t border-border pt-3 text-xs text-muted-fg sm:flex-row sm:items-center sm:justify-between">
					<span>Last synced {{ calendar.lastSyncedAt }} · Published {{ calendar.lastPublishedAt }}</span>
					<DomButton variant="ghost" size="sm" @click="loadCalendar(selectedListingId)">Reload server state</DomButton>
				</footer>
			</template>
		</main>

		<DomDialog
			v-model="publishDialogOpen"
			title="Publish calendar changes"
			description="Review saved drafts before sending resolved inventory to every connected channel."
			size="lg"
		>
			<div v-if="pendingChanges.length" class="divide-y divide-border border-y border-border">
				<div v-for="change in pendingChanges" :key="change.date" class="flex items-start justify-between gap-4 py-3 text-sm">
					<div>
						<p class="font-semibold">{{ formatDate(change.date) }}</p>
						<p class="mt-1 text-xs text-muted-fg">Changed {{ change.fields.join(', ') }}</p>
					</div>
					<DomBadge tone="primary" variant="outline">Saved</DomBadge>
				</div>
			</div>

			<div class="mt-5 grid gap-3 sm:grid-cols-3">
				<div v-for="channel in calendar?.channels || []" :key="channel.id" class="border-l-2 border-success pl-3">
					<p class="text-sm font-semibold">{{ channel.name }}</p>
					<p class="mt-1 text-xs leading-5 text-muted-fg">{{ channel.detail }}</p>
				</div>
			</div>

			<template #footer>
				<DomButton variant="ghost" :loading="isDiscarding" @click="discardChanges">Discard drafts</DomButton>
				<DomButton :loading="isPublishing" @click="publishChanges">Publish {{ pendingChanges.length }} {{ pendingChanges.length === 1 ? 'date' : 'dates' }}</DomButton>
			</template>
		</DomDialog>
	</section>
</template>

Integration

How to use this block

Use this block when calendar inventory is the product workflow, not a decorative report. The Airbnb-style host-calendar hierarchy keeps the month canvas primary and gives one selected date a persistent inspector, without copying product branding or assets.

  • Use GET /api/block-demos/availability-pricing-calendar/:listingId to load resolved rates, holds, restrictions, drafts, channel state, and revision metadata.
  • Save one date with PATCH /api/block-demos/availability-pricing-calendar/:listingId/days/:date, then publish or discard the accumulated drafts through focused mutation routes.
  • Persist date rules as ranges where possible. Store single-day overrides only when they differ from the listing baseline or seasonal policy.
  • The demo API validates price, availability, minimum stay, restrictions, loaded months, listing identity, and optimistic publish revisions.
  • Show demand signals from search volume, booking pace, local events, occupancy targets, or competitor-rate feeds when your product has that data.
  • Keep external-channel sync separate from local save state so users understand whether the rule is saved, published, and distributed.
  • This example uses a process-local store so reloads preserve state while the dev server is running. Production should replace it with authenticated, authorized database writes and real channel integrations.

Data

Recommended availability payload

js
{
	listingId: 'listing_river_loft',
	date: '2026-08-08',
	status: 'available',
	price: 244,
	currency: 'GBP',
	minimumStay: 3,
	restrictions: ['event-pricing'],
	demand: {
		score: 88,
		label: 'Event demand',
		searches: 142,
		bookedNearby: 18
	},
	channelSync: [
		{ channel: 'Direct', status: 'synced' },
		{ channel: 'Marketplace', status: 'pending' }
	],
	revision: 13,
	updatedBy: 'Lina Patel'
}

Customization

Implementation notes

Rule engine boundary

The repository-local API already merges deterministic baseline rates, seasonal rules, holds, and manual overrides. Replace its process-local store with your database and authorization boundary.

Bulk editing

For production, extend date selection to ranges and weekdays, then reuse the same editor payload for single-day and multi-day publishing.

Future updates

Useful follow-ups include range selection, real reservation conflicts, idempotent channel jobs, webhook reconciliation, and an audit record for every publish.