Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(default-theme): promotion code functionality #1155

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1774853
Merge pull request #1 from DivanteLtd/master
niklaswolf Sep 21, 2020
e62deee
Merge pull request #2 from DivanteLtd/master
niklaswolf Sep 22, 2020
1196cec
Merge remote-tracking branch 'upstream/master'
niklaswolf Sep 28, 2020
250bab4
Merge remote-tracking branch 'upstream/master'
niklaswolf Oct 5, 2020
c472945
feat: added functionality to promo code input
niklaswolf Oct 5, 2020
2ca922a
test: added promotion code tests
niklaswolf Oct 5, 2020
f25c9da
docs: added promotion codes to feature list
niklaswolf Oct 5, 2020
1b98965
docs: updated composables.api.md
niklaswolf Oct 5, 2020
f4a3d15
Merge remote-tracking branch 'upstream/master' into 1154-promotion-co…
niklaswolf Oct 6, 2020
ccb06bd
feat: added toast notifications when adding coupon code
niklaswolf Oct 8, 2020
04130c0
Merge remote-tracking branch 'upstream/master' into 1154-promotion-co…
niklaswolf Oct 8, 2020
7597f97
Merge branch 'master' into 1154-promotion-code-functionality
niklaswolf Oct 9, 2020
352f393
Merge branch 'master' into 1154-promotion-code-functionality
niklaswolf Oct 9, 2020
499012a
Update typings
niklaswolf Oct 9, 2020
be85189
Update typings
niklaswolf Oct 9, 2020
37bee8f
feat: refactoring to trigger notification through interceptor
niklaswolf Oct 12, 2020
7b68155
Merge branch 'master' into pr/niklaswolf/1155
patzick Oct 12, 2020
7c12f93
chore: generate docs with composable api file
patzick Oct 12, 2020
6b9b7ef
Merge branch 'master' into pr/niklaswolf/1155
patzick Oct 12, 2020
a2b093d
refactor: interfaces after cr
patzick Oct 12, 2020
e82b924
chore: docs after cr changes
patzick Oct 12, 2020
17f16c9
chore: remove passing method as prop
patzick Oct 12, 2020
ec90bc2
fix: errors during manual tests
patzick Oct 13, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,178 changes: 592 additions & 586 deletions api/composables.api.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/guide/FEATURELIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ sidebar: auto
* Go to Checkout button

### Checkout / Payment

* Add/Remove promotion codes
* Select shipping address
* Select billing address
* Select payment & shipping method
Expand Down
66 changes: 66 additions & 0 deletions packages/composables/__tests__/useCart.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,31 @@ describe("Composables - useCart", () => {
expect(subtotal.value).toEqual(123);
});
});
describe("appliedPromotionCodes", () => {
it("should be empty array on not loaded cart", () => {
stateCart.value = null;
const { appliedPromotionCodes } = useCart(rootContextMock);
expect(appliedPromotionCodes.value).toEqual([]);
});

it("should return an array", () => {
stateCart.value = {};
const { appliedPromotionCodes } = useCart(rootContextMock);
expect(appliedPromotionCodes.value).toEqual([]);
});

it("should return only promotion items", () => {
stateCart.value = {
lineItems: [
{ quantity: 2, type: "product" },
{ quantity: 3, type: "product" },
{ quantity: 1, type: "promotion" },
],
};
const { appliedPromotionCodes } = useCart(rootContextMock);
expect(appliedPromotionCodes.value.length).toEqual(1);
});
});
});

describe("methods", () => {
Expand Down Expand Up @@ -212,6 +237,47 @@ describe("Composables - useCart", () => {
});
});

describe("addPromotionCode", () => {
it("should add promotion code to cart", async () => {
const { appliedPromotionCodes, addPromotionCode } = useCart(
rootContextMock
);
expect(appliedPromotionCodes.value).toEqual([]);
mockedShopwareClient.addPromotionCode.mockResolvedValueOnce({
lineItems: [{ quantity: 1, type: "promotion" }],
} as any);
await addPromotionCode("test-code");
expect(appliedPromotionCodes.value.length).toEqual(1);
});
});

