@brandlab-digital/site-next 3.10.0 → 3.11.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.
Files changed (61) hide show
  1. package/dist/ui-kit/brandlab-skin.d.ts +22 -0
  2. package/dist/ui-kit/brandlab-skin.d.ts.map +1 -1
  3. package/dist/ui-kit/brandlab-skin.js.map +1 -1
  4. package/dist/ui-kit/sections/accordion.d.ts +25 -0
  5. package/dist/ui-kit/sections/accordion.d.ts.map +1 -0
  6. package/dist/ui-kit/sections/accordion.js +14 -0
  7. package/dist/ui-kit/sections/accordion.js.map +1 -0
  8. package/dist/ui-kit/sections/contact-block.d.ts +51 -0
  9. package/dist/ui-kit/sections/contact-block.d.ts.map +1 -0
  10. package/dist/ui-kit/sections/contact-block.js +15 -0
  11. package/dist/ui-kit/sections/contact-block.js.map +1 -0
  12. package/dist/ui-kit/sections/cta-band.d.ts +59 -0
  13. package/dist/ui-kit/sections/cta-band.d.ts.map +1 -0
  14. package/dist/ui-kit/sections/cta-band.js +11 -0
  15. package/dist/ui-kit/sections/cta-band.js.map +1 -0
  16. package/dist/ui-kit/sections/faqs.d.ts +43 -0
  17. package/dist/ui-kit/sections/faqs.d.ts.map +1 -0
  18. package/dist/ui-kit/sections/faqs.js +40 -0
  19. package/dist/ui-kit/sections/faqs.js.map +1 -0
  20. package/dist/ui-kit/sections/index.d.ts +13 -0
  21. package/dist/ui-kit/sections/index.d.ts.map +1 -0
  22. package/dist/ui-kit/sections/index.js +13 -0
  23. package/dist/ui-kit/sections/index.js.map +1 -0
  24. package/dist/ui-kit/sections/page-header.d.ts +29 -0
  25. package/dist/ui-kit/sections/page-header.d.ts.map +1 -0
  26. package/dist/ui-kit/sections/page-header.js +9 -0
  27. package/dist/ui-kit/sections/page-header.js.map +1 -0
  28. package/dist/ui-kit/sections/price-guide.d.ts +56 -0
  29. package/dist/ui-kit/sections/price-guide.d.ts.map +1 -0
  30. package/dist/ui-kit/sections/price-guide.js +28 -0
  31. package/dist/ui-kit/sections/price-guide.js.map +1 -0
  32. package/dist/ui-kit/sections/price-table.d.ts +44 -0
  33. package/dist/ui-kit/sections/price-table.d.ts.map +1 -0
  34. package/dist/ui-kit/sections/price-table.js +29 -0
  35. package/dist/ui-kit/sections/price-table.js.map +1 -0
  36. package/dist/ui-kit/sections/proof-strip.d.ts +57 -0
  37. package/dist/ui-kit/sections/proof-strip.d.ts.map +1 -0
  38. package/dist/ui-kit/sections/proof-strip.js +20 -0
  39. package/dist/ui-kit/sections/proof-strip.js.map +1 -0
  40. package/dist/ui-kit/sections/rating-chip.d.ts +25 -0
  41. package/dist/ui-kit/sections/rating-chip.d.ts.map +1 -0
  42. package/dist/ui-kit/sections/rating-chip.js +15 -0
  43. package/dist/ui-kit/sections/rating-chip.js.map +1 -0
  44. package/dist/ui-kit/sections/reviews-band.d.ts +110 -0
  45. package/dist/ui-kit/sections/reviews-band.d.ts.map +1 -0
  46. package/dist/ui-kit/sections/reviews-band.js +104 -0
  47. package/dist/ui-kit/sections/reviews-band.js.map +1 -0
  48. package/dist/ui-kit/sections/section-sequence.d.ts +39 -0
  49. package/dist/ui-kit/sections/section-sequence.d.ts.map +1 -0
  50. package/dist/ui-kit/sections/section-sequence.js +34 -0
  51. package/dist/ui-kit/sections/section-sequence.js.map +1 -0
  52. package/dist/ui-kit/sections/site-footer-columns.d.ts +12 -0
  53. package/dist/ui-kit/sections/site-footer-columns.d.ts.map +1 -0
  54. package/dist/ui-kit/sections/site-footer-columns.js +63 -0
  55. package/dist/ui-kit/sections/site-footer-columns.js.map +1 -0
  56. package/dist/ui-kit/sections/site-footer.d.ts +59 -0
  57. package/dist/ui-kit/sections/site-footer.d.ts.map +1 -0
  58. package/dist/ui-kit/sections/site-footer.js +41 -0
  59. package/dist/ui-kit/sections/site-footer.js.map +1 -0
  60. package/dist/ui-kit/structure.css +569 -0
  61. package/package.json +6 -2
