astro-component-kit 0.4.0 → 0.4.1

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.
Files changed (180) hide show
  1. package/README.md +105 -27
  2. package/package.json +16 -3
  3. package/src/components/index.ts +2 -0
  4. package/src/components/lib/Galaxy3D.astro +249 -137
  5. package/src/components/lib/app/AppSidebar.astro +249 -0
  6. package/src/components/lib/app/AppTopbar.astro +284 -0
  7. package/src/components/lib/app/ArrivalsBoard.astro +176 -0
  8. package/src/components/lib/app/AuthCard.astro +213 -0
  9. package/src/components/lib/app/DataTable.astro +346 -0
  10. package/src/components/lib/app/GuestProfileCard.astro +133 -0
  11. package/src/components/lib/app/HousekeepingBoard.astro +189 -0
  12. package/src/components/lib/app/PageHeader.astro +157 -0
  13. package/src/components/lib/app/PlanUsageCard.astro +112 -0
  14. package/src/components/lib/app/RoomRack.astro +207 -0
  15. package/src/components/lib/app/SettingsSection.astro +96 -0
  16. package/src/components/lib/app/SwitchRow.astro +71 -0
  17. package/src/components/lib/app/TeamMembers.astro +190 -0
  18. package/src/components/lib/app/app-icons.ts +52 -0
  19. package/src/components/lib/app/index.ts +19 -0
  20. package/src/components/lib/charts/AreaChart.astro +4 -4
  21. package/src/components/lib/charts/BarChart.astro +3 -3
  22. package/src/components/lib/charts/ChartFrame.astro +10 -10
  23. package/src/components/lib/charts/DonutChart.astro +2 -2
  24. package/src/components/lib/charts/DumbbellChart.astro +1 -1
  25. package/src/components/lib/charts/ForecastLineChart.astro +1 -1
  26. package/src/components/lib/charts/GaugeChart.astro +1 -1
  27. package/src/components/lib/charts/HeatmapChart.astro +1 -1
  28. package/src/components/lib/charts/HorizontalBarChart.astro +2 -2
  29. package/src/components/lib/charts/KpiStatTile.astro +4 -4
  30. package/src/components/lib/charts/LinearProgressGauge.astro +4 -4
  31. package/src/components/lib/charts/MetricDeltaTable.astro +3 -3
  32. package/src/components/lib/charts/RadarMetricChart.astro +4 -4
  33. package/src/components/lib/charts/RadialBarChart.astro +1 -1
  34. package/src/components/lib/charts/RetentionCohortTable.astro +2 -2
  35. package/src/components/lib/charts/TreemapChart.astro +1 -1
  36. package/src/components/lib/ecommerce/CartDrawer.astro +483 -0
  37. package/src/components/lib/ecommerce/CartPageList.astro +290 -0
  38. package/src/components/lib/ecommerce/CartToast.astro +276 -0
  39. package/src/components/lib/ecommerce/CategoryTiles.astro +143 -0
  40. package/src/components/lib/ecommerce/CheckoutForm.astro +423 -0
  41. package/src/components/lib/ecommerce/CountdownBanner.astro +230 -0
  42. package/src/components/lib/ecommerce/FilterBar.astro +278 -0
  43. package/src/components/lib/ecommerce/FilterSidebar.astro +405 -0
  44. package/src/components/lib/ecommerce/LiveActivityToast.astro +213 -0
  45. package/src/components/lib/ecommerce/OrderSummary.astro +295 -0
  46. package/src/components/lib/ecommerce/ProductBuyBox.astro +247 -0
  47. package/src/components/lib/ecommerce/ProductCardMinimal.astro +229 -0
  48. package/src/components/lib/ecommerce/ProductGallery.astro +145 -0
  49. package/src/components/lib/ecommerce/ProductGrid.astro +118 -0
  50. package/src/components/lib/ecommerce/ProductMedia.astro +113 -0
  51. package/src/components/lib/ecommerce/ProductRating.astro +78 -0
  52. package/src/components/lib/ecommerce/ProductToolbar.astro +93 -0
  53. package/src/components/lib/ecommerce/PromoBar.astro +145 -0
  54. package/src/components/lib/ecommerce/StockIndicator.astro +127 -0
  55. package/src/components/lib/ecommerce/StoreHeader.astro +446 -0
  56. package/src/components/lib/ecommerce/StoreHero.astro +260 -0
  57. package/src/components/lib/ecommerce/StoreProductCard.astro +307 -0
  58. package/src/components/lib/ecommerce/TrustBadges.astro +101 -0
  59. package/src/components/lib/ecommerce/VariantPicker.astro +145 -0
  60. package/src/components/lib/ecommerce/cart-store.ts +151 -0
  61. package/src/components/lib/ecommerce/catalog-filter.ts +62 -0
  62. package/src/components/lib/ecommerce/index.ts +34 -0
  63. package/src/components/lib/ecommerce/product-icons.ts +28 -0
  64. package/src/components/lib/ecommerce/types.ts +70 -0
  65. package/src/components/lib/forms/AnimatedInput.astro +4 -2
  66. package/src/components/lib/forms/CustomRadioGroup.astro +3 -1
  67. package/src/components/lib/index.ts +2 -0
  68. package/src/components/lib/layout/BentoGrid3.astro +6 -4
  69. package/src/components/lib/layout/CenterFixedBox.astro +2 -2
  70. package/src/components/lib/layout/CenteredContainer.astro +4 -2
  71. package/src/components/lib/layout/ClusterHorizontal.astro +2 -2
  72. package/src/components/lib/layout/FeatureHero.astro +4 -2
  73. package/src/components/lib/layout/FooterCenteredMinimal.astro +4 -2
  74. package/src/components/lib/layout/FullBleedSection.astro +2 -2
  75. package/src/components/lib/layout/GalleryGrid4.astro +2 -2
  76. package/src/components/lib/layout/HolyGrailGrid.astro +11 -9
  77. package/src/components/lib/layout/LandingSectionSplit.astro +5 -3
  78. package/src/components/lib/layout/MasonryGrid.astro +2 -2
  79. package/src/components/lib/layout/MinimalFooterGrid.astro +1 -1
  80. package/src/components/lib/layout/ScrollSnapSection.astro +1 -1
  81. package/src/components/lib/layout/SidebarLayoutWrapper.astro +4 -2
  82. package/src/components/lib/layout/SplitScreenLayout.astro +3 -3
  83. package/src/components/lib/layout/StepLayoutVertical.astro +1 -1
  84. package/src/components/lib/layout/StickySidebarWrapper.astro +4 -2
  85. package/src/components/lib/sections/AgencyContactBrief.astro +194 -0
  86. package/src/components/lib/sections/AgencyFooterCta.astro +94 -0
  87. package/src/components/lib/sections/AgencyHeader.astro +161 -0
  88. package/src/components/lib/sections/AgencyHeroKinetic.astro +172 -0
  89. package/src/components/lib/sections/AgencyServicesList.astro +113 -0
  90. package/src/components/lib/sections/AgencyTeamGrid.astro +111 -0
  91. package/src/components/lib/sections/AgencyWorkGrid.astro +147 -0
  92. package/src/components/lib/sections/BlogGridSection.astro +9 -7
  93. package/src/components/lib/sections/ButtonsShowcase.astro +20 -18
  94. package/src/components/lib/sections/CardsShowcase.astro +28 -26
  95. package/src/components/lib/sections/CareersOpenings.astro +10 -8
  96. package/src/components/lib/sections/ChangelogSection.astro +14 -7
  97. package/src/components/lib/sections/ContactGlassForm.astro +10 -8
  98. package/src/components/lib/sections/ContactSplitMap.astro +11 -9
  99. package/src/components/lib/sections/CtaAppDownload.astro +13 -11
  100. package/src/components/lib/sections/CtaGlowBanner.astro +12 -10
  101. package/src/components/lib/sections/CtaNewsletterSection.astro +13 -11
  102. package/src/components/lib/sections/CtaSplitImage.astro +13 -11
  103. package/src/components/lib/sections/DataShowcase.astro +23 -21
  104. package/src/components/lib/sections/FaqAccordionSection.astro +8 -8
  105. package/src/components/lib/sections/FaqTwoColumn.astro +9 -7
  106. package/src/components/lib/sections/FeatureAlternatingRows.astro +10 -8
  107. package/src/components/lib/sections/FeatureBentoGrid.astro +13 -11
  108. package/src/components/lib/sections/FeatureIconGrid.astro +6 -6
  109. package/src/components/lib/sections/FeatureTabsShowcase.astro +13 -11
  110. package/src/components/lib/sections/FeatureTimelineFlow.astro +8 -6
  111. package/src/components/lib/sections/FeedbackShowcase.astro +15 -13
  112. package/src/components/lib/sections/FooterMegaSection.astro +13 -11
  113. package/src/components/lib/sections/FormsShowcase.astro +22 -20
  114. package/src/components/lib/sections/HeroDashboardPreview.astro +11 -9
  115. package/src/components/lib/sections/HeroGlass.astro +22 -20
  116. package/src/components/lib/sections/HeroGradient.astro +15 -13
  117. package/src/components/lib/sections/HeroMinimalCentered.astro +10 -10
  118. package/src/components/lib/sections/HeroShowcaseSplit.astro +18 -16
  119. package/src/components/lib/sections/HeroSplit.astro +13 -11
  120. package/src/components/lib/sections/HeroTerminal.astro +14 -12
  121. package/src/components/lib/sections/HeroWaitlist.astro +10 -8
  122. package/src/components/lib/sections/HowItWorksSteps.astro +12 -10
  123. package/src/components/lib/sections/IntegrationsGrid.astro +7 -7
  124. package/src/components/lib/sections/LayoutShowcase.astro +21 -19
  125. package/src/components/lib/sections/LogoCloudSection.astro +4 -4
  126. package/src/components/lib/sections/LogoWallGrid.astro +6 -4
  127. package/src/components/lib/sections/NavigationShowcase.astro +15 -13
  128. package/src/components/lib/sections/OverlaysShowcase.astro +21 -19
  129. package/src/components/lib/sections/PricingComparisonMatrix.astro +15 -13
  130. package/src/components/lib/sections/PricingSinglePlan.astro +13 -11
  131. package/src/components/lib/sections/PricingTierCards.astro +11 -9
  132. package/src/components/lib/sections/PricingToggleCards.astro +13 -13
  133. package/src/components/lib/sections/ShopCategoryHero.astro +103 -0
  134. package/src/components/lib/sections/ShopCollectionMosaic.astro +131 -0
  135. package/src/components/lib/sections/ShopFooterWordmark.astro +100 -0
  136. package/src/components/lib/sections/ShopHeaderEditorial.astro +396 -0
  137. package/src/components/lib/sections/ShopHeroEditorial.astro +151 -0
  138. package/src/components/lib/sections/ShopLookbook.astro +250 -0
  139. package/src/components/lib/sections/ShopNewsletterEditorial.astro +128 -0
  140. package/src/components/lib/sections/ShopProductRail.astro +150 -0
  141. package/src/components/lib/sections/ShopReviewsEditorial.astro +133 -0
  142. package/src/components/lib/sections/ShopValuesMarquee.astro +91 -0
  143. package/src/components/lib/sections/StatsCounterBand.astro +9 -7
  144. package/src/components/lib/sections/StatsDashboardSection.astro +4 -2
  145. package/src/components/lib/sections/StatsMetricShowcase.astro +9 -7
  146. package/src/components/lib/sections/TeamGridSection.astro +12 -10
  147. package/src/components/lib/sections/TestimonialCarousel.astro +10 -8
  148. package/src/components/lib/sections/TestimonialFeatured.astro +7 -5
  149. package/src/components/lib/sections/TestimonialGridSection.astro +7 -7
  150. package/src/components/lib/sections/TestimonialWallGlass.astro +11 -9
  151. package/src/components/lib/sections/TypographyShowcase.astro +10 -8
  152. package/src/components/lib/sections/index.ts +10 -0
  153. package/src/components/lib/templates/ecommerce/StoreCategoryPage.astro +160 -0
  154. package/src/components/lib/templates/ecommerce/StoreCheckoutPage.astro +98 -0
  155. package/src/components/lib/templates/ecommerce/StoreHomePage.astro +191 -0
  156. package/src/components/lib/templates/ecommerce/StoreLayout.astro +169 -0
  157. package/src/components/lib/templates/ecommerce/StoreProductPage.astro +165 -0
  158. package/src/components/lib/templates/ecommerce/data.ts +187 -0
  159. package/src/components/lib/templates/ecommerce/index.ts +14 -0
  160. package/src/components/lib/templates/ecommerce-editorial/EditorialBagPage.astro +52 -0
  161. package/src/components/lib/templates/ecommerce-editorial/EditorialCheckoutPage.astro +123 -0
  162. package/src/components/lib/templates/ecommerce-editorial/EditorialHomePage.astro +102 -0
  163. package/src/components/lib/templates/ecommerce-editorial/EditorialLayout.astro +216 -0
  164. package/src/components/lib/templates/ecommerce-editorial/EditorialProductPage.astro +205 -0
  165. package/src/components/lib/templates/ecommerce-editorial/EditorialShopPage.astro +112 -0
  166. package/src/components/lib/templates/ecommerce-editorial/data.ts +125 -0
  167. package/src/components/lib/templates/ecommerce-editorial/index.ts +16 -0
  168. package/src/components/lib/templates/saas-hotel/AppLayout.astro +83 -0
  169. package/src/components/lib/templates/saas-hotel/DashboardPage.astro +66 -0
  170. package/src/components/lib/templates/saas-hotel/GuestsPage.astro +65 -0
  171. package/src/components/lib/templates/saas-hotel/LandingPage.astro +213 -0
  172. package/src/components/lib/templates/saas-hotel/ReportsPage.astro +63 -0
  173. package/src/components/lib/templates/saas-hotel/ReservationsPage.astro +49 -0
  174. package/src/components/lib/templates/saas-hotel/RoomsPage.astro +48 -0
  175. package/src/components/lib/templates/saas-hotel/SettingsPage.astro +115 -0
  176. package/src/components/lib/templates/saas-hotel/SignInPage.astro +50 -0
  177. package/src/components/lib/templates/saas-hotel/data.ts +229 -0
  178. package/src/components/lib/templates/saas-hotel/index.ts +19 -0
  179. package/src/components/lib/templates/saas-hotel/lobby-theme.css +90 -0
  180. package/src/styles/global.css +95 -0
