canvas-ui-sdk 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +680 -15
- package/dist/index.js +6522 -1771
- package/dist/index.js.map +1 -1
- package/mcp/dist/index.js +14 -4
- package/package.json +1 -1
- package/registry/blocks/confirmation-popup.json +18 -0
- package/registry/blocks/contact-form-popup.json +24 -0
- package/registry/blocks/detail-drawer.json +21 -0
- package/registry/blocks/details-popup.json +17 -0
- package/registry/blocks/feedback-popup.json +19 -0
- package/registry/blocks/form-group.json +2 -2
- package/registry/blocks/hero-fullwidth-image.json +1 -1
- package/registry/blocks/hero-section.json +1 -1
- package/registry/blocks/image-popup.json +17 -0
- package/registry/blocks/invoice-popup.json +20 -0
- package/registry/blocks/monthly-calendar-widget.json +1 -1
- package/registry/blocks/page-previews.json +1 -1
- package/registry/blocks/place-detail-panel.json +22 -0
- package/registry/blocks/pricing-cta.json +1 -1
- package/registry/blocks/pricing-plans-popup.json +18 -0
- package/registry/blocks/profile-image-uploader.json +1 -1
- package/registry/blocks/purchase-confirmation-popup.json +18 -0
- package/registry/blocks/sidebar-profile-card.json +1 -1
- package/registry/blocks/slideshow-popup.json +22 -0
- package/registry/blocks/store-location-map.json +18 -0
- package/registry/blocks/terms-of-service-popup.json +18 -0
- package/registry/blocks/video-popup.json +18 -0
- package/registry/blocks/view-profile-popup.json +23 -0
- package/registry/index.json +76 -1
- package/registry/layout/dashboard-shell.json +1 -1
- package/registry/layout/double-sidebar-shell.json +1 -1
- package/registry/layout/header.json +1 -1
- package/registry/layout/icon-sidebar-shell.json +1 -1
- package/registry/layout/mobile-menu-shell.json +1 -1
- package/registry/layout/sidebar.json +1 -1
- package/registry/ui/checkbox.json +1 -1
- package/registry/ui/date-input.json +1 -1
- package/registry/ui/dialog.json +1 -1
- package/registry/ui/dropdown-menu.json +1 -1
- package/registry/ui/input.json +1 -1
- package/registry/ui/label.json +1 -1
- package/registry/ui/line-tabs.json +1 -1
- package/registry/ui/multiselect-checkbox-field.json +1 -1
- package/registry/ui/multiselect-tags.json +1 -1
- package/registry/ui/popover.json +1 -1
- package/registry/ui/searchbox.json +1 -1
- package/registry/ui/select.json +1 -1
- package/registry/ui/selectable-pills.json +1 -1
- package/registry/ui/sheet.json +1 -1
- package/registry/ui/tabs.json +1 -1
- package/registry/ui/text-input.json +1 -1
- package/registry/ui/textarea.json +1 -1
- package/registry/ui/tooltip.json +1 -1
- package/styles/tokens.reference.css +5 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/monthly-calendar-widget.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { ChevronLeft, ChevronRight, ArrowRight } from \"lucide-react\";\nimport {\n format,\n startOfMonth,\n endOfMonth,\n startOfWeek,\n endOfWeek,\n addDays,\n addMonths,\n subMonths,\n isSameMonth,\n isSameDay,\n isWithinInterval,\n isBefore,\n isAfter,\n} from \"date-fns\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PricedDate {\n date: Date;\n price: string;\n}\n\nexport interface DateRange {\n start: Date | null;\n end: Date | null;\n}\n\nexport interface MonthlyCalendarWidgetProps {\n /** Widget title */\n title?: string;\n /** Widget subtitle */\n subtitle?: string;\n /** Initial month to display (defaults to current month) */\n initialMonth?: Date;\n /** Currently selected date range */\n selectedRange?: DateRange;\n /** Array of dates that should be disabled/unavailable */\n disabledDates?: Date[];\n /** Array of dates with prices to display */\n pricedDates?: PricedDate[];\n /** Override for \"today\" (useful for demos) */\n todayDate?: Date;\n /** Callback when a date is selected */\n onDateSelect?: (date: Date) => void;\n /** Callback when the date range changes */\n onRangeChange?: (range: DateRange) => void;\n /** Callback when Confirm button is clicked */\n onConfirm?: () => void;\n /** Callback when Cancel button is clicked */\n onCancel?: () => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Helper Functions\n// ============================================\n\nfunction isDateDisabled(date: Date, disabledDates: Date[]): boolean {\n return disabledDates.some((d) => isSameDay(d, date));\n}\n\nfunction isDateInRange(date: Date, range: DateRange): boolean {\n if (!range.start || !range.end) return false;\n return isWithinInterval(date, { start: range.start, end: range.end });\n}\n\nfunction isRangeStart(date: Date, range: DateRange): boolean {\n return range.start ? isSameDay(date, range.start) : false;\n}\n\nfunction isRangeEnd(date: Date, range: DateRange): boolean {\n return range.end ? isSameDay(date, range.end) : false;\n}\n\nfunction getDatePrice(date: Date, pricedDates: PricedDate[]): string | null {\n const priced = pricedDates.find((p) => isSameDay(p.date, date));\n return priced ? priced.price : null;\n}\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface DateCellProps {\n date: Date;\n currentMonth: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onSelect: (date: Date) => void;\n}\n\nfunction DateCell({\n date,\n currentMonth,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onSelect,\n}: DateCellProps) {\n const isCurrentMonth = isSameMonth(date, currentMonth);\n const isToday = isSameDay(date, today);\n const isDisabled = isDateDisabled(date, disabledDates);\n const isInRange = isDateInRange(date, selectedRange);\n const isStart = isRangeStart(date, selectedRange);\n const isEnd = isRangeEnd(date, selectedRange);\n const isSelected = isStart || isEnd;\n const price = getDatePrice(date, pricedDates);\n\n // Determine if this is a past date (before today, should show as disabled)\n const isPast = isBefore(date, today) && !isSameDay(date, today);\n\n // Don't render dates from other months\n if (!isCurrentMonth) {\n return (\n <div className=\"flex justify-center items-center\">\n <div className=\"size-12\" />\n </div>\n );\n }\n\n const handleClick = () => {\n if (!isDisabled && !isPast) {\n onSelect(date);\n }\n };\n\n // Determine styling based on state\n let bgColor = \"transparent\";\n let textColor = \"var(--canvas-text-placeholder)\";\n let showStrikethrough = false;\n let priceTextColor = \"var(--canvas-text)\";\n\n if (isDisabled || isPast) {\n textColor = \"var(--canvas-border-disabled)\";\n showStrikethrough = true;\n } else if (isSelected) {\n bgColor = \"var(--canvas-primary)\";\n textColor = \"var(--canvas-primary-foreground)\";\n priceTextColor = \"var(--canvas-primary-foreground)\";\n } else if (isInRange) {\n bgColor = \"var(--canvas-surface-brand)\";\n textColor = \"var(--canvas-primary)\";\n }\n\n return (\n <div className=\"flex justify-center items-center\">\n <button\n type=\"button\"\n onClick={handleClick}\n disabled={isDisabled || isPast}\n className={cn(\n \"relative flex flex-col items-center justify-center rounded-full size-12 transition-colors\",\n !isDisabled && !isPast && \"hover:bg-[var(--canvas-surface)] cursor-pointer\",\n (isDisabled || isPast) && \"cursor-not-allowed\"\n )}\n style={{\n backgroundColor: bgColor,\n }}\n >\n {/* Today indicator */}\n {isToday && !isSelected && (\n <div\n className=\"absolute top-1.5 rounded-full\"\n style={{\n width: \"var(--spacing-sm)\",\n height: \"var(--spacing-sm)\",\n backgroundColor: \"var(--canvas-primary)\",\n }}\n />\n )}\n\n {/* Date number */}\n <span\n className={cn(\n \"font-semibold leading-6\",\n showStrikethrough && \"line-through\"\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n color: textColor,\n }}\n >\n {format(date, \"d\")}\n </span>\n\n {/* Price label */}\n {price && !isDisabled && !isPast && (\n <span\n className=\"absolute text-[6px] font-normal\"\n style={{\n bottom: \"4px\",\n color: priceTextColor,\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n }}\n >\n {price}\n </span>\n )}\n </button>\n </div>\n );\n}\n\ninterface MonthCalendarProps {\n month: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onDateSelect: (date: Date) => void;\n onPrevMonth?: () => void;\n onNextMonth?: () => void;\n showPrevArrow?: boolean;\n showNextArrow?: boolean;\n}\n\nfunction MonthCalendar({\n month,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onDateSelect,\n onPrevMonth,\n onNextMonth,\n showPrevArrow = false,\n showNextArrow = false,\n}: MonthCalendarProps) {\n const dayHeaders = [\"SUN\", \"MON\", \"TUE\", \"WED\", \"THU\", \"FRI\", \"SAT\"];\n\n // Generate calendar grid\n const monthStart = startOfMonth(month);\n const monthEnd = endOfMonth(month);\n const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });\n const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });\n\n const weeks: Date[][] = [];\n let currentDate = calendarStart;\n\n while (currentDate <= calendarEnd) {\n const week: Date[] = [];\n for (let i = 0; i < 7; i++) {\n week.push(currentDate);\n currentDate = addDays(currentDate, 1);\n }\n weeks.push(week);\n }\n\n return (\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-lg)\", minWidth: \"336px\" }}\n >\n {/* Month Header */}\n <div\n className=\"flex items-center justify-center\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {showPrevArrow && (\n <button\n type=\"button\"\n onClick={onPrevMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronLeft\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n <span\n className=\"flex-1 text-center font-medium\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n lineHeight: \"30px\",\n color: \"var(--canvas-text)\",\n }}\n >\n {format(month, \"MMMM\")}\n </span>\n {showNextArrow && (\n <button\n type=\"button\"\n onClick={onNextMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronRight\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n </div>\n\n {/* Day Headers */}\n <div\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {dayHeaders.map((day) => (\n <div\n key={day}\n className=\"text-center\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"20px\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {day}\n </div>\n ))}\n </div>\n\n {/* Week Rows */}\n {weeks.map((week, weekIndex) => (\n <div\n key={weekIndex}\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {week.map((date, dateIndex) => (\n <DateCell\n key={dateIndex}\n date={date}\n currentMonth={month}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onSelect={onDateSelect}\n />\n ))}\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Monthly Calendar Widget Block\n *\n * A dual-month calendar widget for date range selection with support for\n * disabled dates, price labels, and today indicator. Commonly used for\n * booking and scheduling interfaces.\n *\n * @example\n * ```tsx\n * <MonthlyCalendarWidget\n * title=\"Browse availability\"\n * subtitle=\"Book your stay\"\n * onRangeChange={(range) => console.log(range)}\n * onConfirm={() => console.log(\"Confirmed\")}\n * />\n * ```\n */\nexport function MonthlyCalendarWidget({\n title = \"Browse availability\",\n subtitle = \"Book your stay\",\n initialMonth,\n selectedRange: controlledRange,\n disabledDates = [],\n pricedDates = [],\n todayDate,\n onDateSelect,\n onRangeChange,\n onConfirm,\n onCancel,\n className,\n}: MonthlyCalendarWidgetProps) {\n const today = todayDate || new Date();\n const [currentMonth, setCurrentMonth] = useState<Date>(\n initialMonth || startOfMonth(today)\n );\n const [internalRange, setInternalRange] = useState<DateRange>({\n start: null,\n end: null,\n });\n\n const selectedRange = controlledRange ?? internalRange;\n const nextMonth = addMonths(currentMonth, 1);\n\n const handleDateSelect = (date: Date) => {\n onDateSelect?.(date);\n\n let newRange: DateRange;\n\n if (!selectedRange.start || (selectedRange.start && selectedRange.end)) {\n // Start new selection\n newRange = { start: date, end: null };\n } else {\n // Complete the selection\n if (isBefore(date, selectedRange.start)) {\n newRange = { start: date, end: selectedRange.start };\n } else {\n newRange = { start: selectedRange.start, end: date };\n }\n }\n\n if (!controlledRange) {\n setInternalRange(newRange);\n }\n onRangeChange?.(newRange);\n };\n\n const handlePrevMonth = () => {\n setCurrentMonth((prev) => subMonths(prev, 1));\n };\n\n const handleNextMonth = () => {\n setCurrentMonth((prev) => addMonths(prev, 1));\n };\n\n const formatInputDate = (date: Date | null): string => {\n if (!date) return \"\";\n return format(date, \"MMM d, yyyy\");\n };\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title Section */}\n <div\n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\n\n {/* Calendar Content */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Dual Month Grid */}\n <div className=\"flex items-start justify-between w-full gap-4 overflow-x-auto\">\n <MonthCalendar\n month={currentMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onPrevMonth={handlePrevMonth}\n showPrevArrow={true}\n showNextArrow={false}\n />\n <MonthCalendar\n month={nextMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onNextMonth={handleNextMonth}\n showPrevArrow={false}\n showNextArrow={true}\n />\n </div>\n\n {/* Footer Section */}\n <div className=\"flex items-start justify-between w-full\">\n {/* Date Inputs */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-md)\", width: \"286px\" }}\n >\n {/* Start Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.start\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.start\n ? formatInputDate(selectedRange.start)\n : \"Start date\"}\n </span>\n </div>\n </div>\n\n {/* Arrow */}\n <ArrowRight\n className=\"size-5 shrink-0\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n\n {/* End Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.end\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.end\n ? formatInputDate(selectedRange.end)\n : \"End date\"}\n </span>\n </div>\n </div>\n </div>\n\n {/* Action Buttons */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"outline\" size=\"lg\" onClick={onCancel}>\n Cancel\n </Button>\n <Button variant=\"primary\" size=\"lg\" onClick={onConfirm}>\n Confirm\n </Button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { ChevronLeft, ChevronRight, ArrowRight } from \"lucide-react\";\nimport {\n format,\n startOfMonth,\n endOfMonth,\n startOfWeek,\n endOfWeek,\n addDays,\n addMonths,\n subMonths,\n isSameMonth,\n isSameDay,\n isWithinInterval,\n isBefore,\n isAfter,\n} from \"date-fns\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PricedDate {\n date: Date;\n price: string;\n}\n\nexport interface DateRange {\n start: Date | null;\n end: Date | null;\n}\n\nexport interface MonthlyCalendarWidgetProps {\n /** Widget title */\n title?: string;\n /** Widget subtitle */\n subtitle?: string;\n /** Initial month to display (defaults to current month) */\n initialMonth?: Date;\n /** Currently selected date range */\n selectedRange?: DateRange;\n /** Array of dates that should be disabled/unavailable */\n disabledDates?: Date[];\n /** Array of dates with prices to display */\n pricedDates?: PricedDate[];\n /** Override for \"today\" (useful for demos) */\n todayDate?: Date;\n /** Callback when a date is selected */\n onDateSelect?: (date: Date) => void;\n /** Callback when the date range changes */\n onRangeChange?: (range: DateRange) => void;\n /** Callback when Confirm button is clicked */\n onConfirm?: () => void;\n /** Callback when Cancel button is clicked */\n onCancel?: () => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Helper Functions\n// ============================================\n\nfunction isDateDisabled(date: Date, disabledDates: Date[]): boolean {\n return disabledDates.some((d) => isSameDay(d, date));\n}\n\nfunction isDateInRange(date: Date, range: DateRange): boolean {\n if (!range.start || !range.end) return false;\n return isWithinInterval(date, { start: range.start, end: range.end });\n}\n\nfunction isRangeStart(date: Date, range: DateRange): boolean {\n return range.start ? isSameDay(date, range.start) : false;\n}\n\nfunction isRangeEnd(date: Date, range: DateRange): boolean {\n return range.end ? isSameDay(date, range.end) : false;\n}\n\nfunction getDatePrice(date: Date, pricedDates: PricedDate[]): string | null {\n const priced = pricedDates.find((p) => isSameDay(p.date, date));\n return priced ? priced.price : null;\n}\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface DateCellProps {\n date: Date;\n currentMonth: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onSelect: (date: Date) => void;\n}\n\nfunction DateCell({\n date,\n currentMonth,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onSelect,\n}: DateCellProps) {\n const isCurrentMonth = isSameMonth(date, currentMonth);\n const isToday = isSameDay(date, today);\n const isDisabled = isDateDisabled(date, disabledDates);\n const isInRange = isDateInRange(date, selectedRange);\n const isStart = isRangeStart(date, selectedRange);\n const isEnd = isRangeEnd(date, selectedRange);\n const isSelected = isStart || isEnd;\n const price = getDatePrice(date, pricedDates);\n\n // Determine if this is a past date (before today, should show as disabled)\n const isPast = isBefore(date, today) && !isSameDay(date, today);\n\n // Don't render dates from other months\n if (!isCurrentMonth) {\n return (\n <div className=\"flex justify-center items-center\">\n <div className=\"size-12\" />\n </div>\n );\n }\n\n const handleClick = () => {\n if (!isDisabled && !isPast) {\n onSelect(date);\n }\n };\n\n // Determine styling based on state\n let bgColor = \"transparent\";\n let textColor = \"var(--canvas-text-placeholder)\";\n let showStrikethrough = false;\n let priceTextColor = \"var(--canvas-text)\";\n\n if (isDisabled || isPast) {\n textColor = \"var(--canvas-border-disabled)\";\n showStrikethrough = true;\n } else if (isSelected) {\n bgColor = \"var(--canvas-primary)\";\n textColor = \"var(--canvas-primary-foreground)\";\n priceTextColor = \"var(--canvas-primary-foreground)\";\n } else if (isInRange) {\n bgColor = \"var(--canvas-surface-brand)\";\n textColor = \"var(--canvas-primary)\";\n }\n\n return (\n <div className=\"flex justify-center items-center\">\n <button\n type=\"button\"\n onClick={handleClick}\n disabled={isDisabled || isPast}\n className={cn(\n \"relative flex flex-col items-center justify-center rounded-full size-12 transition-colors\",\n !isDisabled && !isPast && \"hover:bg-[var(--canvas-surface)] cursor-pointer\",\n (isDisabled || isPast) && \"cursor-not-allowed\"\n )}\n style={{\n backgroundColor: bgColor,\n }}\n >\n {/* Today indicator */}\n {isToday && !isSelected && (\n <div\n className=\"absolute top-1.5 rounded-full\"\n style={{\n width: \"var(--spacing-sm)\",\n height: \"var(--spacing-sm)\",\n backgroundColor: \"var(--canvas-primary)\",\n }}\n />\n )}\n\n {/* Date number */}\n <span\n className={cn(\n \"font-semibold leading-6\",\n showStrikethrough && \"line-through\"\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n color: textColor,\n }}\n >\n {format(date, \"d\")}\n </span>\n\n {/* Price label */}\n {price && !isDisabled && !isPast && (\n <span\n className=\"absolute text-[6px] font-normal\"\n style={{\n bottom: \"4px\",\n color: priceTextColor,\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n }}\n >\n {price}\n </span>\n )}\n </button>\n </div>\n );\n}\n\ninterface MonthCalendarProps {\n month: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onDateSelect: (date: Date) => void;\n onPrevMonth?: () => void;\n onNextMonth?: () => void;\n showPrevArrow?: boolean;\n showNextArrow?: boolean;\n}\n\nfunction MonthCalendar({\n month,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onDateSelect,\n onPrevMonth,\n onNextMonth,\n showPrevArrow = false,\n showNextArrow = false,\n}: MonthCalendarProps) {\n const dayHeaders = [\"SUN\", \"MON\", \"TUE\", \"WED\", \"THU\", \"FRI\", \"SAT\"];\n\n // Generate calendar grid\n const monthStart = startOfMonth(month);\n const monthEnd = endOfMonth(month);\n const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });\n const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });\n\n const weeks: Date[][] = [];\n let currentDate = calendarStart;\n\n while (currentDate <= calendarEnd) {\n const week: Date[] = [];\n for (let i = 0; i < 7; i++) {\n week.push(currentDate);\n currentDate = addDays(currentDate, 1);\n }\n weeks.push(week);\n }\n\n return (\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-lg)\", minWidth: \"336px\" }}\n >\n {/* Month Header */}\n <div\n className=\"flex items-center justify-center\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {showPrevArrow && (\n <button\n type=\"button\"\n onClick={onPrevMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronLeft\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n <span\n className=\"flex-1 text-center font-medium\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n lineHeight: \"30px\",\n color: \"var(--canvas-text)\",\n }}\n >\n {format(month, \"MMMM\")}\n </span>\n {showNextArrow && (\n <button\n type=\"button\"\n onClick={onNextMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronRight\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n </div>\n\n {/* Day Headers */}\n <div\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {dayHeaders.map((day) => (\n <div\n key={day}\n className=\"text-center\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"20px\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {day}\n </div>\n ))}\n </div>\n\n {/* Week Rows */}\n {weeks.map((week, weekIndex) => (\n <div\n key={weekIndex}\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {week.map((date, dateIndex) => (\n <DateCell\n key={dateIndex}\n date={date}\n currentMonth={month}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onSelect={onDateSelect}\n />\n ))}\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Monthly Calendar Widget Block\n *\n * A dual-month calendar widget for date range selection with support for\n * disabled dates, price labels, and today indicator. Commonly used for\n * booking and scheduling interfaces.\n *\n * @example\n * ```tsx\n * <MonthlyCalendarWidget\n * title=\"Browse availability\"\n * subtitle=\"Book your stay\"\n * onRangeChange={(range) => console.log(range)}\n * onConfirm={() => console.log(\"Confirmed\")}\n * />\n * ```\n */\nexport function MonthlyCalendarWidget({\n title = \"Browse availability\",\n subtitle = \"Book your stay\",\n initialMonth,\n selectedRange: controlledRange,\n disabledDates = [],\n pricedDates = [],\n todayDate,\n onDateSelect,\n onRangeChange,\n onConfirm,\n onCancel,\n className,\n}: MonthlyCalendarWidgetProps) {\n const today = todayDate || new Date();\n const [currentMonth, setCurrentMonth] = useState<Date>(\n initialMonth || startOfMonth(today)\n );\n const [internalRange, setInternalRange] = useState<DateRange>({\n start: null,\n end: null,\n });\n\n const selectedRange = controlledRange ?? internalRange;\n const nextMonth = addMonths(currentMonth, 1);\n\n const handleDateSelect = (date: Date) => {\n onDateSelect?.(date);\n\n let newRange: DateRange;\n\n if (!selectedRange.start || (selectedRange.start && selectedRange.end)) {\n // Start new selection\n newRange = { start: date, end: null };\n } else {\n // Complete the selection\n if (isBefore(date, selectedRange.start)) {\n newRange = { start: date, end: selectedRange.start };\n } else {\n newRange = { start: selectedRange.start, end: date };\n }\n }\n\n if (!controlledRange) {\n setInternalRange(newRange);\n }\n onRangeChange?.(newRange);\n };\n\n const handlePrevMonth = () => {\n setCurrentMonth((prev) => subMonths(prev, 1));\n };\n\n const handleNextMonth = () => {\n setCurrentMonth((prev) => addMonths(prev, 1));\n };\n\n const formatInputDate = (date: Date | null): string => {\n if (!date) return \"\";\n return format(date, \"MMM d, yyyy\");\n };\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title Section */}\n <div\n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\n\n {/* Calendar Content */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Dual Month Grid */}\n <div className=\"flex items-start justify-between w-full gap-4 overflow-x-auto\">\n <MonthCalendar\n month={currentMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onPrevMonth={handlePrevMonth}\n showPrevArrow={true}\n showNextArrow={false}\n />\n <MonthCalendar\n month={nextMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onNextMonth={handleNextMonth}\n showPrevArrow={false}\n showNextArrow={true}\n />\n </div>\n\n {/* Footer Section */}\n <div className=\"flex items-start justify-between w-full\">\n {/* Date Inputs */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-md)\", width: \"286px\" }}\n >\n {/* Start Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.start\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.start\n ? formatInputDate(selectedRange.start)\n : \"Start date\"}\n </span>\n </div>\n </div>\n\n {/* Arrow */}\n <ArrowRight\n className=\"size-5 shrink-0\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n\n {/* End Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.end\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.end\n ? formatInputDate(selectedRange.end)\n : \"End date\"}\n </span>\n </div>\n </div>\n </div>\n\n {/* Action Buttons */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"neutral\" size=\"lg\" onClick={onCancel}>\n Cancel\n </Button>\n <Button variant=\"primary\" size=\"lg\" onClick={onConfirm}>\n Confirm\n </Button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/page-previews.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { Header } from \"../layout\";\nimport { ContentDropzone } from \"./content-dropzone\";\nimport { LoginBrandingPanel } from \"./login-branding-panel\";\nimport { MessengerSidebar } from \"./messenger-sidebar\";\nimport { VideoChatControls } from \"./video-chat-controls\";\nimport {\n DashboardShell,\n IconSidebarShell,\n DoubleSidebarShell,\n StandardPageShell,\n MobileMenuShell,\n SearchBarShell,\n MultistepShell,\n MultistepSidebarShell,\n MultistepProgressBarShell,\n VerticalMultistepShell,\n AccountSettingsShell,\n} from \"../layout\";\n\n/**\n * Scaled Preview Wrapper\n * Renders content at a scaled-down size for canvas thumbnails\n */\ninterface ScaledPreviewProps {\n children: React.ReactNode;\n width?: number;\n height?: number;\n scale?: number;\n}\n\nexport function ScaledPreview({\n children,\n width = 400,\n height = 280,\n scale = 0.25\n}: ScaledPreviewProps) {\n const innerWidth = width / scale;\n const innerHeight = height / scale;\n\n return (\n <div\n className=\"overflow-hidden rounded-lg border border-[var(--canvas-border)] shadow-md bg-[var(--canvas-background)]\"\n style={{ width, height }}\n >\n <div\n style={{\n width: innerWidth,\n height: innerHeight,\n transform: `scale(${scale})`,\n transformOrigin: \"top left\",\n }}\n >\n {children}\n </div>\n </div>\n );\n}\n\n// Sample navigation for previews\nconst previewNav = [\n { id: \"home\", label: \"Home\", href: \"#\" },\n { id: \"about\", label: \"About\", href: \"#\" },\n];\n\nconst sampleSidebarSections = [\n {\n items: [\n { id: \"dashboard\", label: \"Dashboard\", icon: \"home\" as const, href: \"#\" },\n { id: \"analytics\", label: \"Analytics\", icon: \"chart\" as const, href: \"#\" },\n { id: \"settings\", label: \"Settings\", icon: \"settings\" as const, href: \"#\" },\n ],\n },\n];\n\n// =====================\n// PAGE TEMPLATE PREVIEWS\n// =====================\n\nexport function PageAboutPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"min-h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} />\n <ContentDropzone />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageAccountPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <AccountSettingsShell>\n <ContentDropzone />\n </AccountSettingsShell>\n </ScaledPreview>\n );\n}\n\nexport function PageAdminPortalPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageCenteredProfilePreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <StandardPageShell showBanner={false} showPageHeader={false}>\n <ContentDropzone />\n </StandardPageShell>\n </ScaledPreview>\n );\n}\n\nexport function PageDoubleSidebarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DoubleSidebarShell>\n <ContentDropzone />\n </DoubleSidebarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageIconSidebarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <IconSidebarShell>\n <ContentDropzone />\n </IconSidebarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageLoginPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"flex h-screen\">\n <div className=\"flex-1 flex items-center justify-center p-8\">\n <ContentDropzone height=\"320px\" className=\"w-[360px]\" />\n </div>\n <LoginBrandingPanel className=\"hidden lg:flex w-1/2\" />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageMenuSectionsPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageMessengerPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"flex h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} className=\"absolute top-0 left-0 right-0\" />\n <div className=\"flex flex-1 pt-16\">\n <MessengerSidebar className=\"w-[320px]\" />\n <div className=\"flex-1 flex flex-col\">\n <ContentDropzone />\n </div>\n </div>\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageMobileMenuPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MobileMenuShell>\n <ContentDropzone />\n </MobileMenuShell>\n </ScaledPreview>\n );\n}\n\nexport function PageMultistepProgressbarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MultistepProgressBarShell>\n <ContentDropzone />\n </MultistepProgressBarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageMultistepSidebarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MultistepSidebarShell>\n <ContentDropzone />\n </MultistepSidebarShell>\n </ScaledPreview>\n );\n}\n\nexport function PagePricingPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"min-h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} />\n <ContentDropzone />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageProductHomepagePreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"min-h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} />\n <ContentDropzone />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageResetPasswordPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"flex h-screen\">\n <div className=\"flex-1 flex items-center justify-center p-8\">\n <ContentDropzone height=\"320px\" className=\"w-[360px]\" />\n </div>\n <LoginBrandingPanel className=\"hidden lg:flex w-1/2\" />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageSearchBarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <SearchBarShell>\n <ContentDropzone />\n </SearchBarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageSidebarProfilePreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageStandardPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <StandardPageShell>\n <ContentDropzone />\n </StandardPageShell>\n </ScaledPreview>\n );\n}\n\nexport function PageStandardMultistepPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MultistepShell>\n <ContentDropzone />\n </MultistepShell>\n </ScaledPreview>\n );\n}\n\nexport function PageStandardSearchPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageVerticalMultistepPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <VerticalMultistepShell>\n <ContentDropzone />\n </VerticalMultistepShell>\n </ScaledPreview>\n );\n}\n\nexport function PageVideoChatPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"h-screen bg-
|
|
9
|
+
"content": "\"use client\";\n\nimport { Header } from \"../layout\";\nimport { ContentDropzone } from \"./content-dropzone\";\nimport { LoginBrandingPanel } from \"./login-branding-panel\";\nimport { MessengerSidebar } from \"./messenger-sidebar\";\nimport { VideoChatControls } from \"./video-chat-controls\";\nimport {\n DashboardShell,\n IconSidebarShell,\n DoubleSidebarShell,\n StandardPageShell,\n MobileMenuShell,\n SearchBarShell,\n MultistepShell,\n MultistepSidebarShell,\n MultistepProgressBarShell,\n VerticalMultistepShell,\n AccountSettingsShell,\n} from \"../layout\";\n\n/**\n * Scaled Preview Wrapper\n * Renders content at a scaled-down size for canvas thumbnails\n */\ninterface ScaledPreviewProps {\n children: React.ReactNode;\n width?: number;\n height?: number;\n scale?: number;\n}\n\nexport function ScaledPreview({\n children,\n width = 400,\n height = 280,\n scale = 0.25\n}: ScaledPreviewProps) {\n const innerWidth = width / scale;\n const innerHeight = height / scale;\n\n return (\n <div\n className=\"overflow-hidden rounded-lg border border-[var(--canvas-border)] shadow-md bg-[var(--canvas-background)]\"\n style={{ width, height }}\n >\n <div\n style={{\n width: innerWidth,\n height: innerHeight,\n transform: `scale(${scale})`,\n transformOrigin: \"top left\",\n }}\n >\n {children}\n </div>\n </div>\n );\n}\n\n// Sample navigation for previews\nconst previewNav = [\n { id: \"home\", label: \"Home\", href: \"#\" },\n { id: \"about\", label: \"About\", href: \"#\" },\n];\n\nconst sampleSidebarSections = [\n {\n items: [\n { id: \"dashboard\", label: \"Dashboard\", icon: \"home\" as const, href: \"#\" },\n { id: \"analytics\", label: \"Analytics\", icon: \"chart\" as const, href: \"#\" },\n { id: \"settings\", label: \"Settings\", icon: \"settings\" as const, href: \"#\" },\n ],\n },\n];\n\n// =====================\n// PAGE TEMPLATE PREVIEWS\n// =====================\n\nexport function PageAboutPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"min-h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} />\n <ContentDropzone />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageAccountPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <AccountSettingsShell>\n <ContentDropzone />\n </AccountSettingsShell>\n </ScaledPreview>\n );\n}\n\nexport function PageAdminPortalPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageCenteredProfilePreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <StandardPageShell showBanner={false} showPageHeader={false}>\n <ContentDropzone />\n </StandardPageShell>\n </ScaledPreview>\n );\n}\n\nexport function PageDoubleSidebarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DoubleSidebarShell>\n <ContentDropzone />\n </DoubleSidebarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageIconSidebarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <IconSidebarShell>\n <ContentDropzone />\n </IconSidebarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageLoginPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"flex h-screen\">\n <div className=\"flex-1 flex items-center justify-center p-8\">\n <ContentDropzone height=\"320px\" className=\"w-[360px]\" />\n </div>\n <LoginBrandingPanel className=\"hidden lg:flex w-1/2\" />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageMenuSectionsPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageMessengerPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"flex h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} className=\"absolute top-0 left-0 right-0\" />\n <div className=\"flex flex-1 pt-16\">\n <MessengerSidebar className=\"w-[320px]\" />\n <div className=\"flex-1 flex flex-col\">\n <ContentDropzone />\n </div>\n </div>\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageMobileMenuPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MobileMenuShell>\n <ContentDropzone />\n </MobileMenuShell>\n </ScaledPreview>\n );\n}\n\nexport function PageMultistepProgressbarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MultistepProgressBarShell>\n <ContentDropzone />\n </MultistepProgressBarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageMultistepSidebarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MultistepSidebarShell>\n <ContentDropzone />\n </MultistepSidebarShell>\n </ScaledPreview>\n );\n}\n\nexport function PagePricingPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"min-h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} />\n <ContentDropzone />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageProductHomepagePreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"min-h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} />\n <ContentDropzone />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageResetPasswordPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"flex h-screen\">\n <div className=\"flex-1 flex items-center justify-center p-8\">\n <ContentDropzone height=\"320px\" className=\"w-[360px]\" />\n </div>\n <LoginBrandingPanel className=\"hidden lg:flex w-1/2\" />\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageSearchBarPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <SearchBarShell>\n <ContentDropzone />\n </SearchBarShell>\n </ScaledPreview>\n );\n}\n\nexport function PageSidebarProfilePreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageStandardPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <StandardPageShell>\n <ContentDropzone />\n </StandardPageShell>\n </ScaledPreview>\n );\n}\n\nexport function PageStandardMultistepPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <MultistepShell>\n <ContentDropzone />\n </MultistepShell>\n </ScaledPreview>\n );\n}\n\nexport function PageStandardSearchPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <DashboardShell navigation={sampleSidebarSections}>\n <ContentDropzone />\n </DashboardShell>\n </ScaledPreview>\n );\n}\n\nexport function PageVerticalMultistepPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <VerticalMultistepShell>\n <ContentDropzone />\n </VerticalMultistepShell>\n </ScaledPreview>\n );\n}\n\nexport function PageVideoChatPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"h-screen bg-[var(--canvas-sidebar-dark-bg)] flex flex-col\">\n <div className=\"flex-1 p-4\">\n <ContentDropzone />\n </div>\n <div className=\"p-4\">\n <VideoChatControls />\n </div>\n </div>\n </ScaledPreview>\n );\n}\n\nexport function PageVideoListPreview() {\n return (\n <ScaledPreview width={400} height={280} scale={0.22}>\n <div className=\"min-h-screen bg-[var(--canvas-background)]\">\n <Header showDesktopLogo navItems={previewNav} />\n <ContentDropzone />\n </div>\n </ScaledPreview>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "place-detail-panel",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/place-detail-panel.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { LineTabs } from \"../ui/line-tabs\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Sheet, SheetContent, SheetTitle } from \"../ui/sheet\";\nimport {\n MapPin,\n Globe,\n Phone,\n Clock,\n Check,\n Copy,\n ExternalLink,\n ChevronUp,\n ChevronDown,\n ImageIcon,\n Star,\n} from \"lucide-react\";\nimport {\n AVATAR_MARCUS_WEBB,\n AVATAR_ETHAN_BROOKS,\n AVATAR_JASON_MORALES,\n AVATAR_ALEX_REEVES,\n AVATAR_RYAN_KESSLER,\n} from \"./demo-avatars\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PlaceInfoRow {\n id: string;\n icon: \"map-pin\" | \"globe\" | \"phone\" | \"clock\";\n text: string;\n action?: \"copy\" | \"external-link\";\n onAction?: () => void;\n}\n\nexport interface OperatingHoursEntry {\n day: string;\n hours: string;\n isCurrent?: boolean;\n}\n\nexport interface AmenityItem {\n id: string;\n label: string;\n available: boolean;\n}\n\nexport interface AdmissionItem {\n id: string;\n name: string;\n description?: string;\n price: string;\n}\n\nexport interface RatingBreakdown {\n stars: number;\n percentage: number;\n}\n\nexport interface PlaceReview {\n id: string;\n name: string;\n avatarUrl?: string;\n date: string;\n rating: number;\n text: string;\n photos?: string[];\n}\n\nexport interface PlaceDetailPanelProps {\n /** Controls drawer visibility */\n open?: boolean;\n /** Callback when drawer open state changes */\n onOpenChange?: (open: boolean) => void;\n /** Place/business name */\n title?: string;\n /** Subtitle text (e.g., \"Created by Jeffrey Connor\") */\n subtitle?: string;\n /** Overall rating number (e.g., 4.8) */\n rating?: number;\n /** Total number of ratings for header display */\n ratingCount?: number;\n /** URL for the hero/cover image */\n heroImageUrl?: string;\n /** Alt text for the hero image */\n heroImageAlt?: string;\n /** Number of photos to display in badge overlay */\n photoCount?: number;\n /** Callback when photo badge is clicked */\n onPhotosClick?: () => void;\n /** Tab labels */\n tabs?: string[];\n /** Currently active tab index (controlled) */\n activeTab?: number;\n /** Callback when tab changes */\n onTabChange?: (index: number) => void;\n /** Description paragraph text */\n description?: string;\n /** Info rows (location, website, phone) */\n infoRows?: PlaceInfoRow[];\n /** Operating hours data */\n operatingHours?: OperatingHoursEntry[];\n /** Whether hours section is initially expanded */\n hoursExpanded?: boolean;\n /** Section title for amenities */\n amenitiesTitle?: string;\n /** List of amenities */\n amenities?: AmenityItem[];\n /** Section title for admission/pricing */\n admissionTitle?: string;\n /** Admission/pricing rows */\n admissionItems?: AdmissionItem[];\n /** Total reviews count */\n totalReviews?: number;\n /** Rating breakdown bars */\n ratingBreakdown?: RatingBreakdown[];\n /** Individual review cards */\n reviews?: PlaceReview[];\n /** Callback when \"Read more\" is clicked on a review */\n onReadMore?: (review: PlaceReview) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultInfoRows: PlaceInfoRow[] = [\n { id: \"location\", icon: \"map-pin\", text: \"75001 Paris, France\", action: \"copy\" },\n { id: \"website\", icon: \"globe\", text: \"louvre.fr\", action: \"external-link\" },\n { id: \"phone\", icon: \"phone\", text: \"+33 1 40 53 17\" },\n];\n\nconst defaultOperatingHours: OperatingHoursEntry[] = [\n { day: \"Thursday\", hours: \"9am - 6pm\", isCurrent: true },\n { day: \"Friday\", hours: \"9am - 8pm\" },\n { day: \"Saturday\", hours: \"9am - 6pm\" },\n { day: \"Sunday\", hours: \"9am - 6pm\" },\n { day: \"Monday\", hours: \"Closed\" },\n { day: \"Tuesday\", hours: \"9am - 6pm\" },\n { day: \"Wednesday\", hours: \"9am - 6pm\" },\n];\n\nconst defaultAmenities: AmenityItem[] = [\n { id: \"restroom\", label: \"Gender-neutral restroom\", available: true },\n { id: \"restaurant\", label: \"Restaurant\", available: true },\n { id: \"restrooms\", label: \"Restroom\", available: true },\n { id: \"wifi\", label: \"Wi-Fi\", available: true },\n { id: \"parking\", label: \"Parking\", available: true },\n { id: \"pet-friendly\", label: \"Pet-friendly\", available: true },\n];\n\nconst defaultAdmissionItems: AdmissionItem[] = [\n { id: \"online\", name: \"Ticket purchased online\", description: \"E-ticket\", price: \"$20\" },\n { id: \"museum\", name: \"Ticket purchased at the museum\", description: \"Limited number available during off-peak times\", price: \"$15\" },\n { id: \"under-18\", name: \"Under 18 years old\", description: \"Must be residents of the EEA\", price: \"FREE\" },\n { id: \"group\", name: \"Group reservation\", description: \"For a group of 7 - 25 people\\n(Excludes $70 reservation fee)\", price: \"$17/person\" },\n];\n\nconst defaultRatingBreakdown: RatingBreakdown[] = [\n { stars: 5, percentage: 60 },\n { stars: 4, percentage: 25 },\n { stars: 3, percentage: 18 },\n { stars: 2, percentage: 6 },\n { stars: 1, percentage: 1 },\n];\n\nconst defaultReviews: PlaceReview[] = [\n {\n id: \"1\",\n name: \"Raj Mishra\",\n avatarUrl: AVATAR_MARCUS_WEBB,\n date: \"Aug 19, 2024\",\n rating: 5,\n text: \"Our tour guide in Paris was an absolute star! They blew us away with a raft of stories and secrets about...\",\n },\n {\n id: \"2\",\n name: \"Aya Williams\",\n avatarUrl: AVATAR_ETHAN_BROOKS,\n date: \"Aug 7, 2024\",\n rating: 3,\n text: \"We enjoyed the day trip but you only get to spend around 30 minutes at each location. Everything was great but we wished we just had a little bit more time to wander around.\",\n },\n {\n id: \"3\",\n name: \"Marcus Chen\",\n avatarUrl: AVATAR_JASON_MORALES,\n date: \"Jul 15, 2024\",\n rating: 5,\n text: \"Absolutely breathtaking. The collection is vast and the building itself is a work of art. Allow yourself at least half a day.\",\n },\n {\n id: \"4\",\n name: \"Sophie Laurent\",\n avatarUrl: AVATAR_ALEX_REEVES,\n date: \"Jun 22, 2024\",\n rating: 4,\n text: \"Beautiful museum but very crowded during peak hours. I recommend visiting early in the morning or on Wednesday/Friday evenings.\",\n },\n {\n id: \"5\",\n name: \"David Park\",\n avatarUrl: AVATAR_RYAN_KESSLER,\n date: \"May 30, 2024\",\n rating: 5,\n text: \"A must-visit destination. The Mona Lisa is smaller than expected but the rest of the museum more than makes up for it.\",\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\nconst ICON_MAP = {\n \"map-pin\": MapPin,\n globe: Globe,\n phone: Phone,\n clock: Clock,\n} as const;\n\nfunction StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: number }) {\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {Array.from({ length: maxRating }, (_, i) => (\n <svg\n key={i}\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M10 1.66667L12.575 6.88334L18.3333 7.725L14.1667 11.7833L15.15 17.5167L10 14.8083L4.85 17.5167L5.83333 11.7833L1.66667 7.725L7.425 6.88334L10 1.66667Z\"\n fill={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n stroke={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n ))}\n </div>\n );\n}\n\nfunction SmallStarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: number }) {\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xxs)\" }}\n >\n {Array.from({ length: maxRating }, (_, i) => (\n <Star\n key={i}\n className=\"size-4\"\n fill={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n stroke={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n />\n ))}\n </div>\n );\n}\n\nfunction HeroImage({\n url,\n alt,\n photoCount,\n onPhotosClick,\n}: {\n url: string;\n alt: string;\n photoCount?: number;\n onPhotosClick?: () => void;\n}) {\n return (\n <div\n className=\"relative w-full overflow-hidden\"\n style={{ padding: \"0 var(--spacing-4xl)\" }}\n >\n <div\n className=\"relative w-full overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img\n src={url}\n alt={alt}\n className=\"w-full object-cover\"\n style={{ aspectRatio: \"16/9\" }}\n />\n {photoCount != null && (\n <button\n onClick={onPhotosClick}\n className=\"absolute bottom-3 right-3 flex items-center cursor-pointer border-0\"\n style={{\n gap: \"var(--spacing-xs)\",\n padding: \"var(--spacing-xs) var(--spacing-md)\",\n borderRadius: \"var(--radius-md)\",\n backgroundColor: \"rgba(0, 0, 0, 0.6)\",\n }}\n >\n <ImageIcon className=\"size-4\" style={{ color: \"#ffffff\" }} />\n <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--btn-standard-font-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"#ffffff\",\n }}\n >\n {photoCount} photos\n </span>\n </button>\n )}\n </div>\n </div>\n );\n}\n\nfunction InfoRow({ row }: { row: PlaceInfoRow }) {\n const IconComponent = ICON_MAP[row.icon];\n\n return (\n <div\n className=\"flex items-center justify-between w-full\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <div\n className=\"flex items-center flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <IconComponent\n className=\"size-5 shrink-0\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n <span\n className=\"truncate\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {row.text}\n </span>\n </div>\n {row.action && (\n <button\n onClick={row.onAction}\n className=\"shrink-0 flex items-center justify-center cursor-pointer border-0 bg-transparent\"\n style={{ padding: \"var(--spacing-xs)\" }}\n >\n {row.action === \"copy\" && (\n <Copy className=\"size-4\" style={{ color: \"var(--canvas-text-muted)\" }} />\n )}\n {row.action === \"external-link\" && (\n <ExternalLink className=\"size-4\" style={{ color: \"var(--canvas-text-muted)\" }} />\n )}\n </button>\n )}\n </div>\n );\n}\n\nfunction OperatingHoursSection({\n hours,\n expanded,\n onToggle,\n}: {\n hours: OperatingHoursEntry[];\n expanded: boolean;\n onToggle: () => void;\n}) {\n const currentDay = hours.find((h) => h.isCurrent) || hours[0];\n\n return (\n <div className=\"flex flex-col w-full\">\n <button\n onClick={onToggle}\n className=\"flex items-center justify-between w-full cursor-pointer border-0 bg-transparent\"\n style={{ gap: \"var(--spacing-md)\", padding: 0 }}\n >\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <Clock className=\"size-5 shrink-0\" style={{ color: \"var(--canvas-text-muted)\" }} />\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {currentDay?.day}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {currentDay?.hours}\n </span>\n </div>\n {expanded ? (\n <ChevronUp className=\"size-5\" style={{ color: \"var(--canvas-text-muted)\" }} />\n ) : (\n <ChevronDown className=\"size-5\" style={{ color: \"var(--canvas-text-muted)\" }} />\n )}\n </button>\n\n {expanded && (\n <div\n className=\"flex flex-col w-full\"\n style={{ paddingLeft: \"28px\", marginTop: \"var(--spacing-md)\" }}\n >\n {hours.map((entry) => (\n <div\n key={entry.day}\n className=\"flex items-center justify-between\"\n style={{ padding: \"var(--spacing-xs) 0\" }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n minWidth: 100,\n }}\n >\n {entry.day}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: entry.hours === \"Closed\" ? \"var(--canvas-destructive)\" : \"var(--canvas-text)\",\n }}\n >\n {entry.hours}\n </span>\n </div>\n ))}\n </div>\n )}\n </div>\n );\n}\n\nfunction SectionHeader({ children }: { children: React.ReactNode }) {\n return (\n <h3\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: \"var(--typo-h6-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {children}\n </h3>\n );\n}\n\nfunction AmenitiesSection({ amenities }: { amenities: AmenityItem[] }) {\n return (\n <div\n className=\"grid grid-cols-2\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {amenities.map((item) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <Check\n className=\"size-4 shrink-0\"\n style={{\n color: item.available ? \"var(--canvas-success)\" : \"var(--canvas-text-placeholder)\",\n }}\n />\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.label}\n </span>\n </div>\n ))}\n </div>\n );\n}\n\nfunction AdmissionSection({ items }: { items: AdmissionItem[] }) {\n return (\n <div className=\"flex flex-col w-full\">\n {items.map((item) => (\n <div\n key={item.id}\n className=\"flex items-start justify-between w-full\"\n style={{\n padding: \"var(--spacing-xl) 0\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n <div className=\"flex flex-col flex-1 min-w-0\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--btn-standard-font-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.name}\n </span>\n {item.description && (\n <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n whiteSpace: \"pre-line\",\n }}\n >\n {item.description}\n </span>\n )}\n </div>\n <span\n className=\"shrink-0\"\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: \"var(--typo-h6-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"var(--canvas-text)\",\n marginLeft: \"var(--spacing-xl)\",\n }}\n >\n {item.price}\n </span>\n </div>\n ))}\n </div>\n );\n}\n\nfunction RatingBreakdownSection({\n rating,\n totalReviews,\n breakdown,\n}: {\n rating: number;\n totalReviews: number;\n breakdown: RatingBreakdown[];\n}) {\n return (\n <div\n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Left: Large rating number */}\n <div className=\"flex flex-col items-center shrink-0\">\n <span\n style={{\n fontFamily: \"var(--typo-h3-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h3-size)\",\n fontWeight: \"var(--typo-h6-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-h3-line-height)\",\n letterSpacing: \"var(--typo-h3-letter-spacing)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {rating}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {totalReviews} reviews\n </span>\n </div>\n\n {/* Right: Bar chart */}\n <div className=\"flex flex-col flex-1\" style={{ gap: \"var(--spacing-xs)\" }}>\n {breakdown.map((item) => (\n <div\n key={item.stars}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <div\n className=\"flex items-center shrink-0\"\n style={{ gap: \"var(--spacing-xxs)\", minWidth: 28 }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {item.stars}\n </span>\n <Star\n className=\"size-3\"\n fill=\"var(--canvas-primary)\"\n stroke=\"var(--canvas-primary)\"\n />\n </div>\n <div\n className=\"flex-1 h-2 overflow-hidden\"\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n borderRadius: \"var(--radius-full)\",\n }}\n >\n <div\n className=\"h-full\"\n style={{\n width: `${item.percentage}%`,\n backgroundColor: \"var(--canvas-primary)\",\n borderRadius: \"var(--radius-full)\",\n }}\n />\n </div>\n <span\n className=\"shrink-0\"\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n minWidth: 32,\n textAlign: \"right\" as const,\n }}\n >\n {item.percentage}%\n </span>\n </div>\n ))}\n </div>\n </div>\n );\n}\n\nfunction ReviewCard({\n review,\n onReadMore,\n}: {\n review: PlaceReview;\n onReadMore?: (review: PlaceReview) => void;\n}) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n padding: \"var(--spacing-xl) 0\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Author row */}\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <Avatar className=\"size-10\">\n {review.avatarUrl && <AvatarImage src={review.avatarUrl} alt={review.name} />}\n <AvatarFallback>{review.name.charAt(0)}</AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col flex-1 min-w-0\">\n <div\n className=\"flex items-center justify-between\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-h6-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {review.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {review.date}\n </span>\n </div>\n <SmallStarRating rating={review.rating} />\n </div>\n </div>\n\n {/* Review text */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n marginTop: \"var(--spacing-md)\",\n }}\n >\n {review.text}\n </p>\n\n {/* Read more link */}\n {onReadMore && (\n <button\n onClick={() => onReadMore(review)}\n className=\"self-start cursor-pointer border-0 bg-transparent\"\n style={{\n padding: 0,\n marginTop: \"var(--spacing-xs)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--btn-standard-font-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-primary)\",\n }}\n >\n Read more\n </button>\n )}\n\n {/* Review photos */}\n {review.photos && review.photos.length > 0 && (\n <div\n className=\"flex items-center overflow-x-auto\"\n style={{ gap: \"var(--spacing-md)\", marginTop: \"var(--spacing-md)\" }}\n >\n {review.photos.map((photo, i) => (\n <img\n key={i}\n src={photo}\n alt={`Review photo ${i + 1}`}\n className=\"shrink-0 object-cover\"\n style={{\n width: 80,\n height: 80,\n borderRadius: \"var(--radius-md)\",\n }}\n />\n ))}\n </div>\n )}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Place Detail Panel Block\n *\n * A comprehensive place/business detail drawer with a hero image, tabbed\n * navigation, info rows, operating hours, amenities, admission pricing,\n * rating breakdown, and review cards.\n *\n * @example\n * ```tsx\n * <PlaceDetailPanel\n * open={open}\n * onOpenChange={setOpen}\n * title=\"Louvre Museum\"\n * subtitle=\"Created by Jeffrey Connor\"\n * rating={4.8}\n * ratingCount={3565}\n * photoCount={122}\n * />\n * ```\n */\nexport function PlaceDetailPanel({\n open,\n onOpenChange,\n title = \"Louvre Museum\",\n subtitle = \"Created by Jeffrey Connor\",\n rating = 4.8,\n ratingCount = 3565,\n heroImageUrl = \"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=960&h=400&fit=crop\",\n heroImageAlt = \"Louvre Museum\",\n photoCount = 122,\n onPhotosClick,\n tabs = [\"Overview\", \"Prices / Services\", \"Reviews\"],\n activeTab: controlledActiveTab,\n onTabChange,\n description = \"Former historic palace housing huge art collection, from Roman sculptures to da Vinci\\u2019s \\\"Mona Lisa.\\\"\",\n infoRows = defaultInfoRows,\n operatingHours = defaultOperatingHours,\n hoursExpanded: initialHoursExpanded = false,\n amenitiesTitle = \"Amenities\",\n amenities = defaultAmenities,\n admissionTitle = \"Admission\",\n admissionItems = defaultAdmissionItems,\n totalReviews = 256,\n ratingBreakdown = defaultRatingBreakdown,\n reviews = defaultReviews,\n onReadMore,\n className,\n}: PlaceDetailPanelProps) {\n const [internalTab, setInternalTab] = React.useState(0);\n const [hoursOpen, setHoursOpen] = React.useState(initialHoursExpanded);\n const currentTab = controlledActiveTab ?? internalTab;\n\n const tabItems = tabs.map((label, i) => ({ id: String(i), label }));\n\n const handleTabChange = (tabId: string) => {\n const idx = Number(tabId);\n setInternalTab(idx);\n onTabChange?.(idx);\n };\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent\n side=\"right\"\n className={cn(\n \"w-[480px] sm:max-w-[480px] p-0 gap-0 overflow-hidden\",\n className\n )}\n >\n <div\n className=\"flex flex-col h-full overflow-y-auto font-[family-name:var(--typo-global-font)]\"\n style={{ backgroundColor: \"var(--canvas-background)\" }}\n >\n {/* Header */}\n <div\n className=\"flex flex-col\"\n style={{ padding: \"var(--spacing-3xl) var(--spacing-4xl)\", gap: \"var(--spacing-xs)\" }}\n >\n <SheetTitle\n className=\"pr-8\"\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </SheetTitle>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {subtitle}\n </span>\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\", marginTop: \"var(--spacing-xs)\" }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--btn-standard-font-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {rating}\n </span>\n <StarRating rating={Math.round(rating)} />\n <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n ({ratingCount.toLocaleString()})\n </span>\n </div>\n </div>\n\n {/* Hero Image */}\n {heroImageUrl && (\n <HeroImage\n url={heroImageUrl}\n alt={heroImageAlt}\n photoCount={photoCount}\n onPhotosClick={onPhotosClick}\n />\n )}\n\n {/* Tabs */}\n <div style={{ padding: \"var(--spacing-xl) var(--spacing-4xl) 0\" }}>\n <LineTabs\n tabs={tabItems}\n activeTab={String(currentTab)}\n onTabChange={handleTabChange}\n />\n </div>\n\n {/* Tab Content */}\n <div style={{ padding: \"var(--spacing-3xl) var(--spacing-4xl)\" }}>\n {/* Overview Tab */}\n {currentTab === 0 && (\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-3xl)\" }}>\n {/* Description */}\n {description && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {description}\n </p>\n )}\n\n {/* Info Rows */}\n {infoRows.length > 0 && (\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xl)\" }}>\n {infoRows.map((row) => (\n <InfoRow key={row.id} row={row} />\n ))}\n {/* Operating Hours */}\n {operatingHours.length > 0 && (\n <OperatingHoursSection\n hours={operatingHours}\n expanded={hoursOpen}\n onToggle={() => setHoursOpen(!hoursOpen)}\n />\n )}\n </div>\n )}\n\n {/* Divider */}\n <div style={{ borderTop: \"1px solid var(--canvas-border)\" }} />\n\n {/* Amenities */}\n {amenities.length > 0 && (\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xl)\" }}>\n <SectionHeader>{amenitiesTitle}</SectionHeader>\n <AmenitiesSection amenities={amenities} />\n </div>\n )}\n\n {/* Divider */}\n <div style={{ borderTop: \"1px solid var(--canvas-border)\" }} />\n\n {/* Admission */}\n {admissionItems.length > 0 && (\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xl)\" }}>\n <SectionHeader>{admissionTitle}</SectionHeader>\n <AdmissionSection items={admissionItems} />\n </div>\n )}\n\n {/* Divider */}\n <div style={{ borderTop: \"1px solid var(--canvas-border)\" }} />\n\n {/* Overall Rating */}\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xl)\" }}>\n <SectionHeader>Overall rating</SectionHeader>\n <RatingBreakdownSection\n rating={rating}\n totalReviews={totalReviews}\n breakdown={ratingBreakdown}\n />\n </div>\n\n {/* Divider */}\n <div style={{ borderTop: \"1px solid var(--canvas-border)\" }} />\n\n {/* Review previews (first 2) */}\n {reviews.length > 0 && (\n <div className=\"flex flex-col\">\n {reviews.slice(0, 2).map((review) => (\n <ReviewCard\n key={review.id}\n review={review}\n onReadMore={onReadMore}\n />\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Prices / Services Tab */}\n {currentTab === 1 && (\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-3xl)\" }}>\n <SectionHeader>{admissionTitle}</SectionHeader>\n <AdmissionSection items={admissionItems} />\n </div>\n )}\n\n {/* Reviews Tab */}\n {currentTab === 2 && (\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-3xl)\" }}>\n <RatingBreakdownSection\n rating={rating}\n totalReviews={totalReviews}\n breakdown={ratingBreakdown}\n />\n <div style={{ borderTop: \"1px solid var(--canvas-border)\" }} />\n <div className=\"flex flex-col\">\n {reviews.map((review) => (\n <ReviewCard\n key={review.id}\n review={review}\n onReadMore={onReadMore}\n />\n ))}\n </div>\n </div>\n )}\n </div>\n </div>\n </SheetContent>\n </Sheet>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"lucide-react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"lib/utils",
|
|
17
|
+
"ui/line-tabs",
|
|
18
|
+
"ui/avatar",
|
|
19
|
+
"ui/sheet",
|
|
20
|
+
"blocks/demo-avatars"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/pricing/pricing-cta.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { Button } from \"../../ui/button\";\nimport { Typography } from \"../../ui/typography\";\n\ninterface PricingCtaProps {\n title?: string;\n primaryButtonText?: string;\n secondaryButtonText?: string;\n onPrimaryClick?: () => void;\n onSecondaryClick?: () => void;\n}\n\nexport function PricingCta({\n title = \"Get started for free\",\n primaryButtonText = \"Open account\",\n secondaryButtonText = \"Explore demo\",\n onPrimaryClick,\n onSecondaryClick,\n}: PricingCtaProps) {\n return (\n <section className=\"w-full px-4 md:px-8 lg:px-20\">\n <div\n className=\"w-full flex flex-col items-center justify-center text-center gap-8 py-12 md:py-16 px-10 overflow-hidden\"\n style={{\n backgroundColor: \"var(--canvas-dark-section-bg)\",\n borderRadius: \"var(--radius-3xl)\",\n }}\n >\n <Typography\n variant=\"h3\"\n as=\"h2\"\n style={{ color: \"white\" }}\n >\n {title}\n </Typography>\n\n <div className=\"flex flex-col sm:flex-row items-center gap-6\">\n <Button variant=\"primary\" size=\"lg\" onClick={onPrimaryClick}>\n {primaryButtonText}\n </Button>\n <Button variant=\"
|
|
9
|
+
"content": "\"use client\";\n\nimport { Button } from \"../../ui/button\";\nimport { Typography } from \"../../ui/typography\";\n\ninterface PricingCtaProps {\n title?: string;\n primaryButtonText?: string;\n secondaryButtonText?: string;\n onPrimaryClick?: () => void;\n onSecondaryClick?: () => void;\n}\n\nexport function PricingCta({\n title = \"Get started for free\",\n primaryButtonText = \"Open account\",\n secondaryButtonText = \"Explore demo\",\n onPrimaryClick,\n onSecondaryClick,\n}: PricingCtaProps) {\n return (\n <section className=\"w-full px-4 md:px-8 lg:px-20\">\n <div\n className=\"w-full flex flex-col items-center justify-center text-center gap-8 py-12 md:py-16 px-10 overflow-hidden\"\n style={{\n backgroundColor: \"var(--canvas-dark-section-bg)\",\n borderRadius: \"var(--radius-3xl)\",\n }}\n >\n <Typography\n variant=\"h3\"\n as=\"h2\"\n style={{ color: \"white\" }}\n >\n {title}\n </Typography>\n\n <div className=\"flex flex-col sm:flex-row items-center gap-6\">\n <Button variant=\"primary\" size=\"lg\" onClick={onPrimaryClick}>\n {primaryButtonText}\n </Button>\n <Button variant=\"primary-neutral\" size=\"lg\" onClick={onSecondaryClick}>\n {secondaryButtonText}\n </Button>\n </div>\n </div>\n </section>\n );\n}\n\n\n\n\n\n\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pricing-plans-popup",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/pricing-plans-popup.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport React, { useState, useEffect } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n DialogDescription,\n} from \"../ui/dialog\";\nimport { Button } from \"../ui/button\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type BillingPeriod = \"monthly\" | \"annually\";\n\nexport interface PricingPlan {\n /** Unique plan identifier */\n id: string;\n /** Display name (e.g. \"Basic\", \"Professional\") */\n name: string;\n /** Monthly price in dollars */\n monthlyPrice: number;\n /** Annual price in dollars — defaults to monthlyPrice * 12 if omitted */\n annualPrice?: number;\n /** Short description of what the plan includes */\n description: string;\n}\n\nexport interface PricingPlansPopupProps {\n /** Controls dialog visibility */\n open?: boolean;\n /** Callback when dialog open state changes */\n onOpenChange?: (open: boolean) => void;\n /** Dialog title */\n title?: string;\n /** Descriptive body text below the title */\n description?: string;\n /** Array of plan options to display as radio cards */\n plans?: PricingPlan[];\n /** Currently selected billing period (controlled) */\n billingPeriod?: BillingPeriod;\n /** Default billing period when uncontrolled */\n defaultBillingPeriod?: BillingPeriod;\n /** Callback when billing period changes */\n onBillingPeriodChange?: (period: BillingPeriod) => void;\n /** Currently selected plan ID (controlled) */\n selectedPlanId?: string;\n /** Default selected plan ID when uncontrolled */\n defaultSelectedPlanId?: string;\n /** Callback when selected plan changes */\n onPlanChange?: (planId: string) => void;\n /** Save/confirm button label */\n confirmLabel?: string;\n /** Cancel button label */\n cancelLabel?: string;\n /** Callback when save button is clicked — receives the selected plan ID and billing period */\n onConfirm?: (planId: string, billingPeriod: BillingPeriod) => void;\n /** Callback when cancel button is clicked */\n onCancel?: () => void;\n /** Disables the confirm button and shows a loading state */\n loading?: boolean;\n /** Additional class names */\n className?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Default data\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TITLE = \"Pricing plans\";\nconst DEFAULT_DESCRIPTION =\n \"Choose the plan that's right for your company. All plans will include a 30-day trial.\";\n\nconst DEFAULT_PLANS: PricingPlan[] = [\n {\n id: \"basic\",\n name: \"Basic\",\n monthlyPrice: 5,\n annualPrice: 50,\n description: \"For hobbyists\",\n },\n {\n id: \"professional\",\n name: \"Professional\",\n monthlyPrice: 10,\n annualPrice: 100,\n description: \"For teams up to 30 people\",\n },\n {\n id: \"enterprise\",\n name: \"Enterprise\",\n monthlyPrice: 30,\n annualPrice: 300,\n description: \"For large teams\",\n },\n];\n\n// ---------------------------------------------------------------------------\n// PricingPlansPopup\n// ---------------------------------------------------------------------------\n\nexport function PricingPlansPopup({\n open,\n onOpenChange,\n title = DEFAULT_TITLE,\n description = DEFAULT_DESCRIPTION,\n plans = DEFAULT_PLANS,\n billingPeriod,\n defaultBillingPeriod = \"monthly\",\n onBillingPeriodChange,\n selectedPlanId,\n defaultSelectedPlanId,\n onPlanChange,\n confirmLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n onConfirm,\n onCancel,\n loading = false,\n className,\n}: PricingPlansPopupProps) {\n // Controlled vs uncontrolled billing period\n const isBillingControlled = billingPeriod !== undefined;\n const [internalBilling, setInternalBilling] =\n useState<BillingPeriod>(defaultBillingPeriod);\n const currentBilling = isBillingControlled ? billingPeriod : internalBilling;\n\n // Controlled vs uncontrolled plan selection\n const isPlanControlled = selectedPlanId !== undefined;\n const [internalPlanId, setInternalPlanId] = useState<string | undefined>(\n defaultSelectedPlanId\n );\n const currentPlanId = isPlanControlled ? selectedPlanId : internalPlanId;\n\n // Reset internal state when dialog closes\n useEffect(() => {\n if (!open) {\n if (!isBillingControlled) setInternalBilling(defaultBillingPeriod);\n if (!isPlanControlled) setInternalPlanId(defaultSelectedPlanId);\n }\n }, [open, isBillingControlled, isPlanControlled, defaultBillingPeriod, defaultSelectedPlanId]);\n\n const handleBillingChange = (period: BillingPeriod) => {\n if (!isBillingControlled) {\n setInternalBilling(period);\n }\n onBillingPeriodChange?.(period);\n };\n\n const handlePlanSelect = (planId: string) => {\n if (!isPlanControlled) {\n setInternalPlanId(planId);\n }\n onPlanChange?.(planId);\n };\n\n const handleCancel = () => {\n onCancel?.();\n onOpenChange?.(false);\n };\n\n const handleConfirm = () => {\n if (currentPlanId) {\n onConfirm?.(currentPlanId, currentBilling);\n }\n };\n\n const getDisplayPrice = (plan: PricingPlan): number => {\n if (currentBilling === \"annually\") {\n return plan.annualPrice ?? plan.monthlyPrice * 12;\n }\n return plan.monthlyPrice;\n };\n\n const priceSuffix = currentBilling === \"annually\" ? \"/ year\" : \"/ month\";\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent\n className={cn(\n \"p-[var(--spacing-4xl)] gap-[var(--spacing-2xl)]\",\n \"rounded-[var(--radius-xl)]\",\n \"shadow-[0px_4px_24px_0px_rgba(0,0,0,0.1)]\",\n \"sm:max-w-[480px]\",\n className\n )}\n showCloseButton\n >\n {/* Title */}\n <DialogTitle\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n fontWeight: 600,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </DialogTitle>\n\n {/* Description */}\n <DialogDescription\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </DialogDescription>\n\n {/* Billing Period Toggle */}\n <div\n className=\"flex w-full overflow-hidden self-center\"\n style={{\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--spacing-xs)\",\n height: 40,\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n width: \"fit-content\",\n }}\n >\n {([\"monthly\", \"annually\"] as const).map((period, idx) => {\n const isActive = currentBilling === period;\n return (\n <button\n key={period}\n type=\"button\"\n onClick={() => handleBillingChange(period)}\n className=\"cursor-pointer\"\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: 500,\n lineHeight: \"24px\",\n background: \"var(--canvas-background)\",\n border: \"none\",\n borderLeft:\n isActive\n ? \"2px solid var(--canvas-primary)\"\n : idx > 0\n ? \"1px solid var(--canvas-border)\"\n : \"none\",\n color: isActive\n ? \"var(--canvas-primary)\"\n : \"var(--canvas-text-placeholder)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {period === \"monthly\" ? \"Monthly\" : \"Annually\"}\n </button>\n );\n })}\n </div>\n\n {/* Plan Radio Cards */}\n <div className=\"flex w-full flex-col\" style={{ gap: \"var(--spacing-2xl)\" }}>\n {plans.map((plan) => {\n const isSelected = currentPlanId === plan.id;\n return (\n <button\n key={plan.id}\n type=\"button\"\n onClick={() => handlePlanSelect(plan.id)}\n className={cn(\n \"flex w-full items-center text-left cursor-pointer transition-colors\"\n )}\n style={{\n background: isSelected ? \"var(--canvas-background)\" : \"var(--canvas-background)\",\n border: isSelected\n ? \"2px solid var(--canvas-primary)\"\n : \"1px solid var(--canvas-border)\",\n padding: isSelected ? 15 : \"var(--spacing-xl)\",\n borderRadius: \"var(--spacing-xs)\",\n gap: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n {/* Radio Circle */}\n <div\n className=\"shrink-0 flex items-center justify-center rounded-full\"\n style={{\n width: 20,\n height: 20,\n border: `1px solid var(--canvas-border)`,\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n background: \"var(--canvas-background)\",\n }}\n >\n {isSelected && (\n <div\n className=\"rounded-full\"\n style={{\n width: 12,\n height: 12,\n backgroundColor: \"var(--canvas-text-subtitle)\",\n }}\n />\n )}\n </div>\n\n {/* Plan Content */}\n <div className=\"flex flex-1 flex-col min-w-0\">\n <div className=\"flex w-full items-start justify-between\" style={{ gap: \"var(--spacing-md)\" }}>\n <span\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: 16,\n fontWeight: 500,\n lineHeight: \"24px\",\n color: \"var(--canvas-text)\",\n }}\n >\n {plan.name}\n </span>\n <span style={{ lineHeight: \"24px\", whiteSpace: \"nowrap\" }}>\n <span\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: 16,\n fontWeight: 600,\n color: \"var(--canvas-text)\",\n }}\n >\n ${getDisplayPrice(plan)}\n </span>\n <span\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: 400,\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {\" \"}\n {priceSuffix}\n </span>\n </span>\n </div>\n <span\n style={{\n fontFamily:\n \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {plan.description}\n </span>\n </div>\n </button>\n );\n })}\n </div>\n\n {/* Actions */}\n <div\n className=\"flex w-full justify-end\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"neutral\" onClick={handleCancel}>\n {cancelLabel}\n </Button>\n <Button\n variant=\"primary\"\n onClick={handleConfirm}\n disabled={loading || !currentPlanId}\n >\n {confirmLabel}\n </Button>\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"lib/utils",
|
|
15
|
+
"ui/dialog",
|
|
16
|
+
"ui/button"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/profile-image-uploader.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState, useRef } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { User, Camera } from \"lucide-react\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\n\ninterface ProfileImageUploaderProps {\n /** Current image URL */\n imageUrl?: string;\n /** Callback when image is selected */\n onImageChange?: (file: File) => void;\n /** Size of the uploader */\n size?: number;\n /** Class name for customization */\n className?: string;\n}\n\n/**\n * Profile Image Uploader\n * \n * A circular avatar with upload functionality.\n * Shows a placeholder user icon when no image is set.\n * Hover state shows camera overlay for upload interaction.\n */\nexport function ProfileImageUploader({\n imageUrl,\n onImageChange,\n size = 132,\n className,\n}: ProfileImageUploaderProps) {\n const [isHovered, setIsHovered] = useState(false);\n const [previewUrl, setPreviewUrl] = useState<string | undefined>(imageUrl);\n const inputRef = useRef<HTMLInputElement>(null);\n\n const handleClick = () => {\n inputRef.current?.click();\n };\n\n const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const file = e.target.files?.[0];\n if (file) {\n // Create preview URL\n const objectUrl = URL.createObjectURL(file);\n setPreviewUrl(objectUrl);\n onImageChange?.(file);\n }\n };\n\n return (\n <div className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n onClick={handleClick}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n className=\"relative rounded-full overflow-hidden border border-[var(--canvas-neutral-border)] transition-all hover:border-[var(--canvas-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--canvas-primary)] focus:ring-offset-2\"\n style={{ width: size, height: size }}\n >\n <Avatar className=\"size-full\">\n {previewUrl ? (\n <AvatarImage src={previewUrl} alt=\"Profile\" className=\"object-cover\" />\n ) : null}\n <AvatarFallback className=\"bg-[var(--canvas-neutral-surface)] size-full flex items-center justify-center\">\n <User \n className=\"text-[var(--canvas-neutral-text)]\" \n style={{ width: size * 0.4, height: size * 0.4 }} \n />\n </AvatarFallback>\n </Avatar>\n\n {/* Hover overlay */}\n <div\n className={cn(\n \"absolute inset-0 bg-
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState, useRef } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { User, Camera } from \"lucide-react\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\n\ninterface ProfileImageUploaderProps {\n /** Current image URL */\n imageUrl?: string;\n /** Callback when image is selected */\n onImageChange?: (file: File) => void;\n /** Size of the uploader */\n size?: number;\n /** Class name for customization */\n className?: string;\n}\n\n/**\n * Profile Image Uploader\n * \n * A circular avatar with upload functionality.\n * Shows a placeholder user icon when no image is set.\n * Hover state shows camera overlay for upload interaction.\n */\nexport function ProfileImageUploader({\n imageUrl,\n onImageChange,\n size = 132,\n className,\n}: ProfileImageUploaderProps) {\n const [isHovered, setIsHovered] = useState(false);\n const [previewUrl, setPreviewUrl] = useState<string | undefined>(imageUrl);\n const inputRef = useRef<HTMLInputElement>(null);\n\n const handleClick = () => {\n inputRef.current?.click();\n };\n\n const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const file = e.target.files?.[0];\n if (file) {\n // Create preview URL\n const objectUrl = URL.createObjectURL(file);\n setPreviewUrl(objectUrl);\n onImageChange?.(file);\n }\n };\n\n return (\n <div className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n onClick={handleClick}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n className=\"relative rounded-full overflow-hidden border border-[var(--canvas-neutral-border)] transition-all hover:border-[var(--canvas-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--canvas-primary)] focus:ring-offset-2\"\n style={{ width: size, height: size }}\n >\n <Avatar className=\"size-full\">\n {previewUrl ? (\n <AvatarImage src={previewUrl} alt=\"Profile\" className=\"object-cover\" />\n ) : null}\n <AvatarFallback className=\"bg-[var(--canvas-neutral-surface)] size-full flex items-center justify-center\">\n <User \n className=\"text-[var(--canvas-neutral-text)]\" \n style={{ width: size * 0.4, height: size * 0.4 }} \n />\n </AvatarFallback>\n </Avatar>\n\n {/* Hover overlay */}\n <div\n className={cn(\n \"absolute inset-0 bg-[var(--canvas-overlay-bg)] flex items-center justify-center transition-opacity\",\n isHovered ? \"opacity-100\" : \"opacity-0\"\n )}\n >\n <Camera className=\"text-white\" style={{ width: size * 0.25, height: size * 0.25 }} />\n </div>\n </button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept=\"image/*\"\n onChange={handleFileChange}\n className=\"hidden\"\n aria-label=\"Upload profile image\"\n />\n </div>\n );\n}\n\n\n\n\n\n\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "purchase-confirmation-popup",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/purchase-confirmation-popup.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n DialogDescription,\n} from \"../ui/dialog\";\nimport { Button } from \"../ui/button\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PurchaseConfirmationPopupProps {\n /** Controls dialog visibility */\n open?: boolean;\n /** Callback when dialog open state changes */\n onOpenChange?: (open: boolean) => void;\n /** Dialog title */\n title?: string;\n /** Descriptive body text — supports React nodes for inline bold/emphasis */\n description?: React.ReactNode;\n /** Label for the card field */\n cardLabel?: string;\n /** Masked card number to display (e.g. \"**** **** **** 8274\") */\n cardLastFour?: string;\n /** Label for the change-card button */\n changeCardLabel?: string;\n /** Callback when the \"Change\" card button is clicked */\n onChangeCard?: () => void;\n /** Confirm button label */\n confirmLabel?: string;\n /** Cancel button label */\n cancelLabel?: string;\n /** Callback when the confirm button is clicked */\n onConfirm?: () => void;\n /** Callback when the cancel button is clicked */\n onCancel?: () => void;\n /** Disables the confirm button and shows a loading state */\n loading?: boolean;\n /** Additional class names */\n className?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Default data\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TITLE = \"Confirm your plan\";\nconst DEFAULT_DESCRIPTION = (\n <>\n You are about to reserve a spot for{\" \"}\n <strong style={{ color: \"var(--canvas-text)\" }}>AirDev Academy</strong> on{\" \"}\n <strong style={{ color: \"var(--canvas-text)\" }}>\n Tuesday, Dec 25 at 4:00pm ET\n </strong>\n .{\"\\n\\n\"}Click below to confirm your credit card and authorize a charge of{\" \"}\n <strong\n style={{\n color: \"var(--canvas-text)\",\n fontSize: \"var(--typo-body-l-size)\",\n fontWeight: 600,\n }}\n >\n $59\n </strong>{\" \"}\n for the class.\n </>\n);\n\n// ---------------------------------------------------------------------------\n// PurchaseConfirmationPopup\n// ---------------------------------------------------------------------------\n\nexport function PurchaseConfirmationPopup({\n open,\n onOpenChange,\n title = DEFAULT_TITLE,\n description = DEFAULT_DESCRIPTION,\n cardLabel = \"Selected credit card\",\n cardLastFour = \"**** **** **** 8274\",\n changeCardLabel = \"Change\",\n onChangeCard,\n confirmLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n onConfirm,\n onCancel,\n loading = false,\n className,\n}: PurchaseConfirmationPopupProps) {\n const handleCancel = () => {\n onCancel?.();\n onOpenChange?.(false);\n };\n\n const handleConfirm = () => {\n onConfirm?.();\n };\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent\n className={cn(\n \"p-[var(--spacing-4xl)] gap-[var(--spacing-2xl)]\",\n \"rounded-[var(--radius-xl)]\",\n \"shadow-[0px_4px_24px_0px_rgba(0,0,0,0.1)]\",\n \"sm:max-w-[375px]\",\n className\n )}\n showCloseButton\n >\n {/* Title */}\n <DialogTitle\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n fontWeight: 600,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </DialogTitle>\n\n {/* Description */}\n <DialogDescription asChild>\n <div\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </div>\n </DialogDescription>\n\n {/* Credit card field */}\n <div className=\"flex flex-col gap-[var(--spacing-xs)] w-full\">\n <span\n style={{\n fontFamily:\n \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n fontWeight: 500,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {cardLabel}\n </span>\n <div\n className={cn(\n \"flex items-center gap-[var(--spacing-3xl)]\",\n \"h-[44px] w-full\",\n \"rounded-[var(--radius-xs)]\",\n \"border border-[var(--canvas-border)]\",\n \"bg-[var(--canvas-background)]\",\n \"pl-[var(--spacing-xl)] pr-[var(--spacing-md)]\",\n \"shadow-[0px_1px_2px_0px_rgba(0,0,0,0.02)]\"\n )}\n >\n <span\n className=\"flex-1\"\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {cardLastFour}\n </span>\n <button\n type=\"button\"\n onClick={onChangeCard}\n className=\"shrink-0 cursor-pointer rounded-[var(--radius-xs)] px-[var(--spacing-lg)] py-[var(--spacing-xs)]\"\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"16px\",\n lineHeight: \"24px\",\n fontWeight: 600,\n color: \"var(--canvas-primary)\",\n background: \"transparent\",\n border: \"none\",\n }}\n >\n {changeCardLabel}\n </button>\n </div>\n </div>\n\n {/* Actions */}\n <div className=\"flex w-full gap-[var(--spacing-3xl)] flex-col-reverse sm:flex-row justify-end\">\n <Button\n variant=\"neutral\"\n className=\"sm:w-[96px]\"\n onClick={handleCancel}\n >\n {cancelLabel}\n </Button>\n <Button\n variant=\"primary\"\n onClick={handleConfirm}\n disabled={loading}\n >\n {confirmLabel}\n </Button>\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"lib/utils",
|
|
15
|
+
"ui/dialog",
|
|
16
|
+
"ui/button"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/sidebar-profile-card.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Typography } from \"../ui/typography\";\nimport { Button } from \"../ui/button\";\nimport {\n MapPin,\n BookOpen,\n Video,\n DollarSign,\n Star,\n Globe,\n Facebook,\n Twitter,\n Instagram,\n Eye,\n} from \"lucide-react\";\n\ninterface InfoRow {\n icon: \"location\" | \"education\" | \"sessions\" | \"earnings\";\n label: string;\n value: string;\n}\n\ninterface SidebarProfileCardProps {\n /** Profile avatar image URL */\n avatarUrl?: string;\n /** Avatar fallback initials */\n avatarFallback?: string;\n /** Whether to show online status indicator */\n showStatus?: boolean;\n /** User's display name */\n name: string;\n /** User's role/title */\n role?: string;\n /** Star rating value */\n rating?: number;\n /** Number of reviews */\n reviewCount?: string;\n /** Certification text */\n certification?: string;\n /** Info rows (location, education, sessions, earnings) */\n infoRows?: InfoRow[];\n /** Contact button click handler */\n onContactClick?: () => void;\n /** Hire button click handler */\n onHireClick?: () => void;\n /** Additional class names */\n className?: string;\n}\n\nconst infoIcons = {\n location: MapPin,\n education: BookOpen,\n sessions: Video,\n earnings: DollarSign,\n};\n\n/**\n * Canvas Design System - Sidebar Profile Card Component\n *\n * A profile card designed for sidebar placement with avatar,\n * name, rating, action buttons, info rows, and social icons.\n */\nexport function SidebarProfileCard({\n avatarUrl,\n avatarFallback = \"JC\",\n showStatus = true,\n name,\n role,\n rating = 4.8,\n reviewCount = \"(2.4k)\",\n certification,\n infoRows = [],\n onContactClick,\n onHireClick,\n className,\n}: SidebarProfileCardProps) {\n return (\n <div\n className={cn(\n \"bg-[var(--canvas-background)] border border-[var(--canvas-border)] rounded-[var(--radius-md)]\",\n \"flex flex-col w-full\",\n className\n )}\n >\n {/* Main Content Section */}\n <div className=\"flex flex-col items-center gap-[var(--spacing-2xl)] px-[var(--spacing-4xl)] pt-[var(--spacing-4xl)]\">\n {/* Avatar with Status */}\n <div className=\"flex flex-col items-center gap-[var(--radius-md)]\">\n <div className=\"relative\">\n <Avatar className=\"size-[120px]\">\n <AvatarImage src={avatarUrl} alt={name} />\n <AvatarFallback className=\"font-semibold bg-[var(--canvas-surface)] text-[var(--canvas-text-muted)]\" style={{ fontSize: \"var(--typo-h6-size)\" }}>\n {avatarFallback}\n </AvatarFallback>\n </Avatar>\n {showStatus && (\n <div className=\"absolute bottom-[10px] right-[10px] size-5 rounded-full bg-[var(--canvas-success)] border-[3px] border-[var(--canvas-background)]\" />\n )}\n </div>\n\n {/* Name & Role */}\n <div className=\"flex flex-col items-center gap-[var(--spacing-xs)]\">\n <Typography variant=\"body-xl\" className=\"text-center\" style={{ fontWeight: 600 }}>\n {name}\n </Typography>\n {role && (\n <Typography variant=\"body-s\" color=\"muted\" className=\"text-center\">\n {role}\n </Typography>\n )}\n </div>\n </div>\n\n {/* Star Rating */}\n <div className=\"flex items-center gap-1 justify-center\">\n <Star className=\"size-4 fill-[var(--canvas-primary)] text-[var(--canvas-primary)]\" />\n <Typography variant=\"body-xs\" className=\"font-semibold\">\n {rating}\n </Typography>\n <Typography variant=\"body-xs\" color=\"muted\">\n {reviewCount}\n </Typography>\n </div>\n\n {/* Action Buttons */}\n <div className=\"flex gap-[var(--spacing-xl)] w-full\">\n <Button\n variant=\"
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Typography } from \"../ui/typography\";\nimport { Button } from \"../ui/button\";\nimport {\n MapPin,\n BookOpen,\n Video,\n DollarSign,\n Star,\n Globe,\n Facebook,\n Twitter,\n Instagram,\n Eye,\n} from \"lucide-react\";\n\ninterface InfoRow {\n icon: \"location\" | \"education\" | \"sessions\" | \"earnings\";\n label: string;\n value: string;\n}\n\ninterface SidebarProfileCardProps {\n /** Profile avatar image URL */\n avatarUrl?: string;\n /** Avatar fallback initials */\n avatarFallback?: string;\n /** Whether to show online status indicator */\n showStatus?: boolean;\n /** User's display name */\n name: string;\n /** User's role/title */\n role?: string;\n /** Star rating value */\n rating?: number;\n /** Number of reviews */\n reviewCount?: string;\n /** Certification text */\n certification?: string;\n /** Info rows (location, education, sessions, earnings) */\n infoRows?: InfoRow[];\n /** Contact button click handler */\n onContactClick?: () => void;\n /** Hire button click handler */\n onHireClick?: () => void;\n /** Additional class names */\n className?: string;\n}\n\nconst infoIcons = {\n location: MapPin,\n education: BookOpen,\n sessions: Video,\n earnings: DollarSign,\n};\n\n/**\n * Canvas Design System - Sidebar Profile Card Component\n *\n * A profile card designed for sidebar placement with avatar,\n * name, rating, action buttons, info rows, and social icons.\n */\nexport function SidebarProfileCard({\n avatarUrl,\n avatarFallback = \"JC\",\n showStatus = true,\n name,\n role,\n rating = 4.8,\n reviewCount = \"(2.4k)\",\n certification,\n infoRows = [],\n onContactClick,\n onHireClick,\n className,\n}: SidebarProfileCardProps) {\n return (\n <div\n className={cn(\n \"bg-[var(--canvas-background)] border border-[var(--canvas-border)] rounded-[var(--radius-md)]\",\n \"flex flex-col w-full\",\n className\n )}\n >\n {/* Main Content Section */}\n <div className=\"flex flex-col items-center gap-[var(--spacing-2xl)] px-[var(--spacing-4xl)] pt-[var(--spacing-4xl)]\">\n {/* Avatar with Status */}\n <div className=\"flex flex-col items-center gap-[var(--radius-md)]\">\n <div className=\"relative\">\n <Avatar className=\"size-[120px]\">\n <AvatarImage src={avatarUrl} alt={name} />\n <AvatarFallback className=\"font-semibold bg-[var(--canvas-surface)] text-[var(--canvas-text-muted)]\" style={{ fontSize: \"var(--typo-h6-size)\" }}>\n {avatarFallback}\n </AvatarFallback>\n </Avatar>\n {showStatus && (\n <div className=\"absolute bottom-[10px] right-[10px] size-5 rounded-full bg-[var(--canvas-success)] border-[3px] border-[var(--canvas-background)]\" />\n )}\n </div>\n\n {/* Name & Role */}\n <div className=\"flex flex-col items-center gap-[var(--spacing-xs)]\">\n <Typography variant=\"body-xl\" className=\"text-center\" style={{ fontWeight: 600 }}>\n {name}\n </Typography>\n {role && (\n <Typography variant=\"body-s\" color=\"muted\" className=\"text-center\">\n {role}\n </Typography>\n )}\n </div>\n </div>\n\n {/* Star Rating */}\n <div className=\"flex items-center gap-1 justify-center\">\n <Star className=\"size-4 fill-[var(--canvas-primary)] text-[var(--canvas-primary)]\" />\n <Typography variant=\"body-xs\" className=\"font-semibold\">\n {rating}\n </Typography>\n <Typography variant=\"body-xs\" color=\"muted\">\n {reviewCount}\n </Typography>\n </div>\n\n {/* Action Buttons */}\n <div className=\"flex gap-[var(--spacing-xl)] w-full\">\n <Button\n variant=\"neutral\"\n className=\"flex-1\"\n onClick={onContactClick}\n >\n Contact\n </Button>\n <Button\n variant=\"primary\"\n className=\"flex-1\"\n onClick={onHireClick}\n >\n Hire me\n </Button>\n </div>\n\n {/* Divider */}\n <div className=\"w-full h-px bg-[var(--canvas-border)]\" />\n\n {/* Certification */}\n {certification && (\n <Typography variant=\"body-s\" color=\"muted\" className=\"text-center\">\n {certification}\n </Typography>\n )}\n\n {/* Info Rows */}\n {infoRows.length > 0 && (\n <div className=\"flex flex-col gap-[var(--spacing-lg)] w-full\">\n {infoRows.map((row, index) => {\n const IconComponent = infoIcons[row.icon];\n return (\n <div\n key={index}\n className=\"flex items-center justify-between w-full\"\n >\n <div className=\"flex items-center gap-[var(--spacing-sm)]\">\n <IconComponent className=\"size-4 text-[var(--canvas-text-muted)]\" />\n <Typography variant=\"body-s\" color=\"muted\">\n {row.label}\n </Typography>\n </div>\n <Typography variant=\"body-s\" className=\"font-semibold text-[var(--canvas-neutral-text)]\">\n {row.value}\n </Typography>\n </div>\n );\n })}\n </div>\n )}\n\n {/* Divider */}\n <div className=\"w-full h-px bg-[var(--canvas-border)]\" />\n\n {/* Social Icons */}\n <div className=\"flex gap-[var(--spacing-xl)] pb-[var(--spacing-3xl)]\">\n <Eye className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n <Facebook className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n <Twitter className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n <Instagram className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n </div>\n </div>\n </div>\n );\n}\n\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slideshow-popup",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/slideshow-popup.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport React, { useState, useEffect, useCallback } from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { ChevronLeft, ChevronRight, X, ThumbsUp, Eye } from \"lucide-react\";\nimport { cn } from \"../../lib/utils\";\nimport { Dialog, DialogPortal, DialogOverlay } from \"../ui/dialog\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { AVATAR_SARAH_CHEN } from \"./demo-avatars\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SlideshowSlide {\n /** Image source URL */\n src: string;\n /** Alt text for the image */\n alt?: string;\n}\n\nexport interface SlideshowAuthor {\n /** Author display name */\n name: string;\n /** Author avatar URL */\n avatarUrl?: string;\n /** Location text (e.g., \"New York, NY\") */\n location?: string;\n}\n\nexport interface SlideshowPopupProps {\n /** Controls dialog visibility */\n open?: boolean;\n /** Callback when dialog open state changes */\n onOpenChange?: (open: boolean) => void;\n /** Array of slides to display */\n slides?: SlideshowSlide[];\n /** Author/creator information displayed below the image */\n author?: SlideshowAuthor;\n /** Like count displayed with thumbs-up icon */\n likes?: number | string;\n /** View count displayed with eye icon */\n views?: number | string;\n /** Index of the initially displayed slide (defaults to 0) */\n initialIndex?: number;\n /** Callback when slide changes */\n onSlideChange?: (index: number) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Default data\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_SLIDES: SlideshowSlide[] = [\n {\n src: \"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800&h=800&fit=crop\",\n alt: \"Abstract gradient art\",\n },\n {\n src: \"https://images.unsplash.com/photo-1558591710-4b4a1ae0f04d?w=800&h=800&fit=crop\",\n alt: \"Colorful marble pattern\",\n },\n {\n src: \"https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&h=800&fit=crop\",\n alt: \"Modern digital art\",\n },\n {\n src: \"https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&h=800&fit=crop\",\n alt: \"Flowing abstract shapes\",\n },\n {\n src: \"https://images.unsplash.com/photo-1561998338-13ad7883b20f?w=800&h=800&fit=crop\",\n alt: \"Ocean view\",\n },\n];\n\nconst DEFAULT_AUTHOR: SlideshowAuthor = {\n name: \"Sarah Chen\",\n avatarUrl: AVATAR_SARAH_CHEN,\n location: \"Copenhagen, Denmark\",\n};\n\nconst DEFAULT_LIKES = \"120\";\nconst DEFAULT_VIEWS = \"1k\";\n\n// ---------------------------------------------------------------------------\n// SlideshowPopup\n// ---------------------------------------------------------------------------\n\nexport function SlideshowPopup({\n open,\n onOpenChange,\n slides = DEFAULT_SLIDES,\n author = DEFAULT_AUTHOR,\n likes = DEFAULT_LIKES,\n views = DEFAULT_VIEWS,\n initialIndex = 0,\n onSlideChange,\n className,\n}: SlideshowPopupProps) {\n const [currentIndex, setCurrentIndex] = useState(initialIndex);\n\n // Reset index when dialog opens or initialIndex changes\n useEffect(() => {\n if (open) {\n setCurrentIndex(initialIndex);\n }\n }, [open, initialIndex]);\n\n const goToPrevious = useCallback(() => {\n setCurrentIndex((prev) => {\n const next = prev === 0 ? slides.length - 1 : prev - 1;\n onSlideChange?.(next);\n return next;\n });\n }, [slides.length, onSlideChange]);\n\n const goToNext = useCallback(() => {\n setCurrentIndex((prev) => {\n const next = prev === slides.length - 1 ? 0 : prev + 1;\n onSlideChange?.(next);\n return next;\n });\n }, [slides.length, onSlideChange]);\n\n const goToSlide = useCallback(\n (index: number) => {\n setCurrentIndex(index);\n onSlideChange?.(index);\n },\n [onSlideChange]\n );\n\n // Keyboard navigation\n useEffect(() => {\n if (!open) return;\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (e.key === \"ArrowLeft\") {\n e.preventDefault();\n goToPrevious();\n } else if (e.key === \"ArrowRight\") {\n e.preventDefault();\n goToNext();\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [open, goToPrevious, goToNext]);\n\n const currentSlide = slides[currentIndex];\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogPortal>\n <DialogOverlay className=\"bg-black/80\" />\n <DialogPrimitive.Content\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center outline-none\",\n \"font-[family-name:var(--typo-global-font)]\",\n className\n )}\n style={{ padding: \"var(--spacing-5xl)\" }}\n aria-describedby={undefined}\n >\n {/* Accessible title (sr-only) */}\n <DialogPrimitive.Title className=\"sr-only\">\n Image slideshow\n </DialogPrimitive.Title>\n\n {/* Close button */}\n <DialogPrimitive.Close\n className=\"absolute top-3 right-3 z-10 cursor-pointer flex items-center justify-center rounded-[var(--radius-full)] transition-opacity opacity-80 hover:opacity-100 outline-none\"\n style={{ width: 30, height: 30, color: \"white\" }}\n >\n <X className=\"w-5 h-5\" />\n <span className=\"sr-only\">Close</span>\n </DialogPrimitive.Close>\n\n {/* Main layout: arrows + content */}\n <div\n className=\"flex items-center w-full max-w-[960px]\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Left arrow */}\n <button\n onClick={goToPrevious}\n aria-label=\"Previous slide\"\n className=\"hidden sm:flex cursor-pointer items-center justify-center shrink-0 transition-transform hover:scale-105\"\n style={{\n width: 50,\n height: 50,\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full)\",\n boxShadow: \"0px 4px 16px 0px rgba(0,0,0,0.04)\",\n }}\n >\n <ChevronLeft\n className=\"w-6 h-6\"\n style={{ color: \"var(--canvas-text)\" }}\n />\n </button>\n\n {/* Center column: image + author bar */}\n <div\n className=\"flex flex-col flex-1 min-w-0 items-center\"\n style={{ gap: \"var(--spacing-2xl)\" }}\n >\n {/* Image container */}\n <div\n className=\"relative w-full\"\n style={{\n maxWidth: 768,\n aspectRatio: \"1\",\n borderRadius: \"var(--radius-xl)\",\n overflow: \"hidden\",\n }}\n >\n <img\n src={currentSlide?.src}\n alt={currentSlide?.alt ?? \"Slideshow image\"}\n className=\"absolute inset-0 w-full h-full object-cover\"\n style={{ borderRadius: \"var(--radius-xl)\" }}\n />\n\n {/* Dot indicators */}\n {slides.length > 1 && (\n <div\n className=\"absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center\"\n style={{\n gap: \"var(--spacing-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n backgroundColor: \"var(--canvas-background)\",\n borderRadius: \"var(--radius-full)\",\n boxShadow: \"0px 1px 8px 0px rgba(14,30,47,0.03)\",\n }}\n >\n {slides.map((_, index) => (\n <button\n key={index}\n onClick={() => goToSlide(index)}\n aria-label={`Go to slide ${index + 1}`}\n aria-current={\n index === currentIndex ? \"true\" : undefined\n }\n className=\"cursor-pointer transition-colors\"\n style={{\n width: 8,\n height: 8,\n borderRadius: \"var(--radius-full)\",\n backgroundColor:\n index === currentIndex\n ? \"var(--canvas-text)\"\n : \"var(--canvas-border)\",\n border: \"none\",\n padding: 0,\n }}\n />\n ))}\n </div>\n )}\n </div>\n\n {/* Author info bar */}\n {author && (\n <div\n className=\"flex items-center w-full flex-wrap\"\n style={{ gap: \"var(--spacing-xl)\", maxWidth: 768 }}\n >\n {/* Avatar */}\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 40,\n height: 40,\n }}\n >\n <AvatarImage\n src={author.avatarUrl}\n alt={author.name}\n />\n <AvatarFallback>\n {author.name\n .split(\" \")\n .map((n) => n[0])\n .join(\"\")\n .slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n\n {/* Name + location */}\n <div className=\"flex flex-col flex-1 min-w-0 justify-center\">\n <p\n className=\"truncate\"\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"white\",\n margin: 0,\n }}\n >\n {author.name}\n </p>\n {author.location && (\n <p\n className=\"truncate\"\n style={{\n fontFamily:\n \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\",\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"rgba(255,255,255,0.7)\",\n margin: 0,\n }}\n >\n {author.location}\n </p>\n )}\n </div>\n\n {/* Stats */}\n <div\n className=\"flex items-center shrink-0\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {/* Likes */}\n {likes != null && (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <ThumbsUp\n className=\"w-5 h-5\"\n style={{ color: \"rgba(255,255,255,0.7)\" }}\n />\n <span\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: \"var(--typo-body-m-weight)\",\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"rgba(255,255,255,0.7)\",\n }}\n >\n {likes}\n </span>\n </div>\n )}\n\n {/* Views */}\n {views != null && (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <Eye\n className=\"w-5 h-5\"\n style={{ color: \"rgba(255,255,255,0.7)\" }}\n />\n <span\n style={{\n fontFamily:\n \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: \"var(--typo-body-m-weight)\",\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"rgba(255,255,255,0.7)\",\n }}\n >\n {views}\n </span>\n </div>\n )}\n </div>\n </div>\n )}\n </div>\n\n {/* Right arrow */}\n <button\n onClick={goToNext}\n aria-label=\"Next slide\"\n className=\"hidden sm:flex cursor-pointer items-center justify-center shrink-0 transition-transform hover:scale-105\"\n style={{\n width: 50,\n height: 50,\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full)\",\n boxShadow: \"0px 4px 16px 0px rgba(0,0,0,0.04)\",\n }}\n >\n <ChevronRight\n className=\"w-6 h-6\"\n style={{ color: \"var(--canvas-text)\" }}\n />\n </button>\n </div>\n </DialogPrimitive.Content>\n </DialogPortal>\n </Dialog>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@radix-ui/react-dialog",
|
|
14
|
+
"lucide-react"
|
|
15
|
+
],
|
|
16
|
+
"registryDependencies": [
|
|
17
|
+
"lib/utils",
|
|
18
|
+
"ui/dialog",
|
|
19
|
+
"ui/avatar",
|
|
20
|
+
"blocks/demo-avatars"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "store-location-map",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Single store location card with address info, directions button, and embedded Google Maps iframe. Uses TitleGroup for header.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/store-location-map.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { TitleGroup } from \"./title-group\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface StoreLocationMapProps {\n /** Section heading rendered by TitleGroup */\n title?: string;\n /** Section subheading rendered by TitleGroup */\n subtitle?: string;\n /** Label above the store name (e.g. \"Main store\", \"Flagship\") */\n storeLabel?: string;\n /** Store name displayed prominently */\n storeName?: string;\n /** Array of address lines */\n addressLines?: string[];\n /** Text for the directions button */\n buttonText?: string;\n /** Callback when the directions button is clicked */\n onDirectionsClick?: () => void;\n /** Google Maps embed URL (or any embeddable map URL) for the right panel */\n mapEmbedUrl?: string;\n /** Additional class names for the root wrapper */\n className?: string;\n}\n\n// ============================================\n// Main Component\n// ============================================\n\nconst DEFAULT_MAP_EMBED_URL =\n \"https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3025.274890339032!2d-73.9336067!3d40.7061768!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x89c25bfb49d2a9e7%3A0xc2427a53a48a1f3c!2s56%20Bogart%20St%2C%20Brooklyn%2C%20NY%2011206!5e0!3m2!1sen!2sus!4v1\";\n\nexport function StoreLocationMap({\n title = \"Our location\",\n subtitle = \"Browse our collection\",\n storeLabel = \"Main store\",\n storeName = \"Bebop Clothing\",\n addressLines = [\"56 Bogart St\", \"Brooklyn, NY 11206\"],\n buttonText = \"Directions\",\n onDirectionsClick,\n mapEmbedUrl = DEFAULT_MAP_EMBED_URL,\n className,\n}: StoreLocationMapProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header */}\n <TitleGroup title={title} subtitle={subtitle} />\n\n {/* Location Card */}\n <div\n className=\"flex flex-col md:flex-row w-full overflow-hidden\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n {/* Left Panel - Store Info */}\n <div\n className=\"flex flex-col w-full md:w-[320px] shrink-0\"\n style={{\n padding: \"var(--spacing-4xl)\",\n gap: \"var(--spacing-2xl)\",\n }}\n >\n {/* Store Label */}\n <p\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: \"var(--btn-standard-font-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n margin: 0,\n }}\n >\n {storeLabel}\n </p>\n\n {/* Store Name + Address */}\n <div className=\"flex flex-col\">\n <p\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: \"var(--typo-h6-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {storeName}\n </p>\n {addressLines.map((line, index) => (\n <p\n key={index}\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: \"var(--typo-body-m-weight)\" as React.CSSProperties[\"fontWeight\"],\n lineHeight: \"var(--typo-body-m-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {line}\n </p>\n ))}\n </div>\n\n {/* Directions Button */}\n <Button\n variant=\"primary\"\n size=\"default\"\n className=\"w-full md:w-auto\"\n onClick={onDirectionsClick}\n >\n {buttonText}\n </Button>\n </div>\n\n {/* Right Panel - Embedded Map */}\n <div className=\"relative flex-1 min-h-[240px] md:min-h-0 aspect-[16/9] md:aspect-auto overflow-hidden\">\n {mapEmbedUrl && (\n <iframe\n src={mapEmbedUrl}\n className=\"absolute inset-0 w-full h-full border-0\"\n allowFullScreen\n loading=\"lazy\"\n referrerPolicy=\"no-referrer-when-downgrade\"\n title=\"Store location map\"\n />\n )}\n </div>\n </div>\n </div>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"lib/utils",
|
|
15
|
+
"ui/button",
|
|
16
|
+
"blocks/title-group"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "terms-of-service-popup",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/terms-of-service-popup.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n} from \"../ui/dialog\";\nimport { Button } from \"../ui/button\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TermsOfServicePopupProps {\n /** Controls dialog visibility */\n open?: boolean;\n /** Callback when dialog open state changes */\n onOpenChange?: (open: boolean) => void;\n /** Dialog title */\n title?: string;\n /** The scrollable terms content — accepts a string or React nodes */\n children?: React.ReactNode;\n /** Confirm / accept button label */\n confirmLabel?: string;\n /** Cancel / decline button label */\n cancelLabel?: string;\n /** Callback when the confirm button is clicked */\n onConfirm?: () => void;\n /** Callback when the cancel button is clicked */\n onCancel?: () => void;\n /** Disables the confirm button and shows a loading state */\n loading?: boolean;\n /** Additional class names */\n className?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Default content\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_TITLE = \"Terms of Service\";\n\nconst DEFAULT_CONTENT = (\n <>\n <p>1. Terms</p>\n <br />\n <p>\n By accessing the website at http://sample.io, you are agreeing to be bound\n by these terms of service, all applicable laws and regulations, and agree\n that you are responsible for compliance with any applicable local laws. If\n you do not agree with any of these terms, you are prohibited from using or\n accessing this site. The materials contained in this website are protected\n by applicable copyright and trademark law.\n </p>\n <br />\n <p>2. Use License</p>\n <br />\n <p>\n Permission is granted to temporarily download one copy of the materials\n (information or software) on sample's website for personal,\n non-commercial transitory viewing only. This is the grant of a license,\n not a transfer of title, and under this license you may not: modify or\n copy the materials;\n </p>\n <p>\n use the materials for any commercial purpose, or for any public display\n (commercial or non-commercial);\n </p>\n </>\n);\n\n// ---------------------------------------------------------------------------\n// TermsOfServicePopup\n// ---------------------------------------------------------------------------\n\nexport function TermsOfServicePopup({\n open,\n onOpenChange,\n title = DEFAULT_TITLE,\n children = DEFAULT_CONTENT,\n confirmLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n onConfirm,\n onCancel,\n loading = false,\n className,\n}: TermsOfServicePopupProps) {\n const handleCancel = () => {\n onCancel?.();\n onOpenChange?.(false);\n };\n\n const handleConfirm = () => {\n onConfirm?.();\n };\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent\n className={cn(\n \"p-[var(--spacing-4xl)] gap-[var(--spacing-2xl)]\",\n \"rounded-[var(--radius-xl)]\",\n \"shadow-[0px_4px_24px_0px_rgba(0,0,0,0.1)]\",\n \"sm:max-w-[576px]\",\n className\n )}\n showCloseButton\n >\n {/* Title */}\n <DialogTitle\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n fontWeight: 600,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </DialogTitle>\n\n {/* Scrollable content area */}\n <div\n className={cn(\n \"w-full rounded-[var(--radius-md)]\",\n \"bg-[var(--canvas-surface)]\",\n \"max-h-[360px] overflow-y-auto\",\n \"[&::-webkit-scrollbar]:w-[6px]\",\n \"[&::-webkit-scrollbar-track]:bg-[var(--canvas-border)]\",\n \"[&::-webkit-scrollbar-track]:rounded-full\",\n \"[&::-webkit-scrollbar-thumb]:bg-[var(--canvas-text-placeholder)]\",\n \"[&::-webkit-scrollbar-thumb]:rounded-full\"\n )}\n >\n <div\n className=\"p-[var(--spacing-xl)]\"\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n lineHeight: \"var(--typo-body-m-line-height)\",\n fontWeight: 400,\n color: \"var(--canvas-text)\",\n }}\n >\n {children}\n </div>\n </div>\n\n {/* Actions */}\n <div className=\"flex w-full gap-[var(--spacing-3xl)] justify-end\">\n <Button variant=\"neutral\" onClick={handleCancel}>\n {cancelLabel}\n </Button>\n <Button\n variant=\"primary\"\n onClick={handleConfirm}\n disabled={loading}\n >\n {confirmLabel}\n </Button>\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"lib/utils",
|
|
15
|
+
"ui/dialog",
|
|
16
|
+
"ui/button"
|
|
17
|
+
]
|
|
18
|
+
}
|