@laconius/cms 0.1.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 (45) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +26 -0
  3. package/lib/module/adapter.js +136 -0
  4. package/lib/module/adapter.js.map +1 -0
  5. package/lib/module/config.js +105 -0
  6. package/lib/module/config.js.map +1 -0
  7. package/lib/module/defaults.js +282 -0
  8. package/lib/module/defaults.js.map +1 -0
  9. package/lib/module/index.js +10 -0
  10. package/lib/module/index.js.map +1 -0
  11. package/lib/module/models.js +29 -0
  12. package/lib/module/models.js.map +1 -0
  13. package/lib/module/normalizer.js +114 -0
  14. package/lib/module/normalizer.js.map +1 -0
  15. package/lib/module/package.json +1 -0
  16. package/lib/module/page.js +223 -0
  17. package/lib/module/page.js.map +1 -0
  18. package/lib/module/queries.js +54 -0
  19. package/lib/module/queries.js.map +1 -0
  20. package/lib/typescript/package.json +1 -0
  21. package/lib/typescript/src/adapter.d.ts +25 -0
  22. package/lib/typescript/src/adapter.d.ts.map +1 -0
  23. package/lib/typescript/src/config.d.ts +84 -0
  24. package/lib/typescript/src/config.d.ts.map +1 -0
  25. package/lib/typescript/src/defaults.d.ts +64 -0
  26. package/lib/typescript/src/defaults.d.ts.map +1 -0
  27. package/lib/typescript/src/index.d.ts +8 -0
  28. package/lib/typescript/src/index.d.ts.map +1 -0
  29. package/lib/typescript/src/models.d.ts +123 -0
  30. package/lib/typescript/src/models.d.ts.map +1 -0
  31. package/lib/typescript/src/normalizer.d.ts +70 -0
  32. package/lib/typescript/src/normalizer.d.ts.map +1 -0
  33. package/lib/typescript/src/page.d.ts +62 -0
  34. package/lib/typescript/src/page.d.ts.map +1 -0
  35. package/lib/typescript/src/queries.d.ts +193 -0
  36. package/lib/typescript/src/queries.d.ts.map +1 -0
  37. package/package.json +69 -0
  38. package/src/adapter.ts +159 -0
  39. package/src/config.ts +119 -0
  40. package/src/defaults.tsx +281 -0
  41. package/src/index.ts +82 -0
  42. package/src/models.ts +120 -0
  43. package/src/normalizer.ts +159 -0
  44. package/src/page.tsx +228 -0
  45. package/src/queries.ts +56 -0