@@ -0,0 +1,346 @@
1
+ ---
2
+ /**
3
+ * DataTable — Client-side data table for SaaS apps: search, status filter chips, sortable
4
+ * columns, row selection with a bulk-action bar, pagination and typed cells
5
+ * (text, person, status, money, date, number, tags).
6
+ *
7
+ * @param {string} title - Card title (optional).
8
+ * @param {Array<{key: string, label: string, type?: 'text'|'person'|'status'|'money'|'date'|'number'|'tags', align?: 'left'|'right', sortable?: boolean}>} columns - Column definitions.
9
+ * @param {Array<Record<string, any>>} rows - Row data; `person` cells take `{ name, sub? }`, `status` cells take a string.
10
+ * @param {Record<string, 'success'|'warning'|'danger'|'info'|'default'>} statusTones - Status → tone map.
11
+ * @param {string} filterKey - Column used for the filter chips (optional).
12
+ * @param {boolean} selectable - Show row checkboxes and the bulk bar. Default true.
13
+ * @param {string[]} bulkActions - Bulk bar buttons. Default ['Export', 'Send email'].
14
+ * @param {number} pageSize - Rows per page. Default 8.
15
+ * @param {string} searchPlaceholder - Search placeholder. Default 'Search…'.
16
+ * @param {string} currency - ISO currency for money cells. Default 'EUR'.
17
+ * @param {string} locale - Number/date locale. Default 'en-GB'.
18
+ * @param {string} emptyText - Shown when nothing matches. Default 'No results match your filters.'
19
+ * @param {string} id - Table id (unique per page). Default 'data-table'.
20
+ * @param {string} variant - Style (card|plain). Default 'card'.
21
+ * @param {string} density - Row height (compact|comfortable). Default 'comfortable'.
22
+ * @param {string} className - Extra class.
23
+ *
24
+ * Accessibility: real <table> with a caption; sortable headers are buttons inside <th> with
25
+ * aria-sort; checkboxes have row labels; result counts and page changes are announced politely.
26
+ */
27
+ import { initials, hueFrom } from './app-icons';
28
+
29
+ type CellType = 'text' | 'person' | 'status' | 'money' | 'date' | 'number' | 'tags';
30
+ interface Column { key: string; label: string; type?: CellType; align?: 'left' | 'right'; sortable?: boolean }
31
+ interface Props {
32
+ title?: string;
33
+ columns?: Column[];
34
+ rows?: Array<Record<string, any>>;
35
+ statusTones?: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'default'>;
36
+ filterKey?: string;
37
+ selectable?: boolean;
38
+ bulkActions?: string[];
39
+ pageSize?: number;
40
+ searchPlaceholder?: string;
41
+ currency?: string;
42
+ locale?: string;
43
+ emptyText?: string;
44
+ id?: string;
45
+ variant?: 'card' | 'plain';
46
+ density?: 'compact' | 'comfortable';
47
+ className?: string;
48
+ }
49
+
50
+ const {
51
+ title = 'Reservations',
52
+ columns = [
53
+ { key: 'ref', label: 'Booking', sortable: true },
54
+ { key: 'guest', label: 'Guest', type: 'person', sortable: true },
55
+ { key: 'room', label: 'Room', sortable: true },
56
+ { key: 'checkIn', label: 'Check-in', type: 'date', sortable: true },
57
+ { key: 'nights', label: 'Nights', type: 'number', align: 'right', sortable: true },
58
+ { key: 'status', label: 'Status', type: 'status' },
59
+ { key: 'total', label: 'Total', type: 'money', align: 'right', sortable: true },
60
+ ],
61
+ rows = [
62
+ { ref: 'MR-2841', guest: { name: 'Elena Duarte', sub: 'Booking.com' }, room: '204 · Deluxe', checkIn: '2026-11-04', nights: 3, status: 'Confirmed', total: 612 },
63
+ { ref: 'MR-2840', guest: { name: 'James Whitfield', sub: 'Direct' }, room: '310 · Suite', checkIn: '2026-11-04', nights: 2, status: 'Checked in', total: 890 },
64
+ { ref: 'MR-2839', guest: { name: 'Aiko Tanaka', sub: 'Expedia' }, room: '108 · Standard', checkIn: '2026-11-05', nights: 4, status: 'Pending', total: 476 },
65
+ { ref: 'MR-2838', guest: { name: 'Marco Bellini', sub: 'Direct' }, room: '212 · Deluxe', checkIn: '2026-11-06', nights: 1, status: 'Cancelled', total: 0 },
66
+ ],
67
+ statusTones = { Confirmed: 'info', 'Checked in': 'success', 'Checked out': 'default', Pending: 'warning', Cancelled: 'danger', 'No show': 'danger' },
68
+ filterKey = 'status',
69
+ selectable = true,
70
+ bulkActions = ['Export', 'Send email'],
71
+ pageSize = 8,
72
+ searchPlaceholder = 'Search…',
73
+ currency = 'EUR',
74
+ locale = 'en-GB',
75
+ emptyText = 'No results match your filters.',
76
+ id = 'data-table',
77
+ variant = 'card',
78
+ density = 'comfortable',
79
+ className = '',
80
+ } = Astro.props;
81
+
82
+ const money = (n: number) => new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(n);
83
+ const date = (v: string) => new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(`${v}T00:00:00Z`));
84
+ const filters = filterKey ? Array.from(new Set(rows.map((r) => String(r[filterKey])))) : [];
85
+ const searchText = (r: Record<string, any>) =>
86
+ columns.map((c) => (c.type === 'person' ? `${r[c.key]?.name ?? ''} ${r[c.key]?.sub ?? ''}` : Array.isArray(r[c.key]) ? r[c.key].join(' ') : r[c.key] ?? '')).join(' ').toLowerCase();
87
+ const sortValue = (c: Column, v: any) => (c.type === 'person' ? v?.name ?? '' : v ?? '');
88
+ ---
89
+
90
+ <section class:list={['dt', `dt--${variant}`, `dt--${density}`, className]} data-table={id} data-page-size={pageSize} aria-labelledby={`${id}-title`}>
91
+ <div class="dt__toolbar">
92
+ <h2 class="dt__title" id={`${id}-title`}>{title} <span class="dt__count" aria-live="polite"><span data-count>{rows.length}</span></span></h2>
93
+ <div class="dt__tools">
94
+ <label class="dt__search">
95
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
96
+ <span class="sr-only">{searchPlaceholder}</span>
97
+ <input type="search" placeholder={searchPlaceholder} data-search />
98
+ </label>
99
+ </div>
100
+ </div>
101
+ {filters.length > 1 && (
102
+ <div class="dt__chips" role="group" aria-label="Filter by status">
103
+ <button type="button" class="dt__chip" aria-pressed="true" data-filter="">All <span>{rows.length}</span></button>
104
+ {filters.map((f) => (
105
+ <button type="button" class="dt__chip" aria-pressed="false" data-filter={f}>{f} <span>{rows.filter((r) => String(r[filterKey]) === f).length}</span></button>
106
+ ))}
107
+ </div>
108
+ )}
109
+
110
+ {selectable && (
111
+ <div class="dt__bulk" hidden data-bulk>
112
+ <span><strong data-selected>0</strong> selected</span>
113
+ {bulkActions.map((a) => <button type="button" class="dt__bulk-btn">{a}</button>)}
114
+ <button type="button" class="dt__bulk-clear" data-clear>Clear</button>
115
+ </div>
116
+ )}
117
+
118
+ <div class="dt__scroll" tabindex="0" role="region" aria-labelledby={`${id}-title`}>
119
+ <table class="dt__table">
120
+ <caption class="sr-only">{title}</caption>
121
+ <thead>
122
+ <tr>
123
+ {selectable && <th class="dt__check" scope="col"><input type="checkbox" aria-label="Select all rows on this page" data-all /></th>}
124
+ {columns.map((c) => (
125
+ <th scope="col" class:list={[{ 'dt__right': c.align === 'right' }]} aria-sort={c.sortable ? 'none' : undefined}>
126
+ {c.sortable ? (
127
+ <button type="button" class="dt__sort" data-sort={c.key} data-type={c.type ?? 'text'}>
128
+ {c.label}
129
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></svg>
130
+ </button>
131
+ ) : c.label}
132
+ </th>
133
+ ))}
134
+ </tr>
135
+ </thead>
136
+ <tbody data-body>
137
+ {rows.map((r, i) => (
138
+ <tr data-row data-search={searchText(r)} data-filter-value={filterKey ? String(r[filterKey]) : ''} data-index={i} {...Object.fromEntries(columns.map((c) => [`data-v-${c.key.toLowerCase()}`, String(sortValue(c, r[c.key]))]))}>
139
+ {selectable && <td class="dt__check"><input type="checkbox" aria-label={`Select ${columns[0] ? sortValue(columns[0], r[columns[0].key]) : `row ${i + 1}`}`} data-select /></td>}
140
+ {columns.map((c) => {
141
+ const v = r[c.key];
142
+ return (
143
+ <td class:list={[{ 'dt__right': c.align === 'right' }]}>
144
+ {c.type === 'person' ? (
145
+ <span class="dt__person">
146
+ <span class="dt__avatar" style={`--h: ${hueFrom(v?.name ?? '')}`} aria-hidden="true">{initials(v?.name ?? '?')}</span>
147
+ <span><strong>{v?.name}</strong>{v?.sub && <small>{v.sub}</small>}</span>
148
+ </span>
149
+ ) : c.type === 'status' ? (
150
+ <span class:list={['dt__status', `dt__status--${statusTones[v] ?? 'default'}`]}><i aria-hidden="true"></i>{v}</span>
151
+ ) : c.type === 'money' ? money(Number(v) || 0)
152
+ : c.type === 'date' ? <time datetime={v}>{date(v)}</time>
153
+ : c.type === 'tags' ? <span class="dt__tags">{(v ?? []).map((tg: string) => <span>{tg}</span>)}</span>
154
+ : v}
155
+ </td>
156
+ );
157
+ })}
158
+ </tr>
159
+ ))}
160
+ </tbody>
161
+ </table>
162
+ <p class="dt__empty" hidden data-empty>{emptyText}</p>
163
+ </div>
164
+
165
+ <div class="dt__foot">
166
+ <p class="dt__range" aria-live="polite" data-range></p>
167
+ <div class="dt__pager">
168
+ <button type="button" class="dt__page" aria-label="Previous page" data-prev>‹</button>
169
+ <span class="dt__pages" data-pages></span>
170
+ <button type="button" class="dt__page" aria-label="Next page" data-next>›</button>
171
+ </div>
172
+ </div>
173
+ </section>
174
+
175
+ <script>
176
+ document.querySelectorAll<HTMLElement>('[data-table]:not([data-bound])').forEach((root) => {
177
+ root.dataset.bound = '';
178
+ const body = root.querySelector<HTMLTableSectionElement>('[data-body]')!;
179
+ const rows = Array.from(body.querySelectorAll<HTMLTableRowElement>('[data-row]'));
180
+ const size = Number(root.dataset.pageSize) || 8;
181
+ const search = root.querySelector<HTMLInputElement>('[data-search]');
182
+ const chips = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-filter]'));
183
+ const sorters = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-sort]'));
184
+ const all = root.querySelector<HTMLInputElement>('[data-all]');
185
+ const bulk = root.querySelector<HTMLElement>('[data-bulk]');
186
+ const pagesEl = root.querySelector<HTMLElement>('[data-pages]')!;
187
+ let query = '';
188
+ let filter = '';
189
+ let sortKey = '';
190
+ let sortDir = 1;
191
+ let page = 0;
192
+
193
+ function visibleRows() {
194
+ return rows.filter((r) => (!query || (r.dataset.search || '').includes(query)) && (!filter || r.dataset.filterValue === filter));
195
+ }
196
+
197
+ function render() {
198
+ let list = visibleRows();
199
+ if (sortKey) {
200
+ const type = sorters.find((s) => s.dataset.sort === sortKey)?.dataset.type;
201
+ const attr = `v${sortKey.toLowerCase().replace(/^./, (c) => c.toUpperCase())}`;
202
+ list = [...list].sort((a, b) => {
203
+ const A = a.dataset[attr] ?? '', B = b.dataset[attr] ?? '';
204
+ const cmp = type === 'money' || type === 'number' ? Number(A) - Number(B) : A.localeCompare(B);
205
+ return cmp * sortDir;
206
+ });
207
+ } else {
208
+ list = [...list].sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
209
+ }
210
+ const pages = Math.max(1, Math.ceil(list.length / size));
211
+ page = Math.min(page, pages - 1);
212
+ const shown = list.slice(page * size, page * size + size);
213
+ rows.forEach((r) => { r.hidden = true; });
214
+ body.append(...list);
215
+ shown.forEach((r) => { r.hidden = false; });
216
+ root.querySelector('[data-count]')!.textContent = String(list.length);
217
+ root.querySelector<HTMLElement>('[data-empty]')!.hidden = list.length > 0;
218
+ root.querySelector('[data-range]')!.textContent = list.length ? `${page * size + 1}–${page * size + shown.length} of ${list.length}` : '';
219
+ pagesEl.replaceChildren(...Array.from({ length: pages }, (_, i) => {
220
+ const b = document.createElement('button');
221
+ b.type = 'button';
222
+ b.className = 'dt__page';
223
+ b.textContent = String(i + 1);
224
+ b.setAttribute('aria-label', `Page ${i + 1}`);
225
+ if (i === page) b.setAttribute('aria-current', 'page');
226
+ b.addEventListener('click', () => { page = i; render(); });
227
+ return b;
228
+ }));
229
+ root.querySelector<HTMLButtonElement>('[data-prev]')!.disabled = page === 0;
230
+ root.querySelector<HTMLButtonElement>('[data-next]')!.disabled = page >= pages - 1;
231
+ syncSelection();
232
+ }
233
+
234
+ function syncSelection() {
235
+ const boxes = rows.map((r) => r.querySelector<HTMLInputElement>('[data-select]')).filter(Boolean) as HTMLInputElement[];
236
+ const selected = boxes.filter((b) => b.checked);
237
+ rows.forEach((r) => r.classList.toggle('is-selected', Boolean(r.querySelector<HTMLInputElement>('[data-select]')?.checked)));
238
+ if (bulk) {
239
+ bulk.hidden = selected.length === 0;
240
+ bulk.querySelector('[data-selected]')!.textContent = String(selected.length);
241
+ }
242
+ if (all) {
243
+ const pageBoxes = rows.filter((r) => !r.hidden).map((r) => r.querySelector<HTMLInputElement>('[data-select]')!).filter(Boolean);
244
+ all.checked = pageBoxes.length > 0 && pageBoxes.every((b) => b.checked);
245
+ all.indeterminate = !all.checked && pageBoxes.some((b) => b.checked);
246
+ }
247
+ }
248
+
249
+ search?.addEventListener('input', () => { query = search.value.trim().toLowerCase(); page = 0; render(); });
250
+ chips.forEach((c) => c.addEventListener('click', () => {
251
+ filter = c.dataset.filter || '';
252
+ chips.forEach((x) => x.setAttribute('aria-pressed', String(x === c)));
253
+ page = 0;
254
+ render();
255
+ }));
256
+ sorters.forEach((s) => s.addEventListener('click', () => {
257
+ if (sortKey === s.dataset.sort) sortDir = sortDir === 1 ? -1 : sortDir === -1 ? 0 : 1;
258
+ else { sortKey = s.dataset.sort || ''; sortDir = 1; }
259
+ if (sortDir === 0) sortKey = '';
260
+ sorters.forEach((x) => x.closest('th')?.setAttribute('aria-sort', x === s && sortKey ? (sortDir === 1 ? 'ascending' : 'descending') : 'none'));
261
+ render();
262
+ }));
263
+ root.querySelector('[data-prev]')?.addEventListener('click', () => { page = Math.max(0, page - 1); render(); });
264
+ root.querySelector('[data-next]')?.addEventListener('click', () => { page += 1; render(); });
265
+ root.addEventListener('change', (e) => { if ((e.target as HTMLElement).matches('[data-select]')) syncSelection(); });
266
+ all?.addEventListener('change', () => {
267
+ rows.filter((r) => !r.hidden).forEach((r) => { const b = r.querySelector<HTMLInputElement>('[data-select]'); if (b) b.checked = all.checked; });
268
+ syncSelection();
269
+ });
270
+ root.querySelector('[data-clear]')?.addEventListener('click', () => {
271
+ rows.forEach((r) => { const b = r.querySelector<HTMLInputElement>('[data-select]'); if (b) b.checked = false; });
272
+ syncSelection();
273
+ });
274
+ render();
275
+ });
276
+ </script>
277
+
278
+ <style>
279
+ .dt { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-3, 0.75rem); min-width: 0; }
280
+ .dt--card { padding: var(--sp-5, 1.25rem); border-radius: var(--r-lg, 16px); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); background: var(--c-bg-1, #0a0a0a); }
281
+ .dt__toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3, 0.75rem); }
282
+ .dt__title { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 1rem; font-weight: 700; color: var(--c-text-1, #fff); }
283
+ .dt__count { padding: 1px 8px; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.07); font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); }
284
+ .dt__search { display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 0.7rem; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); color: var(--c-text-3, #737373); }
285
+ .dt__search:focus-within { border-color: var(--app-accent, var(--c-primary-light, #d4a017)); }
286
+ .dt__search input { width: 200px; border: 0; outline: 0; background: none; color: var(--c-text-1, #fff); font: inherit; font-size: 0.85rem; }
287
+
288
+ .dt__chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
289
+ .dt__chip { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 0.7rem; border-radius: 999px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); background: transparent; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.78rem; font-weight: 600; white-space: nowrap; cursor: pointer; }
290
+ .dt__chip span { color: var(--c-text-3, #737373); }
291
+ .dt__chip[aria-pressed='true'] { background: var(--c-text-1, #fff); border-color: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
292
+ .dt__chip[aria-pressed='true'] span { color: inherit; opacity: 0.7; }
293
+
294
+ .dt__bulk { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2, 0.5rem); padding: var(--sp-2, 0.5rem) var(--sp-3, 0.75rem); border-radius: 10px; background: color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 12%, transparent); font-size: 0.82rem; color: var(--c-text-1, #fff); }
295
+ .dt__bulk[hidden] { display: none; }
296
+ .dt__bulk > span { margin-right: auto; }
297
+ .dt__bulk-btn, .dt__bulk-clear { min-height: 32px; padding: 0 0.7rem; border-radius: 8px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer; }
298
+ .dt__bulk-clear { background: transparent; border-color: transparent; text-decoration: underline; }
299
+
300
+ .dt__scroll { overflow-x: auto; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
301
+ .dt--plain .dt__scroll { border: 0; }
302
+ .dt__table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
303
+ .dt__table th { position: sticky; top: 0; padding: 0 var(--sp-3, 0.75rem); height: 40px; background: rgb(var(--ink-rgb, 255 255 255) / 0.03); color: var(--c-text-3, #737373); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-align: left; text-transform: uppercase; white-space: nowrap; border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
304
+ .dt__table td { padding: 0 var(--sp-3, 0.75rem); height: 56px; color: var(--c-text-2, #a3a3a3); border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.06)); white-space: nowrap; }
305
+ .dt--compact .dt__table td { height: 44px; }
306
+ .dt__table tbody tr:last-child td { border-bottom: 0; }
307
+ .dt__table tbody tr:hover td { background: rgb(var(--ink-rgb, 255 255 255) / 0.025); }
308
+ .dt__table tbody tr.is-selected td { background: color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 8%, transparent); }
309
+ .dt__table tr[hidden] { display: none; }
310
+ .dt__right { text-align: right !important; font-variant-numeric: tabular-nums; }
311
+ .dt__check { width: 44px; }
312
+ .dt__check input { width: 16px; height: 16px; accent-color: var(--app-accent, var(--c-primary-light, #d4a017)); cursor: pointer; }
313
+ .dt__sort { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; padding: 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }
314
+ .dt__sort svg { opacity: 0.4; }
315
+ th[aria-sort='ascending'] .dt__sort, th[aria-sort='descending'] .dt__sort { color: var(--c-text-1, #fff); }
316
+ th[aria-sort='ascending'] .dt__sort svg, th[aria-sort='descending'] .dt__sort svg { opacity: 1; color: var(--app-accent, var(--c-primary-light, #d4a017)); }
317
+ .dt__person { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); }
318
+ .dt__person strong { display: block; color: var(--c-text-1, #fff); font-weight: 600; }
319
+ .dt__person small { display: block; font-size: 0.75rem; color: var(--c-text-3, #737373); }
320
+ .dt__avatar { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border-radius: 50%; background: hsl(var(--h) 50% 45%); color: #fff; font-size: 0.7rem; font-weight: 700; }
321
+ .dt__status { --tone: var(--c-text-2, #a3a3a3); display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--tone) 12%, transparent); color: var(--tone); font-size: 0.75rem; font-weight: 600; }
322
+ .dt__status i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
323
+ .dt__status--success { --tone: var(--c-success, #34d399); }
324
+ .dt__status--warning { --tone: var(--c-warning, #fbbf24); }
325
+ .dt__status--danger { --tone: var(--c-danger, #f87171); }
326
+ .dt__status--info { --tone: var(--app-accent, var(--c-primary-light, #d4a017)); }
327
+ .dt__tags { display: inline-flex; gap: 4px; }
328
+ .dt__tags span { padding: 2px 7px; border-radius: 6px; background: rgb(var(--ink-rgb, 255 255 255) / 0.06); font-size: 0.72rem; }
329
+ .dt__empty { margin: 0; padding: var(--sp-10, 2.5rem); text-align: center; color: var(--c-text-3, #737373); }
330
+ .dt__empty[hidden] { display: none; }
331
+
332
+ .dt__foot { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3, 0.75rem); }
333
+ .dt__range { margin: 0; font-size: 0.8rem; color: var(--c-text-3, #737373); }
334
+ .dt__pager, .dt__pages { display: flex; gap: 4px; }
335
+ .dt__pages :global(.dt__page), .dt__page { min-width: 34px; height: 34px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); border-radius: 8px; background: transparent; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.8rem; cursor: pointer; }
336
+ .dt__pages :global(.dt__page[aria-current='page']) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); border-color: var(--c-text-1, #fff); }
337
+ .dt__page:disabled { opacity: 0.35; cursor: default; }
338
+ .dt :is(button, input, [tabindex='0']):focus-visible, .dt__pages :global(.dt__page:focus-visible) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
339
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
340
+
341
+ @container (max-width: 560px) {
342
+ .dt__toolbar { flex-direction: column; align-items: stretch; }
343
+ .dt__search input { width: 100%; }
344
+ .dt__foot { flex-direction: column; }
345
+ }
346
+ </style>
@@ -0,0 +1,133 @@
1
+ ---
2
+ /**
3
+ * GuestProfileCard — CRM profile for a guest or customer: avatar, loyalty tier, contact details,
4
+ * lifetime stats, preference tags, upcoming stay and internal notes.
5
+ *
6
+ * @param {{name: string, email?: string, phone?: string, country?: string, since?: string}} guest - Identity and contact.
7
+ * @param {string} tier - Loyalty tier label (optional). Default 'Gold'.
8
+ * @param {Array<{label: string, value: string}>} stats - Lifetime figures (stays, nights, spend…).
9
+ * @param {string[]} preferences - Preference tags.
10
+ * @param {{room: string, dates: string, status?: string}} upcoming - Next stay (optional).
11
+ * @param {string[]} notes - Staff notes (optional).
12
+ * @param {Array<{label: string, href?: string, primary?: boolean}>} actions - Buttons in the header.
13
+ * @param {string} variant - Style (card|plain). Default 'card'.
14
+ * @param {string} className - Extra class.
15
+ *
16
+ * Accessibility: h2 with the guest name; contact items are real mailto/tel links; stats are a
17
+ * description list; tags are a plain list.
18
+ */
19
+ import { appIcon, initials, hueFrom } from './app-icons';
20
+
21
+ interface Props {
22
+ guest?: { name: string; email?: string; phone?: string; country?: string; since?: string };
23
+ tier?: string;
24
+ stats?: Array<{ label: string; value: string }>;
25
+ preferences?: string[];
26
+ upcoming?: { room: string; dates: string; status?: string };
27
+ notes?: string[];
28
+ actions?: Array<{ label: string; href?: string; primary?: boolean }>;
29
+ variant?: 'card' | 'plain';
30
+ className?: string;
31
+ }
32
+
33
+ const {
34
+ guest = { name: 'Elena Duarte', email: 'elena.duarte@mail.com', phone: '+34 612 345 678', country: 'Spain', since: 'Mar 2022' },
35
+ tier = 'Gold',
36
+ stats = [{ label: 'Stays', value: '7' }, { label: 'Nights', value: '19' }, { label: 'Lifetime spend', value: '€4,820' }, { label: 'Avg. rating', value: '4.9' }],
37
+ preferences = ['High floor', 'Feather-free pillows', 'Late check-out', 'Sparkling water'],
38
+ upcoming = { room: '204 · Deluxe King', dates: 'Nov 4 – Nov 7', status: 'Confirmed' },
39
+ notes = ['Celebrating anniversary — prepare welcome card.', 'Prefers WhatsApp for contact.'],
40
+ actions = [{ label: 'Message' }, { label: 'New booking', primary: true }],
41
+ variant = 'card',
42
+ className = '',
43
+ } = Astro.props;
44
+ ---
45
+
46
+ <article class:list={['gp', `gp--${variant}`, className]} aria-labelledby="gp-name">
47
+ <header class="gp__head">
48
+ <span class="gp__avatar" style={`--h: ${hueFrom(guest.name)}`} aria-hidden="true">{initials(guest.name)}</span>
49
+ <div class="gp__id">
50
+ <h2 class="gp__name" id="gp-name">{guest.name}</h2>
51
+ <p class="gp__sub">
52
+ {tier && <span class="gp__tier"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><Fragment set:html={appIcon('star')} /></svg>{tier} member</span>}
53
+ {guest.since && <span>Guest since {guest.since}</span>}
54
+ </p>
55
+ </div>
56
+ <div class="gp__actions">
57
+ {actions.map((a) => {
58
+ const Tag = a.href ? 'a' : 'button';
59
+ return <Tag class:list={['gp__btn', { 'gp__btn--primary': a.primary }]} href={a.href} type={a.href ? undefined : 'button'}>{a.label}</Tag>;
60
+ })}
61
+ </div>
62
+ </header>
63
+
64
+ <ul class="gp__contact">
65
+ {guest.email && <li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('mail')} /></svg><a href={`mailto:${guest.email}`}>{guest.email}</a></li>}
66
+ {guest.phone && <li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('phone')} /></svg><a href={`tel:${guest.phone.replace(/\s/g, '')}`}>{guest.phone}</a></li>}
67
+ {guest.country && <li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={appIcon('plane')} /></svg>{guest.country}</li>}
68
+ </ul>
69
+
70
+ <dl class="gp__stats">
71
+ {stats.map((s) => <div><dt>{s.label}</dt><dd>{s.value}</dd></div>)}
72
+ </dl>
73
+
74
+ {upcoming && (
75
+ <div class="gp__block">
76
+ <h3>Upcoming stay</h3>
77
+ <p class="gp__stay"><strong>{upcoming.room}</strong><span>{upcoming.dates}</span>{upcoming.status && <em>{upcoming.status}</em>}</p>
78
+ </div>
79
+ )}
80
+ {preferences.length > 0 && (
81
+ <div class="gp__block">
82
+ <h3>Preferences</h3>
83
+ <ul class="gp__tags">{preferences.map((p) => <li>{p}</li>)}</ul>
84
+ </div>
85
+ )}
86
+ {notes.length > 0 && (
87
+ <div class="gp__block">
88
+ <h3>Staff notes</h3>
89
+ <ul class="gp__notes">{notes.map((n) => <li>{n}</li>)}</ul>
90
+ </div>
91
+ )}
92
+ </article>
93
+
94
+ <style>
95
+ .gp { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5, 1.25rem); min-width: 0; }
96
+ .gp--card { padding: var(--sp-6, 1.5rem); border-radius: var(--r-lg, 16px); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); background: var(--c-bg-1, #0a0a0a); }
97
+ .gp__head { display: flex; align-items: center; gap: var(--sp-4, 1rem); }
98
+ .gp__avatar { display: grid; place-items: center; flex-shrink: 0; width: 56px; height: 56px; border-radius: 16px; background: hsl(var(--h) 50% 45%); color: #fff; font-size: 1.05rem; font-weight: 800; }
99
+ .gp__id { flex: 1; min-width: 0; }
100
+ .gp__name { margin: 0; font-size: 1.25rem; font-weight: 800; letter-spacing: -0.02em; color: var(--c-text-1, #fff); }
101
+ .gp__sub { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 4px 0 0; font-size: 0.8rem; color: var(--c-text-3, #737373); }
102
+ .gp__tier { display: inline-flex; align-items: center; gap: 4px; color: var(--c-warning, #fbbf24); font-weight: 700; }
103
+ .gp__actions { display: flex; gap: var(--sp-2, 0.5rem); }
104
+ .gp__btn { display: inline-flex; align-items: center; min-height: 38px; padding: 0 0.85rem; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); background: transparent; color: var(--c-text-1, #fff); font: inherit; font-size: 0.82rem; font-weight: 600; text-decoration: none; cursor: pointer; }
105
+ .gp__btn:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); color: var(--c-text-1, #fff); }
106
+ .gp__btn--primary { border-color: transparent; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
107
+ .gp__btn--primary:hover { background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); filter: brightness(1.08); }
108
+ .gp :is(a, button):focus-visible { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
109
+
110
+ .gp__contact { display: flex; flex-wrap: wrap; gap: var(--sp-2, 0.5rem) var(--sp-5, 1.25rem); margin: 0; padding: 0; list-style: none; font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
111
+ .gp__contact li { display: inline-flex; align-items: center; gap: 6px; }
112
+ .gp__contact a { color: var(--c-text-1, #fff); text-decoration: none; }
113
+ .gp__contact a:hover { text-decoration: underline; }
114
+ .gp__stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border-radius: 12px; overflow: hidden; background: var(--c-border, rgba(255, 255, 255, 0.08)); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
115
+ .gp__stats div { display: flex; flex-direction: column-reverse; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); background: var(--c-bg-1, #0a0a0a); }
116
+ .gp__stats dt { font-size: 0.72rem; color: var(--c-text-3, #737373); }
117
+ .gp__stats dd { margin: 0; font-size: 1.2rem; font-weight: 800; color: var(--c-text-1, #fff); font-variant-numeric: tabular-nums; }
118
+ .gp__block h3 { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c-text-3, #737373); }
119
+ .gp__stay { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 0; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-radius: 10px; background: color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 8%, transparent); font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
120
+ .gp__stay strong { color: var(--c-text-1, #fff); }
121
+ .gp__stay em { margin-left: auto; font-style: normal; font-size: 0.75rem; font-weight: 700; color: var(--app-accent, var(--c-primary-light, #d4a017)); }
122
+ .gp__tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
123
+ .gp__tags li { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); font-size: 0.78rem; color: var(--c-text-1, #fff); }
124
+ .gp__notes { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
125
+ .gp__notes li { padding: var(--sp-2, 0.5rem) var(--sp-3, 0.75rem); border-left: 2px solid var(--c-warning, #fbbf24); background: rgb(var(--ink-rgb, 255 255 255) / 0.03); font-size: 0.82rem; color: var(--c-text-2, #a3a3a3); }
126
+
127
+ @container (max-width: 560px) {
128
+ .gp__head { flex-wrap: wrap; }
129
+ .gp__actions { width: 100%; }
130
+ .gp__btn { flex: 1; justify-content: center; }
131
+ .gp__stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
132
+ }
133
+ </style>