Calendar
Price Calendar
Календарь бронирования с ценой ночи прямо в клетке: самые дешёвые дни обведены, занятые перечёркнуты, внизу итог за срок.
- calendar
- booking
- price
- availability
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/calendar-019
апрель 2026 г.
от 3 900 ₽ за ночь
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «calendar-019» (Price Calendar) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/calendar-019.json
Registry item: https://vibeui.ru/r/calendar-019.json
Ставится в: components/vibeui/calendar-019.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Календарь бронирования с ценой ночи прямо в клетке: самые дешёвые дни обведены, занятые перечёркнуты, внизу итог за срок.
Календарь бронирования: цена ночи в каждой клетке, обведённый минимум месяца, занятые дни выключены, итог за срок в подвале. Клиентский, один файл, ноль зависимостей.
## 3. Как использовать
import { Calendar019 } from "@/components/vibeui/calendar-019"
<Calendar019
year={2026}
month={4}
basePrice={4200}
nights={2}
currency="₽"
/>
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-calendar-019-* — с токенами чужой темы блок не совпадёт с превью
- детерминированный расчёт цены: случайное число развело бы серверный и клиентский рендер и уронило гидрацию
- цену в клетке, а не после выбора: вопрос «когда дешевле» задают до нажатия
- обводку и цвет подписи на минимальной цене: одним оттенком дешёвый день не выделить
- занятые дни на месте с зачёркиванием: пропавший день читается как ошибка загрузки
- итог за срок в подвале: умножать цену на число ночей в уме пользователь не обязан
## 6. Можно менять
- месяц и год через month и year
- базовую цену через basePrice и число ночей через nights
- знак валюты через currency и занятые дни через soldOut
- язык и формат чисел через locale, цвет выделения через accent
## 7. Правила
- Цены здесь демонстрационные: в бою функцию расчёта заменяют массивом цен с сервера, форма клетки при этом не меняется.
- Минимум ищется только среди доступных дней, поэтому распроданный дешёвый день не подсветится.
- nights умножает цену выбранного дня, а не суммирует цены подряд идущих ночей — для точного расчёта нужен диапазон.
- На ширине меньше двадцати рем цена в клетке становится совсем мелкой: ниже этого предела лучше показывать список, а не сетку.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/calendar-019.jsonhttps://vibeui.ru/r/calendar-019.jsonКлиентский компонент: useState держит выбранный день заезда. Цена считается детерминированной функцией от числа месяца и дня недели, а не случайно, — случайность развела бы серверный и клиентский рендер. Цена стоит в клетке, а не открывается после выбора: календарь бронирования отвечает на вопрос «когда дешевле», и ради этого клетка сделана выше обычной. Минимальная цена месяца вычисляется по доступным дням и помечается обводкой и цветом подписи, а не только цветом. Занятые дни выключены атрибутом disabled и перечёркнуты, но остаются в сетке: пропавший день читается как ошибка загрузки. Подвал показывает итог за выбранное число ночей — умножение в уме тут лишнее.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Calendar019Props = Omit<
ComponentPropsWithoutRef<"div">,
"children" | "onChange" | "defaultValue"
> & {
year?: number
month?: number
/** Базовая цена ночи: выходные и волна спроса считаются от неё. */
basePrice?: number
nights?: number
currency?: string
soldOut?: number[]
locale?: string
onChange?: (iso: string) => void
accent?: string
}
// Идея компонента: цена стоит в клетке, а не открывается после выбора.
// Календарь бронирования отвечает на вопрос «когда дешевле», поэтому самые
// дешёвые дни помечены отдельно, а занятые показывают перечёркнутую клетку,
// а не исчезают: пропавший день читается как ошибка загрузки.
const STYLES = `
:where([data-vibeui-block="calendar-019"]){
--vibeui-calendar-019-bg:oklch(1 0 0);
--vibeui-calendar-019-fg:oklch(0.24 0.014 265);
--vibeui-calendar-019-muted:oklch(0.62 0.014 265);
--vibeui-calendar-019-border:oklch(0.91 0.006 265);
--vibeui-calendar-019-hover:oklch(0.96 0.004 265);
--vibeui-calendar-019-accent:oklch(0.5 0.14 175);
--vibeui-calendar-019-cheap:oklch(0.52 0.15 145);
--vibeui-calendar-019-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-019"]{
display:flex;flex-direction:column;gap:0.75rem;
width:100%;max-width:26rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-calendar-019-bg);
border:1px solid var(--vibeui-calendar-019-border);border-radius:1rem;
color:var(--vibeui-calendar-019-fg);font-family:var(--vibeui-calendar-019-font);
}
[data-vibeui-block="calendar-019"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:0.5rem;
}
[data-vibeui-block="calendar-019"] [data-part="title"]{
margin:0;font-size:0.9375rem;font-weight:700;letter-spacing:-0.01em;
}
/* Заглавная только первая буква: capitalize поднимает и «г.» в «январь 2026 г.». */
[data-vibeui-block="calendar-019"] [data-part="title"]::first-letter{text-transform:uppercase}
[data-vibeui-block="calendar-019"] [data-part="from"]{
font-size:0.75rem;color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="from"] b{color:var(--vibeui-calendar-019-cheap);font-variant-numeric:tabular-nums}
[data-vibeui-block="calendar-019"] [data-part="grid"]{
display:grid;grid-template-columns:repeat(7,1fr);gap:0.1875rem;
}
[data-vibeui-block="calendar-019"] [data-part="wd"]{
padding-bottom:0.125rem;text-align:center;
font-size:0.6875rem;font-weight:600;text-transform:capitalize;
color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button{
appearance:none;cursor:pointer;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.0625rem;
min-height:3rem;padding:0.25rem 0.125rem;border-radius:0.5rem;
border:1px solid transparent;background:transparent;color:inherit;font:inherit;
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:hover:not(:disabled){background:var(--vibeui-calendar-019-hover)}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:focus-visible{outline:2px solid var(--vibeui-calendar-019-accent);outline-offset:-2px}
[data-vibeui-block="calendar-019"] [data-part="day"]{font-size:0.8125rem;line-height:1;font-variant-numeric:tabular-nums}
[data-vibeui-block="calendar-019"] [data-part="price"]{
font-size:0.625rem;line-height:1.1;font-variant-numeric:tabular-nums;
color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[data-cheap="true"] [data-part="price"]{
color:var(--vibeui-calendar-019-cheap);font-weight:700;
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[data-cheap="true"]{border-color:color-mix(in oklab,var(--vibeui-calendar-019-cheap) 45%,transparent)}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:disabled{
cursor:not-allowed;color:var(--vibeui-calendar-019-muted);opacity:.45;
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:disabled [data-part="day"]{text-decoration:line-through}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[aria-pressed="true"]{
background:var(--vibeui-calendar-019-accent);border-color:var(--vibeui-calendar-019-accent);
color:oklch(0.99 0.01 175);
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[aria-pressed="true"] [data-part="price"]{color:oklch(0.99 0.01 175);font-weight:650}
[data-vibeui-block="calendar-019"] [data-part="total"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:0.5rem;
padding-top:0.75rem;border-top:1px solid var(--vibeui-calendar-019-border);
font-size:0.8125rem;color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="sum"]{
font-size:1.125rem;font-weight:700;color:var(--vibeui-calendar-019-fg);
font-variant-numeric:tabular-nums;letter-spacing:-0.01em;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-019"] *{animation:none!important;transition:none!important}}
`
function priceOf(base: number, date: Date) {
const weekday = date.getDay()
const bump = weekday === 5 || weekday === 6 ? 1.35 : weekday === 0 ? 1.15 : 1
// Волна спроса детерминирована числом месяца: случайность сломала бы
// совпадение серверного и клиентского рендера.
const wave = ((date.getDate() * 37) % 11) - 5
return Math.round((base * bump + wave * 80) / 50) * 50
}
function pluralize(count: number, forms: [string, string, string]) {
const tens = count % 100
const ones = count % 10
if (tens > 10 && tens < 20) {
return forms[2]
}
if (ones === 1) {
return forms[0]
}
if (ones > 1 && ones < 5) {
return forms[1]
}
return forms[2]
}
/**
* Календарь бронирования с ценой ночи в каждой клетке и итогом за срок.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Calendar019({
year = 2026,
month = 4,
basePrice = 4200,
nights = 2,
currency = "₽",
soldOut = [9, 10, 22],
locale = "ru-RU",
onChange,
accent,
className,
style,
...props
}: Calendar019Props) {
const first = new Date(year, month - 1, 1)
const lead = (first.getDay() + 6) % 7
const length = new Date(year, month, 0).getDate()
const days = Array.from({ length }, (_, index) => {
const date = new Date(year, month - 1, index + 1)
return {
day: index + 1,
date,
price: priceOf(basePrice, date),
sold: soldOut.includes(index + 1),
}
})
const cheapest = Math.min(
...days.filter((entry) => !entry.sold).map((entry) => entry.price),
)
const [selected, setSelected] = useState(
() =>
days.find((entry) => !entry.sold && entry.price === cheapest)?.day ?? 1,
)
const money = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 })
const weekdayName = new Intl.DateTimeFormat(locale, { weekday: "short" })
const weekdays = Array.from({ length: 7 }, (_, index) =>
weekdayName.format(new Date(2026, 0, 5 + index)),
)
const title = new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
}).format(first)
const long = new Intl.DateTimeFormat(locale, { dateStyle: "long" })
const palette = {
...(accent ? { "--vibeui-calendar-019-accent": accent } : null),
...style,
} as CSSProperties
const current = days.find((entry) => entry.day === selected) ?? days[0]
const total = current.price * nights
const pick = (day: number) => {
setSelected(day)
onChange?.(
`${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
)
}
return (
<>
<style href="vibeui-calendar-019" precedence="medium">
{STYLES}
</style>
<div
{...props}
data-vibeui-block="calendar-019"
className={className}
style={palette}
>
<div data-part="head">
<p data-part="title">{title}</p>
<p data-part="from">
от{" "}
<b>
{money.format(cheapest)} {currency}
</b>{" "}
за ночь
</p>
</div>
<div data-part="grid" role="group" aria-label="Даты заезда и цены">
{weekdays.map((label) => (
<span key={label} data-part="wd" aria-hidden="true">
{label}
</span>
))}
{Array.from({ length: lead }, (_, index) => (
<span key={`lead-${index}`} />
))}
{days.map((entry) => (
<button
key={entry.day}
type="button"
aria-pressed={entry.day === selected}
disabled={entry.sold}
data-cheap={!entry.sold && entry.price === cheapest}
aria-label={
entry.sold
? `${long.format(entry.date)} — мест нет`
: `${long.format(entry.date)} — ${money.format(entry.price)} ${currency} за ночь`
}
onClick={() => pick(entry.day)}
>
<span data-part="day">{entry.day}</span>
<span data-part="price">
{entry.sold ? "нет" : money.format(entry.price)}
</span>
</button>
))}
</div>
<div data-part="total">
<span>
Заезд {long.format(current.date)}, {nights}{" "}
{pluralize(nights, ["ночь", "ночи", "ночей"])}
</span>
<span data-part="sum">
{money.format(total)} {currency}
</span>
</div>
</div>
</>
)
}