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

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,37 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.75-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 [804d2bd6c]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a631]
31
- - Updated dependencies [e18836b20]
32
- - Updated dependencies [b1111d7b]
33
- - @akinon/next@2.0.75-rc.0
34
- - @akinon/theme-contract@2.0.75-rc.0
3
+ ## 2.0.75
4
+
5
+ ### Patch Changes
6
+
7
+ - 01f69c6a: ZERO-4840: add basket gift card, wallet, gift wrap and upsell
8
+ - @akinon/next@2.0.75
9
+ - @akinon/theme-contract@2.0.75
35
10
 
36
11
  ## 2.0.74
37
12
 
@@ -1,6 +1,8 @@
1
1
  // Mock for @akinon/next/components/plugin-module: the real one dynamically
2
2
  // imports brand plugins, which have no place in a unit test. The slot renders
3
- // a marker so a test can assert where a block placed it.
3
+ // a marker so a test can assert where a block placed it. When the block hands
4
+ // the gift pack plugin a customUIRender, the mock drives it with a local
5
+ // hasGiftPack so a test can exercise the add/remove toggling.
4
6
  const React = require('react');
5
7
 
6
8
  const Component = {
@@ -8,8 +10,22 @@ const Component = {
8
10
  CheckoutGiftPack: 'CheckoutGiftPack'
9
11
  };
10
12
 
11
- const PluginModule = ({ component }) =>
12
- React.createElement('div', { 'data-plugin-module': component });
13
+ const PluginModule = ({ component, props }) => {
14
+ const [hasGiftPack, setHasGiftPack] = React.useState(false);
15
+ const customUIRender = props?.customUIRender;
16
+ return React.createElement(
17
+ 'div',
18
+ { 'data-plugin-module': component },
19
+ customUIRender
20
+ ? customUIRender({
21
+ hasGiftPack,
22
+ onAddGiftPack: () => setHasGiftPack(true),
23
+ onRemoveGiftPack: () => setHasGiftPack(false),
24
+ translations: props?.translations ?? {}
25
+ })
26
+ : null
27
+ );
28
+ };
13
29
 
14
30
  module.exports = {
15
31
  __esModule: true,
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.75-rc.0",
4
+ "version": "2.0.75",
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.75-rc.0",
11
+ "@akinon/next": "2.0.75",
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.75-rc.0",
16
+ "@akinon/theme-contract": "2.0.75",
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.75-rc.0",
23
+ "@akinon/next": "2.0.75",
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",
@@ -38,6 +38,12 @@ const basketLine = {
38
38
  let basketData: unknown = {
39
39
  basketitem_set: [basketLine],
40
40
  total_quantity: 2,
41
+ upsell_details: [
42
+ {
43
+ message: '2 more to go',
44
+ params: { required: 3, partial: 1, remaining: 2 }
45
+ }
46
+ ],
41
47
  total_product_amount: '11998.00',
42
48
  total_discount_amount: '0.00',
43
49
  total_amount: '11998.00',
@@ -173,6 +179,53 @@ describe('basket-items line layout', () => {
173
179
  ).not.toBeNull();
174
180
  });
175
181
 
182
+ it('renders the checkbox gift wrap presentation and toggles it through the plugin', () => {
183
+ const { container } = renderBlock(BasketItemsBlock, 'basket-items', {
184
+ showGiftWrap: true,
185
+ giftWrapPresentation: 'checkbox',
186
+ giftWrapAddLabel: 'Hediye Paketi'
187
+ });
188
+
189
+ const toggle = container.querySelector(
190
+ '[data-part="gift-wrap-toggle"]'
191
+ ) as HTMLButtonElement;
192
+ expect(toggle).not.toBeNull();
193
+ expect(toggle.getAttribute('aria-checked')).toBe('false');
194
+ expect(
195
+ container.querySelector('[data-part="gift-wrap-label"]')?.textContent
196
+ ).toBe('Hediye Paketi');
197
+ expect(
198
+ container.querySelector('[data-part="gift-wrap-box"] svg')
199
+ ).toBeNull();
200
+
201
+ fireEvent.click(toggle);
202
+ expect(toggle.getAttribute('aria-checked')).toBe('true');
203
+ expect(toggle.getAttribute('data-state')).toContain('checked');
204
+ expect(
205
+ container.querySelector('[data-part="gift-wrap-box"] svg')
206
+ ).not.toBeNull();
207
+
208
+ fireEvent.click(toggle);
209
+ expect(toggle.getAttribute('aria-checked')).toBe('false');
210
+ });
211
+
212
+ it('shows the upsell campaign banner only when asked, with placeholders', () => {
213
+ const silent = renderBlock(BasketItemsBlock, 'basket-items');
214
+ expect(
215
+ silent.container.querySelector('[data-part="upsell-banner"]')
216
+ ).toBeNull();
217
+
218
+ const { container } = renderBlock(BasketItemsBlock, 'basket-items', {
219
+ showUpsellBanner: true,
220
+ upsellBannerFormat: 'Kampanya: {message} ({remaining}/{required})'
221
+ });
222
+ const banner = container.querySelector('[data-part="upsell-banner"]');
223
+ expect(banner?.textContent).toBe('Kampanya: 2 more to go (2/3)');
224
+ expect(
225
+ container.querySelector('[data-part="root"] > [data-part="upsell-banner"]')
226
+ ).not.toBeNull();
227
+ });
228
+
176
229
  it('formats the unit price with the shared money options', () => {
177
230
  const { container } = renderBlock(BasketItemsBlock, 'basket-items', {
178
231
  decimals: 'never',
@@ -229,13 +282,14 @@ describe('basket-summary panels', () => {
229
282
  expect(container.querySelector('[data-part="coupon-form"]')).not.toBeNull();
230
283
  });
231
284
 
232
- it('adds a gift card panel that redeems through the voucher endpoint', () => {
285
+ it('redeems the gift card as number|cvv through the voucher endpoint', () => {
233
286
  const { container } = renderBlock(BasketSummaryBlock, 'basket-summary', {
234
287
  showCoupon: false,
235
288
  showGiftCard: true,
236
289
  couponPlacement: 'after-cta',
237
290
  giftCardToggleLabel: 'Hediye Kartı',
238
- giftCardApplyLabel: 'Uygula'
291
+ giftCardApplyLabel: 'Uygula',
292
+ giftCardError: 'Geçersiz kart.'
239
293
  });
240
294
 
241
295
  const toggle = container.querySelector(
@@ -248,12 +302,127 @@ describe('basket-summary panels', () => {
248
302
  const number = container.querySelector(
249
303
  '[data-part="gift-card-input"]'
250
304
  ) as HTMLInputElement;
305
+ const form = container.querySelector(
306
+ '[data-part="gift-card-form"]'
307
+ ) as HTMLFormElement;
251
308
  fireEvent.change(number, { target: { value: '1234 5678' } });
252
- fireEvent.submit(
253
- container.querySelector('[data-part="gift-card-form"]') as HTMLFormElement
309
+
310
+ // A missing CVV is the same invalid-card answer, without the round trip;
311
+ // both fields carry the invalid state the design colours red.
312
+ fireEvent.submit(form);
313
+ expect(applyVoucher).not.toHaveBeenCalled();
314
+ expect(
315
+ container.querySelector('[data-part="gift-card-error"]')?.textContent
316
+ ).toBe('Geçersiz kart.');
317
+ expect(
318
+ container
319
+ .querySelector('[data-part="gift-card-input"]')
320
+ ?.getAttribute('data-state')
321
+ ).toBe('invalid');
322
+ expect(
323
+ container
324
+ .querySelector('[data-part="gift-card-cvv"]')
325
+ ?.getAttribute('data-state')
326
+ ).toBe('invalid');
327
+
328
+ fireEvent.change(
329
+ container.querySelector('[data-part="gift-card-cvv"]') as HTMLInputElement,
330
+ { target: { value: '123' } }
254
331
  );
332
+ fireEvent.submit(form);
333
+ expect(applyVoucher).toHaveBeenCalledWith({
334
+ voucher_code: '1234 5678|123'
335
+ });
336
+ });
255
337
 
256
- expect(applyVoucher).toHaveBeenCalledWith({ voucher_code: '1234 5678' });
338
+ it('locks the gift card panel while a promo code is applied', () => {
339
+ basketData = {
340
+ basketitem_set: [basketLine],
341
+ total_quantity: 2,
342
+ total_product_amount: '11998.00',
343
+ total_discount_amount: '0.00',
344
+ total_amount: '11998.00',
345
+ voucher_code: 'GANT2026'
346
+ };
347
+
348
+ const { container } = renderBlock(BasketSummaryBlock, 'basket-summary', {
349
+ showGiftCard: true,
350
+ couponPresentation: 'collapsible'
351
+ });
352
+
353
+ const giftToggle = container.querySelector(
354
+ '[data-part="gift-card-toggle"]'
355
+ ) as HTMLButtonElement;
356
+ expect(giftToggle.disabled).toBe(true);
357
+ fireEvent.click(giftToggle);
358
+ expect(container.querySelector('[data-part="gift-card-form"]')).toBeNull();
359
+
360
+ // The promo panel is the applied code's owner and stays usable.
361
+ const couponToggle = container.querySelector(
362
+ '[data-part="coupon-toggle"]'
363
+ ) as HTMLButtonElement;
364
+ expect(couponToggle.disabled).toBe(false);
365
+ fireEvent.click(couponToggle);
366
+ expect(
367
+ container.querySelector('[data-part="coupon-code"]')?.textContent
368
+ ).toBe('GANT2026');
369
+
370
+ basketData = {
371
+ basketitem_set: [basketLine],
372
+ total_quantity: 2,
373
+ total_product_amount: '11998.00',
374
+ total_discount_amount: '0.00',
375
+ total_amount: '11998.00',
376
+ voucher_code: null
377
+ };
378
+ });
379
+
380
+ it('locks the promo panel while a gift card is applied and offers its removal', () => {
381
+ basketData = {
382
+ basketitem_set: [basketLine],
383
+ total_quantity: 2,
384
+ total_product_amount: '11998.00',
385
+ total_discount_amount: '0.00',
386
+ total_amount: '11998.00',
387
+ voucher_code: '1234 5678|123'
388
+ };
389
+
390
+ const { container } = renderBlock(BasketSummaryBlock, 'basket-summary', {
391
+ showGiftCard: true,
392
+ couponPresentation: 'collapsible',
393
+ giftCardRemoveLabel: 'Kaldır'
394
+ });
395
+
396
+ const couponToggle = container.querySelector(
397
+ '[data-part="coupon-toggle"]'
398
+ ) as HTMLButtonElement;
399
+ expect(couponToggle.disabled).toBe(true);
400
+ fireEvent.click(couponToggle);
401
+ expect(container.querySelector('[data-part="coupon-form"]')).toBeNull();
402
+ // The gift card's code must not leak into the promo panel's applied row.
403
+ expect(container.querySelector('[data-part="coupon-applied"]')).toBeNull();
404
+
405
+ const giftToggle = container.querySelector(
406
+ '[data-part="gift-card-toggle"]'
407
+ ) as HTMLButtonElement;
408
+ expect(giftToggle.disabled).toBe(false);
409
+ fireEvent.click(giftToggle);
410
+ // The applied row prints the number without its CVV.
411
+ expect(
412
+ container.querySelector('[data-part="gift-card-code"]')?.textContent
413
+ ).toBe('1234 5678');
414
+ expect(
415
+ container.querySelector('[data-part="gift-card-remove"]')?.textContent
416
+ ).toBe('Kaldır');
417
+
418
+ basketData = {
419
+ basketitem_set: [basketLine],
420
+ total_quantity: 2,
421
+ total_product_amount: '11998.00',
422
+ total_discount_amount: '0.00',
423
+ total_amount: '11998.00',
424
+ voucher_code: null
425
+ };
257
426
  });
258
427
 
259
428
  it('prints one discount row per basket campaign when asked to', () => {
@@ -61,6 +61,18 @@ const basketDiscountLine = {
61
61
  product: { ...plainLine.product, pk: 1003 }
62
62
  };
63
63
 
64
+ // A basket-level voucher writes its share into `discount_amount` but leaves
65
+ // the unit price alone — that must never read as an in-basket campaign.
66
+ const voucherTouchedLine = {
67
+ ...plainLine,
68
+ id: 4,
69
+ price: '2199.00',
70
+ unit_price: '2199.00',
71
+ total_amount: '2199.00',
72
+ discount_amount: '2177.00',
73
+ product: { ...plainLine.product, pk: 1004, price: '2199.00' }
74
+ };
75
+
64
76
  let basketData: unknown = { basketitem_set: [plainLine] };
65
77
 
66
78
  jest.mock('next-auth/react', () => ({
@@ -153,6 +165,18 @@ describe('basket-items price extras', () => {
153
165
  expect(text(container, 'price')).toBe('5,499 TL');
154
166
  });
155
167
 
168
+ it('does not badge a line whose discount came from a basket voucher', () => {
169
+ basketData = { basketitem_set: [voucherTouchedLine] };
170
+ const { container } = renderLines({
171
+ ...money,
172
+ showBasketDiscount: true,
173
+ basketDiscountFormat: 'SEPETTE {price}'
174
+ });
175
+
176
+ expect(container.querySelector('[data-part="basket-price"]')).toBeNull();
177
+ expect(text(container, 'price')).toBe('2,199 TL');
178
+ });
179
+
156
180
  it('shows the undiscounted price with the in-basket figure when a campaign cut the line', () => {
157
181
  basketData = { basketitem_set: [basketDiscountLine] };
158
182
  const { container } = renderLines({
@@ -30,6 +30,7 @@ let offersPayload: unknown = {
30
30
  ]
31
31
  };
32
32
  let voucherCode: string | null = null;
33
+ let basketDiscounts: Array<{ description: string; discount: string }> = [];
33
34
 
34
35
  const applyVoucher = jest.fn(() => ({
35
36
  unwrap: () => Promise.resolve({ total_amount: '1.00' })
@@ -57,7 +58,8 @@ jest.mock('@akinon/next/data/client/basket', () => ({
57
58
  total_quantity: 1,
58
59
  total_product_amount: '500.00',
59
60
  total_amount: '500.00',
60
- voucher_code: voucherCode
61
+ voucher_code: voucherCode,
62
+ discounts: basketDiscounts
61
63
  },
62
64
  isLoading: false
63
65
  }),
@@ -97,6 +99,7 @@ const renderSummary = (
97
99
  beforeEach(() => {
98
100
  sessionStatus = 'authenticated';
99
101
  voucherCode = null;
102
+ basketDiscounts = [];
100
103
  applyVoucher.mockClear();
101
104
  removeVoucher.mockClear();
102
105
  });
@@ -190,6 +193,98 @@ describe('basket-summary coupon wallet', () => {
190
193
  ).toBe(true);
191
194
  });
192
195
 
196
+ it('reads the all_discounts payload and keeps only the active entries', () => {
197
+ offersPayload = {
198
+ total_discount: 0,
199
+ discounts: [],
200
+ all_discounts: [
201
+ {
202
+ label: 'test kupon',
203
+ amount: 0,
204
+ percentage: 99,
205
+ end_datetime: '2026-08-31T15:22:19.709000Z',
206
+ voucher_code: null,
207
+ status: { value: 'active', label: 'Active' },
208
+ offer_type: { value: 'coupon', label: 'Coupon' }
209
+ },
210
+ {
211
+ label: 'test indirim kodu',
212
+ amount: 0,
213
+ percentage: 99,
214
+ end_datetime: '2026-08-31T08:23:41.887000Z',
215
+ voucher_code: '3BC357B3',
216
+ status: { value: 'active', label: 'Active' },
217
+ offer_type: { value: 'coupon_code', label: 'Coupon Code' }
218
+ },
219
+ {
220
+ label: 'bitmiş kampanya',
221
+ amount: 0,
222
+ percentage: 10,
223
+ voucher_code: 'OLD',
224
+ status: { value: 'expired', label: 'Expired' }
225
+ }
226
+ ]
227
+ };
228
+
229
+ const { container } = renderSummary({
230
+ showOffers: true,
231
+ offerApplyLabel: 'Uygula'
232
+ });
233
+
234
+ const cards = container.querySelectorAll('[data-part="offer"]');
235
+ expect(cards).toHaveLength(2);
236
+ expect(cards[0].querySelector('[data-part="offer-value"]')?.textContent).toBe(
237
+ '%99'
238
+ );
239
+ // The automatic coupon has no code, so no apply control.
240
+ expect(cards[0].querySelector('[data-part="offer-apply"]')).toBeNull();
241
+
242
+ const apply = cards[1].querySelector(
243
+ '[data-part="offer-apply"]'
244
+ ) as HTMLButtonElement;
245
+ fireEvent.click(apply);
246
+ expect(applyVoucher).toHaveBeenCalledWith({ voucher_code: '3BC357B3' });
247
+ });
248
+
249
+ it('prints the applied voucher discount row as "Kupon Kodu - <code>"', () => {
250
+ voucherCode = '3BC357B3';
251
+ basketDiscounts = [
252
+ { description: 'Sepette %30 İndirim', discount: '150.00' },
253
+ { description: 'test indirim kodu', discount: '345.00' }
254
+ ];
255
+ offersPayload = {
256
+ discounts: [],
257
+ all_discounts: [
258
+ {
259
+ label: 'test indirim kodu',
260
+ percentage: 99,
261
+ voucher_code: '3BC357B3',
262
+ status: { value: 'active' }
263
+ }
264
+ ]
265
+ };
266
+
267
+ const { container } = renderSummary({
268
+ showOffers: true,
269
+ discountRows: 'each',
270
+ voucherDiscountLabel: 'Kupon Kodu - {code}',
271
+ decimals: 'never',
272
+ currencyDisplay: 'custom',
273
+ currencyText: 'TL',
274
+ currencyPosition: 'after'
275
+ });
276
+
277
+ const labels = Array.from(
278
+ container.querySelectorAll('[data-basket-summary-row="discount"]')
279
+ ).map((row) => row.textContent);
280
+ // The campaign row keeps its name; the voucher's own row takes the
281
+ // "Kupon Kodu - <code>" form of the design.
282
+ expect(labels).toEqual([
283
+ 'Sepette %30 İndirim-150 TL',
284
+ 'Kupon Kodu - 3BC357B3-345 TL'
285
+ ]);
286
+ });
287
+
193
288
  it('caps the list with offersLimit', () => {
194
289
  const { container } = renderSummary({ showOffers: true, offersLimit: 1 });
195
290