@akinon/pz-theme 2.0.37-beta.0 → 2.0.37

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 (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,126 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { BlockRendererProps } from './block-renderer-registry';
7
+ import {
8
+ listingPlaceholderStyle,
9
+ readToggle,
10
+ useListingContext
11
+ } from './listing-context';
12
+
13
+ /**
14
+ * Functional listing block: the "N products" line. The number is
15
+ * commerce-driven (the pagination count of the category/search response, not
16
+ * the number of cards on the current page); the editor owns the wording,
17
+ * the singular/empty variants and whether the number is its own element.
18
+ *
19
+ * Parts: text, count.
20
+ * Properties: template, oneTemplate, zeroTemplate, groupThousands,
21
+ * highlightCount.
22
+ */
23
+ const ListingResultCountBlock = ({
24
+ block,
25
+ currentBreakpoint = 'desktop',
26
+ isDesigner = false
27
+ }: BlockRendererProps) => {
28
+ const listing = useListingContext();
29
+ const { locale } = useLocalization();
30
+
31
+ const template = String(
32
+ getResponsiveValue(
33
+ block.properties?.template,
34
+ currentBreakpoint,
35
+ '{count} products'
36
+ )
37
+ );
38
+ const oneTemplate = String(
39
+ getResponsiveValue(block.properties?.oneTemplate, currentBreakpoint, '')
40
+ );
41
+ const zeroTemplate = String(
42
+ getResponsiveValue(block.properties?.zeroTemplate, currentBreakpoint, '')
43
+ );
44
+ const groupThousands = readToggle(
45
+ block.properties?.groupThousands,
46
+ currentBreakpoint,
47
+ false
48
+ );
49
+ const highlightCount = readToggle(
50
+ block.properties?.highlightCount,
51
+ currentBreakpoint,
52
+ false
53
+ );
54
+
55
+ if (!listing) {
56
+ return isDesigner ? (
57
+ <div data-listing-result-count="" style={listingPlaceholderStyle}>
58
+ Result count (no listing data)
59
+ </div>
60
+ ) : null;
61
+ }
62
+
63
+ const count = listing.resultCount ?? 0;
64
+
65
+ // The locale is explicit: an implicit Intl locale resolves to the Node
66
+ // default on the server and the browser's on the client, so the grouped
67
+ // number would differ between SSR and hydration.
68
+ let formatted = String(count);
69
+ if (groupThousands) {
70
+ try {
71
+ formatted = new Intl.NumberFormat(locale || 'tr-TR').format(count);
72
+ } catch {
73
+ formatted = String(count);
74
+ }
75
+ }
76
+
77
+ // A zero/one wording only applies when the brand supplied one, so the
78
+ // plural template stays the single source by default.
79
+ const activeTemplate =
80
+ count === 0 && zeroTemplate
81
+ ? zeroTemplate
82
+ : count === 1 && oneTemplate
83
+ ? oneTemplate
84
+ : template;
85
+
86
+ const hasPlaceholder = activeTemplate.includes('{count}');
87
+ // A template without the placeholder must still show the number, and it
88
+ // reads as a count only in front of the wording ("24 products").
89
+ const text = hasPlaceholder
90
+ ? activeTemplate.replace('{count}', formatted)
91
+ : `${formatted} ${activeTemplate}`.trim();
92
+
93
+ const rootProps = {
94
+ 'data-listing-result-count': '',
95
+ 'data-listing-result-total': count,
96
+ ...partAttrs('text', { empty: count === 0 })
97
+ };
98
+
99
+ if (!highlightCount) {
100
+ return <span {...rootProps}>{text}</span>;
101
+ }
102
+
103
+ const countNode = (
104
+ <span {...partAttrs('count', { empty: count === 0 })}>{formatted}</span>
105
+ );
106
+
107
+ if (!hasPlaceholder) {
108
+ return (
109
+ <span {...rootProps}>
110
+ {countNode} {activeTemplate}
111
+ </span>
112
+ );
113
+ }
114
+
115
+ const placeholderIndex = activeTemplate.indexOf('{count}');
116
+
117
+ return (
118
+ <span {...rootProps}>
119
+ {activeTemplate.slice(0, placeholderIndex)}
120
+ {countNode}
121
+ {activeTemplate.slice(placeholderIndex + '{count}'.length)}
122
+ </span>
123
+ );
124
+ };
125
+
126
+ export default ListingResultCountBlock;
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import {
7
+ listingPlaceholderStyle,
8
+ readToggle,
9
+ useListingContext
10
+ } from './listing-context';
11
+
12
+ const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'div', 'span'];
13
+
14
+ /**
15
+ * Functional listing block: the category/search page title. The text is
16
+ * commerce-driven (category name, or "Search results for …" on search);
17
+ * the editor owns the heading level, the optional result count and styling.
18
+ *
19
+ * Parts: title, count.
20
+ * Properties: tag, fallbackTitle, titleTemplate, maxLength, showCount,
21
+ * countTemplate, countPosition.
22
+ */
23
+ const ListingTitleBlock = ({
24
+ block,
25
+ currentBreakpoint = 'desktop',
26
+ isDesigner = false
27
+ }: BlockRendererProps) => {
28
+ const listing = useListingContext();
29
+
30
+ const requestedTag = String(
31
+ getResponsiveValue(block.properties?.tag, currentBreakpoint, 'h1')
32
+ );
33
+ const Tag = (
34
+ HEADING_TAGS.includes(requestedTag) ? requestedTag : 'h1'
35
+ ) as 'h1';
36
+ const showCount = readToggle(
37
+ block.properties?.showCount,
38
+ currentBreakpoint,
39
+ false
40
+ );
41
+ const countTemplate = String(
42
+ getResponsiveValue(
43
+ block.properties?.countTemplate,
44
+ currentBreakpoint,
45
+ '({count})'
46
+ )
47
+ );
48
+ const countPosition = String(
49
+ getResponsiveValue(block.properties?.countPosition, currentBreakpoint, 'after')
50
+ );
51
+ const fallbackTitle = String(
52
+ getResponsiveValue(block.properties?.fallbackTitle, currentBreakpoint, '')
53
+ );
54
+ const titleTemplate = String(
55
+ getResponsiveValue(
56
+ block.properties?.titleTemplate,
57
+ currentBreakpoint,
58
+ '{title}'
59
+ )
60
+ );
61
+ const maxLengthRaw = Math.round(
62
+ Number(getResponsiveValue(block.properties?.maxLength, currentBreakpoint, 0))
63
+ );
64
+ // 0 keeps the full title; anything else is a hard character cap.
65
+ const maxLength = Number.isFinite(maxLengthRaw) && maxLengthRaw > 0 ? maxLengthRaw : 0;
66
+ const ellipsis = String(
67
+ getResponsiveValue(block.properties?.ellipsis, currentBreakpoint, '…')
68
+ );
69
+
70
+ // A search page with no commerce title still needs a heading, so the brand
71
+ // can supply one; empty by default, which keeps the old "render nothing".
72
+ const sourceTitle = listing?.title || fallbackTitle;
73
+
74
+ if (!sourceTitle) {
75
+ return isDesigner ? (
76
+ <div data-listing-title="" style={listingPlaceholderStyle}>
77
+ Listing title (no listing data)
78
+ </div>
79
+ ) : null;
80
+ }
81
+
82
+ const truncated =
83
+ maxLength > 0 && sourceTitle.length > maxLength
84
+ ? `${sourceTitle.slice(0, maxLength).trimEnd()}${ellipsis}`
85
+ : sourceTitle;
86
+ const title = titleTemplate.includes('{title}')
87
+ ? titleTemplate.replace('{title}', truncated)
88
+ : truncated;
89
+
90
+ const resultCount = listing?.resultCount ?? 0;
91
+ const countLabel = countTemplate.includes('{count}')
92
+ ? countTemplate.replace('{count}', String(resultCount))
93
+ : `${countTemplate} ${resultCount}`.trim();
94
+ const showCountBefore = countPosition === 'before';
95
+
96
+ const countNode = showCount ? (
97
+ <span
98
+ data-listing-title-count=""
99
+ {...partAttrs('count', { empty: resultCount === 0 })}
100
+ style={showCountBefore ? { marginRight: '8px' } : { marginLeft: '8px' }}
101
+ >
102
+ {countLabel}
103
+ </span>
104
+ ) : null;
105
+
106
+ return (
107
+ <Tag data-listing-title="" {...partAttrs('title')} style={{ margin: 0 }}>
108
+ {showCountBefore && countNode}
109
+ {title}
110
+ {!showCountBefore && countNode}
111
+ </Tag>
112
+ );
113
+ };
114
+
115
+ export default ListingTitleBlock;
@@ -0,0 +1,205 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+ import { getResponsiveValue, partAttrs } from '../utils';
6
+ import { DesignerPlaceholder, readToggle } from './product-block-utils';
7
+ import { hasItemTemplate, ItemTemplateList } from './item-template';
8
+ import { BlockRendererProps } from './block-renderer-registry';
9
+
10
+ /**
11
+ * Social sign-in on v1 is NOT a next-auth social provider. Brands register only
12
+ * credential providers (`default` for login/registration, `oauth` to adopt an
13
+ * already-established commerce session), so `signIn('google')` would resolve to
14
+ * no provider and bounce to the error page.
15
+ *
16
+ * The real flow is a plain navigation to `/{provider}/login/`, which the core
17
+ * `withOauthLogin` middleware rewrites to `{commerceUrl}/{provider}/login/`.
18
+ * Commerce performs the handshake, sets `osessionid`, and sends the shopper
19
+ * back through `/auth/oauth-login`, where the core component calls
20
+ * `signIn('oauth')` to mint the next-auth session. A real link (rather than a
21
+ * button + router.push) also matters because the middleware stores the return
22
+ * url from the `referer` header that the browser only sends on a navigation.
23
+ */
24
+ const providerHref = (provider: string) => `/${provider}/login/`;
25
+
26
+ /** Brands configure providers commerce-side, so the label is derived. */
27
+ const providerLabel = (provider: string) =>
28
+ provider.charAt(0).toUpperCase() + provider.slice(1);
29
+
30
+ /**
31
+ * `google=Continue with Google,apple=Sign in with Apple`. A provider with no
32
+ * entry keeps the derived label, so an empty setting reproduces the built-in
33
+ * capitalised names.
34
+ */
35
+ const parseProviderLabels = (raw: string): Record<string, string> => {
36
+ const overrides: Record<string, string> = {};
37
+
38
+ raw
39
+ .split(',')
40
+ .map((entry) => entry.trim())
41
+ .filter(Boolean)
42
+ .forEach((entry) => {
43
+ const separator = entry.indexOf('=');
44
+ if (separator <= 0) return;
45
+
46
+ const key = entry.slice(0, separator).trim().toLowerCase();
47
+ const label = entry.slice(separator + 1).trim();
48
+
49
+ if (key && label) overrides[key] = label;
50
+ });
51
+
52
+ return overrides;
53
+ };
54
+
55
+ /**
56
+ * Functional auth block: social login buttons. Which providers exist is a
57
+ * commerce-side setting with no storefront manifest to read, so the author
58
+ * lists the ones the brand actually enabled; nothing is rendered for an empty
59
+ * list rather than guessing.
60
+ * Properties: providers, providerLabels, layout, equalWidth, showIcons,
61
+ * emptyLabel.
62
+ * Parts: list, button-{provider}, label, icon, icon-{provider}. Buttons are
63
+ * addressed per provider because Google and Apple both mandate their own
64
+ * button styling, and one shared part could not satisfy both.
65
+ */
66
+ const OauthButtonsBlock = ({
67
+ block,
68
+ placeholderId,
69
+ sectionId,
70
+ selectedBlockId = null,
71
+ currentBreakpoint = 'desktop',
72
+ isDesigner = false
73
+ }: BlockRendererProps) => {
74
+ const prop = (key: string, fallback: string) =>
75
+ String(
76
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
77
+ );
78
+
79
+ const rawProviders = prop('providers', 'google,apple,facebook');
80
+ const layout = prop('layout', 'row') === 'column' ? 'column' : 'row';
81
+ const labelOverrides = parseProviderLabels(prop('providerLabels', ''));
82
+ const showIcons = readToggle(
83
+ block.properties?.showIcons,
84
+ currentBreakpoint,
85
+ false
86
+ );
87
+ const equalWidth = readToggle(
88
+ block.properties?.equalWidth,
89
+ currentBreakpoint,
90
+ true
91
+ );
92
+ const emptyLabel = prop('emptyLabel', 'Social login (no providers listed)');
93
+
94
+ const providers = rawProviders
95
+ .split(',')
96
+ .map((entry) => entry.trim().toLowerCase())
97
+ // The value becomes a url segment, so anything that is not a plain slug is
98
+ // dropped instead of being interpolated into the path.
99
+ .filter((entry) => /^[a-z0-9-]+$/.test(entry));
100
+
101
+ // A designer who marked a child as the button layout gets that layout
102
+ // repeated per provider; otherwise the built-in button renders unchanged.
103
+ const customButtons = hasItemTemplate(block, currentBreakpoint) ? (
104
+ <ItemTemplateList
105
+ block={block}
106
+ items={providers.map((provider) => ({
107
+ provider,
108
+ label: labelOverrides[provider] || providerLabel(provider),
109
+ name: labelOverrides[provider] || providerLabel(provider),
110
+ url: providerHref(provider)
111
+ }))}
112
+ placeholderId={placeholderId}
113
+ sectionId={sectionId}
114
+ isDesigner={isDesigner}
115
+ selectedBlockId={selectedBlockId}
116
+ currentBreakpoint={currentBreakpoint}
117
+ />
118
+ ) : null;
119
+
120
+ if (providers.length === 0 && !customButtons) {
121
+ return isDesigner ? (
122
+ <DesignerPlaceholder
123
+ label={emptyLabel}
124
+ attribute="data-oauth-buttons"
125
+ />
126
+ ) : null;
127
+ }
128
+
129
+ return (
130
+ <div
131
+ data-oauth-buttons=""
132
+ data-oauth-buttons-layout={layout}
133
+ {...partAttrs('list')}
134
+ style={{
135
+ display: 'flex',
136
+ flexDirection: layout === 'column' ? 'column' : 'row',
137
+ flexWrap: layout === 'row' ? 'wrap' : 'nowrap',
138
+ gap: 'var(--oauth-buttons-gap, 12px)'
139
+ }}
140
+ >
141
+ {customButtons ??
142
+ providers.map((provider) => (
143
+ <NextLink
144
+ key={provider}
145
+ data-oauth-button={provider}
146
+ {...partAttrs(`button-${provider}`)}
147
+ href={isDesigner ? '#' : providerHref(provider)}
148
+ // The handshake leaves the Next.js app entirely, so the client router
149
+ // must not try to prefetch or soft-navigate it.
150
+ prefetch={false}
151
+ onClick={(event) => {
152
+ if (isDesigner) event.preventDefault();
153
+ }}
154
+ style={{
155
+ display: 'inline-flex',
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ gap: '8px',
159
+ flex:
160
+ layout === 'row' ? (equalWidth ? '1 1 0' : '0 0 auto') : '0 0 auto',
161
+ padding: '10px 16px',
162
+ border: '1px solid var(--oauth-buttons-border, #d1d5db)',
163
+ color: 'inherit',
164
+ textDecoration: 'none',
165
+ cursor: 'pointer'
166
+ }}
167
+ >
168
+ {showIcons && (
169
+ // Two nested boxes on purpose: the outer one is the shared size
170
+ // slot every provider agrees on, the inner one is where a brand
171
+ // drops that provider's own mark.
172
+ <span
173
+ {...partAttrs('icon')}
174
+ aria-hidden="true"
175
+ style={{
176
+ display: 'inline-flex',
177
+ flex: '0 0 auto',
178
+ width: '18px',
179
+ height: '18px'
180
+ }}
181
+ >
182
+ <span
183
+ data-oauth-icon={provider}
184
+ {...partAttrs(`icon-${provider}`)}
185
+ style={{
186
+ display: 'block',
187
+ width: '100%',
188
+ height: '100%',
189
+ backgroundSize: 'contain',
190
+ backgroundRepeat: 'no-repeat',
191
+ backgroundPosition: 'center'
192
+ }}
193
+ />
194
+ </span>
195
+ )}
196
+ <span {...partAttrs('label')}>
197
+ {labelOverrides[provider] || providerLabel(provider)}
198
+ </span>
199
+ </NextLink>
200
+ ))}
201
+ </div>
202
+ );
203
+ };
204
+
205
+ export default OauthButtonsBlock;