@akinon/pz-theme 2.0.74-rc.0 → 2.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,36 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.74-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.74-rc.0
33
- - @akinon/theme-contract@2.0.74-rc.0
3
+ ## 2.0.74
4
+
5
+ ### Patch Changes
6
+
7
+ - e03dbf7c: Add coupon-wallet block: a two-column (active/used) coupon list for the account coupons page, selectable from the theme editor as an alternative to the default page — same takeover mechanism as the order detail page.
8
+ - @akinon/next@2.0.74
9
+ - @akinon/theme-contract@2.0.74
34
10
 
35
11
  ## 2.0.73
36
12
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.74-rc.0",
4
+ "version": "2.0.74",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.74-rc.0",
11
+ "@akinon/next": "2.0.74",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.74-rc.0",
16
+ "@akinon/theme-contract": "2.0.74",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.74-rc.0",
23
+ "@akinon/next": "2.0.74",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -45,6 +45,7 @@ const EXPECTED_BLOCK_TYPES = [
45
45
  'countdown',
46
46
  'counter',
47
47
  'coupon-list',
48
+ 'coupon-wallet',
48
49
  'currency-selector',
49
50
  'divider',
50
51
  'embed',
@@ -49,6 +49,7 @@ import ProfileFormBlock from './profile-form-block';
49
49
  import ChangePasswordFormBlock from './change-password-form-block';
50
50
  import OrderDetailBlock from './order-detail-block';
51
51
  import CouponListBlock from './coupon-list-block';
52
+ import CouponWalletBlock from './coupon-wallet-block';
52
53
  import FavouritesGridBlock from './favourites-grid-block';
53
54
  import MegaMenuBlock from './mega-menu-block';
54
55
  import CurrencySelectorBlock from './currency-selector-block';
@@ -164,6 +165,7 @@ blockRendererRegistry.register('profile-form', ProfileFormBlock);
164
165
  blockRendererRegistry.register('change-password-form', ChangePasswordFormBlock);
165
166
  blockRendererRegistry.register('order-detail', OrderDetailBlock);
166
167
  blockRendererRegistry.register('coupon-list', CouponListBlock);
168
+ blockRendererRegistry.register('coupon-wallet', CouponWalletBlock);
167
169
  blockRendererRegistry.register('favourites-grid', FavouritesGridBlock);
168
170
 
169
171
  // Chrome extras
@@ -180,13 +182,22 @@ blockRendererRegistry.register('checkout-steps', CheckoutStepsBlock);
180
182
  blockRendererRegistry.register('order-success', OrderSuccessBlock);
181
183
  blockRendererRegistry.register('order-tracking', OrderTrackingBlock);
182
184
  blockRendererRegistry.register('checkout-flow-outlet', CheckoutFlowOutletBlock);
183
- blockRendererRegistry.register('checkout-steps-outlet', CheckoutStepsOutletBlock);
184
- blockRendererRegistry.register('checkout-summary-outlet', CheckoutSummaryOutletBlock);
185
+ blockRendererRegistry.register(
186
+ 'checkout-steps-outlet',
187
+ CheckoutStepsOutletBlock
188
+ );
189
+ blockRendererRegistry.register(
190
+ 'checkout-summary-outlet',
191
+ CheckoutSummaryOutletBlock
192
+ );
185
193
 
186
194
  // Catalog extras
187
195
  blockRendererRegistry.register('product-breadcrumb', ProductBreadcrumbBlock);
188
196
  blockRendererRegistry.register('product-attributes', ProductAttributesBlock);
189
- blockRendererRegistry.register('product-recommendations', ProductRecommendationsBlock);
197
+ blockRendererRegistry.register(
198
+ 'product-recommendations',
199
+ ProductRecommendationsBlock
200
+ );
190
201
  blockRendererRegistry.register('panel', PanelBlock);
191
202
  // Legacy alias: published widget JSON still carries `size-guide`, and the AI
192
203
  // generator manifest still emits it. Same renderer, same parts, same markup.
@@ -0,0 +1,348 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useSession } from 'next-auth/react';
5
+ import {
6
+ useGetBasketOffersQuery,
7
+ useGetDiscountItemsQuery
8
+ } from '@akinon/next/data/client/account';
9
+ import { useLocalization } from '@akinon/next/hooks';
10
+ import { getResponsiveValue, partAttrs } from '../utils';
11
+ import { formatDate, formatMoney } from './commerce-format';
12
+ import { BlockRendererProps } from './block-renderer-registry';
13
+ import { DesignerPlaceholder, readToggle } from './product-block-utils';
14
+
15
+ interface CouponLike {
16
+ pk?: number;
17
+ label?: string;
18
+ voucher_code?: string;
19
+ amount?: string;
20
+ percentage?: string | number;
21
+ // 'fixed' -> amount is a currency value; 'percentage' -> percentage is the
22
+ // discount rate. Missing/unknown falls back to "whichever field is set".
23
+ benefit_type?: string;
24
+ start_datetime?: string;
25
+ end_datetime?: string;
26
+ created_date?: string;
27
+ order_number?: string;
28
+ }
29
+
30
+ /**
31
+ * The active-coupon endpoint answers with `{ discounts: [...] }` while some
32
+ * payloads answer with the paginated `{ results: [...] }` shape. Both are
33
+ * probed (plus a bare array) so one reader covers every response — same
34
+ * approach as coupon-list-block.
35
+ */
36
+ const readCoupons = (payload: unknown): CouponLike[] => {
37
+ if (Array.isArray(payload)) return payload as CouponLike[];
38
+
39
+ const record = (payload ?? {}) as Record<string, unknown>;
40
+
41
+ if (Array.isArray(record.discounts)) return record.discounts as CouponLike[];
42
+ if (Array.isArray(record.results)) return record.results as CouponLike[];
43
+
44
+ return [];
45
+ };
46
+
47
+ /**
48
+ * Functional account block: the shopper's coupon wallet split into two
49
+ * columns — active and used — on a CSS grid with two equal `1fr` tracks
50
+ * (collapsing to one track when only one side is shown), so the split stays
51
+ * exactly 50/50 regardless of which side has more rows, fewer rows, or none
52
+ * at all — a grid track's size is set by the track definition, not by the
53
+ * content inside it, which is what a flex `flex-basis` split can't guarantee
54
+ * once one side is empty. Stacked full-width on mobile. "Active" reads the
55
+ * basket-offers endpoint; "used" reads the discount-items endpoint (a coupon
56
+ * that was actually redeemed on an order), which is a different fact than
57
+ * "expired" (coupon-list-block's distinction).
58
+ * The column title's HTML tag is fixed (h2) — its visual style is a
59
+ * `column-title` typography preset (font, size, weight in one step), applied
60
+ * from the editor, not a per-block property.
61
+ * Properties: activeTitle, showActive, activeEmptyLabel, usedTitle, showUsed,
62
+ * usedEmptyLabel, limit, showDate, showCode, showAmount, percentagePrefix,
63
+ * termsLinkLabel, termsHref, termsLinkTarget.
64
+ * Parts: wrapper, column (active/used via data-coupon-wallet-column),
65
+ * column-title, list, item (state: disabled, for a used/redeemed row), date,
66
+ * title, code, amount, terms-link, empty.
67
+ */
68
+ const CouponWalletBlock = ({
69
+ block,
70
+ currentBreakpoint = 'desktop',
71
+ isDesigner = false
72
+ }: BlockRendererProps) => {
73
+ const showActive = readToggle(
74
+ block.properties?.showActive,
75
+ currentBreakpoint,
76
+ true
77
+ );
78
+ const showUsed = readToggle(
79
+ block.properties?.showUsed,
80
+ currentBreakpoint,
81
+ true
82
+ );
83
+ const showCode = readToggle(
84
+ block.properties?.showCode,
85
+ currentBreakpoint,
86
+ true
87
+ );
88
+ const showAmount = readToggle(
89
+ block.properties?.showAmount,
90
+ currentBreakpoint,
91
+ true
92
+ );
93
+ const showDate = readToggle(
94
+ block.properties?.showDate,
95
+ currentBreakpoint,
96
+ true
97
+ );
98
+ const text = (key: string, fallback: string) =>
99
+ String(
100
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
101
+ );
102
+ const activeTitle = text('activeTitle', 'Active Coupons');
103
+ const usedTitle = text('usedTitle', 'Used Coupons');
104
+ const percentagePrefix = text('percentagePrefix', '%');
105
+ const activeEmptyLabel = text('activeEmptyLabel', '');
106
+ const usedEmptyLabel = text('usedEmptyLabel', '');
107
+ const termsLinkLabel = text('termsLinkLabel', '');
108
+ const termsHref = text('termsHref', '');
109
+ const termsLinkTarget = text('termsLinkTarget', '_self');
110
+ const rawLimit = Math.round(
111
+ Number(getResponsiveValue(block.properties?.limit, currentBreakpoint, 0))
112
+ );
113
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
114
+
115
+ const isMobile = currentBreakpoint === 'mobile';
116
+ const { locale, currency } = useLocalization();
117
+ const { status } = useSession();
118
+ const isAuthenticated = status === 'authenticated';
119
+
120
+ // Guests have no coupon wallet; skipping avoids a guaranteed 403 on every
121
+ // page that hosts this block.
122
+ const { data: activeData, isLoading: activeLoading } =
123
+ useGetBasketOffersQuery(undefined, {
124
+ skip: !isAuthenticated || !showActive
125
+ });
126
+ const { data: usedData, isLoading: usedLoading } = useGetDiscountItemsQuery(
127
+ undefined,
128
+ { skip: !isAuthenticated || !showUsed }
129
+ );
130
+
131
+ const activeCoupons = readCoupons(activeData);
132
+ const usedCoupons = readCoupons(usedData);
133
+ const visibleActive =
134
+ limit > 0 ? activeCoupons.slice(0, limit) : activeCoupons;
135
+ const visibleUsed = limit > 0 ? usedCoupons.slice(0, limit) : usedCoupons;
136
+
137
+ /** The one value a row actually prices at, driven by `benefit_type`. */
138
+ const describeValue = (coupon: CouponLike): string => {
139
+ const amount = formatMoney(coupon.amount, { locale, currency });
140
+ const percentage =
141
+ coupon.percentage !== undefined &&
142
+ coupon.percentage !== null &&
143
+ String(coupon.percentage).trim()
144
+ ? `${percentagePrefix}${coupon.percentage}`
145
+ : '';
146
+ const benefitType = String(coupon.benefit_type || '').toLowerCase();
147
+
148
+ if (benefitType === 'fixed') return amount;
149
+ if (benefitType === 'percentage') return percentage;
150
+
151
+ // Payload omitted benefit_type: fall back to whichever field is set.
152
+ return percentage || amount;
153
+ };
154
+
155
+ const guard = (event: React.MouseEvent) => {
156
+ if (isDesigner) event.preventDefault();
157
+ };
158
+
159
+ const renderTermsLink = () =>
160
+ termsLinkLabel ? (
161
+ <a
162
+ href={isDesigner ? '#' : termsHref || '#'}
163
+ target={isDesigner ? undefined : termsLinkTarget}
164
+ rel={termsLinkTarget === '_blank' ? 'noreferrer' : undefined}
165
+ onClick={guard}
166
+ data-coupon-wallet-terms-link=""
167
+ {...partAttrs('terms-link')}
168
+ style={{
169
+ display: 'inline-block',
170
+ marginTop: '8px',
171
+ color: 'inherit',
172
+ textDecoration: 'none'
173
+ }}
174
+ >
175
+ → {termsLinkLabel}
176
+ </a>
177
+ ) : null;
178
+
179
+ const renderColumn = (
180
+ title: string,
181
+ coupons: CouponLike[],
182
+ isUsed: boolean,
183
+ emptyLabel: string,
184
+ loading: boolean
185
+ ) => (
186
+ // A column with nothing to show still renders its shell (title + empty
187
+ // state) so the grid keeps both tracks — see the block doc comment above
188
+ // for why the split is a grid track, not a flex-basis split.
189
+ <div
190
+ data-coupon-wallet-column={isUsed ? 'used' : 'active'}
191
+ {...partAttrs('column')}
192
+ style={{ minWidth: 0 }}
193
+ >
194
+ <h2
195
+ data-coupon-wallet-column-title=""
196
+ {...partAttrs('column-title')}
197
+ style={{ marginBottom: '24px' }}
198
+ >
199
+ {title}
200
+ </h2>
201
+
202
+ {coupons.length > 0 ? (
203
+ <ul
204
+ data-coupon-wallet-list=""
205
+ {...partAttrs('list')}
206
+ style={{
207
+ display: 'flex',
208
+ flexDirection: 'column',
209
+ gap: 'var(--coupon-wallet-item-gap, 24px)',
210
+ listStyle: 'none',
211
+ margin: 0,
212
+ padding: 0
213
+ }}
214
+ >
215
+ {coupons.map((coupon, index) => {
216
+ const value = describeValue(coupon);
217
+
218
+ return (
219
+ <li
220
+ key={coupon.pk ?? `${coupon.voucher_code}-${index}`}
221
+ data-coupon-wallet-item=""
222
+ data-coupon-wallet-item-used={isUsed ? 'true' : 'false'}
223
+ {...partAttrs('item', { disabled: isUsed })}
224
+ style={{ opacity: isUsed ? 0.5 : 1 }}
225
+ >
226
+ {showDate && (
227
+ <span
228
+ data-coupon-wallet-date=""
229
+ {...partAttrs('date')}
230
+ style={{
231
+ display: 'inline-block',
232
+ padding: '4px 10px',
233
+ marginBottom: '12px',
234
+ background: 'var(--coupon-wallet-date-bg, #efece3)',
235
+ fontSize: '12px'
236
+ }}
237
+ >
238
+ {formatDate(
239
+ isUsed ? coupon.created_date : coupon.end_datetime,
240
+ locale
241
+ )}
242
+ </span>
243
+ )}
244
+ <div
245
+ style={{
246
+ display: 'flex',
247
+ alignItems: 'baseline',
248
+ flexWrap: 'wrap',
249
+ gap: '8px'
250
+ }}
251
+ >
252
+ <span
253
+ data-coupon-wallet-title=""
254
+ {...partAttrs('title')}
255
+ style={{ fontWeight: 600, textTransform: 'uppercase' }}
256
+ >
257
+ {coupon.label}
258
+ </span>
259
+ {showCode && coupon.voucher_code && (
260
+ <span
261
+ data-coupon-wallet-code=""
262
+ {...partAttrs('code')}
263
+ style={{ letterSpacing: '0.05em' }}
264
+ >
265
+ {coupon.voucher_code}
266
+ </span>
267
+ )}
268
+ {showAmount && value && (
269
+ <span
270
+ data-coupon-wallet-amount=""
271
+ {...partAttrs('amount')}
272
+ style={{ opacity: 0.7 }}
273
+ >
274
+ {value}
275
+ </span>
276
+ )}
277
+ </div>
278
+ {renderTermsLink()}
279
+ </li>
280
+ );
281
+ })}
282
+ </ul>
283
+ ) : isDesigner ? (
284
+ <DesignerPlaceholder
285
+ label={loading ? 'Coupons (loading)' : 'Coupons (none)'}
286
+ attribute="data-coupon-wallet-list"
287
+ />
288
+ ) : (
289
+ !loading &&
290
+ emptyLabel && (
291
+ <div
292
+ data-coupon-wallet-empty=""
293
+ {...partAttrs('empty')}
294
+ style={{ opacity: 0.6 }}
295
+ >
296
+ {emptyLabel}
297
+ </div>
298
+ )
299
+ )}
300
+ </div>
301
+ );
302
+
303
+ if (!showActive && !showUsed) return null;
304
+
305
+ const bothShown = showActive && showUsed;
306
+
307
+ return (
308
+ <div
309
+ data-coupon-wallet=""
310
+ {...partAttrs('wrapper')}
311
+ style={
312
+ isMobile
313
+ ? {
314
+ display: 'flex',
315
+ flexDirection: 'column',
316
+ gap: '48px',
317
+ width: '100%'
318
+ }
319
+ : {
320
+ display: 'grid',
321
+ // Two equal tracks only when both columns render — a single
322
+ // shown column gets the one track it needs, not half the row.
323
+ gridTemplateColumns: bothShown ? '1fr 1fr' : '1fr',
324
+ gap: '64px',
325
+ // Without an explicit width, a grid box's auto-width can shrink
326
+ // to its content's min-content instead of filling the row (the
327
+ // theme editor's own root "width" style targets the block's
328
+ // outer wrapper, not this element), so the 1fr/1fr split never
329
+ // gets a full row to divide.
330
+ width: '100%'
331
+ }
332
+ }
333
+ >
334
+ {showActive &&
335
+ renderColumn(
336
+ activeTitle,
337
+ visibleActive,
338
+ false,
339
+ activeEmptyLabel,
340
+ activeLoading
341
+ )}
342
+ {showUsed &&
343
+ renderColumn(usedTitle, visibleUsed, true, usedEmptyLabel, usedLoading)}
344
+ </div>
345
+ );
346
+ };
347
+
348
+ export default CouponWalletBlock;