describe("removePromotionCode", () => {
it("should remove promotion code from cart", async () => {
const { appliedPromotionCodes, removePromotionCode } = useCart(
rootContextMock
);
stateCart.value = {
lineItems: [{ quantity: 1, type: "promotion" }],
};
expect(appliedPromotionCodes.value.length).toEqual(1);
mockedShopwareClient.removeCartItem.mockResolvedValueOnce({
lineItems: [],
} as any);
await removePromotionCode({ id: "qwe" });
expect(appliedPromotionCodes.value.length).toEqual(0);
});

it("should invoke client with correct params", async () => {
const { removeProduct } = useCart(rootContextMock);
mockedShopwareClient.removeCartItem.mockResolvedValueOnce({} as any);
await removeProduct({ id: "qwe" });
expect(mockedShopwareClient.removeCartItem).toBeCalledWith(
"qwe",
rootContextMock.$shopwareApiInstance
);
});
});

describe("changeProductQuantity", () => {
it("should change product quantity in cart", async () => {
const { count, changeProductQuantity } = useCart(rootContextMock);
Expand Down
52 changes: 50 additions & 2 deletions packages/composables/src/hooks/useCart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,32 @@ import { ref, Ref, computed } from "@vue/composition-api";
import {
getCart,
addProductToCart,
addPromotionCode,
removeCartItem,
changeCartItemQuantity,
} from "@shopware-pwa/shopware-6-client";
import { ClientApiError } from "@shopware-pwa/commons/interfaces/errors/ApiError";
import { Cart } from "@shopware-pwa/commons/interfaces/models/checkout/cart/Cart";
import { Product } from "@shopware-pwa/commons/interfaces/models/content/product/Product";
import { LineItem } from "@shopware-pwa/commons/interfaces/models/checkout/cart/line-item/LineItem";
import { getApplicationContext } from "@shopware-pwa/composables";
import {
getApplicationContext,
useNotifications,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notifications Should not be used inside other composables. Users need to have a choice of how they want to react on events across the system. Use interceptors instead: https://shopware-pwa-docs.vuestorefront.io/landing/concepts/interceptor.html#events-interceptor

} from "@shopware-pwa/composables";
import { ApplicationVueContext } from "../../appContext";

const TYPE_PROMOTION = "promotion";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be a type in commons package
example

export type CartItemType = "promotion" | "product"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right and there even is a CartItemType interface already with the needed types... I'll use that one.

const TYPE_PRODUCT = "product";

/**
* interface for {@link useCart} composable
*
* @beta
*/
export interface IUseCart {
addProduct: ({ id, quantity }: { id: string; quantity?: number }) => void;
addPromotionCode: (promoCode: string) => void;
appliedPromotionCodes: Readonly<Ref<Readonly<LineItem[]>>>;
niklaswolf marked this conversation as resolved.
Show resolved Hide resolved
cart: Readonly<Ref<Readonly<Cart>>>;
cartItems: Readonly<Ref<Readonly<LineItem[]>>>;
changeProductQuantity: ({
Expand All @@ -33,6 +42,7 @@ export interface IUseCart {
loading: Readonly<Ref<Readonly<boolean>>>;
refreshCart: () => void;
removeProduct: ({ id }: Partial<Product>) => void;
removePromotionCode: ({ id }: Partial<Product>) => void;
niklaswolf marked this conversation as resolved.
Show resolved Hide resolved
totalPrice: Readonly<Ref<Readonly<number>>>;
subtotal: Readonly<Ref<Readonly<number>>>;
}
Expand All @@ -51,6 +61,8 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => {
const loading: Ref<boolean> = ref(false);
const error: Ref<any> = ref(null);

const { pushSuccess, pushError } = useNotifications(rootContext);

async function refreshCart(): Promise<void> {
loading.value = true;
try {
Expand Down Expand Up @@ -85,6 +97,39 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => {
vuexStore.commit("SET_CART", result);
}

async function submitPromotionCode(promoCode: string) {
try {
const result = await addPromotionCode(promoCode, apiInstance);
vuexStore.commit("SET_CART", result);

// It's strange that success also ends up as an error in the API response
const err = <any>Object.values(result.errors)[0];
switch (err.messageKey) {
case "promotion-discount-added":
pushSuccess(rootContext.$t("Promotion code added successfully"));
break;
case "promotion-not-found":
pushError(rootContext.$t("Promotion code does not exist"));
break;
default:
pushError(err.message.toString());
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic should go into notifications.js

} catch (e) {
const err: ClientApiError = e;
error.value = err.message;
}
}

async function removePromotionCode(lineItem: Product) {
await removeProduct(lineItem);
}

const appliedPromotionCodes = computed(() => {
return cartItems.value.filter(
(cartItem) => cartItem.type === TYPE_PROMOTION
);
});

const cart: Readonly<Ref<Readonly<Cart>>> = computed(() => {
return vuexStore.getters.getCart;
});
Expand All @@ -96,7 +141,7 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => {
const count = computed(() => {
return cartItems.value.reduce(
(accumulator: number, lineItem: LineItem) =>
lineItem.type === "product"
lineItem.type === TYPE_PRODUCT
? lineItem.quantity + accumulator
: accumulator,
0
Expand All @@ -116,6 +161,8 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => {

return {
addProduct,
addPromotionCode: submitPromotionCode,
appliedPromotionCodes,
cart,
cartItems,
changeProductQuantity,
Expand All @@ -124,6 +171,7 @@ export const useCart = (rootContext: ApplicationVueContext): IUseCart => {
loading,
refreshCart,
removeProduct,
removePromotionCode,
totalPrice,
subtotal,
};
Expand Down
104 changes: 104 additions & 0 deletions packages/default-theme/components/SwPromoCode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<template>
<div class="promo-code">
<div class="promo-code__input-wrapper">
<SwInput
v-model="promoCode"
name="promoCode"
:label="$t('Enter promo code')"
class="sf-input--filled promo-code__input"
@keyup.enter="addPromotionCode(promoCode)"
/>
<SfCircleIcon
class="promo-code__circle-icon"
icon="check"
@click="addPromotionCode(promoCode)"
/>
</div>
<div v-if="showPromotionCodes" class="applied-codes">
<SfHeading
:title="$t('Applied promo codes:')"
:level="4"
class="sf-heading--left sf-heading--no-underline title"
/>
<ul class="applied-codes__list">
<SwPromoCodeItem
v-for="appliedPromotionCode in appliedPromotionCodes"
:key="appliedPromotionCode.id"
:code="appliedPromotionCode"
:remove-promotion-code="removePromotionCode"
/>
</ul>
</div>
</div>
</template>

<script>
import SwInput from "@/components/atoms/SwInput"
import { SfCircleIcon, SfHeading } from "@storefront-ui/vue"
import SwPromoCodeItem from "@/components/SwPromoCodeItem"
import { useCart } from "@shopware-pwa/composables"
import { computed } from "@vue/composition-api"

export default {
name: "SwPromoCode",
setup(props, { root }) {
const {
appliedPromotionCodes,
addPromotionCode,
removePromotionCode,
} = useCart(root)

const showPromotionCodes = computed(
() => appliedPromotionCodes.value.length > 0
)

return {
appliedPromotionCodes,
addPromotionCode,
removePromotionCode,
showPromotionCodes,
}
},
components: {
SwPromoCodeItem,
SfHeading,
SwInput,
SfCircleIcon,
},
data: () => {
return {
promoCode: "",
}
},
}
</script>

<style lang="scss" scoped>
@import "@/assets/scss/variables";

.promo-code {
padding: var(--spacer-lg) 0 var(--spacer-base) 0;
&__input-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-start;

.promo-code__circle-icon {
--button-size: 2rem;
--icon-size: 0.6875rem;
}

.promo-code__input {
--input-background: var(--c-white);
flex: 1;
margin: 0 var(--spacer-lg) 0 0;
}
}
.applied-codes {
&__list {
list-style: none;
padding: 0;
}
}
}
</style>
41 changes: 41 additions & 0 deletions packages/default-theme/components/SwPromoCodeItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<li class="promo-code-item">
<span>{{ code.label }}</span>
<SfCircleIcon
class="promo-code-item__remove"
icon="cross"
@click="removePromotionCode(code)"
/>
</li>
</template>

<script>
import { SfCircleIcon } from "@storefront-ui/vue"

export default {
name: "SwPromoCodeItem",
components: {
SfCircleIcon,
},
props: {
code: {
type: Object,
},
removePromotionCode: {
type: Function,
},
},
}
</script>

<style lang="scss" scoped>
.promo-code-item {
display: flex;
align-items: center;
justify-content: space-between;
&__remove {
--button-size: 2rem;
--icon-size: 0.6875rem;
}
}
</style>
Loading