package/src/adapter.ts ADDED
@@ -0,0 +1,159 @@
1
+ import {
2
+ absolutizeMedia,
3
+ getMediaBaseUrl,
4
+ type Converter,
5
+ type LaconiusRuntime,
6
+ } from '@laconius/core';
7
+
8
+ import type { CmsComponent, CmsStructure, PageContext } from './models';
9
+ import { HOME_PAGE_CONTEXT } from './models';
10
+ import { normalizeCmsPage, type OccCmsPage } from './normalizer';
11
+
12
+ /** OCC's own ceiling for one `componentIds` request. */
13
+ export const MAX_COMPONENTS_PER_REQUEST = 50;
14
+
15
+ /** How long ids collect before a batch goes out. One frame is enough to catch a whole slot. */
16
+ const BATCH_WINDOW_MS = 10;
17
+
18
+ export type CmsAdapter = {
19
+ loadPage(runtime: LaconiusRuntime, context: PageContext): Promise<CmsStructure>;
20
+ loadComponents(
21
+ runtime: LaconiusRuntime,
22
+ ids: string[],
23
+ context?: PageContext,
24
+ ): Promise<CmsComponent[]>;
25
+ };
26
+
27
+ /**
28
+ * `users/{userId}/cms/*` is the default; `capabilities.userScopedCms: false` selects the legacy
29
+ * unscoped form through the endpoint's `legacy` scope ([chapter 03](../../../docs/spec/03-occ-layer.md)).
30
+ */
31
+ function scopeFor(runtime: LaconiusRuntime): string {
32
+ return runtime.config.capabilities?.userScopedCms === false ? 'legacy' : 'default';
33
+ }
34
+
35
+ /**
36
+ * OCC queries pages by a combination of type, label and code: a `ContentPage` by
37
+ * `pageLabelOrId`, everything else by `code`. The homepage sentinel sends no params at all —
38
+ * that is how the backend is asked for "whatever the base site's homepage is".
39
+ */
40
+ function pageParams(context: PageContext): Record<string, string | undefined> {
41
+ if (context.id === HOME_PAGE_CONTEXT) return {};
42
+ return {
43
+ pageType: context.type,
44
+ ...(context.type === 'ContentPage' || !context.type
45
+ ? { pageLabelOrId: context.id }
46
+ : { code: context.id }),
47
+ };
48
+ }
49
+
50
+ /** Product and category pages scope their components; content pages do not. */
51
+ function componentParams(context?: PageContext): Record<string, string | undefined> {
52
+ if (context?.type === 'ProductPage') return { productCode: context.id };
53
+ if (context?.type === 'CategoryPage') return { categoryCode: context.id };
54
+ if (context?.type === 'CatalogPage') return { catalogCode: context.id };
55
+ return {};
56
+ }
57
+
58
+ export const defaultCmsAdapter: CmsAdapter = {
59
+ async loadPage(runtime, context) {
60
+ const scope = scopeFor(runtime);
61
+ // The homepage sentinel is not an id: `cms/pages/__HOMEPAGE__` is a 400, and `cmsPage` is
62
+ // configured by default, so without this the sentinel branch of `pageParams` is unreachable.
63
+ const useById =
64
+ context.id !== HOME_PAGE_CONTEXT &&
65
+ !context.type &&
66
+ runtime.client.endpoints.isConfigured('cmsPage', scope);
67
+ const payload = await runtime.client.request<OccCmsPage>({
68
+ endpoint: useById ? 'cmsPage' : 'cmsPages',
69
+ scope,
70
+ urlParams: useById ? { id: context.id } : undefined,
71
+ queryParams: useById ? undefined : pageParams(context),
72
+ });
73
+ const structure = runtime.converters.convert<OccCmsPage, CmsStructure>(payload, 'cmsPage');
74
+ return absolutizeMedia(structure, getMediaBaseUrl(runtime.config));
75
+ },
76
+
77
+ async loadComponents(runtime, ids, context) {
78
+ const response = await runtime.client.request<{ component?: unknown[] }>({
79
+ endpoint: 'cmsComponents',
80
+ scope: scopeFor(runtime),
81
+ queryParams: {
82
+ componentIds: ids.join(','),
83
+ pageSize: ids.length,
84
+ currentPage: 0,
85
+ ...componentParams(context),
86
+ },
87
+ });
88
+ const components = runtime.converters.convertAll<unknown, CmsComponent>(
89
+ response?.component,
90
+ 'cmsComponent',
91
+ );
92
+ return absolutizeMedia(components, getMediaBaseUrl(runtime.config));
93
+ },
94
+ };
95
+
96
+ export function cmsAdapter(runtime: LaconiusRuntime): CmsAdapter {
97
+ return { ...defaultCmsAdapter, ...runtime.config.backend.adapters?.cms };
98
+ }
99
+
100
+ type Pending = {
101
+ uid: string;
102
+ resolve: (component: CmsComponent) => void;
103
+ reject: (error: unknown) => void;
104
+ };
105
+
106
+ let queue: Pending[] = [];
107
+ let timer: ReturnType<typeof setTimeout> | undefined;
108
+
109
+ /**
110
+ * Debounce-batched component loading, chunked at OCC's limit.
111
+ *
112
+ * A slot with twelve unloaded components issues one request, not twelve — the reason Spartacus
113
+ * batches too. The batch is keyed by nothing: components are page-independent once the page
114
+ * context is applied, and the queue only ever lives for one frame.
115
+ */
116
+ export function loadCmsComponent(
117
+ runtime: LaconiusRuntime,
118
+ uid: string,
119
+ context?: PageContext,
120
+ ): Promise<CmsComponent> {
121
+ return new Promise<CmsComponent>((resolve, reject) => {
122
+ queue.push({ uid, resolve, reject });
123
+ timer ??= setTimeout(() => {
124
+ const batch = queue;
125
+ queue = [];
126
+ timer = undefined;
127
+ void flush(runtime, batch, context);
128
+ }, BATCH_WINDOW_MS);
129
+ });
130
+ }
131
+
132
+ async function flush(
133
+ runtime: LaconiusRuntime,
134
+ batch: Pending[],
135
+ context?: PageContext,
136
+ ): Promise<void> {
137
+ const ids = [...new Set(batch.map((entry) => entry.uid))];
138
+ for (let index = 0; index < ids.length; index += MAX_COMPONENTS_PER_REQUEST) {
139
+ const chunk = ids.slice(index, index + MAX_COMPONENTS_PER_REQUEST);
140
+ const waiting = batch.filter((entry) => chunk.includes(entry.uid));
141
+ try {
142
+ const components = await cmsAdapter(runtime).loadComponents(runtime, chunk, context);
143
+ const byUid = new Map(components.map((component) => [component.uid, component]));
144
+ for (const entry of waiting) {
145
+ const component = byUid.get(entry.uid);
146
+ if (component) entry.resolve(component);
147
+ else entry.reject(new Error(`[laconius] CMS component "${entry.uid}" was not returned.`));
148
+ }
149
+ } catch (error) {
150
+ for (const entry of waiting) entry.reject(error);
151
+ }
152
+ }
153
+ }
154
+
155
+ /** Converters are registered by explicit spread, so a default can be dropped by not spreading it. */
156
+ export const defaultCmsConverters = {
157
+ cmsPage: [normalizeCmsPage as Converter<OccCmsPage, CmsStructure>],
158
+ cmsComponent: [] as Converter<unknown, CmsComponent>[],
159
+ };
package/src/config.ts ADDED
@@ -0,0 +1,119 @@
1
+ import type { LaconiusConfigChunk } from '@laconius/core';
2
+ import type { ComponentType } from 'react';
3
+
4
+ import { defaultCmsConverters, type CmsAdapter } from './adapter';
5
+ import {
6
+ CmsBanner,
7
+ CmsBannerCarousel,
8
+ CmsLink,
9
+ CmsNavigation,
10
+ CmsParagraph,
11
+ } from './defaults';
12
+ import type { CmsLinkTarget } from './models';
13
+
14
+ /** The registry value. Spartacus' `guards`, `i18nKeys`, `deferLoading` and `childRoutes` are dropped. */
15
+ export type CmsComponentMapping = { component: ComponentType<any> };
16
+
17
+ declare module '@laconius/ui' {
18
+ /** The CMS defaults are replaceable through the UI registry like every other component. */
19
+ interface LaconiusUiComponents {
20
+ Banner: unknown;
21
+ Link: unknown;
22
+ Paragraph: unknown;
23
+ NavigationList: unknown;
24
+ Carousel: unknown;
25
+ }
26
+ }
27
+
28
+ declare module '@laconius/core' {
29
+ interface LaconiusCmsConfig {
30
+ /**
31
+ * Backend type code -> component. The key space is **open**, unlike `ui.components`: these
32
+ * keys are whatever the content catalog contains
33
+ * ([ADR-0007](../../../docs/adr/0007-two-registries-two-contracts.md)).
34
+ */
35
+ components?: Record<string, CmsComponentMapping>;
36
+ /** Slot order per page template. Client configuration, not backend data. */
37
+ layouts?: Record<string, { slots: string[] }>;
38
+ /** Where a pressed CMS link goes. Without it, the default components warn and do nothing. */
39
+ onNavigate?: (target: CmsLinkTarget) => void;
40
+ }
41
+
42
+ interface LaconiusEndpoints {
43
+ cmsPages: string | Record<string, string>;
44
+ cmsPage: string | Record<string, string>;
45
+ cmsComponents: string | Record<string, string>;
46
+ cmsComponent: string | Record<string, string>;
47
+ }
48
+
49
+ interface LaconiusConverters {
50
+ cmsPage: unknown;
51
+ cmsComponent: unknown;
52
+ }
53
+
54
+ interface LaconiusAdapters {
55
+ cms: CmsAdapter;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The `default` scope is the user-scoped form, mandatory from 221121.7; `legacy` is selected by
61
+ * `capabilities.userScopedCms: false` ([chapter 03](../../../docs/spec/03-occ-layer.md)).
62
+ */
63
+ export const defaultCmsEndpoints = {
64
+ cmsPages: {
65
+ default: 'users/${userId}/cms/pages',
66
+ legacy: 'cms/pages',
67
+ },
68
+ cmsPage: {
69
+ default: 'users/${userId}/cms/pages/${id}',
70
+ legacy: 'cms/pages/${id}',
71
+ },
72
+ cmsComponents: {
73
+ default: 'users/${userId}/cms/components?fields=DEFAULT',
74
+ legacy: 'cms/components?fields=DEFAULT',
75
+ },
76
+ cmsComponent: {
77
+ default: 'users/${userId}/cms/components/${id}',
78
+ legacy: 'cms/components/${id}',
79
+ },
80
+ };
81
+
82
+ /**
83
+ * Tier-1 content types. `@laconius/product`, `@laconius/cart` and `@laconius/user` register the
84
+ * ones that read their domain data, through this same chunk.
85
+ */
86
+ export const defaultCmsComponents: Record<string, CmsComponentMapping> = {
87
+ SimpleBannerComponent: { component: CmsBanner },
88
+ BannerComponent: { component: CmsBanner },
89
+ SimpleResponsiveBannerComponent: { component: CmsBanner },
90
+ CMSLinkComponent: { component: CmsLink },
91
+ CMSParagraphComponent: { component: CmsParagraph },
92
+ CMSTabParagraphComponent: { component: CmsParagraph },
93
+ NavigationComponent: { component: CmsNavigation },
94
+ FooterNavigationComponent: { component: CmsNavigation },
95
+ CategoryNavigationComponent: { component: CmsNavigation },
96
+ RotatingImagesComponent: { component: CmsBannerCarousel },
97
+ };
98
+
99
+ /**
100
+ * Slot order for the stock templates, ported from
101
+ * `core-libs/storefront/recipes/config/layout-config.ts:32` minus the web chrome slots (header,
102
+ * navigation, footer) — the app's chrome is native. `app-site.impex` adds its own templates at M7.
103
+ */
104
+ export const defaultCmsLayouts: Record<string, { slots: string[] }> = {
105
+ LandingPage2Template: {
106
+ slots: ['Section1', 'Section2A', 'Section2B', 'Section2C', 'Section3', 'Section4', 'Section5'],
107
+ },
108
+ ContentPage1Template: { slots: ['Section2A', 'Section2B'] },
109
+ CategoryPageTemplate: { slots: ['Section1', 'Section2', 'Section3'] },
110
+ ProductDetailsPageTemplate: { slots: ['Summary', 'Tabs', 'UpSelling', 'CrossSelling'] },
111
+ SearchResultsListPageTemplate: { slots: ['Section2', 'SearchResultsListSlot', 'Section4'] },
112
+ CartPageTemplate: { slots: ['TopContent', 'CenterRightContentSlot', 'EmptyCartMiddleContent'] },
113
+ };
114
+
115
+ export const defaultCmsConfig: LaconiusConfigChunk = {
116
+ backend: { occ: { endpoints: defaultCmsEndpoints } },
117
+ cms: { components: defaultCmsComponents, layouts: defaultCmsLayouts },
118
+ converters: defaultCmsConverters,
119
+ };
@@ -0,0 +1,281 @@
1
+ import { useLaconiusConfig, type MediaContainer } from '@laconius/core';
2
+ import { Media, Text, useStyles, useTheme, useUiComponent, type Theme } from '@laconius/ui';
3
+ import { useCallback } from 'react';
4
+ import {
5
+ FlatList,
6
+ Pressable,
7
+ StyleSheet,
8
+ View,
9
+ type StyleProp,
10
+ type TextStyle,
11
+ type ViewStyle,
12
+ } from 'react-native';
13
+
14
+ import type {
15
+ CmsBannerData,
16
+ CmsCarouselData,
17
+ CmsLinkData,
18
+ CmsLinkTarget,
19
+ CmsNavigationData,
20
+ CmsNavigationNode,
21
+ CmsParagraphData,
22
+ } from './models';
23
+ import { resolveLinkTarget, stripHtml } from './normalizer';
24
+ import { CmsComponentByUid, useCmsComponentData } from './page';
25
+
26
+ const warned = new Set<string>();
27
+
28
+ function warnOnce(message: string): void {
29
+ if (process.env.NODE_ENV === 'production' || warned.has(message)) return;
30
+ warned.add(message);
31
+ console.warn(`[laconius] ${message}`);
32
+ }
33
+
34
+ /**
35
+ * Where a CMS link goes. No Laconius package imports `expo-router` or navigates
36
+ * ([chapter 01](../../../docs/spec/01-packages.md)), so the app hands the engine one function and
37
+ * keeps its own routing.
38
+ */
39
+ export function useCmsNavigate(): (target: CmsLinkTarget | undefined) => void {
40
+ const config = useLaconiusConfig();
41
+ const onNavigate = config.cms?.onNavigate;
42
+ return useCallback(
43
+ (target) => {
44
+ if (!target) return;
45
+ if (!onNavigate) {
46
+ warnOnce('A CMS link was pressed but cms.onNavigate is not configured; nothing happened.');
47
+ return;
48
+ }
49
+ onNavigate(target);
50
+ },
51
+ [onNavigate],
52
+ );
53
+ }
54
+
55
+ /** HTML with no DOM: the configured renderer, or tags stripped to a `<Text>`. */
56
+ export function CmsHtml({ html, style }: { html?: string; style?: StyleProp<TextStyle> }) {
57
+ const config = useLaconiusConfig();
58
+ const Renderer = config.cms?.htmlRenderer;
59
+ if (!html) return null;
60
+ if (Renderer) return <Renderer html={html} />;
61
+ return <Text style={style}>{stripHtml(html)}</Text>;
62
+ }
63
+
64
+ /* ------------------------------------------------------------------ presentational primitives */
65
+
66
+ export type BannerProps = {
67
+ title?: string;
68
+ subtitle?: string;
69
+ media?: MediaContainer;
70
+ onPress?: () => void;
71
+ style?: StyleProp<ViewStyle>;
72
+ styles?: { container?: StyleProp<ViewStyle>; title?: StyleProp<TextStyle> };
73
+ };
74
+
75
+ export function Banner({ title, subtitle, media, onPress, style, styles }: BannerProps) {
76
+ const sheet = useStyles(createStyles);
77
+ const body = (
78
+ <View style={[sheet.banner, styles?.container, style]}>
79
+ {media ? <Media media={media} mediaRole="product" aspectRatio="banner" decorative={!title} /> : null}
80
+ {title ? (
81
+ <Text variant="subheading" style={[sheet.bannerTitle, styles?.title]}>
82
+ {title}
83
+ </Text>
84
+ ) : null}
85
+ {subtitle ? <Text muted>{stripHtml(subtitle)}</Text> : null}
86
+ </View>
87
+ );
88
+ if (!onPress) return body;
89
+ return (
90
+ <Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={title}>
91
+ {body}
92
+ </Pressable>
93
+ );
94
+ }
95
+
96
+ export type LinkProps = {
97
+ label: string;
98
+ onPress?: () => void;
99
+ style?: StyleProp<ViewStyle>;
100
+ };
101
+
102
+ export function Link({ label, onPress, style }: LinkProps) {
103
+ const sheet = useStyles(createStyles);
104
+ return (
105
+ <Pressable
106
+ onPress={onPress}
107
+ style={[sheet.link, style]}
108
+ accessibilityRole="link"
109
+ accessibilityLabel={label}
110
+ hitSlop={8}
111
+ >
112
+ <Text variant="label">{label}</Text>
113
+ </Pressable>
114
+ );
115
+ }
116
+
117
+ export type ParagraphProps = {
118
+ title?: string;
119
+ /** HTML, as OCC stores it. */
120
+ content?: string;
121
+ style?: StyleProp<ViewStyle>;
122
+ };
123
+
124
+ export function Paragraph({ title, content, style }: ParagraphProps) {
125
+ const sheet = useStyles(createStyles);
126
+ return (
127
+ <View style={[sheet.paragraph, style]}>
128
+ {title ? <Text variant="subheading">{title}</Text> : null}
129
+ <CmsHtml html={content} />
130
+ </View>
131
+ );
132
+ }
133
+
134
+ export type NavigationListProps = {
135
+ node?: CmsNavigationNode;
136
+ onSelect?: (node: CmsNavigationNode) => void;
137
+ style?: StyleProp<ViewStyle>;
138
+ };
139
+
140
+ /**
141
+ * One level of a navigation tree. Deeper trees are app chrome — a drawer, a tab bar — built from
142
+ * `useCmsNavigation(uid)`, which is why this default stays a flat list.
143
+ */
144
+ export function NavigationList({ node, onSelect, style }: NavigationListProps) {
145
+ const sheet = useStyles(createStyles);
146
+ const children = node?.children ?? [];
147
+ if (children.length === 0) return null;
148
+ return (
149
+ <View style={[sheet.navigation, style]}>
150
+ {children.map((child, index) => (
151
+ <Link
152
+ key={child.uid ?? index}
153
+ label={child.title ?? ''}
154
+ onPress={onSelect ? () => onSelect(child) : undefined}
155
+ />
156
+ ))}
157
+ </View>
158
+ );
159
+ }
160
+
161
+ export type CarouselProps<T> = {
162
+ items: T[];
163
+ renderItem: (item: T, index: number) => React.ReactElement | null;
164
+ itemWidth?: number;
165
+ title?: string;
166
+ style?: StyleProp<ViewStyle>;
167
+ };
168
+
169
+ /** A horizontal `FlatList` with snapping. The first thing to cut if it proves expensive. */
170
+ export function Carousel<T>({ items, renderItem, itemWidth, title, style }: CarouselProps<T>) {
171
+ const theme = useTheme();
172
+ const sheet = useStyles(createStyles);
173
+ return (
174
+ <View style={style}>
175
+ {title ? (
176
+ <Text variant="subheading" style={sheet.carouselTitle}>
177
+ {title}
178
+ </Text>
179
+ ) : null}
180
+ <FlatList
181
+ horizontal
182
+ data={items}
183
+ keyExtractor={(_item, index) => String(index)}
184
+ renderItem={({ item, index }) => (
185
+ <View style={itemWidth === undefined ? undefined : { width: itemWidth }}>
186
+ {renderItem(item, index)}
187
+ </View>
188
+ )}
189
+ showsHorizontalScrollIndicator={false}
190
+ snapToInterval={itemWidth}
191
+ decelerationRate="fast"
192
+ contentContainerStyle={{ gap: theme.spacing.md, paddingHorizontal: theme.spacing.md }}
193
+ />
194
+ </View>
195
+ );
196
+ }
197
+
198
+ /* ------------------------------------------------------------------------- the CMS wrappers */
199
+
200
+ export function CmsBanner() {
201
+ const data = useCmsComponentData<CmsBannerData>();
202
+ const navigate = useCmsNavigate();
203
+ const target = resolveLinkTarget(data);
204
+ const Component = useUiComponent<BannerProps>('Banner', Banner);
205
+ return (
206
+ <Component
207
+ title={data.headline ? stripHtml(data.headline) : undefined}
208
+ subtitle={data.content}
209
+ media={data.media}
210
+ onPress={target ? () => navigate(target) : undefined}
211
+ />
212
+ );
213
+ }
214
+
215
+ export function CmsLink() {
216
+ const data = useCmsComponentData<CmsLinkData>();
217
+ const navigate = useCmsNavigate();
218
+ const target = resolveLinkTarget(data);
219
+ const Component = useUiComponent<LinkProps>('Link', Link);
220
+ return (
221
+ <Component
222
+ label={data.linkName ?? data.name ?? ''}
223
+ onPress={target ? () => navigate(target) : undefined}
224
+ />
225
+ );
226
+ }
227
+
228
+ export function CmsParagraph() {
229
+ const data = useCmsComponentData<CmsParagraphData>();
230
+ const Component = useUiComponent<ParagraphProps>('Paragraph', Paragraph);
231
+ return <Component title={data.title} content={data.content} />;
232
+ }
233
+
234
+ export function CmsNavigation() {
235
+ const data = useCmsComponentData<CmsNavigationData>();
236
+ const navigate = useCmsNavigate();
237
+ const Component = useUiComponent<NavigationListProps>('NavigationList', NavigationList);
238
+ return (
239
+ <Component
240
+ node={data.navigationNode}
241
+ onSelect={(node) => navigate(entryTarget(node))}
242
+ />
243
+ );
244
+ }
245
+
246
+ /**
247
+ * A navigation node points at a page through its first entry: `itemId` plus an `itemSuperType`
248
+ * that says what kind of thing it is.
249
+ */
250
+ function entryTarget(node: CmsNavigationNode): CmsLinkTarget | undefined {
251
+ const entry = node.entries?.[0];
252
+ if (!entry?.itemId) return undefined;
253
+ if (entry.itemSuperType === 'AbstractPage') return { kind: 'contentPage', label: entry.itemId };
254
+ if (entry.itemSuperType === 'Product') return { kind: 'product', code: entry.itemId };
255
+ if (entry.itemSuperType === 'Category') return { kind: 'category', code: entry.itemId };
256
+ return undefined;
257
+ }
258
+
259
+ export function CmsBannerCarousel() {
260
+ const data = useCmsComponentData<CmsCarouselData>();
261
+ const Component = useUiComponent<CarouselProps<string>>('Carousel', Carousel);
262
+ const uids = (data.banners ?? '').split(' ').filter(Boolean);
263
+ if (uids.length === 0) return null;
264
+ return (
265
+ <Component
266
+ items={uids}
267
+ itemWidth={280}
268
+ renderItem={(uid) => <CmsComponentByUid uid={uid} />}
269
+ />
270
+ );
271
+ }
272
+
273
+ const createStyles = (theme: Theme) =>
274
+ StyleSheet.create({
275
+ banner: { gap: theme.spacing.sm, padding: theme.spacing.md },
276
+ bannerTitle: { marginTop: theme.spacing.xs },
277
+ link: { paddingVertical: theme.spacing.sm, minHeight: 44, justifyContent: 'center' },
278
+ paragraph: { gap: theme.spacing.sm, padding: theme.spacing.md },
279
+ navigation: { paddingHorizontal: theme.spacing.md },
280
+ carouselTitle: { paddingHorizontal: theme.spacing.md, marginBottom: theme.spacing.sm },
281
+ });
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ export {
2
+ HOME_PAGE_CONTEXT,
3
+ type CmsBannerData,
4
+ type CmsCarouselData,
5
+ type CmsComponent,
6
+ type CmsLinkData,
7
+ type CmsLinkTarget,
8
+ type CmsNavigationData,
9
+ type CmsNavigationNode,
10
+ type CmsPageStructure,
11
+ type CmsParagraphData,
12
+ type CmsStructure,
13
+ type ContentSlot,
14
+ type ContentSlotComponent,
15
+ type PageContext,
16
+ type PageType,
17
+ } from './models';
18
+
19
+ export {
20
+ getComponentKey,
21
+ normalizeCmsPage,
22
+ resolveLinkTarget,
23
+ stripHtml,
24
+ type OccCmsPage,
25
+ } from './normalizer';
26
+
27
+ export {
28
+ MAX_COMPONENTS_PER_REQUEST,
29
+ defaultCmsAdapter,
30
+ defaultCmsConverters,
31
+ loadCmsComponent,
32
+ type CmsAdapter,
33
+ } from './adapter';
34
+
35
+ export {
36
+ cmsQueries,
37
+ useCmsComponent,
38
+ useCmsNavigation,
39
+ useCmsPage,
40
+ } from './queries';
41
+
42
+ export {
43
+ CmsComponentByUid,
44
+ CmsComponentOutlet,
45
+ CmsPage,
46
+ CmsPageProvider,
47
+ CmsSlot,
48
+ useCmsComponentData,
49
+ useCmsComponentUid,
50
+ useCmsPageStructure,
51
+ type CmsPageProps,
52
+ type CmsPageProviderProps,
53
+ type CmsSlotProps,
54
+ } from './page';
55
+
56
+ export {
57
+ Banner,
58
+ Carousel,
59
+ CmsBanner,
60
+ CmsBannerCarousel,
61
+ CmsHtml,
62
+ CmsLink,
63
+ CmsNavigation,
64
+ CmsParagraph,
65
+ Link,
66
+ NavigationList,
67
+ Paragraph,
68
+ useCmsNavigate,
69
+ type BannerProps,
70
+ type CarouselProps,
71
+ type LinkProps,
72
+ type NavigationListProps,
73
+ type ParagraphProps,
74
+ } from './defaults';
75
+
76
+ export {
77
+ defaultCmsComponents,
78
+ defaultCmsConfig,
79
+ defaultCmsEndpoints,
80
+ defaultCmsLayouts,
81
+ type CmsComponentMapping,
82
+ } from './config';