@reblu/site-blocks 0.18.0 → 0.19.0
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/CHANGELOG.md +17 -0
- package/README.md +123 -0
- package/dist/_heading-BsNbc1wc.d.cts +4 -0
- package/dist/_heading-BsNbc1wc.d.ts +4 -0
- package/dist/client.cjs +256 -49
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +60 -2
- package/dist/client.d.ts +60 -2
- package/dist/client.js +255 -50
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +352 -302
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -6
- package/dist/index.d.ts +28 -6
- package/dist/index.js +351 -302
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @reblu/site-blocks
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 38d27c5: Add `ProfessionalFields`, a drop-in for the tenant-defined professional fields on a team profile (`professional.fields`, e.g. a registration number). `ProfileCard` gains an optional `fields` prop that shows the card fields. Additive: existing usage renders as before.
|
|
8
|
+
- 5ef5630: Add `ProductCarousel` on `@reblu/site-blocks/client`, a drop-in product strip for «Productos relacionados». It scrolls natively with scroll-snap, so touch users swipe it, and has previous/next arrows from `sm` up. The arrows are hidden when everything fits and disabled at the end you have reached. The track is a focusable, labelled region and arrow scrolling respects reduced motion. It renders `ProductCard` by default, or your own card through `renderProduct`, and needs no carousel dependency. Additive: no existing export changes.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- 5ef5630: Every button the organisms render shows the pointer cursor on hover under Tailwind v4: `AddToCartButton`, the `CartLineList` quantity stepper and remove button, `CheckoutForm`, `CheckoutLoginPrompt`, `Collection` pagination and facet chips, `DataRequestForm`, `OrderConfirmation` and the `ShareButtons` copy button. Disabled buttons show `cursor-not-allowed`. A source guard (`test/button-cursor.test.ts`) fails on any new `<button>` without the pointer cursor.
|
|
13
|
+
- 49384d1: README: document the `MarkdownRenderer` `images` option (URL-prefix allowlist, custom `render`, `<figure>` captions) with a `next/image` example. No code change.
|
|
14
|
+
- Updated dependencies [95f5b5f]
|
|
15
|
+
- Updated dependencies [5ef5630]
|
|
16
|
+
- Updated dependencies [e27e1b0]
|
|
17
|
+
- @reblu/site-contracts@1.19.0
|
|
18
|
+
- @reblu/site-ui@0.7.2
|
|
19
|
+
|
|
3
20
|
## 0.18.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ colour on the red background. See `docs/theming/theming.md`.
|
|
|
26
26
|
| `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
|
|
27
27
|
| `CourseDescription` | `.` | Drop-in for the rich-text course `description` (`client.academy.bySlug()`). Server Component. See "Rich-text descriptions". |
|
|
28
28
|
| `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
|
|
29
|
+
| `ProfessionalFields` | `.` | The tenant's own professional fields (`professional.fields`) on a team card or profile. RSC-safe. See below. |
|
|
29
30
|
| `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
|
|
30
31
|
| `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
|
|
31
32
|
| `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
|
|
@@ -37,6 +38,7 @@ colour on the red background. See `docs/theming/theming.md`.
|
|
|
37
38
|
| `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
|
|
38
39
|
| `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
|
|
39
40
|
| `DataRequestForm` | `./client` | Drop-in public GDPR rights form over `POST /api/site/data-requests` (REB-980). See "GDPR rights form" below. |
|
|
41
|
+
| `ProductCarousel` | `./client` | Scroll-snap product strip with prev/next arrows («Productos relacionados»). See "Product carousel" below. |
|
|
40
42
|
|
|
41
43
|
## Boundary
|
|
42
44
|
|
|
@@ -171,6 +173,39 @@ Internal links (`/privacidad`, `#clause-4`) and `mailto:` links open in the same
|
|
|
171
173
|
tab. External links (`https://…`, `//…`) open in a new tab and always carry
|
|
172
174
|
`rel="noopener noreferrer"`.
|
|
173
175
|
|
|
176
|
+
### Images: the `images` option
|
|
177
|
+
|
|
178
|
+
Without `images`, every image the sanitiser keeps renders as a lazy `<img>`
|
|
179
|
+
inside its paragraph. Pass `images` to restrict where they may load from and,
|
|
180
|
+
optionally, how they render:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
import Image from 'next/image'
|
|
184
|
+
|
|
185
|
+
const MEDIA = 'https://<ref>.supabase.co/storage/v1/object/public/blog-media/'
|
|
186
|
+
|
|
187
|
+
<MarkdownRenderer
|
|
188
|
+
content={post.content}
|
|
189
|
+
headingOffset={1}
|
|
190
|
+
normalizeHeadings
|
|
191
|
+
images={{
|
|
192
|
+
// Absolute URL prefixes (ending in `/`); any other image is dropped.
|
|
193
|
+
allowedUrlPrefixes: [MEDIA],
|
|
194
|
+
// Optional: render an allowed image yourself. Default: a lazy <img>.
|
|
195
|
+
render: ({ src, alt }) => <Image src={src} alt={alt} width={1600} height={900} />,
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- The prefix is compared on the parsed, normalised URL (`..` resolved, scheme
|
|
201
|
+
included), so `http://…` never matches an `https://…` prefix.
|
|
202
|
+
- A paragraph holding only an allowed image becomes a `<figure>`; the image
|
|
203
|
+
title (``) is its `<figcaption>`.
|
|
204
|
+
- `render` receives `{ src, alt }` only: give `next/image` a size you know
|
|
205
|
+
(e.g. encoded in the object name) or use `fill` inside a sized box, and allow
|
|
206
|
+
the host in `images.remotePatterns`.
|
|
207
|
+
- `SafeHtml` has no `images` option: its images keep the default rendering.
|
|
208
|
+
|
|
174
209
|
### Which authored attributes survive
|
|
175
210
|
|
|
176
211
|
The renderer rebuilds each element to apply the tenant's design tokens and to
|
|
@@ -764,6 +799,94 @@ export async function ServicesTeaser() {
|
|
|
764
799
|
}
|
|
765
800
|
```
|
|
766
801
|
|
|
802
|
+
## Professional fields — `ProfessionalFields` (REB-1099)
|
|
803
|
+
|
|
804
|
+
Each tenant defines its own fields for team profiles in nexus › Equipo ›
|
|
805
|
+
«Campos profesionales» — for example a registration number, a licence or a
|
|
806
|
+
membership id — and marks which are public and which also go on the card. The
|
|
807
|
+
SITE API serves the public ones as `professional.fields`
|
|
808
|
+
(`{ label, value, showOnCard }[]`): the list (`client.pages.list`) carries only
|
|
809
|
+
the card fields, the profile (`client.pages.bySlug`) every public one. The
|
|
810
|
+
labels are the tenant's; this package names no field.
|
|
811
|
+
|
|
812
|
+
On the team card, `ProfileCard` takes them directly:
|
|
813
|
+
|
|
814
|
+
```tsx
|
|
815
|
+
import { ProfileCard } from '@reblu/site-blocks'
|
|
816
|
+
|
|
817
|
+
const { data: team } = await client.pages.list({ type: ['team'] })
|
|
818
|
+
|
|
819
|
+
{team.map((p) => (
|
|
820
|
+
<ProfileCard
|
|
821
|
+
key={p.slug}
|
|
822
|
+
name={p.title}
|
|
823
|
+
role={p.professional?.role ?? undefined}
|
|
824
|
+
imageUrl={p.coverImageUrl ?? undefined}
|
|
825
|
+
fields={p.professional?.fields}
|
|
826
|
+
href={`/equipo/${p.slug}`}
|
|
827
|
+
/>
|
|
828
|
+
))}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
On the profile page, drop in `ProfessionalFields` (a `<dl>`):
|
|
832
|
+
|
|
833
|
+
```tsx
|
|
834
|
+
import { ProfessionalFields } from '@reblu/site-blocks'
|
|
835
|
+
|
|
836
|
+
const page = await client.pages.bySlug(slug)
|
|
837
|
+
|
|
838
|
+
<ProfessionalFields fields={page.professional?.fields} />
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
`variant='card'` keeps only `showOnCard` fields. It renders nothing when there
|
|
842
|
+
is nothing to show, so it is safe to place unconditionally. Style it with
|
|
843
|
+
`className` or `[data-slot="professional-fields"]`.
|
|
844
|
+
|
|
845
|
+
## Product carousel — `ProductCarousel` (REB-1120)
|
|
846
|
+
|
|
847
|
+
A horizontal product strip for «Productos relacionados» or «También te puede
|
|
848
|
+
interesar», with no carousel dependency. The track is a native scroll container
|
|
849
|
+
with CSS scroll-snap, so touch users swipe it. From `sm` up, previous/next
|
|
850
|
+
arrows scroll one visible page. The arrows are not rendered when every product
|
|
851
|
+
fits, and the arrow at the end you have reached is disabled.
|
|
852
|
+
|
|
853
|
+
```tsx
|
|
854
|
+
'use client'
|
|
855
|
+
import { ProductCarousel } from '@reblu/site-blocks/client'
|
|
856
|
+
|
|
857
|
+
// `related` is a `ShopProductListItem[]`, e.g. from `client.shop.products({ category })`
|
|
858
|
+
<ProductCarousel
|
|
859
|
+
products={related}
|
|
860
|
+
heading='También te puede interesar'
|
|
861
|
+
productHref={(p) => `/shop/${p.slug}`}
|
|
862
|
+
/>
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
It renders the package's `ProductCard` by default. To use your own card, pass
|
|
866
|
+
`renderProduct`. Any item with an `id` works:
|
|
867
|
+
|
|
868
|
+
```tsx
|
|
869
|
+
<ProductCarousel
|
|
870
|
+
products={related}
|
|
871
|
+
heading='También te puede interesar'
|
|
872
|
+
renderProduct={(p) => <MyProductCard product={p} />}
|
|
873
|
+
/>
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
- **Accessibility.** The track is a focusable `region` with
|
|
877
|
+
`aria-roledescription="carrusel"`. It is named by `heading` when you pass one,
|
|
878
|
+
otherwise by `labels.region`. Once the track has focus, the keyboard arrow
|
|
879
|
+
keys scroll it. The arrows are `<button>`s with `aria-label`s and
|
|
880
|
+
`aria-controls` pointing at the track. Under `prefers-reduced-motion: reduce`
|
|
881
|
+
they jump instead of animating.
|
|
882
|
+
- **Copy.** The defaults are Spanish (`DEFAULT_PRODUCT_CAROUSEL_LABELS`).
|
|
883
|
+
Override them with `labels={{ region, roleDescription, previous, next }}`.
|
|
884
|
+
- **Layout.** About 1.5 cards are visible on mobile, so the next one peeks in,
|
|
885
|
+
then 3 from `sm` and 4 from `lg`. Change the width with `itemClassName`
|
|
886
|
+
(e.g. `basis-64`). Style it with `className` or `[data-slot="product-carousel"]`.
|
|
887
|
+
`headingClassName` replaces the heading's typography (see the className rule
|
|
888
|
+
above). It renders nothing for an empty list.
|
|
889
|
+
|
|
767
890
|
## Page blocks
|
|
768
891
|
|
|
769
892
|
`PageBlocks` renders a page's registry blocks (`PageDetail.blocks` from
|
package/dist/client.cjs
CHANGED
|
@@ -37,7 +37,9 @@ __export(client_exports, {
|
|
|
37
37
|
DATA_REQUEST_FIELD_LIMITS: () => DATA_REQUEST_FIELD_LIMITS,
|
|
38
38
|
DATA_REQUEST_TYPES: () => import_site_contracts.DATA_REQUEST_TYPES,
|
|
39
39
|
DEFAULT_DATA_REQUEST_FORM_LABELS: () => DEFAULT_DATA_REQUEST_FORM_LABELS,
|
|
40
|
+
DEFAULT_PRODUCT_CAROUSEL_LABELS: () => DEFAULT_PRODUCT_CAROUSEL_LABELS,
|
|
40
41
|
DataRequestForm: () => DataRequestForm,
|
|
42
|
+
ProductCarousel: () => ProductCarousel,
|
|
41
43
|
ShareButtons: () => ShareButtons,
|
|
42
44
|
useCollection: () => useCollection
|
|
43
45
|
});
|
|
@@ -226,7 +228,7 @@ function Collection({
|
|
|
226
228
|
] })
|
|
227
229
|
] });
|
|
228
230
|
}
|
|
229
|
-
var PAGE_BTN_CLASS = "inline-flex h-9 items-center rounded-md border border-border px-4 text-sm font-medium transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
231
|
+
var PAGE_BTN_CLASS = "inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-4 text-sm font-medium transition-colors hover:bg-muted disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
230
232
|
function FacetChip({ label, pressed, color, onClick }) {
|
|
231
233
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
232
234
|
"button",
|
|
@@ -236,7 +238,7 @@ function FacetChip({ label, pressed, color, onClick }) {
|
|
|
236
238
|
onClick,
|
|
237
239
|
style: color ? { "--facet-color": color } : void 0,
|
|
238
240
|
className: (0, import_utils.cn)(
|
|
239
|
-
"inline-flex items-center rounded-full border px-3 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
241
|
+
"inline-flex cursor-pointer items-center rounded-full border px-3 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
240
242
|
pressed ? color ? "border-transparent bg-[var(--facet-color)] text-white" : "border-transparent bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
241
243
|
),
|
|
242
244
|
children: label
|
|
@@ -321,7 +323,7 @@ function ShareButtons({ url, title, className, labels }) {
|
|
|
321
323
|
{
|
|
322
324
|
type: "button",
|
|
323
325
|
onClick: handleCopy,
|
|
324
|
-
className: (0, import_utils2.cn)(LINK_CLASS, "w-auto gap-2 px-3", copied && "border-primary text-primary"),
|
|
326
|
+
className: (0, import_utils2.cn)(LINK_CLASS, "w-auto cursor-pointer gap-2 px-3", copied && "border-primary text-primary"),
|
|
325
327
|
"aria-label": t.copy,
|
|
326
328
|
children: [
|
|
327
329
|
copied ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CheckIcon, {}) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CopyIcon, {}),
|
|
@@ -442,7 +444,7 @@ function AddToCartButton({
|
|
|
442
444
|
"data-slot": "add-to-cart-button",
|
|
443
445
|
"data-status": status,
|
|
444
446
|
className: (0, import_utils3.cn)(
|
|
445
|
-
"inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
447
|
+
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
446
448
|
isDisabled ? "cursor-not-allowed bg-muted text-muted-foreground" : "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
447
449
|
status === "error" && "bg-destructive text-destructive-foreground",
|
|
448
450
|
className
|
|
@@ -527,11 +529,214 @@ function CartIcon({ itemCount, href, hydrated, bumpOnAdd, labels, className }) {
|
|
|
527
529
|
);
|
|
528
530
|
}
|
|
529
531
|
|
|
530
|
-
// src/components/
|
|
532
|
+
// src/components/product-carousel.tsx
|
|
531
533
|
var import_react4 = require("react");
|
|
534
|
+
var import_button_variants = require("@reblu/site-ui/button-variants");
|
|
535
|
+
var import_utils6 = require("@reblu/site-ui/utils");
|
|
536
|
+
|
|
537
|
+
// src/components/_heading.tsx
|
|
538
|
+
var import_jsx_runtime5 = require("react/jsx-runtime");
|
|
539
|
+
function Heading({
|
|
540
|
+
level,
|
|
541
|
+
className,
|
|
542
|
+
children
|
|
543
|
+
}) {
|
|
544
|
+
const Tag = `h${level}`;
|
|
545
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(Tag, { className, children });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/components/product-card.tsx
|
|
532
549
|
var import_utils5 = require("@reblu/site-ui/utils");
|
|
550
|
+
|
|
551
|
+
// src/lib/format-price.ts
|
|
552
|
+
function formatPrice(cents, currency = "EUR", locale = "es-ES") {
|
|
553
|
+
return new Intl.NumberFormat(locale, {
|
|
554
|
+
style: "currency",
|
|
555
|
+
currency
|
|
556
|
+
}).format(cents / 100);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/components/product-card.tsx
|
|
560
|
+
var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
561
|
+
function ProductCard({ product, href, currencyCode = "EUR", locale, className }) {
|
|
562
|
+
const image = product.images.find((i) => i.isPrimary) ?? product.images[0];
|
|
563
|
+
const hasDiscount = product.comparePrice != null && product.comparePrice > product.price;
|
|
564
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
|
|
565
|
+
"article",
|
|
566
|
+
{
|
|
567
|
+
className: (0, import_utils5.cn)(
|
|
568
|
+
"group flex flex-col overflow-hidden rounded-lg border bg-card transition-shadow hover:shadow-lg",
|
|
569
|
+
className
|
|
570
|
+
),
|
|
571
|
+
"data-slot": "product-card",
|
|
572
|
+
children: [
|
|
573
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "relative aspect-square overflow-hidden bg-muted", children: image ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
574
|
+
"img",
|
|
575
|
+
{
|
|
576
|
+
src: image.url,
|
|
577
|
+
alt: image.alt ?? product.name,
|
|
578
|
+
loading: "lazy",
|
|
579
|
+
className: "h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
580
|
+
}
|
|
581
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "flex h-full items-center justify-center text-4xl text-muted-foreground/30", "aria-hidden": "true", children: "\u{1F6CD}\uFE0F" }) }),
|
|
582
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "flex flex-1 flex-col gap-2 p-4", children: [
|
|
583
|
+
product.category && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "inline-flex w-fit items-center rounded-full bg-secondary px-2.5 py-0.5 text-xs font-medium text-secondary-foreground", children: product.category.name }),
|
|
584
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("h3", { className: "text-base font-semibold leading-tight", children: href ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("a", { href, className: "transition-colors hover:text-primary", children: product.name }) : product.name }),
|
|
585
|
+
product.shortDescription && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "line-clamp-2 text-sm text-muted-foreground", children: product.shortDescription }),
|
|
586
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "mt-auto flex items-baseline gap-2 pt-2", children: [
|
|
587
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "text-lg font-bold text-foreground", children: formatPrice(product.price, currencyCode, locale) }),
|
|
588
|
+
hasDiscount && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "text-sm text-muted-foreground line-through", children: formatPrice(product.comparePrice, currencyCode, locale) })
|
|
589
|
+
] })
|
|
590
|
+
] })
|
|
591
|
+
]
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/components/product-carousel.tsx
|
|
597
|
+
var import_jsx_runtime7 = require("react/jsx-runtime");
|
|
598
|
+
var DEFAULT_PRODUCT_CAROUSEL_LABELS = {
|
|
599
|
+
region: "Productos relacionados",
|
|
600
|
+
roleDescription: "carrusel",
|
|
601
|
+
previous: "Anterior",
|
|
602
|
+
next: "Siguiente"
|
|
603
|
+
};
|
|
604
|
+
var EDGE_EPSILON = 1;
|
|
605
|
+
function prefersReducedMotion() {
|
|
606
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false;
|
|
607
|
+
}
|
|
608
|
+
function ChevronIcon({ direction }) {
|
|
609
|
+
return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
610
|
+
"svg",
|
|
611
|
+
{
|
|
612
|
+
"aria-hidden": "true",
|
|
613
|
+
viewBox: "0 0 24 24",
|
|
614
|
+
fill: "none",
|
|
615
|
+
stroke: "currentColor",
|
|
616
|
+
strokeWidth: 2,
|
|
617
|
+
strokeLinecap: "round",
|
|
618
|
+
strokeLinejoin: "round",
|
|
619
|
+
className: "rtl:-scale-x-100",
|
|
620
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("path", { d: direction === "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6" })
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
function ProductCarousel(props) {
|
|
625
|
+
const { products, heading, headingLevel = 2, headingClassName, labels, className, itemClassName } = props;
|
|
626
|
+
const t = { ...DEFAULT_PRODUCT_CAROUSEL_LABELS, ...labels };
|
|
627
|
+
const trackId = (0, import_react4.useId)();
|
|
628
|
+
const headingId = (0, import_react4.useId)();
|
|
629
|
+
const trackRef = (0, import_react4.useRef)(null);
|
|
630
|
+
const [canPrev, setCanPrev] = (0, import_react4.useState)(false);
|
|
631
|
+
const [canNext, setCanNext] = (0, import_react4.useState)(false);
|
|
632
|
+
const update = (0, import_react4.useCallback)(() => {
|
|
633
|
+
const el = trackRef.current;
|
|
634
|
+
if (!el) return;
|
|
635
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
636
|
+
const pos = Math.abs(el.scrollLeft);
|
|
637
|
+
setCanPrev(pos > EDGE_EPSILON);
|
|
638
|
+
setCanNext(pos < max - EDGE_EPSILON);
|
|
639
|
+
}, []);
|
|
640
|
+
(0, import_react4.useEffect)(() => {
|
|
641
|
+
const el = trackRef.current;
|
|
642
|
+
if (!el) return;
|
|
643
|
+
update();
|
|
644
|
+
el.addEventListener("scroll", update, { passive: true });
|
|
645
|
+
let observer;
|
|
646
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
647
|
+
observer = new ResizeObserver(update);
|
|
648
|
+
observer.observe(el);
|
|
649
|
+
} else {
|
|
650
|
+
window.addEventListener("resize", update);
|
|
651
|
+
}
|
|
652
|
+
return () => {
|
|
653
|
+
el.removeEventListener("scroll", update);
|
|
654
|
+
if (observer) observer.disconnect();
|
|
655
|
+
else window.removeEventListener("resize", update);
|
|
656
|
+
};
|
|
657
|
+
}, [update, products.length]);
|
|
658
|
+
const scrollByPage = (direction) => {
|
|
659
|
+
const el = trackRef.current;
|
|
660
|
+
if (!el) return;
|
|
661
|
+
const rtl = typeof getComputedStyle === "function" && getComputedStyle(el).direction === "rtl";
|
|
662
|
+
el.scrollBy({
|
|
663
|
+
left: direction * el.clientWidth * (rtl ? -1 : 1),
|
|
664
|
+
behavior: prefersReducedMotion() ? "auto" : "smooth"
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
if (products.length === 0) return null;
|
|
668
|
+
const scrollable = canPrev || canNext;
|
|
669
|
+
return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("section", { "data-slot": "product-carousel", className: (0, import_utils6.cn)("flex flex-col gap-4", className), children: [
|
|
670
|
+
(heading || scrollable) && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-center justify-between gap-4", children: [
|
|
671
|
+
heading ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { id: headingId, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Heading, { level: headingLevel, className: headingClassName ?? "text-xl font-semibold", children: heading }) }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", {}),
|
|
672
|
+
scrollable && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "hidden gap-2 sm:flex", "data-slot": "product-carousel-controls", children: [
|
|
673
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
674
|
+
"button",
|
|
675
|
+
{
|
|
676
|
+
type: "button",
|
|
677
|
+
className: (0, import_utils6.cn)((0, import_button_variants.buttonVariants)({ variant: "outline", size: "icon" }), "rounded-full"),
|
|
678
|
+
"aria-label": t.previous,
|
|
679
|
+
"aria-controls": trackId,
|
|
680
|
+
disabled: !canPrev,
|
|
681
|
+
onClick: () => scrollByPage(-1),
|
|
682
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ChevronIcon, { direction: "left" })
|
|
683
|
+
}
|
|
684
|
+
),
|
|
685
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
686
|
+
"button",
|
|
687
|
+
{
|
|
688
|
+
type: "button",
|
|
689
|
+
className: (0, import_utils6.cn)((0, import_button_variants.buttonVariants)({ variant: "outline", size: "icon" }), "rounded-full"),
|
|
690
|
+
"aria-label": t.next,
|
|
691
|
+
"aria-controls": trackId,
|
|
692
|
+
disabled: !canNext,
|
|
693
|
+
onClick: () => scrollByPage(1),
|
|
694
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ChevronIcon, { direction: "right" })
|
|
695
|
+
}
|
|
696
|
+
)
|
|
697
|
+
] })
|
|
698
|
+
] }),
|
|
699
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
700
|
+
"div",
|
|
701
|
+
{
|
|
702
|
+
ref: trackRef,
|
|
703
|
+
id: trackId,
|
|
704
|
+
role: "region",
|
|
705
|
+
"aria-roledescription": t.roleDescription,
|
|
706
|
+
...heading ? { "aria-labelledby": headingId } : { "aria-label": t.region },
|
|
707
|
+
tabIndex: 0,
|
|
708
|
+
"data-slot": "product-carousel-track",
|
|
709
|
+
className: "snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth rounded-lg pb-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:scroll-auto",
|
|
710
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("ul", { role: "list", className: "m-0 flex list-none gap-4 p-0", children: products.map((product, index) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
711
|
+
"li",
|
|
712
|
+
{
|
|
713
|
+
className: (0, import_utils6.cn)(
|
|
714
|
+
"shrink-0 snap-start basis-[calc((100%-1rem)/1.5)] sm:basis-[calc((100%-2rem)/3)] lg:basis-[calc((100%-3rem)/4)]",
|
|
715
|
+
itemClassName
|
|
716
|
+
),
|
|
717
|
+
children: props.renderProduct ? props.renderProduct(product, index) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
718
|
+
ProductCard,
|
|
719
|
+
{
|
|
720
|
+
product,
|
|
721
|
+
href: props.productHref?.(product),
|
|
722
|
+
currencyCode: props.currencyCode,
|
|
723
|
+
locale: props.locale,
|
|
724
|
+
className: "h-full"
|
|
725
|
+
}
|
|
726
|
+
)
|
|
727
|
+
},
|
|
728
|
+
product.id
|
|
729
|
+
)) })
|
|
730
|
+
}
|
|
731
|
+
)
|
|
732
|
+
] });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/components/data-request-form.tsx
|
|
736
|
+
var import_react5 = require("react");
|
|
737
|
+
var import_utils7 = require("@reblu/site-ui/utils");
|
|
533
738
|
var import_site_contracts = require("@reblu/site-contracts");
|
|
534
|
-
var
|
|
739
|
+
var import_jsx_runtime8 = require("react/jsx-runtime");
|
|
535
740
|
var DATA_REQUEST_FIELD_LIMITS = Object.freeze({ name: 200, email: 320, details: 5e3 });
|
|
536
741
|
var DEFAULT_DATA_REQUEST_FORM_LABELS = {
|
|
537
742
|
requestTypeLabel: "Derecho que quieres ejercer",
|
|
@@ -557,7 +762,7 @@ var DEFAULT_DATA_REQUEST_FORM_LABELS = {
|
|
|
557
762
|
nameRequired: "Indica tu nombre.",
|
|
558
763
|
emailInvalid: "Introduce un email v\xE1lido.",
|
|
559
764
|
consentRequired: "Debes aceptar el tratamiento de tus datos para gestionar la solicitud.",
|
|
560
|
-
success: (deadline) => /* @__PURE__ */ (0,
|
|
765
|
+
success: (deadline) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
|
|
561
766
|
"Hemos recibido tu solicitud. Te responderemos como m\xE1ximo el ",
|
|
562
767
|
deadline,
|
|
563
768
|
"."
|
|
@@ -606,18 +811,18 @@ function DataRequestForm({
|
|
|
606
811
|
...labels,
|
|
607
812
|
requestTypes: { ...DEFAULT_DATA_REQUEST_FORM_LABELS.requestTypes, ...labels?.requestTypes }
|
|
608
813
|
};
|
|
609
|
-
const id = (0,
|
|
610
|
-
const [requestType, setRequestType] = (0,
|
|
611
|
-
const [name, setName] = (0,
|
|
612
|
-
const [email, setEmail] = (0,
|
|
613
|
-
const [details, setDetails] = (0,
|
|
614
|
-
const [consent, setConsent] = (0,
|
|
615
|
-
const [errors, setErrors] = (0,
|
|
616
|
-
const [formError, setFormError] = (0,
|
|
617
|
-
const [submitting, setSubmitting] = (0,
|
|
618
|
-
const [ack, setAck] = (0,
|
|
619
|
-
const statusRef = (0,
|
|
620
|
-
(0,
|
|
814
|
+
const id = (0, import_react5.useId)();
|
|
815
|
+
const [requestType, setRequestType] = (0, import_react5.useState)("");
|
|
816
|
+
const [name, setName] = (0, import_react5.useState)("");
|
|
817
|
+
const [email, setEmail] = (0, import_react5.useState)("");
|
|
818
|
+
const [details, setDetails] = (0, import_react5.useState)("");
|
|
819
|
+
const [consent, setConsent] = (0, import_react5.useState)(false);
|
|
820
|
+
const [errors, setErrors] = (0, import_react5.useState)({});
|
|
821
|
+
const [formError, setFormError] = (0, import_react5.useState)(null);
|
|
822
|
+
const [submitting, setSubmitting] = (0, import_react5.useState)(false);
|
|
823
|
+
const [ack, setAck] = (0, import_react5.useState)(null);
|
|
824
|
+
const statusRef = (0, import_react5.useRef)(null);
|
|
825
|
+
(0, import_react5.useEffect)(() => {
|
|
621
826
|
if (ack) statusRef.current?.focus();
|
|
622
827
|
}, [ack]);
|
|
623
828
|
async function handleSubmit(event) {
|
|
@@ -668,21 +873,21 @@ function DataRequestForm({
|
|
|
668
873
|
dateStyle: "long",
|
|
669
874
|
timeStyle: "short"
|
|
670
875
|
}).format(new Date(ack.slaDueAt));
|
|
671
|
-
return /* @__PURE__ */ (0,
|
|
876
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
672
877
|
"div",
|
|
673
878
|
{
|
|
674
879
|
ref: statusRef,
|
|
675
880
|
tabIndex: -1,
|
|
676
881
|
role: "status",
|
|
677
882
|
"data-slot": "data-request-form-success",
|
|
678
|
-
className: (0,
|
|
883
|
+
className: (0, import_utils7.cn)("rounded-lg border border-border p-6 text-sm", className),
|
|
679
884
|
children: t.success(deadline)
|
|
680
885
|
}
|
|
681
886
|
);
|
|
682
887
|
}
|
|
683
888
|
const errorId = (field) => errors[field] ? `${id}-${field}-error` : void 0;
|
|
684
|
-
const fieldError = (field) => errors[field] ? /* @__PURE__ */ (0,
|
|
685
|
-
return /* @__PURE__ */ (0,
|
|
889
|
+
const fieldError = (field) => errors[field] ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { id: `${id}-${field}-error`, className: "text-xs text-destructive", children: errors[field] }) : null;
|
|
890
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
|
|
686
891
|
"form",
|
|
687
892
|
{
|
|
688
893
|
noValidate: true,
|
|
@@ -690,11 +895,11 @@ function DataRequestForm({
|
|
|
690
895
|
onFocusCapture: onInteract,
|
|
691
896
|
onPointerDown: onInteract,
|
|
692
897
|
"data-slot": "data-request-form",
|
|
693
|
-
className: (0,
|
|
898
|
+
className: (0, import_utils7.cn)("space-y-4", className),
|
|
694
899
|
children: [
|
|
695
|
-
/* @__PURE__ */ (0,
|
|
696
|
-
/* @__PURE__ */ (0,
|
|
697
|
-
/* @__PURE__ */ (0,
|
|
900
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
|
|
901
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-type`, className: "text-sm font-medium", children: t.requestTypeLabel }),
|
|
902
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
|
|
698
903
|
"select",
|
|
699
904
|
{
|
|
700
905
|
id: `${id}-type`,
|
|
@@ -702,18 +907,18 @@ function DataRequestForm({
|
|
|
702
907
|
onChange: (e) => setRequestType(e.target.value),
|
|
703
908
|
"aria-invalid": errors.requestType ? true : void 0,
|
|
704
909
|
"aria-describedby": errorId("requestType"),
|
|
705
|
-
className: (0,
|
|
910
|
+
className: (0, import_utils7.cn)(FIELD_CLASS, errors.requestType ? "border-destructive" : "border-border"),
|
|
706
911
|
children: [
|
|
707
|
-
/* @__PURE__ */ (0,
|
|
708
|
-
requestTypes.map((type) => /* @__PURE__ */ (0,
|
|
912
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("option", { value: "", children: t.requestTypePlaceholder }),
|
|
913
|
+
requestTypes.map((type) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("option", { value: type, children: t.requestTypes[type] }, type))
|
|
709
914
|
]
|
|
710
915
|
}
|
|
711
916
|
),
|
|
712
917
|
fieldError("requestType")
|
|
713
918
|
] }),
|
|
714
|
-
/* @__PURE__ */ (0,
|
|
715
|
-
/* @__PURE__ */ (0,
|
|
716
|
-
/* @__PURE__ */ (0,
|
|
919
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
|
|
920
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-name`, className: "text-sm font-medium", children: t.nameLabel }),
|
|
921
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
717
922
|
"input",
|
|
718
923
|
{
|
|
719
924
|
id: `${id}-name`,
|
|
@@ -724,14 +929,14 @@ function DataRequestForm({
|
|
|
724
929
|
onChange: (e) => setName(e.target.value),
|
|
725
930
|
"aria-invalid": errors.name ? true : void 0,
|
|
726
931
|
"aria-describedby": errorId("name"),
|
|
727
|
-
className: (0,
|
|
932
|
+
className: (0, import_utils7.cn)(FIELD_CLASS, errors.name ? "border-destructive" : "border-border")
|
|
728
933
|
}
|
|
729
934
|
),
|
|
730
935
|
fieldError("name")
|
|
731
936
|
] }),
|
|
732
|
-
/* @__PURE__ */ (0,
|
|
733
|
-
/* @__PURE__ */ (0,
|
|
734
|
-
/* @__PURE__ */ (0,
|
|
937
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
|
|
938
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-email`, className: "text-sm font-medium", children: t.emailLabel }),
|
|
939
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
735
940
|
"input",
|
|
736
941
|
{
|
|
737
942
|
id: `${id}-email`,
|
|
@@ -742,14 +947,14 @@ function DataRequestForm({
|
|
|
742
947
|
onChange: (e) => setEmail(e.target.value),
|
|
743
948
|
"aria-invalid": errors.email ? true : void 0,
|
|
744
949
|
"aria-describedby": errorId("email"),
|
|
745
|
-
className: (0,
|
|
950
|
+
className: (0, import_utils7.cn)(FIELD_CLASS, errors.email ? "border-destructive" : "border-border")
|
|
746
951
|
}
|
|
747
952
|
),
|
|
748
953
|
fieldError("email")
|
|
749
954
|
] }),
|
|
750
|
-
/* @__PURE__ */ (0,
|
|
751
|
-
/* @__PURE__ */ (0,
|
|
752
|
-
/* @__PURE__ */ (0,
|
|
955
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
|
|
956
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-details`, className: "text-sm font-medium", children: t.detailsLabel }),
|
|
957
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
753
958
|
"textarea",
|
|
754
959
|
{
|
|
755
960
|
id: `${id}-details`,
|
|
@@ -758,13 +963,13 @@ function DataRequestForm({
|
|
|
758
963
|
value: details,
|
|
759
964
|
onChange: (e) => setDetails(e.target.value),
|
|
760
965
|
placeholder: t.detailsPlaceholder,
|
|
761
|
-
className: (0,
|
|
966
|
+
className: (0, import_utils7.cn)(FIELD_CLASS, "border-border")
|
|
762
967
|
}
|
|
763
968
|
)
|
|
764
969
|
] }),
|
|
765
|
-
/* @__PURE__ */ (0,
|
|
766
|
-
/* @__PURE__ */ (0,
|
|
767
|
-
/* @__PURE__ */ (0,
|
|
970
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
|
|
971
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "flex items-start gap-2", children: [
|
|
972
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
768
973
|
"input",
|
|
769
974
|
{
|
|
770
975
|
id: `${id}-consent`,
|
|
@@ -776,17 +981,17 @@ function DataRequestForm({
|
|
|
776
981
|
className: "mt-1"
|
|
777
982
|
}
|
|
778
983
|
),
|
|
779
|
-
/* @__PURE__ */ (0,
|
|
984
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-consent`, className: "text-sm text-foreground/80", children: t.consent })
|
|
780
985
|
] }),
|
|
781
986
|
fieldError("consent")
|
|
782
987
|
] }),
|
|
783
|
-
formError && /* @__PURE__ */ (0,
|
|
784
|
-
/* @__PURE__ */ (0,
|
|
988
|
+
formError && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { role: "alert", className: "text-sm text-destructive", children: formError }),
|
|
989
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
785
990
|
"button",
|
|
786
991
|
{
|
|
787
992
|
type: "submit",
|
|
788
993
|
disabled: submitting,
|
|
789
|
-
className: "rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-60",
|
|
994
|
+
className: "cursor-pointer rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60",
|
|
790
995
|
children: submitting ? t.submitting : t.submit
|
|
791
996
|
}
|
|
792
997
|
)
|
|
@@ -802,7 +1007,9 @@ function DataRequestForm({
|
|
|
802
1007
|
DATA_REQUEST_FIELD_LIMITS,
|
|
803
1008
|
DATA_REQUEST_TYPES,
|
|
804
1009
|
DEFAULT_DATA_REQUEST_FORM_LABELS,
|
|
1010
|
+
DEFAULT_PRODUCT_CAROUSEL_LABELS,
|
|
805
1011
|
DataRequestForm,
|
|
1012
|
+
ProductCarousel,
|
|
806
1013
|
ShareButtons,
|
|
807
1014
|
useCollection
|
|
808
1015
|
});
|