@zoxllc/shopify-checkout-extensions 0.0.1

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 (41) hide show
  1. package/README.md +3 -0
  2. package/package.json +31 -0
  3. package/src/common/AndSelector.ts +35 -0
  4. package/src/common/BuyXGetY.ts +109 -0
  5. package/src/common/Campaign.ts +55 -0
  6. package/src/common/CampaignConfiguration.ts +10 -0
  7. package/src/common/CampaignFactory.ts +180 -0
  8. package/src/common/CartAmountQualifier.ts +64 -0
  9. package/src/common/CartHasItemQualifier.ts +64 -0
  10. package/src/common/CartQuantityQualifier.ts +73 -0
  11. package/src/common/CustomerEmailQualifier.ts +60 -0
  12. package/src/common/CustomerSubscriberQualifier.ts +32 -0
  13. package/src/common/CustomerTagQualifier.ts +56 -0
  14. package/src/common/DiscountCart.ts +130 -0
  15. package/src/common/DiscountInterface.ts +13 -0
  16. package/src/common/OrSelector.ts +35 -0
  17. package/src/common/PostCartAmountQualifier.ts +21 -0
  18. package/src/common/ProductHandleSelector.ts +29 -0
  19. package/src/common/ProductIdSelector.ts +28 -0
  20. package/src/common/ProductTagSelector.ts +54 -0
  21. package/src/common/ProductTypeSelector.ts +34 -0
  22. package/src/common/Qualifier.ts +67 -0
  23. package/src/common/SaleItemSelector.ts +35 -0
  24. package/src/common/Selector.ts +53 -0
  25. package/src/common/SubscriptionItemSelector.ts +21 -0
  26. package/src/generated/api.ts +2103 -0
  27. package/src/index.ts +39 -0
  28. package/src/lineItem/ConditionalDiscount.ts +102 -0
  29. package/src/lineItem/DiscountCodeList.ts +91 -0
  30. package/src/lineItem/FixedItemDiscount.ts +53 -0
  31. package/src/lineItem/PercentageDiscount.ts +46 -0
  32. package/tests/AndSelector.test.ts +27 -0
  33. package/tests/CartQuantityQualifier.test.ts +381 -0
  34. package/tests/CustomerSubscriberQualifier.test.ts +101 -0
  35. package/tests/DiscountCart.test.ts +115 -0
  36. package/tests/OrSelector.test.ts +27 -0
  37. package/tests/ProductTagSelector.test.ts +75 -0
  38. package/tests/Qualifier.test.ts +193 -0
  39. package/tests/SaleItemSelector.test.ts +113 -0
  40. package/tests/Selector.test.ts +83 -0
  41. package/tsconfig.json +25 -0