@@ -0,0 +1,104 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * S7 `ReviewsBand` (`reviews`): prominent, honest proof from the Business Profile feed. Server component, no client
4
+ * JS. Google's own average and total sit beside the selected reviews with a link to the profile; the text is verbatim
5
+ * and always in the HTML (a long review folds behind a native "Read more" disclosure); the band says how the reviews
6
+ * were chosen ("Selected five-star Google reviews" or "Recent Google reviews"). No review or rating schema: a business
7
+ * that controls its own reviews isn't eligible for review stars.
8
+ *
9
+ * Data comes only from `prepareBusinessProfileReviews` (site-core), which applies the policy, mention ordering, the
10
+ * 3.7.0 exclude guard and the limit. No prepared reviews, or none and no rating, renders nothing at all (no heading).
11
+ * With no written reviews but a rating, every variant renders as `summary`: the heading and the rating chip only.
12
+ * Never place it on the contact page or inside another section.
13
+ *
14
+ * Sites that arrange their own layout use the parts (`ReviewsBand.Root`, `.Heading`, `.Summary`, `.List`, `.Item`):
15
+ * the root keeps the section semantics, the empty rule and the disclosure line; the parts keep the data and markup.
16
+ */
17
+ import { Children } from 'react';
18
+ import { businessProfileReviewMentions, } from '@brandlab-digital/site-core/google-business-reviews';
19
+ import { RatingStars, Section, SectionHeading } from '../primitives/layout.js';
20
+ import { slot } from '../skin.js';
21
+ import { hasGoogleRating, RatingChip } from './rating-chip.js';
22
+ export const REVIEWS_BAND_VARIANTS = ['band', 'feature', 'cards', 'wall', 'summary'];
23
+ export const REVIEWS_BAND_HEADING_ID = 'bl-reviews-heading';
24
+ const DEFAULT_EXCERPT = 320;
25
+ /**
26
+ * The variant a band renders, or null when it has nothing true to show. With no written reviews every variant is
27
+ * `summary`, and `summary` needs Google's rating: a heading over nothing never renders.
28
+ */
29
+ function shownVariant(reviews, variant) {
30
+ if (!reviews)
31
+ return null;
32
+ const shown = reviews.reviews.length === 0 ? 'summary' : variant;
33
+ return shown === 'summary' && !hasGoogleRating(reviews) ? null : shown;
34
+ }
35
+ const MONTH = new Intl.DateTimeFormat('en-AU', { month: 'long', year: 'numeric', timeZone: 'Australia/Sydney' });
36
+ const DAY = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'Australia/Sydney' });
37
+ /**
38
+ * A long review split at a sentence end (or else a space) near `limit`, so the first part reads on its own. The two
39
+ * parts together are the review word for word. Short reviews, and reviews only a little over the limit, don't fold.
40
+ */
41
+ export function splitReviewText(text, limit) {
42
+ if (limit === false || text.length <= limit * 1.25)
43
+ return null;
44
+ const window = text.slice(0, limit);
45
+ const sentence = Math.max(...['. ', '! ', '? ', '\n'].map((mark) => window.lastIndexOf(mark)));
46
+ const space = window.lastIndexOf(' ');
47
+ const cut = sentence >= limit * 0.4 ? sentence + 1 : space > 0 ? space : limit;
48
+ return [text.slice(0, cut).trimEnd(), text.slice(cut).trimStart()];
49
+ }
50
+ /** The `feature` variant leads with the longest review naming a mention word, or else the longest review. */
51
+ function featureOrder(reviews) {
52
+ const named = reviews.mention.length > 0 ? reviews.reviews.filter((review) => businessProfileReviewMentions(review, reviews.mention)) : [];
53
+ const pool = named.length > 0 ? named : reviews.reviews;
54
+ const lead = pool.reduce((longest, review) => (!longest || review.text.length > longest.text.length ? review : longest), undefined);
55
+ return lead ? [lead, ...reviews.reviews.filter((review) => review !== lead)] : [];
56
+ }
57
+ /**
58
+ * The section: `data-bl-section="reviews"` with `data-bl-source="feed"` (the ready gate's mark for feed numbers),
59
+ * the empty rule, and the locked disclosure line after the children.
60
+ */
61
+ export function ReviewsBandRoot({ reviews, headingId = REVIEWS_BAND_HEADING_ID, tone, decoration, variant = 'band', layout, classes, children, }) {
62
+ const shown = shownVariant(reviews, variant);
63
+ if (!shown || !reviews)
64
+ return null;
65
+ return (_jsxs(Section, { id: "reviews", labelledBy: headingId, tone: tone, variant: shown, classes: { root: classes?.root, inner: classes?.inner }, decoration: decoration, hooks: { 'data-bl-source': 'feed', 'data-columns': String(layout?.columns ?? 3), 'data-align': layout?.align ?? 'start' }, children: [children, shown === 'summary' ? null : (_jsxs("p", { ...slot(classes, 'disclosure'), children: [reviews.policy === 'five-star' ? 'Selected five-star Google reviews.' : 'Recent Google reviews.', ' ', _jsx("a", { ...slot(classes, 'profileLink'), href: reviews.profileUrl, target: "_blank", rel: "noopener noreferrer", children: "Read all reviews on Google" })] }))] }));
66
+ }
67
+ /** The heading block: an optional eyebrow, the H2 and an optional intro. */
68
+ export function ReviewsBandHeading({ id = REVIEWS_BAND_HEADING_ID, children, eyebrow, intro, layout, classes }) {
69
+ return (_jsx(SectionHeading, { id: id, title: children, eyebrow: eyebrow, lead: intro, layout: { align: layout?.align ?? 'start' }, classes: { head: classes?.head, eyebrow: classes?.eyebrow, heading: classes?.heading, lead: classes?.lead } }));
70
+ }
71
+ /** Google's own average and total with the profile link; nothing when the feed has no rating. */
72
+ export function ReviewsBandSummary({ reviews, classes }) {
73
+ if (!hasGoogleRating(reviews))
74
+ return null;
75
+ return (_jsx("div", { ...slot(classes, 'summary'), children: _jsx(RatingChip, { reviews: reviews, classes: { rating: classes?.rating, source: classes?.source } }) }));
76
+ }
77
+ /** The review list; nothing without items. */
78
+ export function ReviewsBandList({ classes, children }) {
79
+ if (Children.toArray(children).length === 0)
80
+ return null;
81
+ return _jsx("ul", { ...slot(classes, 'list'), children: children });
82
+ }
83
+ /** One review, word for word: its stars, the quote and the reviewer with the month. */
84
+ export function ReviewsBandItem({ review, lead = false, layout, classes }) {
85
+ const parts = splitReviewText(review.text, layout?.excerpt ?? DEFAULT_EXCERPT);
86
+ const date = new Date(review.createTime);
87
+ return (_jsx("li", { ...slot(classes, 'item'), "data-lead": lead ? 'true' : undefined, children: _jsxs("figure", { children: [_jsx("span", { ...slot(classes, 'stars'), children: _jsx(RatingStars, { rating: review.rating, label: `Rated ${review.rating} out of 5` }) }), _jsx("blockquote", { ...slot(classes, 'quote'), children: parts ? (_jsxs(_Fragment, { children: [_jsx("p", { children: parts[0] }), _jsxs("details", { ...slot(classes, 'more'), children: [_jsx("summary", { children: "Read more" }), _jsx("p", { children: parts[1] })] })] })) : (_jsx("p", { children: review.text })) }), _jsxs("figcaption", { ...slot(classes, 'author'), children: [layout?.avatars && review.authorPhotoUrl ? (_jsx("img", { ...slot(classes, 'avatar'), src: review.authorPhotoUrl, alt: "", width: 48, height: 48, loading: "lazy", decoding: "async", referrerPolicy: "no-referrer" })) : null, _jsx("span", { ...slot(classes, 'name'), children: review.authorName }), ' ', _jsx("time", { ...slot(classes, 'date'), dateTime: DAY.format(date), children: MONTH.format(date) })] })] }) }));
88
+ }
89
+ function ReviewsBandSection({ reviews, heading, eyebrow, intro, headingId = REVIEWS_BAND_HEADING_ID, tone, decoration, variant = 'band', layout, classes }) {
90
+ const shown = shownVariant(reviews, variant);
91
+ if (!shown || !reviews)
92
+ return null;
93
+ const items = shown === 'summary' ? [] : shown === 'feature' ? featureOrder(reviews) : reviews.reviews;
94
+ return (_jsxs(ReviewsBandRoot, { reviews: reviews, headingId: headingId, tone: tone, decoration: decoration, variant: shown, layout: layout, classes: classes, children: [_jsxs("div", { ...slot(classes, 'top'), children: [_jsx(ReviewsBandHeading, { id: headingId, eyebrow: eyebrow, intro: intro, layout: layout, classes: classes, children: heading }), _jsx(ReviewsBandSummary, { reviews: reviews, classes: classes })] }), _jsx(ReviewsBandList, { classes: classes, children: items.map((review, index) => (_jsx(ReviewsBandItem, { review: review, lead: shown === 'feature' && index === 0, layout: layout, classes: classes }, review.id))) })] }));
95
+ }
96
+ /** The reviews band, with its parts for sites that arrange their own layout. */
97
+ export const ReviewsBand = Object.assign(ReviewsBandSection, {
98
+ Root: ReviewsBandRoot,
99
+ Heading: ReviewsBandHeading,
100
+ Summary: ReviewsBandSummary,
101
+ List: ReviewsBandList,
102
+ Item: ReviewsBandItem,
103
+ });
104
+ //# sourceMappingURL=reviews-band.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reviews-band.js","sourceRoot":"","sources":["../../../src/ui-kit/sections/reviews-band.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * S7 `ReviewsBand` (`reviews`): prominent, honest proof from the Business Profile feed. Server component, no client\n * JS. Google's own average and total sit beside the selected reviews with a link to the profile; the text is verbatim\n * and always in the HTML (a long review folds behind a native \"Read more\" disclosure); the band says how the reviews\n * were chosen (\"Selected five-star Google reviews\" or \"Recent Google reviews\"). No review or rating schema: a business\n * that controls its own reviews isn't eligible for review stars.\n *\n * Data comes only from `prepareBusinessProfileReviews` (site-core), which applies the policy, mention ordering, the\n * 3.7.0 exclude guard and the limit. No prepared reviews, or none and no rating, renders nothing at all (no heading).\n * With no written reviews but a rating, every variant renders as `summary`: the heading and the rating chip only.\n * Never place it on the contact page or inside another section.\n *\n * Sites that arrange their own layout use the parts (`ReviewsBand.Root`, `.Heading`, `.Summary`, `.List`, `.Item`):\n * the root keeps the section semantics, the empty rule and the disclosure line; the parts keep the data and markup.\n */\nimport { Children, type ReactNode } from 'react'\nimport {\n businessProfileReviewMentions,\n type BusinessProfileReview,\n type PreparedBusinessProfileReviews,\n} from '@brandlab-digital/site-core/google-business-reviews'\nimport { RatingStars, Section, SectionHeading, type SectionTone } from '../primitives/layout.js'\nimport { slot, type SkinEntry, type SlotClasses } from '../skin.js'\nimport { hasGoogleRating, RatingChip } from './rating-chip.js'\n\nexport const REVIEWS_BAND_VARIANTS = ['band', 'feature', 'cards', 'wall', 'summary'] as const\nexport type ReviewsBandVariant = (typeof REVIEWS_BAND_VARIANTS)[number]\n\nexport type ReviewsBandSlot =\n | 'root'\n | 'inner'\n | 'top'\n | 'head'\n | 'eyebrow'\n | 'heading'\n | 'lead'\n | 'summary'\n | 'rating'\n | 'source'\n | 'list'\n | 'item'\n | 'stars'\n | 'quote'\n | 'more'\n | 'author'\n | 'avatar'\n | 'name'\n | 'date'\n | 'disclosure'\n | 'profileLink'\n\nexport interface ReviewsBandLayout {\n readonly columns?: 1 | 2 | 3\n readonly align?: 'start' | 'center'\n /** Reviewer photos beside the names (Google's own small photos, decorative). */\n readonly avatars?: boolean\n /** Characters shown before a long review folds behind \"Read more\" (default 320); `false` never folds. */\n readonly excerpt?: number | false\n}\n\nexport type ReviewsBandSkin = SkinEntry<ReviewsBandSlot, ReviewsBandVariant, ReviewsBandLayout>\n\ntype Classes = SlotClasses<ReviewsBandSlot> | undefined\n\nexport const REVIEWS_BAND_HEADING_ID = 'bl-reviews-heading'\nconst DEFAULT_EXCERPT = 320\n\n/**\n * The variant a band renders, or null when it has nothing true to show. With no written reviews every variant is\n * `summary`, and `summary` needs Google's rating: a heading over nothing never renders.\n */\nfunction shownVariant(reviews: PreparedBusinessProfileReviews | null | undefined, variant: ReviewsBandVariant): ReviewsBandVariant | null {\n if (!reviews) return null\n const shown = reviews.reviews.length === 0 ? 'summary' : variant\n return shown === 'summary' && !hasGoogleRating(reviews) ? null : shown\n}\n\nconst MONTH = new Intl.DateTimeFormat('en-AU', { month: 'long', year: 'numeric', timeZone: 'Australia/Sydney' })\nconst DAY = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'Australia/Sydney' })\n\n/**\n * A long review split at a sentence end (or else a space) near `limit`, so the first part reads on its own. The two\n * parts together are the review word for word. Short reviews, and reviews only a little over the limit, don't fold.\n */\nexport function splitReviewText(text: string, limit: number | false): readonly [string, string] | null {\n if (limit === false || text.length <= limit * 1.25) return null\n const window = text.slice(0, limit)\n const sentence = Math.max(...['. ', '! ', '? ', '\\n'].map((mark) => window.lastIndexOf(mark)))\n const space = window.lastIndexOf(' ')\n const cut = sentence >= limit * 0.4 ? sentence + 1 : space > 0 ? space : limit\n return [text.slice(0, cut).trimEnd(), text.slice(cut).trimStart()]\n}\n\n/** The `feature` variant leads with the longest review naming a mention word, or else the longest review. */\nfunction featureOrder(reviews: PreparedBusinessProfileReviews): BusinessProfileReview[] {\n const named = reviews.mention.length > 0 ? reviews.reviews.filter((review) => businessProfileReviewMentions(review, reviews.mention)) : []\n const pool = named.length > 0 ? named : reviews.reviews\n const lead = pool.reduce<BusinessProfileReview | undefined>((longest, review) => (!longest || review.text.length > longest.text.length ? review : longest), undefined)\n return lead ? [lead, ...reviews.reviews.filter((review) => review !== lead)] : []\n}\n\nexport interface ReviewsBandRootProps extends ReviewsBandSkin {\n readonly reviews: PreparedBusinessProfileReviews | null | undefined\n /** The id of the band's H2 (default `bl-reviews-heading`). */\n readonly headingId?: string\n /** Set by `SectionSequence`. */\n readonly tone?: SectionTone\n /** Signature art, rendered `aria-hidden`. */\n readonly decoration?: ReactNode\n readonly children: ReactNode\n}\n\n/**\n * The section: `data-bl-section=\"reviews\"` with `data-bl-source=\"feed\"` (the ready gate's mark for feed numbers),\n * the empty rule, and the locked disclosure line after the children.\n */\nexport function ReviewsBandRoot({\n reviews,\n headingId = REVIEWS_BAND_HEADING_ID,\n tone,\n decoration,\n variant = 'band',\n layout,\n classes,\n children,\n}: ReviewsBandRootProps) {\n const shown = shownVariant(reviews, variant)\n if (!shown || !reviews) return null\n return (\n <Section\n id=\"reviews\"\n labelledBy={headingId}\n tone={tone}\n variant={shown}\n classes={{ root: classes?.root, inner: classes?.inner }}\n decoration={decoration}\n hooks={{ 'data-bl-source': 'feed', 'data-columns': String(layout?.columns ?? 3), 'data-align': layout?.align ?? 'start' }}\n >\n {children}\n {shown === 'summary' ? null : (\n <p {...slot(classes, 'disclosure')}>\n {reviews.policy === 'five-star' ? 'Selected five-star Google reviews.' : 'Recent Google reviews.'}{' '}\n <a {...slot(classes, 'profileLink')} href={reviews.profileUrl} target=\"_blank\" rel=\"noopener noreferrer\">\n Read all reviews on Google\n </a>\n </p>\n )}\n </Section>\n )\n}\n\nexport interface ReviewsBandHeadingProps {\n /** The H2's id; the same `headingId` the root has (default `bl-reviews-heading`). */\n readonly id?: string\n /** The H2 text the site owns. */\n readonly children: string\n readonly eyebrow?: string\n readonly intro?: string\n readonly layout?: Pick<ReviewsBandLayout, 'align'>\n readonly classes?: Classes\n}\n\n/** The heading block: an optional eyebrow, the H2 and an optional intro. */\nexport function ReviewsBandHeading({ id = REVIEWS_BAND_HEADING_ID, children, eyebrow, intro, layout, classes }: ReviewsBandHeadingProps) {\n return (\n <SectionHeading\n id={id}\n title={children}\n eyebrow={eyebrow}\n lead={intro}\n layout={{ align: layout?.align ?? 'start' }}\n classes={{ head: classes?.head, eyebrow: classes?.eyebrow, heading: classes?.heading, lead: classes?.lead }}\n />\n )\n}\n\nexport interface ReviewsBandSummaryProps {\n readonly reviews: PreparedBusinessProfileReviews | null | undefined\n readonly classes?: Classes\n}\n\n/** Google's own average and total with the profile link; nothing when the feed has no rating. */\nexport function ReviewsBandSummary({ reviews, classes }: ReviewsBandSummaryProps) {\n if (!hasGoogleRating(reviews)) return null\n return (\n <div {...slot(classes, 'summary')}>\n <RatingChip reviews={reviews} classes={{ rating: classes?.rating, source: classes?.source }} />\n </div>\n )\n}\n\n/** The review list; nothing without items. */\nexport function ReviewsBandList({ classes, children }: { readonly classes?: Classes; readonly children: ReactNode }) {\n if (Children.toArray(children).length === 0) return null\n return <ul {...slot(classes, 'list')}>{children}</ul>\n}\n\nexport interface ReviewsBandItemProps {\n readonly review: BusinessProfileReview\n /** The `feature` variant's wide lead card. */\n readonly lead?: boolean\n readonly layout?: Pick<ReviewsBandLayout, 'avatars' | 'excerpt'>\n readonly classes?: Classes\n}\n\n/** One review, word for word: its stars, the quote and the reviewer with the month. */\nexport function ReviewsBandItem({ review, lead = false, layout, classes }: ReviewsBandItemProps) {\n const parts = splitReviewText(review.text, layout?.excerpt ?? DEFAULT_EXCERPT)\n const date = new Date(review.createTime)\n return (\n <li {...slot(classes, 'item')} data-lead={lead ? 'true' : undefined}>\n <figure>\n <span {...slot(classes, 'stars')}>\n <RatingStars rating={review.rating} label={`Rated ${review.rating} out of 5`} />\n </span>\n <blockquote {...slot(classes, 'quote')}>\n {parts ? (\n <>\n <p>{parts[0]}</p>\n <details {...slot(classes, 'more')}>\n <summary>Read more</summary>\n <p>{parts[1]}</p>\n </details>\n </>\n ) : (\n <p>{review.text}</p>\n )}\n </blockquote>\n <figcaption {...slot(classes, 'author')}>\n {layout?.avatars && review.authorPhotoUrl ? (\n <img\n {...slot(classes, 'avatar')}\n src={review.authorPhotoUrl}\n alt=\"\"\n width={48}\n height={48}\n loading=\"lazy\"\n decoding=\"async\"\n referrerPolicy=\"no-referrer\"\n />\n ) : null}\n <span {...slot(classes, 'name')}>{review.authorName}</span>{' '}\n <time {...slot(classes, 'date')} dateTime={DAY.format(date)}>\n {MONTH.format(date)}\n </time>\n </figcaption>\n </figure>\n </li>\n )\n}\n\nexport interface ReviewsBandProps extends ReviewsBandSkin {\n /** From `prepareBusinessProfileReviews`; null renders nothing at all. */\n readonly reviews: PreparedBusinessProfileReviews | null | undefined\n /** The H2 text the site owns. */\n readonly heading: string\n readonly eyebrow?: string\n readonly intro?: string\n /** The id of the band's H2 (default `bl-reviews-heading`). */\n readonly headingId?: string\n /** Set by `SectionSequence`. */\n readonly tone?: SectionTone\n /** Signature art, rendered `aria-hidden`. */\n readonly decoration?: ReactNode\n}\n\nfunction ReviewsBandSection({ reviews, heading, eyebrow, intro, headingId = REVIEWS_BAND_HEADING_ID, tone, decoration, variant = 'band', layout, classes }: ReviewsBandProps) {\n const shown = shownVariant(reviews, variant)\n if (!shown || !reviews) return null\n const items = shown === 'summary' ? [] : shown === 'feature' ? featureOrder(reviews) : reviews.reviews\n return (\n <ReviewsBandRoot reviews={reviews} headingId={headingId} tone={tone} decoration={decoration} variant={shown} layout={layout} classes={classes}>\n <div {...slot(classes, 'top')}>\n <ReviewsBandHeading id={headingId} eyebrow={eyebrow} intro={intro} layout={layout} classes={classes}>\n {heading}\n </ReviewsBandHeading>\n <ReviewsBandSummary reviews={reviews} classes={classes} />\n </div>\n <ReviewsBandList classes={classes}>\n {items.map((review, index) => (\n <ReviewsBandItem key={review.id} review={review} lead={shown === 'feature' && index === 0} layout={layout} classes={classes} />\n ))}\n </ReviewsBandList>\n </ReviewsBandRoot>\n )\n}\n\n/** The reviews band, with its parts for sites that arrange their own layout. */\nexport const ReviewsBand = Object.assign(ReviewsBandSection, {\n Root: ReviewsBandRoot,\n Heading: ReviewsBandHeading,\n Summary: ReviewsBandSummary,\n List: ReviewsBandList,\n Item: ReviewsBandItem,\n})\n"]}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `SectionSequence` (F15): renders a page family's sections in order. Server component, ported from Omega's
3
+ * `Sequence` and `alternateTones`. Each section the page offers comes with its guard, computed from the same data it
4
+ * renders (`faqsReady(props)`, `priceGuideReady(props)` …), so a section that would render nothing is skipped with
5
+ * its reason without rendering twice. Then the cap drops optional sections first, the light tones alternate so
6
+ * neighbours never share a background (at most one `band` and one `photo`), and the page's manifest is written as
7
+ * `<script type="application/json" data-bl-manifest>` (`SECTION_MANIFEST_ATTRIBUTE`) for K12: what was planned, what
8
+ * rendered with which tone, and what was skipped and why.
9
+ *
10
+ * ```tsx
11
+ * <SectionSequence
12
+ * order={orders.service}
13
+ * page="/tree-removal"
14
+ * sections={[
15
+ * { id: 'hero', tone: 'photo', render: (tone) => <Hero tone={tone} {...hero} /> },
16
+ * { id: 'price-guide', ready: priceGuideReady(guide), render: (tone) => <PriceGuide tone={tone} {...guide} /> },
17
+ * { id: 'cta', tone: 'band', render: (tone) => <CTABand tone={tone} {...cta} /> },
18
+ * ]}
19
+ * />
20
+ * ```
21
+ */
22
+ import { type ReactNode } from 'react';
23
+ import { type BrandlabFamilyOrder, type BrandlabSectionManifest, type BrandlabSequenceCandidate } from '@brandlab-digital/site-core/section-order';
24
+ import type { SectionTone } from '../primitives/layout.js';
25
+ /** One section the page offers: its matrix id, its guard, the tone it asks for and how to render it with a tone. */
26
+ export interface SequenceSection extends BrandlabSequenceCandidate {
27
+ readonly render: (tone: SectionTone) => ReactNode;
28
+ }
29
+ export interface SectionSequenceProps {
30
+ /** The family's order, from the site's `defineSectionOrder` (or `defaultSectionOrder`). */
31
+ readonly order: BrandlabFamilyOrder;
32
+ readonly sections: readonly SequenceSection[];
33
+ /** The page path, recorded in the manifest. */
34
+ readonly page?: string;
35
+ }
36
+ /** The manifest as safe inline JSON (no `<`, `>` or `&` that could close the script). */
37
+ export declare function serialiseSectionManifest(manifest: BrandlabSectionManifest): string;
38
+ export declare function SectionSequence({ order, sections, page }: SectionSequenceProps): import("react/jsx-runtime").JSX.Element;
39
+ //# sourceMappingURL=section-sequence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"section-sequence.d.ts","sourceRoot":"","sources":["../../../src/ui-kit/sections/section-sequence.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAEhD,OAAO,EAGL,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,yBAAyB,EAC/B,MAAM,2CAA2C,CAAA;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAE1D,oHAAoH;AACpH,MAAM,WAAW,eAAgB,SAAQ,yBAAyB;IAChE,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,SAAS,CAAA;CAClD;AAED,MAAM,WAAW,oBAAoB;IACnC,2FAA2F;IAC3F,QAAQ,CAAC,KAAK,EAAE,mBAAmB,CAAA;IACnC,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAA;IAC7C,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,yFAAyF;AACzF,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,uBAAuB,GAAG,MAAM,CAElF;AAED,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,oBAAoB,2CAc9E"}
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * `SectionSequence` (F15): renders a page family's sections in order. Server component, ported from Omega's
4
+ * `Sequence` and `alternateTones`. Each section the page offers comes with its guard, computed from the same data it
5
+ * renders (`faqsReady(props)`, `priceGuideReady(props)` …), so a section that would render nothing is skipped with
6
+ * its reason without rendering twice. Then the cap drops optional sections first, the light tones alternate so
7
+ * neighbours never share a background (at most one `band` and one `photo`), and the page's manifest is written as
8
+ * `<script type="application/json" data-bl-manifest>` (`SECTION_MANIFEST_ATTRIBUTE`) for K12: what was planned, what
9
+ * rendered with which tone, and what was skipped and why.
10
+ *
11
+ * ```tsx
12
+ * <SectionSequence
13
+ * order={orders.service}
14
+ * page="/tree-removal"
15
+ * sections={[
16
+ * { id: 'hero', tone: 'photo', render: (tone) => <Hero tone={tone} {...hero} /> },
17
+ * { id: 'price-guide', ready: priceGuideReady(guide), render: (tone) => <PriceGuide tone={tone} {...guide} /> },
18
+ * { id: 'cta', tone: 'band', render: (tone) => <CTABand tone={tone} {...cta} /> },
19
+ * ]}
20
+ * />
21
+ * ```
22
+ */
23
+ import { Fragment } from 'react';
24
+ import { sanitizeJsonLd } from '@brandlab-digital/site-core/schema';
25
+ import { SECTION_MANIFEST_ATTRIBUTE, planSectionSequence, } from '@brandlab-digital/site-core/section-order';
26
+ /** The manifest as safe inline JSON (no `<`, `>` or `&` that could close the script). */
27
+ export function serialiseSectionManifest(manifest) {
28
+ return sanitizeJsonLd(manifest);
29
+ }
30
+ export function SectionSequence({ order, sections, page }) {
31
+ const plan = planSectionSequence(order, sections, page);
32
+ return (_jsxs(_Fragment, { children: [plan.rendered.map((item) => (_jsx(Fragment, { children: sections[item.candidate]?.render(item.tone) }, item.id))), _jsx("script", { type: "application/json", [SECTION_MANIFEST_ATTRIBUTE]: '', dangerouslySetInnerHTML: { __html: serialiseSectionManifest(plan.manifest) } })] }));
33
+ }
34
+ //# sourceMappingURL=section-sequence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"section-sequence.js","sourceRoot":"","sources":["../../../src/ui-kit/sections/section-sequence.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,QAAQ,EAAkB,MAAM,OAAO,CAAA;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAA;AACnE,OAAO,EACL,0BAA0B,EAC1B,mBAAmB,GAIpB,MAAM,2CAA2C,CAAA;AAgBlD,yFAAyF;AACzF,MAAM,UAAU,wBAAwB,CAAC,QAAiC;IACxE,OAAO,cAAc,CAAC,QAAQ,CAAC,CAAA;AACjC,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAwB;IAC7E,MAAM,IAAI,GAAG,mBAAmB,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAA;IACvD,OAAO,CACL,8BACG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAC3B,KAAC,QAAQ,cAAgB,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAArD,IAAI,CAAC,EAAE,CAA0D,CACjF,CAAC,EACF,iBACE,IAAI,EAAC,kBAAkB,EACjB,CAAC,0BAA0B,CAAC,EAAE,EAAE,EACtC,uBAAuB,EAAE,EAAE,MAAM,EAAE,wBAAwB,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,GAC5E,IACD,CACJ,CAAA;AACH,CAAC","sourcesContent":["/**\n * `SectionSequence` (F15): renders a page family's sections in order. Server component, ported from Omega's\n * `Sequence` and `alternateTones`. Each section the page offers comes with its guard, computed from the same data it\n * renders (`faqsReady(props)`, `priceGuideReady(props)` …), so a section that would render nothing is skipped with\n * its reason without rendering twice. Then the cap drops optional sections first, the light tones alternate so\n * neighbours never share a background (at most one `band` and one `photo`), and the page's manifest is written as\n * `<script type=\"application/json\" data-bl-manifest>` (`SECTION_MANIFEST_ATTRIBUTE`) for K12: what was planned, what\n * rendered with which tone, and what was skipped and why.\n *\n * ```tsx\n * <SectionSequence\n * order={orders.service}\n * page=\"/tree-removal\"\n * sections={[\n * { id: 'hero', tone: 'photo', render: (tone) => <Hero tone={tone} {...hero} /> },\n * { id: 'price-guide', ready: priceGuideReady(guide), render: (tone) => <PriceGuide tone={tone} {...guide} /> },\n * { id: 'cta', tone: 'band', render: (tone) => <CTABand tone={tone} {...cta} /> },\n * ]}\n * />\n * ```\n */\nimport { Fragment, type ReactNode } from 'react'\nimport { sanitizeJsonLd } from '@brandlab-digital/site-core/schema'\nimport {\n SECTION_MANIFEST_ATTRIBUTE,\n planSectionSequence,\n type BrandlabFamilyOrder,\n type BrandlabSectionManifest,\n type BrandlabSequenceCandidate,\n} from '@brandlab-digital/site-core/section-order'\nimport type { SectionTone } from '../primitives/layout.js'\n\n/** One section the page offers: its matrix id, its guard, the tone it asks for and how to render it with a tone. */\nexport interface SequenceSection extends BrandlabSequenceCandidate {\n readonly render: (tone: SectionTone) => ReactNode\n}\n\nexport interface SectionSequenceProps {\n /** The family's order, from the site's `defineSectionOrder` (or `defaultSectionOrder`). */\n readonly order: BrandlabFamilyOrder\n readonly sections: readonly SequenceSection[]\n /** The page path, recorded in the manifest. */\n readonly page?: string\n}\n\n/** The manifest as safe inline JSON (no `<`, `>` or `&` that could close the script). */\nexport function serialiseSectionManifest(manifest: BrandlabSectionManifest): string {\n return sanitizeJsonLd(manifest)\n}\n\nexport function SectionSequence({ order, sections, page }: SectionSequenceProps) {\n const plan = planSectionSequence(order, sections, page)\n return (\n <>\n {plan.rendered.map((item) => (\n <Fragment key={item.id}>{sections[item.candidate]?.render(item.tone)}</Fragment>\n ))}\n <script\n type=\"application/json\"\n {...{ [SECTION_MANIFEST_ATTRIBUTE]: '' }}\n dangerouslySetInnerHTML={{ __html: serialiseSectionManifest(plan.manifest) }}\n />\n </>\n )\n}\n"]}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The footer's phone accordion (client island). Below 640 px of footer width each column's H2 shows its disclosure
3
+ * button and one column is open at a time; wider, every list shows and the buttons stay hidden. The headings, buttons
4
+ * and links are server HTML passed in as children; this island only toggles `hidden`, `aria-expanded` and
5
+ * `data-state`, so without scripts every link shows.
6
+ */
7
+ import { type ReactNode } from 'react';
8
+ export declare function SiteFooterColumns({ className, children }: {
9
+ readonly className?: string | undefined;
10
+ readonly children: ReactNode;
11
+ }): import("react/jsx-runtime").JSX.Element;
12
+ //# sourceMappingURL=site-footer-columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-footer-columns.d.ts","sourceRoot":"","sources":["../../../src/ui-kit/sections/site-footer-columns.tsx"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAEzD,wBAAgB,iBAAiB,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;CAAE,2CAsDnI"}
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * The footer's phone accordion (client island). Below 640 px of footer width each column's H2 shows its disclosure
5
+ * button and one column is open at a time; wider, every list shows and the buttons stay hidden. The headings, buttons
6
+ * and links are server HTML passed in as children; this island only toggles `hidden`, `aria-expanded` and
7
+ * `data-state`, so without scripts every link shows.
8
+ */
9
+ import { useEffect, useRef } from 'react';
10
+ export function SiteFooterColumns({ className, children }) {
11
+ const ref = useRef(null);
12
+ useEffect(() => {
13
+ const root = ref.current;
14
+ const footer = root?.closest('[data-bl="footer"]');
15
+ if (!root || !footer)
16
+ return;
17
+ const columns = Array.from(root.querySelectorAll('[data-slot="column"]'));
18
+ let narrow = false;
19
+ let open = null;
20
+ const render = () => {
21
+ root.dataset.accordion = String(narrow);
22
+ for (const column of columns) {
23
+ const expanded = narrow && column === open;
24
+ const toggle = column.querySelector('[data-slot="toggle"]');
25
+ const text = column.querySelector('[data-slot="headingText"]');
26
+ const list = column.querySelector('[data-slot="list"]');
27
+ if (toggle) {
28
+ toggle.hidden = !narrow;
29
+ toggle.setAttribute('aria-expanded', String(expanded));
30
+ }
31
+ if (text)
32
+ text.hidden = narrow;
33
+ if (list)
34
+ list.hidden = narrow && !expanded;
35
+ if (narrow)
36
+ column.dataset.state = expanded ? 'open' : 'closed';
37
+ else
38
+ delete column.dataset.state;
39
+ }
40
+ };
41
+ const onClick = (event) => {
42
+ const column = event.target?.closest('[data-slot="toggle"]')?.closest('[data-slot="column"]');
43
+ if (!narrow || !column)
44
+ return;
45
+ open = open === column ? null : column;
46
+ render();
47
+ };
48
+ const observer = new ResizeObserver(() => {
49
+ narrow = footer.getBoundingClientRect().width < 640;
50
+ if (!narrow)
51
+ open = null;
52
+ render();
53
+ });
54
+ observer.observe(footer);
55
+ root.addEventListener('click', onClick);
56
+ return () => {
57
+ observer.disconnect();
58
+ root.removeEventListener('click', onClick);
59
+ };
60
+ }, []);
61
+ return (_jsx("div", { ref: ref, "data-slot": "columns", className: className, children: children }));
62
+ }
63
+ //# sourceMappingURL=site-footer-columns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-footer-columns.js","sourceRoot":"","sources":["../../../src/ui-kit/sections/site-footer-columns.tsx"],"names":[],"mappings":"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","sourcesContent":["'use client'\n\n/**\n * The footer's phone accordion (client island). Below 640 px of footer width each column's H2 shows its disclosure\n * button and one column is open at a time; wider, every list shows and the buttons stay hidden. The headings, buttons\n * and links are server HTML passed in as children; this island only toggles `hidden`, `aria-expanded` and\n * `data-state`, so without scripts every link shows.\n */\nimport { useEffect, useRef, type ReactNode } from 'react'\n\nexport function SiteFooterColumns({ className, children }: { readonly className?: string | undefined; readonly children: ReactNode }) {\n const ref = useRef<HTMLDivElement>(null)\n\n useEffect(() => {\n const root = ref.current\n const footer = root?.closest<HTMLElement>('[data-bl=\"footer\"]')\n if (!root || !footer) return\n const columns = Array.from(root.querySelectorAll<HTMLElement>('[data-slot=\"column\"]'))\n let narrow = false\n let open: HTMLElement | null = null\n\n const render = () => {\n root.dataset.accordion = String(narrow)\n for (const column of columns) {\n const expanded = narrow && column === open\n const toggle = column.querySelector<HTMLElement>('[data-slot=\"toggle\"]')\n const text = column.querySelector<HTMLElement>('[data-slot=\"headingText\"]')\n const list = column.querySelector<HTMLElement>('[data-slot=\"list\"]')\n if (toggle) {\n toggle.hidden = !narrow\n toggle.setAttribute('aria-expanded', String(expanded))\n }\n if (text) text.hidden = narrow\n if (list) list.hidden = narrow && !expanded\n if (narrow) column.dataset.state = expanded ? 'open' : 'closed'\n else delete column.dataset.state\n }\n }\n\n const onClick = (event: MouseEvent) => {\n const column = (event.target as Element | null)?.closest('[data-slot=\"toggle\"]')?.closest<HTMLElement>('[data-slot=\"column\"]')\n if (!narrow || !column) return\n open = open === column ? null : column\n render()\n }\n\n const observer = new ResizeObserver(() => {\n narrow = footer.getBoundingClientRect().width < 640\n if (!narrow) open = null\n render()\n })\n observer.observe(footer)\n root.addEventListener('click', onClick)\n return () => {\n observer.disconnect()\n root.removeEventListener('click', onClick)\n }\n }, [])\n\n return (\n <div ref={ref} data-slot=\"columns\" className={className}>\n {children}\n </div>\n )\n}\n"]}
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type SkinEntry } from '../skin.js';
3
+ import type { UiLink, UiPhone, UiSocial } from '../types.js';
4
+ export declare const SITE_FOOTER_VARIANTS: readonly ["columns", "photo"];
5
+ export type SiteFooterVariant = (typeof SITE_FOOTER_VARIANTS)[number];
6
+ export type SiteFooterSlot = 'root' | 'panel' | 'media' | 'inner' | 'brand' | 'logo' | 'description' | 'licences' | 'licenceLink' | 'social' | 'socialLink' | 'contact' | 'phone' | 'phoneLabel' | 'phoneNumber' | 'email' | 'address' | 'columns' | 'column' | 'heading' | 'toggle' | 'list' | 'link' | 'bottom' | 'bottomInner' | 'legal' | 'policies' | 'policyLink';
7
+ export type SiteFooterSkin = SkinEntry<SiteFooterSlot, SiteFooterVariant>;
8
+ /** One licence line: the text as the register shows it, and the register link when there is one. */
9
+ export interface SiteFooterLicence {
10
+ readonly text: string;
11
+ readonly href?: string;
12
+ }
13
+ export interface SiteFooterColumn {
14
+ /** The H2 text, for example "Services". */
15
+ readonly heading: string;
16
+ readonly links: readonly UiLink[];
17
+ }
18
+ export interface SiteFooterProps extends SiteFooterSkin {
19
+ /** The logo link's accessible name. */
20
+ readonly brandName: string;
21
+ readonly homeHref?: string;
22
+ /** The site's logo node (decorative inside the named link). */
23
+ readonly logo: ReactNode;
24
+ /** Two or three lines about the business. */
25
+ readonly description: string;
26
+ readonly licences?: readonly SiteFooterLicence[];
27
+ readonly socials?: readonly UiSocial[];
28
+ readonly phone: UiPhone;
29
+ /** Optional words before the number, for example "Call us". */
30
+ readonly phoneLabel?: string;
31
+ readonly email?: string;
32
+ /** Only when the business shows its address publicly. */
33
+ readonly address?: string;
34
+ /** Services, Areas and Company, rendered in that order; leave one out when the site has nothing true for it. */
35
+ readonly columns: {
36
+ readonly services?: SiteFooterColumn;
37
+ readonly areas?: SiteFooterColumn;
38
+ readonly company?: SiteFooterColumn;
39
+ };
40
+ readonly legal: {
41
+ /** The legal or trading name. */
42
+ readonly name: string;
43
+ readonly abn?: string;
44
+ /** Defaults to the current year in Sydney. */
45
+ readonly year?: number;
46
+ /** The privacy policy link, labelled by the site. */
47
+ readonly privacy?: UiLink;
48
+ /** The terms link, labelled by the site. */
49
+ readonly terms?: UiLink;
50
+ };
51
+ /** The `photo` variant's backdrop image for the panel (decorative). */
52
+ readonly media?: ReactNode;
53
+ /** Signature art, rendered `aria-hidden`. */
54
+ readonly decoration?: ReactNode;
55
+ /** Prefix for the column list ids (default `bl-footer`). */
56
+ readonly idPrefix?: string;
57
+ }
58
+ export declare function SiteFooter({ brandName, homeHref, logo, description, licences, socials, phone, phoneLabel, email, address, columns, legal, media, decoration, idPrefix, variant, classes, }: SiteFooterProps): import("react/jsx-runtime").JSX.Element;
59
+ //# sourceMappingURL=site-footer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-footer.d.ts","sourceRoot":"","sources":["../../../src/ui-kit/sections/site-footer.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * S27 `SiteFooter`: the v1 §6 footer, structured the same on every site. Server component; one small client island
4
+ * (`SiteFooterColumns`) turns the columns into a phone accordion.
5
+ *
6
+ * Column 1: the logo, a short description, one line per licence (with its register link when given), social and
7
+ * Business Profile icons, then the phone (`phone_link_click` placement `footer`), the email and the address only when
8
+ * it is public. Then Services, Areas and Company, each under a real H2, in that order. A bottom bar: © year name ·
9
+ * ABN · the privacy and terms links. Never a quote button, "based in", opening hours, a guides column or a "back to
10
+ * top" text link: the props have no place for them.
11
+ *
12
+ * Variants: `columns`; `photo`, with the site's backdrop image for the panel in `media` (decorative, `aria-hidden`),
13
+ * for example Superior's sunlit water. Signature art goes in `decoration`.
14
+ */
15
+ import Link from 'next/link';
16
+ import { Icon, SocialIcon } from '../icons.js';
17
+ import { PhoneLink } from '../primitives/links.js';
18
+ import { slot } from '../skin.js';
19
+ import { SiteFooterColumns } from './site-footer-columns.js';
20
+ export const SITE_FOOTER_VARIANTS = ['columns', 'photo'];
21
+ const COLUMN_KEYS = ['services', 'areas', 'company'];
22
+ function isExternal(href) {
23
+ return /^https?:\/\//i.test(href);
24
+ }
25
+ function FooterLink({ link, hooks }) {
26
+ return isExternal(link.href) ? (_jsx("a", { href: link.href, ...hooks, target: "_blank", rel: "noopener noreferrer", children: link.label })) : (_jsx(Link, { href: link.href, ...hooks, children: link.label }));
27
+ }
28
+ function currentYear() {
29
+ return Number(new Intl.DateTimeFormat('en-AU', { year: 'numeric', timeZone: 'Australia/Sydney' }).format(new Date()));
30
+ }
31
+ export function SiteFooter({ brandName, homeHref = '/', logo, description, licences = [], socials = [], phone, phoneLabel, email, address, columns, legal, media, decoration, idPrefix = 'bl-footer', variant = 'columns', classes, }) {
32
+ const policies = [legal.privacy, legal.terms].filter((link) => !!link);
33
+ return (_jsxs("footer", { "data-bl": "footer", "data-variant": variant, className: classes?.root, children: [decoration ? (_jsx("div", { "data-slot": "decoration", "aria-hidden": "true", children: decoration })) : null, _jsxs("div", { ...slot(classes, 'panel'), children: [variant === 'photo' && media ? (_jsx("div", { ...slot(classes, 'media'), "aria-hidden": "true", children: media })) : null, _jsxs("div", { ...slot(classes, 'inner'), children: [_jsxs("div", { ...slot(classes, 'brand'), children: [_jsx(Link, { href: homeHref, ...slot(classes, 'logo'), "aria-label": brandName, children: logo }), _jsx("p", { ...slot(classes, 'description'), children: description }), licences.length > 0 ? (_jsx("ul", { ...slot(classes, 'licences'), children: licences.map((licence) => (_jsx("li", { children: licence.href ? (_jsx("a", { ...slot(classes, 'licenceLink'), href: licence.href, target: "_blank", rel: "noopener noreferrer", children: licence.text })) : (licence.text) }, licence.text))) })) : null, socials.length > 0 ? (_jsx("ul", { ...slot(classes, 'social'), children: socials.map((social) => (_jsx("li", { children: _jsx("a", { ...slot(classes, 'socialLink'), href: social.href, "aria-label": social.label, target: "_blank", rel: "noopener noreferrer", children: _jsx(SocialIcon, { kind: social.kind }) }) }, social.href))) })) : null, _jsxs("div", { ...slot(classes, 'contact'), children: [_jsx(PhoneLink, { phone: phone, placement: "footer", label: phoneLabel, classes: { root: classes?.phone, label: classes?.phoneLabel, number: classes?.phoneNumber } }), email ? (_jsx("a", { ...slot(classes, 'email'), href: `mailto:${email}`, children: email })) : null, address ? _jsx("p", { ...slot(classes, 'address'), children: address }) : null] })] }), _jsx(SiteFooterColumns, { className: classes?.columns, children: COLUMN_KEYS.map((key) => {
34
+ const column = columns[key];
35
+ if (!column || column.links.length === 0)
36
+ return null;
37
+ const listId = `${idPrefix}-${key}`;
38
+ return (_jsxs("div", { ...slot(classes, 'column'), "data-column": key, children: [_jsxs("h2", { ...slot(classes, 'heading'), children: [_jsx("span", { "data-slot": "headingText", children: column.heading }), _jsxs("button", { type: "button", ...slot(classes, 'toggle'), "aria-expanded": "false", "aria-controls": listId, hidden: true, children: [_jsx("span", { children: column.heading }), _jsx(Icon, { name: "chevron" })] })] }), _jsx("ul", { id: listId, ...slot(classes, 'list'), children: column.links.map((link) => (_jsx("li", { children: _jsx(FooterLink, { link: link, hooks: slot(classes, 'link') }) }, link.href))) })] }, key));
39
+ }) })] })] }), _jsx("div", { ...slot(classes, 'bottom'), children: _jsxs("div", { ...slot(classes, 'bottomInner'), children: [_jsxs("p", { ...slot(classes, 'legal'), children: ["\u00A9 ", legal.year ?? currentYear(), " ", legal.name, legal.abn ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: " \u00B7 " }), _jsxs("span", { children: ["ABN ", legal.abn] })] })) : null] }), policies.length > 0 ? (_jsx("ul", { ...slot(classes, 'policies'), children: policies.map((link) => (_jsx("li", { children: _jsx(FooterLink, { link: link, hooks: slot(classes, 'policyLink') }) }, link.href))) })) : null] }) })] }));
40
+ }
41
+ //# sourceMappingURL=site-footer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-footer.js","sourceRoot":"","sources":["../../../src/ui-kit/sections/site-footer.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * S27 `SiteFooter`: the v1 §6 footer, structured the same on every site. Server component; one small client island\n * (`SiteFooterColumns`) turns the columns into a phone accordion.\n *\n * Column 1: the logo, a short description, one line per licence (with its register link when given), social and\n * Business Profile icons, then the phone (`phone_link_click` placement `footer`), the email and the address only when\n * it is public. Then Services, Areas and Company, each under a real H2, in that order. A bottom bar: © year name ·\n * ABN · the privacy and terms links. Never a quote button, \"based in\", opening hours, a guides column or a \"back to\n * top\" text link: the props have no place for them.\n *\n * Variants: `columns`; `photo`, with the site's backdrop image for the panel in `media` (decorative, `aria-hidden`),\n * for example Superior's sunlit water. Signature art goes in `decoration`.\n */\nimport Link from 'next/link'\nimport type { ReactNode } from 'react'\nimport { Icon, SocialIcon } from '../icons.js'\nimport { PhoneLink } from '../primitives/links.js'\nimport { slot, type SkinEntry } from '../skin.js'\nimport type { UiLink, UiPhone, UiSocial } from '../types.js'\nimport { SiteFooterColumns } from './site-footer-columns.js'\n\nexport const SITE_FOOTER_VARIANTS = ['columns', 'photo'] as const\nexport type SiteFooterVariant = (typeof SITE_FOOTER_VARIANTS)[number]\n\nexport type SiteFooterSlot =\n | 'root'\n | 'panel'\n | 'media'\n | 'inner'\n | 'brand'\n | 'logo'\n | 'description'\n | 'licences'\n | 'licenceLink'\n | 'social'\n | 'socialLink'\n | 'contact'\n | 'phone'\n | 'phoneLabel'\n | 'phoneNumber'\n | 'email'\n | 'address'\n | 'columns'\n | 'column'\n | 'heading'\n | 'toggle'\n | 'list'\n | 'link'\n | 'bottom'\n | 'bottomInner'\n | 'legal'\n | 'policies'\n | 'policyLink'\n\nexport type SiteFooterSkin = SkinEntry<SiteFooterSlot, SiteFooterVariant>\n\n/** One licence line: the text as the register shows it, and the register link when there is one. */\nexport interface SiteFooterLicence {\n readonly text: string\n readonly href?: string\n}\n\nexport interface SiteFooterColumn {\n /** The H2 text, for example \"Services\". */\n readonly heading: string\n readonly links: readonly UiLink[]\n}\n\nexport interface SiteFooterProps extends SiteFooterSkin {\n /** The logo link's accessible name. */\n readonly brandName: string\n readonly homeHref?: string\n /** The site's logo node (decorative inside the named link). */\n readonly logo: ReactNode\n /** Two or three lines about the business. */\n readonly description: string\n readonly licences?: readonly SiteFooterLicence[]\n readonly socials?: readonly UiSocial[]\n readonly phone: UiPhone\n /** Optional words before the number, for example \"Call us\". */\n readonly phoneLabel?: string\n readonly email?: string\n /** Only when the business shows its address publicly. */\n readonly address?: string\n /** Services, Areas and Company, rendered in that order; leave one out when the site has nothing true for it. */\n readonly columns: {\n readonly services?: SiteFooterColumn\n readonly areas?: SiteFooterColumn\n readonly company?: SiteFooterColumn\n }\n readonly legal: {\n /** The legal or trading name. */\n readonly name: string\n readonly abn?: string\n /** Defaults to the current year in Sydney. */\n readonly year?: number\n /** The privacy policy link, labelled by the site. */\n readonly privacy?: UiLink\n /** The terms link, labelled by the site. */\n readonly terms?: UiLink\n }\n /** The `photo` variant's backdrop image for the panel (decorative). */\n readonly media?: ReactNode\n /** Signature art, rendered `aria-hidden`. */\n readonly decoration?: ReactNode\n /** Prefix for the column list ids (default `bl-footer`). */\n readonly idPrefix?: string\n}\n\nconst COLUMN_KEYS = ['services', 'areas', 'company'] as const\n\nfunction isExternal(href: string) {\n return /^https?:\\/\\//i.test(href)\n}\n\nfunction FooterLink({ link, hooks }: { link: UiLink; hooks?: { 'data-slot': string; className: string | undefined } }) {\n return isExternal(link.href) ? (\n <a href={link.href} {...hooks} target=\"_blank\" rel=\"noopener noreferrer\">\n {link.label}\n </a>\n ) : (\n <Link href={link.href} {...hooks}>\n {link.label}\n </Link>\n )\n}\n\nfunction currentYear() {\n return Number(new Intl.DateTimeFormat('en-AU', { year: 'numeric', timeZone: 'Australia/Sydney' }).format(new Date()))\n}\n\nexport function SiteFooter({\n brandName,\n homeHref = '/',\n logo,\n description,\n licences = [],\n socials = [],\n phone,\n phoneLabel,\n email,\n address,\n columns,\n legal,\n media,\n decoration,\n idPrefix = 'bl-footer',\n variant = 'columns',\n classes,\n}: SiteFooterProps) {\n const policies = [legal.privacy, legal.terms].filter((link): link is UiLink => !!link)\n return (\n <footer data-bl=\"footer\" data-variant={variant} className={classes?.root}>\n {decoration ? (\n <div data-slot=\"decoration\" aria-hidden=\"true\">\n {decoration}\n </div>\n ) : null}\n <div {...slot(classes, 'panel')}>\n {variant === 'photo' && media ? (\n <div {...slot(classes, 'media')} aria-hidden=\"true\">\n {media}\n </div>\n ) : null}\n <div {...slot(classes, 'inner')}>\n <div {...slot(classes, 'brand')}>\n <Link href={homeHref} {...slot(classes, 'logo')} aria-label={brandName}>\n {logo}\n </Link>\n <p {...slot(classes, 'description')}>{description}</p>\n {licences.length > 0 ? (\n <ul {...slot(classes, 'licences')}>\n {licences.map((licence) => (\n <li key={licence.text}>\n {licence.href ? (\n <a {...slot(classes, 'licenceLink')} href={licence.href} target=\"_blank\" rel=\"noopener noreferrer\">\n {licence.text}\n </a>\n ) : (\n licence.text\n )}\n </li>\n ))}\n </ul>\n ) : null}\n {socials.length > 0 ? (\n <ul {...slot(classes, 'social')}>\n {socials.map((social) => (\n <li key={social.href}>\n <a {...slot(classes, 'socialLink')} href={social.href} aria-label={social.label} target=\"_blank\" rel=\"noopener noreferrer\">\n <SocialIcon kind={social.kind} />\n </a>\n </li>\n ))}\n </ul>\n ) : null}\n <div {...slot(classes, 'contact')}>\n <PhoneLink phone={phone} placement=\"footer\" label={phoneLabel} classes={{ root: classes?.phone, label: classes?.phoneLabel, number: classes?.phoneNumber }} />\n {email ? (\n <a {...slot(classes, 'email')} href={`mailto:${email}`}>\n {email}\n </a>\n ) : null}\n {address ? <p {...slot(classes, 'address')}>{address}</p> : null}\n </div>\n </div>\n <SiteFooterColumns className={classes?.columns}>\n {COLUMN_KEYS.map((key) => {\n const column = columns[key]\n if (!column || column.links.length === 0) return null\n const listId = `${idPrefix}-${key}`\n return (\n <div key={key} {...slot(classes, 'column')} data-column={key}>\n <h2 {...slot(classes, 'heading')}>\n <span data-slot=\"headingText\">{column.heading}</span>\n <button type=\"button\" {...slot(classes, 'toggle')} aria-expanded=\"false\" aria-controls={listId} hidden>\n <span>{column.heading}</span>\n <Icon name=\"chevron\" />\n </button>\n </h2>\n <ul id={listId} {...slot(classes, 'list')}>\n {column.links.map((link) => (\n <li key={link.href}>\n <FooterLink link={link} hooks={slot(classes, 'link')} />\n </li>\n ))}\n </ul>\n </div>\n )\n })}\n </SiteFooterColumns>\n </div>\n </div>\n <div {...slot(classes, 'bottom')}>\n <div {...slot(classes, 'bottomInner')}>\n <p {...slot(classes, 'legal')}>\n © {legal.year ?? currentYear()} {legal.name}\n {legal.abn ? (\n <>\n <span aria-hidden=\"true\"> · </span>\n <span>ABN {legal.abn}</span>\n </>\n ) : null}\n </p>\n {policies.length > 0 ? (\n <ul {...slot(classes, 'policies')}>\n {policies.map((link) => (\n <li key={link.href}>\n <FooterLink link={link} hooks={slot(classes, 'policyLink')} />\n </li>\n ))}\n </ul>\n ) : null}\n </div>\n </div>\n </footer>\n )\n}\n"]}