@nuskin/product-components 3.0.4 → 3.1.0-product-offer.2

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/.releaserc CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "branches": [
3
- "master"
3
+ "master", {"name":"added-product-offer", "channel":"prerelease", "prerelease":"product-offer"}
4
4
  ],
5
5
  "plugins": [
6
6
  "@semantic-release/npm",
package/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ # [3.1.0-product-offer.2](https://code.tls.nuskin.io/ns-am/ux/product-components/compare/v3.1.0-product-offer.1...v3.1.0-product-offer.2) (2022-04-22)
2
+
3
+
4
+ ### Fix
5
+
6
+ * added hide favorites and some more query parsing ([b490535](https://code.tls.nuskin.io/ns-am/ux/product-components/commit/b490535c98e03e3ab45ac4710810d01b68019e83))
7
+
8
+ # [3.1.0-product-offer.1](https://code.tls.nuskin.io/ns-am/ux/product-components/compare/v3.0.5...v3.1.0-product-offer.1) (2022-04-22)
9
+
10
+
11
+ ### Fix
12
+
13
+ * ignore node 12 attempt ([8846ec5](https://code.tls.nuskin.io/ns-am/ux/product-components/commit/8846ec5a237b2442a16a35955b1d7613071ee94e))
14
+
15
+ ### New
16
+
17
+ * converted ns-personal-offer-landing to vue component NsProductOffer, storybook updates (#CX12-4584) ([e0b4636](https://code.tls.nuskin.io/ns-am/ux/product-components/commit/e0b463634118326caa3f02793fcb4105b721f569)), closes [#CX12-4584](https://code.tls.nuskin.io/ns-am/ux/product-components/issues/CX12-4584)
18
+
19
+ ## [3.0.5](https://code.tls.nuskin.io/ns-am/ux/product-components/compare/v3.0.4...v3.0.5) (2022-04-21)
20
+
21
+
22
+ ### Fix
23
+
24
+ * Stop qualifications getting messed up and allow variants(#CX15-4531) ([a94c742](https://code.tls.nuskin.io/ns-am/ux/product-components/commit/a94c742c37f77afa3ae397926a3f5ab5661e3af2)), closes [#CX15-4531](https://code.tls.nuskin.io/ns-am/ux/product-components/issues/CX15-4531)
25
+
1
26
  ## [3.0.4](https://code.tls.nuskin.io/ns-am/ux/product-components/compare/v3.0.3...v3.0.4) (2022-04-20)
2
27
 
3
28
 
@@ -0,0 +1,1001 @@
1
+ <template>
2
+ <div v-if="offerLoading" :class="$style.spinnerWrapper">
3
+ <NsSpinner />
4
+ </div>
5
+ <article v-else :class="$style.personalOfferDetails">
6
+ <template v-if="!showOffer && !productsLoading">
7
+ <!-- OFFER NOT FOUND -->
8
+ <section v-if="offerNotFound" :class="$style.offerNotFound">
9
+ {{ localTranslations.offerNotFound }}
10
+ </section>
11
+
12
+ <!-- OFFER EXPIRED -->
13
+ <section v-else-if="offerExpired" :class="$style.offerExpired">
14
+ {{ localTranslations.offerExpired }}
15
+ </section>
16
+ </template>
17
+
18
+ <div v-if="productsLoading" :class="$style.spinnerWrapper">
19
+ <NsSpinner />
20
+ </div>
21
+
22
+ <!-- OFFER TITLE -->
23
+ <section v-if="showOffer" :class="$style.offerTitleMessaging">
24
+ <div :class="$style.offerMessaging">
25
+ <h2 :class="$style.offerTitle">{{ localTranslations.welcome }}</h2>
26
+ <p :class="$style.offerMessage">
27
+ <span v-if="!offer.isGroupOffer">{{ offer.name }}, </span>
28
+ <span v-html="offer.greeting"></span>
29
+ </p>
30
+ <p :class="$style.storePreferredName">&#45; {{ storePreferredName }}</p>
31
+ </div>
32
+ </section>
33
+
34
+ <!-- OFFER BODY -->
35
+ <section :class="[$style.offerBody, { [$style.showOffer]: showOffer }]">
36
+ <!-- TOP PURCHASE ALL -->
37
+ <div v-if="showOffer" :class="$style.offerPurchaseAll">
38
+ <!-- OFFER TOTAL -->
39
+ <div :class="$style.offerTotalContainer">
40
+ <span v-if="offerTotalBeforeHtml" v-html="offerTotalBeforeHtml" />
41
+ <template v-if="showOfferTotal">
42
+ <span v-if="!hideDiscount" :class="$style.originalOfferTotal">
43
+ <NsCurrency :amount="originalOfferTotal" strike-through />
44
+ </span>
45
+ <span :class="$style.offerTotal">
46
+ <NsCurrency :amount="offerTotal" />
47
+ </span>
48
+ </template>
49
+ <span v-if="offerTotalAfterHtml" v-html="offerTotalAfterHtml" />
50
+ </div>
51
+
52
+ <!-- OFFER SAVINGS -->
53
+ <div
54
+ v-if="!hideDiscount && showOfferSavings"
55
+ :class="$style.offerSavingsContainer"
56
+ >
57
+ <span v-if="offerSavingsBeforeHtml" v-html="offerSavingsBeforeHtml" />
58
+ <span :class="$style.offerSavings">
59
+ <NsCurrency :amount="offerSavings" />
60
+ </span>
61
+ <span v-if="offerSavingsAfterHtml" v-html="offerSavingsAfterHtml" />
62
+ </div>
63
+
64
+ <!-- OFFER ACTION -->
65
+ <div :class="$style.offerAction">
66
+ <button
67
+ class="primary-solid fluid"
68
+ :class="$style.button"
69
+ :disabled="disableAddAll"
70
+ @click="addFullOfferToCart"
71
+ >
72
+ <NsIcon :class="$style.addToCartIcon" icon-name="icon-bag" />
73
+ {{ localTranslations.addAllItems }}
74
+ </button>
75
+ </div>
76
+ </div>
77
+
78
+ <!-- OFFER PRODUCTS -->
79
+ <div :class="$style.offerProducts">
80
+ <NsProductCard
81
+ v-for="(sku, index) in Object.keys(products)"
82
+ :key="index"
83
+ :class="[
84
+ $style.offerProduct,
85
+ { [$style.invalid]: !!products[sku].invalid }
86
+ ]"
87
+ :sku="sku"
88
+ :quantity="products[sku].requestedQuantity"
89
+ mobile-side-by-side
90
+ hide-favorites
91
+ @active-sku="activeSku => modifyProduct(sku, { activeSku })"
92
+ @availability="availability => modifyProduct(sku, { availability })"
93
+ @product-update="handleProductUpdate"
94
+ />
95
+ </div>
96
+
97
+ <!-- BOTTOM PURCHASE ALL -->
98
+ <div
99
+ v-if="showOffer && !hideBottomPurchaseAll"
100
+ :class="$style.offerPurchaseAll"
101
+ >
102
+ <!-- OFFER TOTAL -->
103
+ <div :class="$style.offerTotalContainer">
104
+ <span v-if="offerTotalBeforeHtml" v-html="offerTotalBeforeHtml" />
105
+ <template v-if="showOfferTotal">
106
+ <span v-if="!hideDiscount" :class="$style.originalOfferTotal">
107
+ <NsCurrency :amount="originalOfferTotal" strike-through />
108
+ </span>
109
+ <span :class="$style.offerTotal">
110
+ <NsCurrency :amount="offerTotal" />
111
+ </span>
112
+ </template>
113
+ <span v-if="offerTotalAfterHtml" v-html="offerTotalAfterHtml" />
114
+ </div>
115
+
116
+ <!-- OFFER SAVINGS -->
117
+ <div
118
+ v-if="!hideDiscount && showOfferSavings"
119
+ :class="$style.offerSavingsContainer"
120
+ >
121
+ <span v-if="offerSavingsBeforeHtml" v-html="offerSavingsBeforeHtml" />
122
+ <span :class="$style.offerSavings">
123
+ <NsCurrency :amount="offerSavings" />
124
+ </span>
125
+ <span v-if="offerSavingsAfterHtml" v-html="offerSavingsAfterHtml" />
126
+ </div>
127
+
128
+ <!-- OFFER ACTION -->
129
+ <div :class="$style.offerAction">
130
+ <button
131
+ class="primary-solid fluid"
132
+ :class="$style.button"
133
+ :disabled="disableAddAll"
134
+ @click="addFullOfferToCart"
135
+ >
136
+ <NsIcon :class="$style.addToCartIcon" icon-name="icon-bag" />
137
+ {{ localTranslations.addAllItems }}
138
+ </button>
139
+ </div>
140
+ </div>
141
+ </section>
142
+ </article>
143
+ </template>
144
+
145
+ <script>
146
+ import {
147
+ ShoppingContext,
148
+ PersonalOfferStorageService,
149
+ SponsorStorageService,
150
+ events,
151
+ StoreFrontSponsorStorageService
152
+ } from "@nuskin/ns-util";
153
+ import { MySiteRestService } from "@nuskin/my-site-api";
154
+ import {
155
+ CartService,
156
+ CurrencyService,
157
+ PersonalOfferService
158
+ } from "@nuskin/ns-shop";
159
+ import { AccountService } from "@nuskin/ns-account";
160
+ import { isNullOrEmpty, isNumber } from "@nuskin/ns-common-lib";
161
+ import { NsSpinner, NsIcon } from "@nuskin/design-components";
162
+ import debounce from "lodash/debounce";
163
+
164
+ import NsCurrency from "./NsCurrency.vue";
165
+ import NsProductCard from "./NsProductCard.vue";
166
+
167
+ export default {
168
+ name: "NsProductOffer",
169
+ components: {
170
+ NsSpinner,
171
+ NsIcon,
172
+ // eslint-disable-next-line vue/no-unused-components
173
+ NsCurrency,
174
+ NsProductCard
175
+ },
176
+ props: {
177
+ // the store owner/user id
178
+ storeId: {
179
+ type: String,
180
+ default: ""
181
+ },
182
+
183
+ // the personal/product offer - otherwise known as pitch - id
184
+ offerId: {
185
+ type: String,
186
+ default: ""
187
+ },
188
+ translations: {
189
+ type: Object,
190
+ default() {
191
+ return {
192
+ welcome: "Welcome! (welcome)",
193
+ bundleTotal: "Bundle Total - {price} (bundleTotal)",
194
+ discountPercentagePrice:
195
+ "{percent}% Off - {savings} Savings (discountPercentagePrice)",
196
+ addAllItems: "Add {qty} Items To Bag (addAllItems)",
197
+ offerNotFound:
198
+ "Offer Not Found. Please contact {user}. (offerNotFound)",
199
+ offerExpired: "Offer Expired. Please contact {user}. (offerExpired)",
200
+ offerName: "{name} (offerName)"
201
+ };
202
+ }
203
+ }
204
+ },
205
+ data() {
206
+ return {
207
+ currencyCode: "",
208
+ queryStoreId: "",
209
+ queryOfferId: "",
210
+ storeOwner: null,
211
+ storePreferredName: "",
212
+ offer: null,
213
+ discount: null,
214
+ isAdr: false,
215
+
216
+ products: {},
217
+ totalProducts: 0,
218
+ totalProductQuantity: 0,
219
+ localTranslations: { ...(this.translations || {}) },
220
+
221
+ offerLoading: true,
222
+ offerNotFound: false,
223
+ offerExpired: false,
224
+ hideDiscount: true,
225
+ someProductsValid: false,
226
+
227
+ offerSavings: 0,
228
+ showOfferSavings: false,
229
+ offerSavingsBeforeHtml: "",
230
+ offerSavingsAfterHtml: "",
231
+
232
+ originalOfferTotal: 0,
233
+ offerTotal: 0,
234
+ showOfferTotal: false,
235
+ offerTotalBeforeHtml: "",
236
+ offerTotalAfterHtml: ""
237
+ };
238
+ },
239
+ computed: {
240
+ runConfig() {
241
+ return this.$NsProductAppService.runConfig;
242
+ },
243
+ market() {
244
+ return this.runConfig.country;
245
+ },
246
+ language() {
247
+ return this.runConfig.language;
248
+ },
249
+ showWholeSalePricing() {
250
+ return this.$NsProductAppService.showWholeSalePricing;
251
+ },
252
+ isLoggedIn() {
253
+ return this.$NsProductUserService.isLoggedIn;
254
+ },
255
+ user() {
256
+ return this.$NsProductUserService.user;
257
+ },
258
+ userId() {
259
+ return this.$NsProductUserService.userId;
260
+ },
261
+ isDistributor() {
262
+ return this.$NsProductUserService.isDistributor;
263
+ },
264
+ isPreferredCustomer() {
265
+ return this.$NsProductUserService.isPreferredCustomer;
266
+ },
267
+ localStoreId() {
268
+ return (this.storeOwner || {}).sapId || this.storeId || this.queryStoreId;
269
+ },
270
+ isStorefront() {
271
+ return !!this.localStoreId;
272
+ },
273
+ localOfferId() {
274
+ return (this.offer || {}).pitchID || this.offerId || this.queryOfferId;
275
+ },
276
+ isPersonalOffer() {
277
+ return !!this.localOfferId && !!(this.offer || {}).active;
278
+ },
279
+ disableAddAll() {
280
+ return this.offerLoading || this.totalProductQuantity === 0;
281
+ },
282
+ productsLoading() {
283
+ return this.$NsProductDataService.batches.some(
284
+ batch =>
285
+ !["success", "failed"].includes(batch.status) &&
286
+ batch.skus.some(batchSku =>
287
+ Object.keys(this.products).includes(batchSku)
288
+ )
289
+ );
290
+ },
291
+ showOffer() {
292
+ return (
293
+ !this.offerLoading &&
294
+ !this.productsLoading &&
295
+ !this.offerNotFound &&
296
+ !this.offerExpired &&
297
+ this.someProductsValid
298
+ );
299
+ },
300
+ mobileOrPhablet() {
301
+ return this.$mq === "phablet" || this.$mq === "mobile";
302
+ },
303
+ hideBottomPurchaseAll() {
304
+ return (
305
+ (!this.mobileOrPhablet && this.totalProducts < 7) ||
306
+ (this.mobileOrPhablet && this.totalProducts < 3)
307
+ );
308
+ }
309
+ },
310
+ watch: {
311
+ productsLoading(productsLoading) {
312
+ if (!productsLoading) {
313
+ this.checkProductsHaveData();
314
+ }
315
+ }
316
+ },
317
+ async mounted() {
318
+ await this.addListeners();
319
+ },
320
+ beforeDestroy() {
321
+ this.removeListeners();
322
+ },
323
+ methods: {
324
+ async init() {
325
+ this.currencyCode = CurrencyService.getCurrency(this.market).currencyCode;
326
+
327
+ // in AEM, the storeId (userId) and offerId (pitchId) are query parameters
328
+ this.queryStoreId =
329
+ this.getSearchParameter("storeId") || this.getSearchParameter("userId");
330
+ this.queryOfferId =
331
+ this.getSearchParameter("offerId") ||
332
+ this.getSearchParameter("pitchId");
333
+
334
+ const shoppingContext = ShoppingContext.getShoppingContext();
335
+ if (isNullOrEmpty(shoppingContext) && !!this.localOfferId) {
336
+ ShoppingContext.setShoppingContext(ShoppingContext.PERSONAL_OFFER);
337
+ }
338
+
339
+ this.storeOwner = StoreFrontSponsorStorageService.getStoreFrontSponsor();
340
+ if (
341
+ this.localStoreId &&
342
+ (isNullOrEmpty(this.storeOwner) ||
343
+ this.storeOwner.sapId !== this.localStoreId)
344
+ ) {
345
+ let sponsor = null;
346
+
347
+ try {
348
+ // getSponsorData threw an error for accessing user id when the user/sponsor isn't found, catch it
349
+ sponsor = await MySiteRestService.getSponsorData(this.localStoreId);
350
+ } catch (err) {
351
+ console.error("Unable to get Store Owner. Error: ", err);
352
+ }
353
+
354
+ if (sponsor) {
355
+ StoreFrontSponsorStorageService.setStoreFrontSponsor(sponsor);
356
+ this.storeOwner = StoreFrontSponsorStorageService.getStoreFrontSponsor();
357
+ }
358
+ }
359
+
360
+ if (!isNullOrEmpty(this.storeOwner) && !!this.localOfferId) {
361
+ // get store preferred name from account service if possible
362
+ const storeOwnerAccountInfo = await AccountService.getAccountInfo(
363
+ this.localStoreId
364
+ );
365
+ this.storePreferredName =
366
+ (storeOwnerAccountInfo || {}).preferredName ||
367
+ this.storeOwner.displayName;
368
+
369
+ this.setT(
370
+ "offerNotFound",
371
+ this.t("offerNotFound").replace("{user}", this.storePreferredName)
372
+ );
373
+
374
+ this.setT(
375
+ "offerExpired",
376
+ this.t("offerExpired").replace("{user}", this.storePreferredName)
377
+ );
378
+
379
+ await this.getOffer();
380
+
381
+ this.offerLoading = false;
382
+ } else {
383
+ this.offerNotFound = true;
384
+ this.showOfferSavings = false;
385
+ this.offerLoading = false;
386
+ }
387
+ },
388
+
389
+ getSearchParameter(name) {
390
+ const params = new URLSearchParams(window.location.search);
391
+ return params.get(name) || "";
392
+ },
393
+
394
+ async getOffer() {
395
+ // original source: https://code.tls.nuskin.io/ns-am/nu-skin-aem/wm/ns-shop-elements/-/blob/master/src/ns-personal-offer-landing/ns-personal-offer-landing.js
396
+
397
+ // "Stop Status"(05) showing "out of stock"(在庫切れ) message on PO welcome page, it should show “Stop selling“(販売停止).
398
+ // https://test.nuskin.com/content/markets/ja_JP/products/product.03003536.mysite.html
399
+ // https://test.nskn.co/vgR82L
400
+ // https://test.nuskin.com/content/markets/ja_JP/personal-offer.mysite.html?userId=JA10153135&pitchId=-MqCvG0kuPDgU1dYV6Dv
401
+
402
+ // base product scenario: CA00075667 ///// https://test.nskn.co/iDSndb
403
+
404
+ let offer = await PersonalOfferService.getOfferV2(
405
+ this.localStoreId,
406
+ this.localOfferId
407
+ );
408
+
409
+ if (isNullOrEmpty(offer)) {
410
+ this.offerExpired = false;
411
+ this.isAdr = false;
412
+ return;
413
+ }
414
+
415
+ await this.getOfferPitch(offer);
416
+
417
+ const expirationDate = offer.expirationDate;
418
+ if (
419
+ expirationDate &&
420
+ new Date(expirationDate).getTime() <= new Date().getTime()
421
+ ) {
422
+ this.offerExpired = true;
423
+ return;
424
+ }
425
+
426
+ this.isAdr = !!offer.useADR;
427
+
428
+ const storageOffer = localStorage.getItem("personalOffer_v2");
429
+ const parsedOffer = !isNullOrEmpty(storageOffer)
430
+ ? JSON.parse(storageOffer)
431
+ : null;
432
+ const currentSessionId = (parsedOffer || {}).sessionId;
433
+ const currentSession = currentSessionId
434
+ ? (offer.sessions || {})[currentSessionId]
435
+ : null;
436
+
437
+ // new session
438
+ if (!currentSession) {
439
+ try {
440
+ const viewedResponse = await PersonalOfferService.callViewed(
441
+ this.localStoreId,
442
+ this.localOfferId
443
+ );
444
+ PersonalOfferStorageService.setSessionId(viewedResponse.sessionId);
445
+ } catch (err) {
446
+ console.error("Unable to set offer as viewed. Error: ", err);
447
+ }
448
+ }
449
+ },
450
+
451
+ async getOfferPitch(offer) {
452
+ if (
453
+ (this.offerId && this.offerId !== offer.pitchID) ||
454
+ (this.queryOfferId && this.queryOfferId !== offer.pitchID)
455
+ ) {
456
+ CartService.clearCart();
457
+ }
458
+
459
+ let response = await PersonalOfferService.getPitch(
460
+ this.localStoreId,
461
+ this.localOfferId
462
+ );
463
+
464
+ let responseObject =
465
+ (response || {}).data && Object.keys(response.data).length
466
+ ? response.data
467
+ : null;
468
+
469
+ if (
470
+ isNullOrEmpty(responseObject) ||
471
+ (!responseObject.items && !responseObject.products)
472
+ ) {
473
+ response = {
474
+ data: offer
475
+ };
476
+ }
477
+
478
+ responseObject =
479
+ response && response.data && Object.keys(response.data).length
480
+ ? response.data
481
+ : null;
482
+
483
+ if (
484
+ responseObject &&
485
+ responseObject.active !== false &&
486
+ !responseObject.isDeleted
487
+ ) {
488
+ responseObject.sapId = this.localStoreId;
489
+ responseObject.pitchID = this.localOfferId;
490
+
491
+ responseObject.congratulations =
492
+ responseObject.congratulations || responseObject.confirmationMessage;
493
+ responseObject.isGroupOffer =
494
+ responseObject.isGroupOffer || responseObject.isGroup;
495
+ responseObject.language =
496
+ responseObject.language || responseObject.languageCode;
497
+ responseObject.greeting =
498
+ responseObject.greeting || responseObject.message;
499
+ responseObject.items =
500
+ responseObject.items ||
501
+ responseObject.products.map(item => {
502
+ return {
503
+ SKU: item.sku,
504
+ RequestedQuantity: item.qty
505
+ };
506
+ });
507
+
508
+ const useMemberPricing = !!responseObject.useMemberPricing;
509
+ this.setMemberPricing(useMemberPricing);
510
+
511
+ responseObject.landingPageURL = window.location.href;
512
+ SponsorStorageService.setSponsor(responseObject.sapId);
513
+
514
+ this.setT(
515
+ "offerName",
516
+ this.t("offerName").replace("{name}", responseObject.name)
517
+ );
518
+
519
+ responseObject.name = this.localTranslations.offerName;
520
+ this.offer = responseObject;
521
+
522
+ if (this.isLoggedIn) {
523
+ if (this.isDistributor || this.isPreferredCustomer) {
524
+ this.discount = {
525
+ code: "RETAILPRICE",
526
+ description: "RetailPricing",
527
+ multiplier: 1
528
+ };
529
+ responseObject.discount = this.discount;
530
+ } else {
531
+ this.discount = responseObject.discount;
532
+ }
533
+ } else {
534
+ this.discount = responseObject.discount;
535
+ }
536
+
537
+ PersonalOfferStorageService.setPersonalOffer(responseObject);
538
+ let multiplier = this.discount ? this.discount.multiplier : 1;
539
+ this.hideDiscount = Number(multiplier) == 1;
540
+
541
+ for (const item of responseObject.items) {
542
+ this.products[item.SKU] = {
543
+ sku: item.SKU,
544
+ requestedQuantity: item.RequestedQuantity,
545
+ data: {}
546
+ };
547
+ }
548
+ this.totalProducts = Object.keys(this.products).length;
549
+
550
+ events.publish(events.shop.PRODUCT_IMPRESSION, {
551
+ ecommerce: {
552
+ currencyCode: this.currencyCode,
553
+ impressions: responseObject.items.map((item, index) => {
554
+ return {
555
+ id: item.SKU,
556
+ name: item.name,
557
+ price: item.price,
558
+ quantity: item.RequestedQuantity,
559
+ // "category": item.category, // category comes from EDW categories
560
+ position: index,
561
+ list: "pitchapp"
562
+ };
563
+ })
564
+ }
565
+ });
566
+ } else {
567
+ this.offerNotFound = true;
568
+ this.showOfferSavings = false;
569
+ this.offerLoading = false;
570
+ }
571
+ },
572
+
573
+ t(key) {
574
+ return (this.translations || {})[key] || "";
575
+ },
576
+
577
+ setT(key, value) {
578
+ this.$set(this.localTranslations, key, value);
579
+ },
580
+
581
+ modifyProduct(sku, props) {
582
+ if (this.products[sku]) {
583
+ this.products[sku] = { ...this.products[sku], ...props };
584
+ } else {
585
+ this.products[sku] = props;
586
+ }
587
+
588
+ this.checkProductsHaveData();
589
+ },
590
+
591
+ handleProductUpdate(product) {
592
+ if ((product || {}).sku && this.products[product.sku]) {
593
+ this.products[product.sku].data = product;
594
+ }
595
+
596
+ this.checkProductsHaveData();
597
+ },
598
+
599
+ checkProductsHaveData: debounce(function() {
600
+ const products = Object.values(this.products);
601
+ if (products.every(product => !isNullOrEmpty(product.availability))) {
602
+ this.someProductsValid = products.some(
603
+ product =>
604
+ !isNullOrEmpty(product.availability) && !isNullOrEmpty(product.data)
605
+ );
606
+
607
+ this.getOfferPrice();
608
+
609
+ if (products.every(product => isNullOrEmpty(product.data))) {
610
+ this.offerNotFound = true;
611
+ }
612
+ }
613
+ }, 250),
614
+
615
+ async addFullOfferToCart() {
616
+ const validSkus = Object.keys(this.products).filter(sku => {
617
+ const product = this.products[sku];
618
+ return (
619
+ !product.invalid &&
620
+ !isNullOrEmpty(product.data) &&
621
+ !isNullOrEmpty(product.availability) &&
622
+ product.availability.addToCart
623
+ );
624
+ });
625
+
626
+ if (!validSkus.length) {
627
+ return;
628
+ }
629
+
630
+ window.sessionStorage.setItem("nstoast-allowed", "true");
631
+
632
+ const countryCode = CartService.getCartProperty("cntryCd") || "";
633
+
634
+ let checkoutProducts = [];
635
+
636
+ for (const validSku of validSkus) {
637
+ const validProduct = this.products[validSku];
638
+ const selectedSku = validProduct.activeSku;
639
+ const selectedQuantity = validProduct.availability.selectedQuantity;
640
+
641
+ const cartOptions = {
642
+ sku: selectedSku,
643
+ product: validProduct.data,
644
+ qty: selectedQuantity,
645
+ userId: this.userId,
646
+ cntryCd: countryCode,
647
+ adr: false,
648
+ referrer: "myStore",
649
+ domain: window.location.hostname
650
+ };
651
+
652
+ CartService.addProductToCart(cartOptions)
653
+ .then(() => {
654
+ events.publish(events.shop.ADD_TO_CART, cartOptions);
655
+
656
+ events.publish(events.shop.ADD_TO_CART_NEW, {
657
+ event: "addToCart",
658
+ ecommerce: {
659
+ currencyCode: this.currencyCode,
660
+ add: {
661
+ actionField: {
662
+ list: "myStore"
663
+ },
664
+ products: [
665
+ {
666
+ name: validProduct.data.title,
667
+ id: selectedSku,
668
+ price: validProduct.availability.price,
669
+ quantity: selectedQuantity,
670
+ method: "personaloffer", // personal offer or quickView or productPage or cart
671
+ cartType: "Standard"
672
+ }
673
+ ]
674
+ }
675
+ }
676
+ });
677
+
678
+ events.publish(events.shop.SHOW_ADD_TO_BAG, cartOptions);
679
+
680
+ checkoutProducts.push({
681
+ name: validProduct.data.title,
682
+ id: selectedSku,
683
+ price: validProduct.availability.price,
684
+ quantity: selectedQuantity
685
+ });
686
+ })
687
+ .catch(error => {
688
+ console.error(
689
+ `Failed to add '${selectedSku}' x${selectedQuantity} to cart. Error: `,
690
+ error
691
+ );
692
+ });
693
+ }
694
+
695
+ if (!checkoutProducts.length) {
696
+ return;
697
+ }
698
+
699
+ events.publish(events.shop.CART_CHECKOUT, {
700
+ event: "checkout",
701
+ ecommerce: {
702
+ currencyCode: this.currencyCode,
703
+ checkout: {
704
+ actionField: { step: 2 },
705
+ products: checkoutProducts
706
+ }
707
+ }
708
+ });
709
+
710
+ try {
711
+ await PersonalOfferService.callProduct(
712
+ this.localStoreId,
713
+ this.localOfferId
714
+ );
715
+ } catch (err) {
716
+ console.error("Failed updating product status to 'product_added'");
717
+ }
718
+
719
+ CartService.goToCartPage({ isMySite: true });
720
+ },
721
+
722
+ setMemberPricing(useMemberPricing) {
723
+ let contextOptions = {
724
+ showWholeSalePricing: useMemberPricing,
725
+ allowAnonymousShopping: !useMemberPricing,
726
+ preferredSignupOnly: useMemberPricing
727
+ };
728
+
729
+ ShoppingContext.setShoppingContext(
730
+ ShoppingContext.PERSONAL_OFFER,
731
+ contextOptions
732
+ );
733
+ },
734
+
735
+ getOfferPrice() {
736
+ let totalPrice = 0;
737
+ this.totalProductQuantity = 0;
738
+
739
+ for (const sku of Object.keys(this.products)) {
740
+ this.products[sku].invalid = true;
741
+ const availability = this.products[sku].availability;
742
+ if (availability) {
743
+ const selectedQuantity = availability.selectedQuantity || 0;
744
+ if (selectedQuantity > 0) {
745
+ const price = availability.price || 0;
746
+ if (availability.addToCart) {
747
+ totalPrice += price * selectedQuantity;
748
+ this.totalProductQuantity += selectedQuantity;
749
+ this.products[sku].invalid = false;
750
+ }
751
+ }
752
+ }
753
+ }
754
+
755
+ this.setT(
756
+ "addAllItems",
757
+ this.t("addAllItems").replace("{qty}", this.totalProductQuantity)
758
+ );
759
+
760
+ if (isNumber(totalPrice)) {
761
+ this.originalOfferTotal = totalPrice;
762
+ this.offerTotal = totalPrice;
763
+
764
+ if (
765
+ (this.discount && !this.user) ||
766
+ (this.user &&
767
+ (this.user.priceType === "RTL" ||
768
+ this.user.priceType === "WRTL" ||
769
+ this.user.isGuest))
770
+ ) {
771
+ let multiplier = this.discount ? this.discount.multiplier : 1;
772
+
773
+ this.offerTotal = totalPrice * multiplier;
774
+ this.offerSavings = this.originalOfferTotal - this.offerTotal;
775
+
776
+ const discountNum = Math.round((1 - multiplier) * 100);
777
+ this.showOfferSavings =
778
+ this.offerSavings > 0 && parseInt(discountNum);
779
+ if (this.showOfferSavings) {
780
+ this.setT(
781
+ "discountPercentagePrice",
782
+ this.t("discountPercentagePrice").replace(
783
+ "{percent}",
784
+ discountNum
785
+ )
786
+ );
787
+ const discountPercentagePrice = this.localTranslations
788
+ .discountPercentagePrice;
789
+ const offerSavingsHtmlParts = discountPercentagePrice.split(
790
+ "{savings}"
791
+ );
792
+ this.offerSavingsBeforeHtml = offerSavingsHtmlParts[0];
793
+ this.offerSavingsAfterHtml =
794
+ offerSavingsHtmlParts.length > 1 ? offerSavingsHtmlParts[1] : "";
795
+ }
796
+ } else {
797
+ this.hideDiscount = true;
798
+ this.showOfferSavings = false;
799
+ }
800
+
801
+ const offerTotalHtmlParts = this.t("bundleTotal").split("{price}");
802
+ this.showOfferTotal = offerTotalHtmlParts.length > 1;
803
+ this.offerTotalBeforeHtml = offerTotalHtmlParts[0];
804
+ this.offerTotalAfterHtml = this.showOfferTotal
805
+ ? offerTotalHtmlParts[1]
806
+ : "";
807
+
808
+ this.offerNotFound = false;
809
+ } else {
810
+ this.offerNotFound = true;
811
+ }
812
+ },
813
+
814
+ /**
815
+ * Handle product status events
816
+ */
817
+ handleProductStatus(results) {
818
+ this.$NsProductDataService.handleProductStatus(results);
819
+ },
820
+
821
+ /**
822
+ * Subscribe to events, should only be called when mounted
823
+ */
824
+ async addListeners() {
825
+ // watch app service finished loading
826
+ this.$watch(
827
+ "!$NsProductAppService.loading",
828
+ async () => await this.init(),
829
+ {
830
+ immediate: true
831
+ }
832
+ );
833
+
834
+ // listen to config changes
835
+ this.$NsProductAppService.$on("config-changed", async () => {
836
+ await this.init();
837
+ });
838
+
839
+ events.subscribe(events.authentication.REQUEST_FOR_LOGIN, function() {
840
+ CartService.clearCart();
841
+ });
842
+
843
+ // listen to product status changes
844
+ events.subscribe(
845
+ events.shop.PRODUCT_STATUS_RESULT,
846
+ this.handleProductStatus
847
+ );
848
+ },
849
+
850
+ /**
851
+ * Unsubscribe from events, should only be called when destroyed
852
+ */
853
+ removeListeners() {
854
+ // stop listening to product app service events
855
+ this.$NsProductAppService.$off();
856
+
857
+ // stop listening to product status changes
858
+ events.unsubscribe(
859
+ events.shop.PRODUCT_STATUS_RESULT,
860
+ this.handleProductStatus
861
+ );
862
+ }
863
+ }
864
+ };
865
+ </script>
866
+
867
+ <style lang="scss" module>
868
+ @use "sass:map";
869
+ @use "~@nuskin/ns-core-styles/src/scss/colors";
870
+ @use "~@nuskin/ns-core-styles/src/scss/other";
871
+ @use "~@nuskin/ns-core-styles/src/scss/typographyVariables";
872
+ @use "~@nuskin/ns-core-styles/src/scss/global.mixins";
873
+
874
+ * {
875
+ box-sizing: border-box;
876
+ -webkit-font-smoothing: antialiased;
877
+ }
878
+
879
+ .personalOfferDetails {
880
+ width: 100%;
881
+ background-color: white;
882
+ font-family: map.get(typographyVariables.$typography, font-family-primary);
883
+ }
884
+
885
+ .addToCartIcon {
886
+ position: relative;
887
+ top: 1px;
888
+ stroke-width: 1.5px;
889
+ }
890
+
891
+ section {
892
+ max-width: 1000px;
893
+ margin: auto;
894
+ }
895
+
896
+ .offerNotFound {
897
+ padding: 30px;
898
+ text-align: center;
899
+ }
900
+
901
+ .offerTitleMessaging {
902
+ max-width: 100%;
903
+ background-color: #f6f7f7;
904
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15);
905
+ }
906
+
907
+ .offerMessaging {
908
+ margin: auto;
909
+ padding: 12px 25px 25px 25px;
910
+ max-width: 600px;
911
+ color: rgb(68, 68, 68);
912
+ text-align: center;
913
+ }
914
+
915
+ .offerTitle {
916
+ font-size: 28px;
917
+ line-height: 34px;
918
+ font-weight: 100;
919
+ margin-block-start: 0.83em;
920
+ margin-block-end: 0.83em;
921
+ margin-inline-start: 0px;
922
+ margin-inline-end: 0px;
923
+ margin: 10px 0 10px 0;
924
+ }
925
+
926
+ .offerMessage {
927
+ margin: 0;
928
+ font-size: 16px;
929
+ line-height: 21px;
930
+ font-weight: 300;
931
+ }
932
+
933
+ .storePreferredName {
934
+ margin-top: 16px;
935
+ text-transform: uppercase;
936
+ font-weight: 400;
937
+ }
938
+
939
+ .offerBody {
940
+ &.showOffer {
941
+ padding-bottom: 50px;
942
+ }
943
+ }
944
+
945
+ .offerPurchaseAll {
946
+ display: flex;
947
+ flex-direction: column;
948
+ align-items: center;
949
+ margin: 40px 0;
950
+ }
951
+
952
+ .offerTotalContainer {
953
+ display: flex;
954
+ font-size: 18px;
955
+ line-height: 24px;
956
+ font-weight: 600;
957
+ gap: 5px;
958
+
959
+ .originalOfferTotal {
960
+ font-weight: 300;
961
+ }
962
+
963
+ .offerTotal {
964
+ font-weight: 600;
965
+ }
966
+ }
967
+
968
+ .offerSavingsContainer {
969
+ display: flex;
970
+ font-weight: 300;
971
+ gap: 4px;
972
+ }
973
+
974
+ .offerAction {
975
+ margin-top: 1em;
976
+ }
977
+
978
+ .offerProducts {
979
+ display: flex;
980
+ flex-wrap: wrap;
981
+ margin: 0 -7.5px;
982
+ justify-content: center;
983
+ }
984
+
985
+ .offerProduct {
986
+ margin: auto;
987
+ padding: 0 7.5px;
988
+ margin-bottom: 48px;
989
+
990
+ &.invalid {
991
+ box-shadow: #c23934 0px 1px 10px 1px;
992
+ }
993
+ }
994
+
995
+ .spinnerWrapper {
996
+ padding: 40px;
997
+ display: flex;
998
+ justify-content: center;
999
+ width: 100%;
1000
+ }
1001
+ </style>
package/index.js CHANGED
@@ -32,6 +32,7 @@ import NsProductCarousel from "./components/NsProductCarousel.vue";
32
32
  import NsProductList from "./components/NsProductList.vue";
33
33
  import NsProductListSortable from "./components/NsProductListSortable.vue";
34
34
  import NsProductLine from "./components/NsProductLine.vue";
35
+ import NsProductOffer from "./components/NsProductOffer.vue";
35
36
 
36
37
  export {
37
38
  // Services
@@ -50,5 +51,6 @@ export {
50
51
  NsProductCarousel,
51
52
  NsProductList,
52
53
  NsProductListSortable,
53
- NsProductLine
54
+ NsProductLine,
55
+ NsProductOffer
54
56
  };
@@ -337,7 +337,10 @@ const NsProductMixin = {
337
337
  this.resetProductData(sku);
338
338
 
339
339
  this.activeSku = sku;
340
+
340
341
  this.setFromProductData(sku);
342
+
343
+ this.$emit("active-sku", this.activeSku);
341
344
  },
342
345
 
343
346
  setFromProductData(sku, product) {
@@ -347,6 +350,7 @@ const NsProductMixin = {
347
350
 
348
351
  if (!product) {
349
352
  this.$NsProductDataService.queue(sku);
353
+ this.emitAvailability();
350
354
  return;
351
355
  }
352
356
 
@@ -536,8 +540,7 @@ const NsProductMixin = {
536
540
  */
537
541
  setProductAvailability() {
538
542
  if (this.product) {
539
- // TODO: replace Product in ns-shop with the one from ns-product-lib
540
- // WARNING: There's a instanceof check on CartService.getAddToCartQty for the ns-shop Product
543
+ // WARNING: There's a instanceof check on CartService.getAddToCartQty for the ns-shop Product, previously different
541
544
  const shopProduct = new ShopProduct(this.product);
542
545
  // check if a user is qualified to purchase the product
543
546
  CartService.getAddToCartQty(shopProduct)
@@ -556,6 +559,7 @@ const NsProductMixin = {
556
559
  .finally(() => {
557
560
  this.setStatus();
558
561
  this.checkedQualifications = true;
562
+ this.emitAvailability();
559
563
  });
560
564
  }
561
565
  },
@@ -578,10 +582,25 @@ const NsProductMixin = {
578
582
  const productQualification = await QualificationService.getQualification(
579
583
  this.activeSku
580
584
  );
581
- const cartItem = await CartService.getFirstItemBySku(this.activeSku, {
582
- cartOrderItems: true
583
- });
584
- const cartQuantity = (cartItem || {}).qty || 0;
585
+
586
+ let cartQuantity;
587
+ if (!this.baseSku) {
588
+ const cartItem = await CartService.getFirstItemBySku(
589
+ this.activeSku,
590
+ {
591
+ cartOrderItems: true
592
+ }
593
+ );
594
+ cartQuantity = (cartItem || {}).qty || 0;
595
+ } else {
596
+ const cartData =
597
+ (await QualificationService.convertCartDataToBaseSkus(
598
+ CartService.getItemData(),
599
+ true
600
+ )) || {};
601
+ const cartItem = cartData[this.baseSku] || {};
602
+ cartQuantity = cartItem.qty || 0;
603
+ }
585
604
 
586
605
  if (!productQualification) {
587
606
  // the event has never been set up or is in the past (post-event/qualification not configured)
@@ -807,11 +826,28 @@ const NsProductMixin = {
807
826
  ) {
808
827
  this.disableAddToCart = true;
809
828
  this.disableAddToAdr = true;
829
+
830
+ this.$emit("disable-add-to-cart", quantity);
810
831
  } else if (this.userIsQualified) {
811
832
  this.checkAddToCartAndAdr();
812
833
  }
813
834
 
814
835
  this.$emit("quantity-select", quantity);
836
+ this.emitAvailability();
837
+ },
838
+
839
+ emitAvailability() {
840
+ this.$emit("availability", {
841
+ isBaseSku: this.isBaseSku,
842
+ isVariantSku: this.isVariantSku,
843
+ addToCart: !this.disableAddToCart,
844
+ addToAdr: !this.disableAddToCart,
845
+ selectedQuantity: Number(this.selectedQuantity),
846
+ maxQuantity: this.maxQuantity,
847
+ originalPrice: this.originalPrice,
848
+ price: this.price,
849
+ adrPrice: this.adrPrice
850
+ });
815
851
  },
816
852
 
817
853
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuskin/product-components",
3
- "version": "3.0.4",
3
+ "version": "3.1.0-product-offer.2",
4
4
  "description": "Nu Skin Product Components",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -24,10 +24,10 @@
24
24
  "dependencies": {
25
25
  "@mdi/font": "3.9.97",
26
26
  "@nuskin/design-components": "5.36.1",
27
- "@nuskin/ns-common-lib": "1.3.0",
27
+ "@nuskin/ns-common-lib": "1.4.5",
28
28
  "@nuskin/ns-core-styles": "2.11.2",
29
29
  "@nuskin/ns-loyalty-web": "1.5.5",
30
- "@nuskin/ns-product-lib": "1.3.4",
30
+ "@nuskin/ns-product-lib": "1.4.2",
31
31
  "@nuskin/product-recommendation": "2.0.1",
32
32
  "axios": "^0.19.2",
33
33
  "lodash": "^4.17.15",
@@ -47,6 +47,7 @@
47
47
  "@nuskin/ns-feature-flags": "1.x",
48
48
  "@nuskin/ns-product": "3.x",
49
49
  "@nuskin/ns-shop": "5.x",
50
- "@nuskin/ns-util": "3.x"
50
+ "@nuskin/ns-util": "3.x",
51
+ "@nuskin/my-site-api": "3.x"
51
52
  }
52
53
  }
@@ -1,4 +1,4 @@
1
- import { Meta, Description } from '@storybook/addon-docs/blocks';
1
+ import { Meta, Description } from '@storybook/addon-docs';
2
2
  import GettingStarted from "../README.md";
3
3
 
4
4
  <Meta title="Product Components/Getting Started" />
@@ -1,4 +1,4 @@
1
- import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
1
+ import { Meta, Story, Preview, Props } from '@storybook/addon-docs';
2
2
 
3
3
  import NsStorybookToolbox from "./NsStorybookToolbox.vue";
4
4
  import NsProductMixinPreview from "./NsProductMixinPreview.vue";
@@ -0,0 +1,51 @@
1
+ import NsStorybookToolbox from "./NsStorybookToolbox.vue";
2
+ import NsProductOffer from "../components/NsProductOffer.vue";
3
+
4
+ export default {
5
+ title: "Product Components/Ns Product Offer",
6
+ component: NsProductOffer
7
+ };
8
+
9
+ const Template = (args, { argTypes }) => ({
10
+ components: { NsStorybookToolbox, NsProductOffer },
11
+ props: Object.keys(argTypes),
12
+ data() {
13
+ return {
14
+ key: Date.now(),
15
+ args
16
+ };
17
+ },
18
+ methods: {
19
+ reloadProductList() {
20
+ this.key = Date.now();
21
+ }
22
+ },
23
+ template: `
24
+ <div style="width: 100vw">
25
+ <ns-storybook-toolbox @changes="reloadProductList" />
26
+ <ns-product-offer
27
+ :key="key"
28
+ v-bind="$props"
29
+ v-on="args"
30
+ />
31
+ </div>
32
+ `
33
+ });
34
+
35
+ export const Simple = Template.bind({});
36
+ Simple.args = {
37
+ storeId: "JA10153135",
38
+ offerId: "-MqCvG0kuPDgU1dYV6Dv"
39
+ };
40
+ Simple.parameters = {
41
+ docs: {
42
+ source: {
43
+ code: `
44
+ <NsProductOffer
45
+ store-id="JA10153135",
46
+ offer-id="-MqCvG0kuPDgU1dYV6Dv"
47
+ />
48
+ `
49
+ }
50
+ }
51
+ };
@@ -1,4 +1,4 @@
1
- import { Meta, Description } from '@storybook/addon-docs/blocks';
1
+ import { Meta, Description } from '@storybook/addon-docs';
2
2
  import ChangeLog from "../CHANGELOG.md";
3
3
 
4
4
  <Meta title="Product Components/Releases" />