@@ -0,0 +1,32 @@
1
+ import type { DiscountCart } from "./DiscountCart";
2
+ import { QualifierMatchType, Qualifier } from "./Qualifier";
3
+ import { type Selector } from "./Selector";
4
+
5
+ export class CustomerSubscriberQualifier extends Qualifier {
6
+ invert: boolean;
7
+
8
+ /**
9
+ * Whether the customer contains an active subscription or not.
10
+ * @param matchType Pass :DOES or :DOES_NOT respectively
11
+ */
12
+ constructor(matchType: QualifierMatchType) {
13
+ super();
14
+ this.invert = matchType == QualifierMatchType.DOES_NOT;
15
+ }
16
+
17
+ /**
18
+ * Determines if the customer has specified tags
19
+ * @param discountCart DiscountCart
20
+ * @param selector Selector
21
+ */
22
+ match(discountCart: DiscountCart, selector?: Selector) {
23
+ const matchResult =
24
+ discountCart.cart.buyerIdentity?.customer?.isActiveSubscriber || false;
25
+
26
+ if (this.invert) {
27
+ return !matchResult;
28
+ }
29
+
30
+ return matchResult;
31
+ }
32
+ }
@@ -0,0 +1,56 @@
1
+ import type { DiscountCart } from "./DiscountCart";
2
+ import {
3
+ StringComparisonType,
4
+ QualifierMatchType,
5
+ Qualifier,
6
+ } from "./Qualifier";
7
+ import { type Selector } from "./Selector";
8
+
9
+ export class CustomerTagQualifier extends Qualifier {
10
+ invert: boolean;
11
+ matchCondition: StringComparisonType;
12
+ tags: string[];
13
+
14
+ constructor(
15
+ matchType: QualifierMatchType,
16
+ matchCondition: StringComparisonType,
17
+ tags: string[]
18
+ ) {
19
+ super();
20
+ this.invert = matchType == QualifierMatchType.DOES_NOT;
21
+ this.matchCondition = matchCondition;
22
+ this.tags = tags.map((e) => e.toLowerCase());
23
+ }
24
+
25
+ /**
26
+ * Determines if the customer has specified tags
27
+ * @param discountCart DiscountCart
28
+ * @param selector Selector
29
+ */
30
+ match(discountCart: DiscountCart, selector?: Selector) {
31
+ const matchResult =
32
+ (
33
+ discountCart.cart.buyerIdentity?.customer?.hasTags.filter((t) => {
34
+ if (t.hasTag) {
35
+ switch (this.matchCondition) {
36
+ case StringComparisonType.MATCH:
37
+ return this.tags.includes(t.tag);
38
+ case StringComparisonType.CONTAINS:
39
+ return this.tags.filter((e) => e.includes(t.tag)).length > 0;
40
+ case StringComparisonType.START_WITH:
41
+ return this.tags.filter((e) => e.startsWith(t.tag)).length > 0;
42
+ case StringComparisonType.END_WITH:
43
+ return this.tags.filter((e) => e.endsWith(t.tag)).length > 0;
44
+ }
45
+ }
46
+ return false;
47
+ }) || []
48
+ ).length > 0;
49
+
50
+ if (this.invert) {
51
+ return !matchResult;
52
+ }
53
+
54
+ return matchResult;
55
+ }
56
+ }
@@ -0,0 +1,130 @@
1
+ import type {
2
+ RunInput,
3
+ Discount,
4
+ } from "extensions/zox-product-discount/generated/api";
5
+
6
+ export class DiscountCart {
7
+ cart: RunInput["cart"];
8
+ subtotalAmount: number;
9
+ appliedDiscountTotal: number;
10
+ discounts: Discount[];
11
+
12
+ constructor(cart: RunInput["cart"]) {
13
+ this.cart = cart;
14
+ this.subtotalAmount = 0;
15
+ this.appliedDiscountTotal = 0;
16
+ this.discounts = [];
17
+
18
+ this.setupCart(cart);
19
+ }
20
+
21
+ subtotalExcludingVariants(variants: string[]) {
22
+ return this.cart.lines.reduce((prev, curr) => {
23
+ if (curr.merchandise.__typename == "ProductVariant") {
24
+ if (variants.indexOf(curr.merchandise.id) > -1) {
25
+ return prev - curr.cost.subtotalAmount.amount;
26
+ }
27
+ }
28
+
29
+ return prev;
30
+ }, this.subtotalExcludingGifts());
31
+ }
32
+
33
+ private subtotalExcludingGifts() {
34
+ return this.cart.lines.reduce((prev, curr) => {
35
+ if (curr.merchandise.__typename == "ProductVariant") {
36
+ if (
37
+ curr.merchandise.product.hasTags.filter(
38
+ (t) => t.hasTag && t.tag == "meta-exclude-gift"
39
+ ).length
40
+ ) {
41
+ return prev - curr.cost.subtotalAmount.amount;
42
+ }
43
+ }
44
+
45
+ return prev;
46
+ }, this.subtotalAmount);
47
+ }
48
+
49
+ totalLineItemQuantity() {
50
+ return this.cart.lines.reduce((total, item) => total + item.quantity, 0);
51
+ }
52
+
53
+ totalLineItemQuantityExcludingGifts() {
54
+ return this.cart.lines.reduce((total, item) => {
55
+ if (item.merchandise.__typename == "ProductVariant") {
56
+ if (
57
+ item.merchandise.product.hasTags.filter(
58
+ (t) => t.hasTag && t.tag == "meta-exclude-gift"
59
+ ).length
60
+ ) {
61
+ return total;
62
+ }
63
+ }
64
+ return total + item.quantity;
65
+ }, 0);
66
+ }
67
+
68
+ private setupCart(cart: RunInput["cart"]) {
69
+ this.subtotalAmount = cart.cost.subtotalAmount.amount as number;
70
+ // Adjust the subtotal amount by excluding the value from gift items
71
+ this.subtotalAmount = this.subtotalExcludingGifts();
72
+ this.appliedDiscountTotal = 0;
73
+ this.discounts = [];
74
+ }
75
+
76
+ addDiscount(discount: Discount) {
77
+ // Figure out how much discount has been applied
78
+ const totalDiscounts = discount.targets.reduce((total, target) => {
79
+ // gather target lineItem
80
+ const lineItems = this.cart.lines.filter((line) =>
81
+ line.merchandise.__typename == "ProductVariant"
82
+ ? line.merchandise.id == target.productVariant.id
83
+ : false
84
+ );
85
+
86
+ // loop over the line items and do maths
87
+ lineItems.forEach((line) => {
88
+ let discountableItems = target.productVariant.quantity
89
+ ? target.productVariant.quantity
90
+ : line.quantity;
91
+
92
+ const lineItemCost = parseFloat(line.cost.amountPerQuantity.amount);
93
+
94
+ // TODO: Make sure we're not discounting more items than are available on the line item quantity
95
+
96
+ // When it's a fixed amount, calculate the items per but only if it applies to each
97
+ // individual item...
98
+ if (discount.value.fixedAmount) {
99
+ if (discount.value.fixedAmount.appliesToEachItem) {
100
+ total +=
101
+ lineItemCost - discount.value.fixedAmount?.amount > 0
102
+ ? discount.value.fixedAmount?.amount * discountableItems
103
+ : lineItemCost * discountableItems;
104
+ } else {
105
+ total +=
106
+ lineItemCost - discount.value.fixedAmount?.amount > 0
107
+ ? discount.value.fixedAmount?.amount
108
+ : lineItemCost;
109
+ }
110
+ } else if (discount.value.percentage) {
111
+ total +=
112
+ lineItemCost *
113
+ (discount.value.percentage?.value / 100) *
114
+ discountableItems;
115
+ }
116
+ });
117
+
118
+ return total;
119
+ }, 0);
120
+
121
+ this.appliedDiscountTotal += totalDiscounts;
122
+ console.error("appliedDiscountTotal", this.appliedDiscountTotal);
123
+
124
+ this.discounts.push(discount);
125
+ }
126
+
127
+ reset() {
128
+ this.setupCart(this.cart);
129
+ }
130
+ }
@@ -0,0 +1,13 @@
1
+ import type {
2
+ CartLine,
3
+ Discount,
4
+ } from "extensions/zox-product-discount/generated/api";
5
+
6
+ export type ApplyDiscount = {
7
+ lineItem: CartLine;
8
+ maxDiscounts?: number;
9
+ };
10
+
11
+ export interface DiscountInterface {
12
+ apply(items: ApplyDiscount[]): Discount;
13
+ }
@@ -0,0 +1,35 @@
1
+ import type { DiscountCart } from "./DiscountCart";
2
+ import type { Qualifier } from "./Qualifier";
3
+ import type { Selector } from "./Selector";
4
+ import type { CartLine } from "extensions/zox-product-discount/generated/api";
5
+
6
+ export class OrSelector {
7
+ is_a: "OrSelector";
8
+ conditions: (Selector | Qualifier)[];
9
+
10
+ constructor(conditions: (Selector | Qualifier)[]) {
11
+ this.is_a = "OrSelector";
12
+ this.conditions = conditions;
13
+ }
14
+
15
+ match(item: CartLine | DiscountCart, selector?: Selector) {
16
+ try {
17
+ const conditionsResult = this.conditions
18
+ .map((condition) => {
19
+ if (selector) {
20
+ return (condition as Qualifier).match(
21
+ item as DiscountCart,
22
+ selector
23
+ );
24
+ } else {
25
+ return (condition as Selector).match(item as CartLine);
26
+ }
27
+ })
28
+ .filter((result) => result === true);
29
+
30
+ return conditionsResult.length > 0;
31
+ } catch (e) {
32
+ return false;
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,21 @@
1
+ import type { DiscountCart } from "./DiscountCart";
2
+ import { type NumericalComparisonType, Qualifier } from "./Qualifier";
3
+
4
+ export class PostCartAmountQualifier extends Qualifier {
5
+ comparisonType: NumericalComparisonType;
6
+ amount: number;
7
+
8
+ constructor(comparisonType: NumericalComparisonType, amount: number) {
9
+ super();
10
+ this.comparisonType = comparisonType;
11
+ this.amount = amount;
12
+ }
13
+
14
+ match(discountCart: DiscountCart) {
15
+ return this.compareAmounts(
16
+ discountCart.subtotalAmount - discountCart.appliedDiscountTotal,
17
+ this.comparisonType,
18
+ this.amount
19
+ );
20
+ }
21
+ }
@@ -0,0 +1,29 @@
1
+ import type {
2
+ CartLine,
3
+ ProductVariant,
4
+ } from "extensions/zox-product-discount/generated/api";
5
+ import { MatchType, Selector } from "./Selector";
6
+
7
+ export class ProductHandleSelector extends Selector {
8
+ invert: boolean;
9
+ handles: string[];
10
+
11
+ constructor(matchType: MatchType, handles: string[]) {
12
+ super();
13
+ this.invert = matchType == MatchType.NOT_ONE;
14
+ this.handles = handles.map((t) => t.toLowerCase());
15
+ }
16
+
17
+ match(lineItem: CartLine) {
18
+ const variant = lineItem.merchandise as ProductVariant;
19
+
20
+ const matchResult =
21
+ this.handles.indexOf(variant.product.handle.toLowerCase()) > -1;
22
+
23
+ if (this.invert) {
24
+ return !matchResult;
25
+ }
26
+
27
+ return matchResult;
28
+ }
29
+ }
@@ -0,0 +1,28 @@
1
+ import type {
2
+ CartLine,
3
+ ProductVariant,
4
+ } from "extensions/zox-product-discount/generated/api";
5
+ import { MatchType, Selector } from "./Selector";
6
+
7
+ export class ProductIdSelector extends Selector {
8
+ invert: boolean;
9
+ productIds: string[];
10
+
11
+ constructor(matchType: MatchType, productIds: string[]) {
12
+ super();
13
+ this.invert = matchType == MatchType.NOT_ONE;
14
+ this.productIds = productIds;
15
+ }
16
+
17
+ match(lineItem: CartLine) {
18
+ const variant = lineItem.merchandise as ProductVariant;
19
+
20
+ const matchResult = this.productIds.includes(variant.product.id);
21
+
22
+ if (this.invert) {
23
+ return !matchResult;
24
+ }
25
+
26
+ return matchResult;
27
+ }
28
+ }
@@ -0,0 +1,54 @@
1
+ import type {
2
+ CartLine,
3
+ ProductVariant,
4
+ } from "extensions/zox-product-discount/generated/api";
5
+ import { Selector } from "./Selector";
6
+ import { QualifierMatchType, StringComparisonType } from "./Qualifier";
7
+
8
+ export class ProductTagSelector extends Selector {
9
+ invert: boolean;
10
+ matchCondition: StringComparisonType;
11
+ tags: string[];
12
+
13
+ constructor(
14
+ matchType: QualifierMatchType,
15
+ matchCondition: StringComparisonType,
16
+ tags: string[]
17
+ ) {
18
+ super();
19
+ this.matchCondition = matchCondition;
20
+ this.invert = matchType == QualifierMatchType.DOES_NOT;
21
+ this.tags = tags.map((t) => t.toLowerCase());
22
+ }
23
+
24
+ match(lineItem: CartLine) {
25
+ const variant = lineItem.merchandise as ProductVariant;
26
+ const productTags = variant.product.hasTags.reduce((tags, t) => {
27
+ if (t.hasTag) {
28
+ tags.push(t.tag.toLowerCase());
29
+ }
30
+ return tags;
31
+ }, []);
32
+
33
+ let matchResult: boolean;
34
+
35
+ switch (this.matchCondition) {
36
+ case StringComparisonType.MATCH:
37
+ matchResult =
38
+ productTags.filter((tag) => this.tags.includes(tag)).length > 0;
39
+ break;
40
+ default:
41
+ matchResult = this.partialMatch(
42
+ this.matchCondition,
43
+ productTags,
44
+ this.tags
45
+ );
46
+ }
47
+
48
+ if (this.invert) {
49
+ return !matchResult;
50
+ }
51
+
52
+ return matchResult;
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ import type {
2
+ CartLine,
3
+ ProductVariant,
4
+ } from "extensions/zox-product-discount/generated/api";
5
+ import { MatchType, Selector } from "./Selector";
6
+
7
+ export class ProductTypeSelector extends Selector {
8
+ invert: boolean;
9
+ productTypes: string[];
10
+
11
+ constructor(matchType: MatchType, productTypes: string[]) {
12
+ super();
13
+ this.invert = matchType == MatchType.NOT_ONE;
14
+ this.productTypes = productTypes.map((item) => item.toLowerCase());
15
+ }
16
+
17
+ match(lineItem: CartLine) {
18
+ const variant = lineItem.merchandise as ProductVariant;
19
+
20
+ if (variant.product.productType) {
21
+ const matchResult = this.productTypes.includes(
22
+ variant.product.productType.toLowerCase()
23
+ );
24
+
25
+ if (this.invert) {
26
+ return !matchResult;
27
+ }
28
+
29
+ return matchResult;
30
+ }
31
+
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,67 @@
1
+ import type { DiscountCart } from './DiscountCart';
2
+ import type { Selector } from './Selector';
3
+
4
+ export enum QualifierMatchType {
5
+ DOES = ':does',
6
+ DOES_NOT = ':does_not',
7
+ }
8
+
9
+ export enum StringComparisonType {
10
+ MATCH = ':match',
11
+ CONTAINS = ':contains',
12
+ START_WITH = ':start_with',
13
+ END_WITH = ':end_with',
14
+ }
15
+
16
+ export enum NumericalComparisonType {
17
+ GREATER_THAN = ':greater_than',
18
+ GREATER_THAN_OR_EQUAL = ':greater_than_or_equal',
19
+ LESS_THAN = ':less_than',
20
+ LESS_THAN_OR_EQUAL = ':less_than_or_equal',
21
+ EQUAL_TO = ':equal_to',
22
+ }
23
+
24
+ // export enum ComparisonType {
25
+ // GREATER_THAN = ":greater_than",
26
+ // GREATER_THAN_OR_EQUAL = ":greater_than_or_equal",
27
+ // LESS_THAN = ":less_than",
28
+ // LESS_THAN_OR_EQUAL = ":less_than_or_equal",
29
+ // EQUAL_TO = ":equal_to",
30
+ // }
31
+
32
+ export enum QualifierBehavior {
33
+ ALL = ':all',
34
+ ANY = ':any',
35
+ }
36
+ export class Qualifier {
37
+ is_a: 'Qualifier';
38
+
39
+ constructor() {
40
+ this.is_a = 'Qualifier';
41
+ }
42
+
43
+ compareAmounts(
44
+ compare: number,
45
+ comparisonType: NumericalComparisonType,
46
+ compareTo: number
47
+ ) {
48
+ switch (comparisonType) {
49
+ case NumericalComparisonType.GREATER_THAN:
50
+ return compare > compareTo;
51
+ case NumericalComparisonType.GREATER_THAN_OR_EQUAL:
52
+ return compare >= compareTo;
53
+ case NumericalComparisonType.LESS_THAN:
54
+ return compare < compareTo;
55
+ case NumericalComparisonType.LESS_THAN_OR_EQUAL:
56
+ return compare <= compareTo;
57
+ case NumericalComparisonType.EQUAL_TO:
58
+ return compare == compareTo;
59
+ default:
60
+ throw new Error('Invalid comparison type');
61
+ }
62
+ }
63
+
64
+ match(discountCart: DiscountCart, selector?: Selector) {
65
+ return false;
66
+ }
67
+ }
@@ -0,0 +1,35 @@
1
+ import { CartLine } from '../generated/api';
2
+ import { Selector } from './Selector';
3
+
4
+ export enum SaleItemSelectorMatchType {
5
+ IS = ':is',
6
+ NOT = ':not',
7
+ }
8
+
9
+ export class SaleItemSelector extends Selector {
10
+ invert: boolean;
11
+
12
+ constructor(matchType: SaleItemSelectorMatchType) {
13
+ super();
14
+ // Unlike other common scenarios, we invert if we pass an :IS
15
+ // because it is easier to figure out of the item is NOT on sale.
16
+ this.invert = matchType == SaleItemSelectorMatchType.IS;
17
+ }
18
+
19
+ match(lineItem: CartLine) {
20
+ // Check to see if the item is NOT on sale
21
+ const matchResult =
22
+ !lineItem.cost.compareAtAmountPerQuantity ||
23
+ parseFloat(
24
+ lineItem.cost.compareAtAmountPerQuantity.amount
25
+ ) <=
26
+ parseFloat(lineItem.cost.amountPerQuantity.amount);
27
+
28
+ // If we want to check that the item IS on sale, we'll invert the result
29
+ if (this.invert) {
30
+ return !matchResult;
31
+ }
32
+
33
+ return matchResult;
34
+ }
35
+ }
@@ -0,0 +1,53 @@
1
+ import type { CartLine } from "extensions/zox-product-discount/generated/api";
2
+ import { StringComparisonType } from "./Qualifier";
3
+
4
+ export enum MatchType {
5
+ "ALL" = ":all",
6
+ "IS_ONE" = ":is_one",
7
+ "NOT_ONE" = ":not_one",
8
+ "DOES" = ":does",
9
+ "DOES_NOT" = ":does_not",
10
+ }
11
+
12
+ export class Selector {
13
+ match(subject: CartLine, selector?: Selector) {
14
+ return true;
15
+ }
16
+
17
+ /**
18
+ * Checks each member of the subject array for possible matches. Returns true when any possible match is made.
19
+ * @param matchType StringComparisonType
20
+ * @param itemInfo string | string[]
21
+ * @param possibleMatches string[]
22
+ * @returns boolean
23
+ */
24
+ partialMatch(
25
+ matchType:
26
+ | StringComparisonType.CONTAINS
27
+ | StringComparisonType.START_WITH
28
+ | StringComparisonType.END_WITH,
29
+ itemInfo: string | string[],
30
+ possibleMatches: string[]
31
+ ) {
32
+ // Convert to an array if it isn't one
33
+ const subject = Array.isArray(itemInfo) ? itemInfo : [itemInfo];
34
+ let foundMatch = false;
35
+ possibleMatches.forEach((possibility) => {
36
+ subject.forEach((search) => {
37
+ switch (matchType) {
38
+ case StringComparisonType.CONTAINS:
39
+ if (search.search(possibility) > -1) foundMatch = true;
40
+ break;
41
+ case StringComparisonType.START_WITH:
42
+ if (search.startsWith(possibility)) foundMatch = true;
43
+ break;
44
+ case StringComparisonType.END_WITH:
45
+ if (search.endsWith(possibility)) foundMatch = true;
46
+ break;
47
+ }
48
+ });
49
+ });
50
+
51
+ return foundMatch;
52
+ }
53
+ }
@@ -0,0 +1,21 @@
1
+ import type { CartLine } from "extensions/zox-product-discount/generated/api";
2
+ import { MatchType, Selector } from "./Selector";
3
+
4
+ export class SubscriptionItemSelector extends Selector {
5
+ invert: boolean;
6
+
7
+ constructor(matchType: MatchType) {
8
+ super();
9
+ this.invert = matchType == MatchType.NOT_ONE;
10
+ }
11
+
12
+ match(lineItem: CartLine) {
13
+ const matchResult = lineItem.sellingPlanAllocation !== undefined;
14
+
15
+ if (this.invert) {
16
+ return !matchResult;
17
+ }
18
+
19
+ return false;
20
+ }
21
+ }