This repository is a fork from Sunrise SPA which extends it by features that allows you use codes provided by Voucherify application via Commerce Tools integration.
- Related applications
- Prerequisites
- Dependencies
- Installation
- High level integration requirements
- Changes in our sunrise fork
- Contributing
- Changelog
- Contact
- Licence
- Voucherify https://www.voucherify.io
- Sunrise SPA https://github.com/commercetools/sunrise-spa
- Commerce Tools https://commercetools.com/?location=emea
- Commerce Tools Integration https://github.com/voucherifyio/commerce-tools-integration
Before you begin, ensure you have the following requirements
- Voucherify account and valid API keys
- Commerce Tools account with created API Client and valid API keys
- Hosted Commerce Tools Integration
- Node.js >= 16.15.0
- npm >= 8.5.5
To install follow these steps:
Windows, Linux and macOS:
- Clone repository
- run command
npm install
- add
.env
file generated in Commerce Tools during creation of new API Client (selectSunrise SPA
option)- run command
npm run dev
for development environment ornpm run build
for productionconsider importing test data into the Commerce Tools https://docs.commercetools.com/sdk/sunrise-data
.env example file
VUE_APP_CT_PROJECT_KEY=
VUE_APP_CT_CLIENT_ID=
VUE_APP_CT_CLIENT_SECRET=
VUE_APP_CT_SCOPE=
VUE_APP_CT_AUTH_HOST=
VUE_APP_CT_API_HOST=
To flawless work of your frontend application you need to cover a few use case
- appliging single discount code to your cart
- listing single or multiple discount codes applied (we provide support for single and multiple code as well)
- removing codes from your cart
- handle removing products of changing of their quantity (applied codes needs to be revalidated, some codes may be not able to apply after changes)
- handle coupons that add new products to your cart
In our example, each action related to codes (adding, removing) and changing products in the cart (adding, removing, changing quantity) mutate the state of the cart. Next, by Graphql query to Commerce Tools API, we get data with codes validation results shown in cart view.
Basic Graphql quary need to be extended due to recieving additional customFields where data about V% codes are stored.
When you want to add new code by your AddDiscountCodeFrom you need to pass all used codes so far and new one
// Each array element need to be stringified by JSON.stringify()
const codes = [
{
"code": "UNIT_TYPE_CODE",
"status": "APPLIED"
}
{
"code": "50%OFF",
"status": "NEW"
}
]
Then you need to pass it to your Graphql query through a custom field named "discount_codes", more here: https://docs.commercetools.com/api/projects/custom-fields#customfields
{
setCustomField: {
name: "discount_codes",
value: JSON.stringify(codes.map(code => JSON.stringify(code)))
},
}
query myCart($locale: Locale!) {
myCart: me {
activeCart {
(...)
lineItems {
(...)
custom {
customFieldsRaw {
name
value
}
}
}
(...)
custom {
customFieldsRaw {
name
value
}
}
customLineItems {
name(locale: $locale)
totalPrice {
centAmount
currencyCode
fractionDigits
}
slug
}
}
}
}
{
activeCart {
(...)
lineItems {
(...)
custom {
customFieldsRaw {
"name": "applied_codes",
// Each value element needs to be parsed from stringified json to object
// "{\"code\":\"UNIT_TYPE_CODE\",\"type\":\"UNIT\",\"effect\":\"ADD_MISSING_ITEMS\",\"quantity\":1,\"totalDiscountQuantity\":1}"
"value": [
{
code: "UNIT_TYPE_CODE",
type: "UNIT",
effect: "ADD_MISSING_ITEMS",
quantity: 1,
totalDiscountQuantity: 1,
}
]
}
}
}
(...)
custom: {
customFieldsRaw: {
name: "discount_codes",
// Each value element needs to be parsed from stringified json to object
// "{\"code\":\"UNIT_TYPE_CODE\",\"status\":\"APPLIED\",\"value\":17900}"
value: [
{
code: "UNIT_TYPE_CODE",
status: "APPLIED",
value: 17900
}
]
}
}
customLineItems: {
"name": "Voucher, coupon value => 181.50",
"totalPrice": {
"centAmount": -18150,
"currencyCode": "EUR",
"fractionDigits": 2
}
}
}
}
activeCart.lineItems.custom.customFieldsRaw is commerce tools custom field that store data about codes applied for your products. Currently we handle "unit type" codes. activeCart.custom.customFieldsRaw store information about each codes which was applied for Your cart. activeCart.customLineItems there are information about your summary Voucher and currency detail
In purpose to use our commercetools example application Sunrise SPA https://github.com/commercetools/sunrise-spa with Voucherify integration you need to make some changes in code. You can simply use our Sunrise fork with following changes
CartDetail.vue is our main component related to cart. There is only single change - passing cart object to AddDiscountCodeForm component This component contains there main children where chagnes was realized.
- CartLikePriceDetail,
- AddDiscountCodeForm,
- CartLikeContentDetail
<AddDiscountCodeForm :cart="cart" />
In CartLikePriceDetail.vue DiscountCodes component was made dependent on discountVoucherifyCodesExist and template about discount was a bit changed
<div>
<Promotions v-if="!couponsLimitExceeded"
:cart="cart"
:editable="editable"
/>
<div v-else>
<span class="voucher-error">
{{t('couponsLimitExceeded')}} {{couponsLimit}}
</span>
</div>
(...)
<DiscountCodes
v-if="discountVoucherifyCodesExist(cart)"
:cart="cart"
:editable="editable"
/>
</div>
<div class="cart-total-wrap">
<div class="row" v-if="discountValue.centAmount != 0">
<div class="single-cart-total-left col-sm-6">
<b>{{ t('discount') }}</b>
</div>
<div
class="single-cart-total-right col-sm-6"
data-test="cart-total-price"
>
<b>
<BasePrice :price="{ value: discountValue }" />
</b>
</div>
</div>
<div class="row">
<div class="single-cart-total-left col-sm-6">
<b>{{ t('total') }}</b>
</div>
<div
class="single-cart-total-right col-sm-6"
data-test="cart-total-price"
>
<b>
<BasePrice :price="{ value: cart.totalPrice }" />
</b>
</div>
</div>
</div>
CartLikePriceDetail.js was extended by logic that allows to filter and get data about the current applied voucher
import {CUSTOM_LINE_ITEM_VOUCHER_SLUG} from '../../../../constants'
import useCouponsLimitExceeded from "hooks/useCouponsLimitExceeded";
export default {
(...)
computed: {
discountValue(props) {
const customLineItemWithDiscount = props.cart.customLineItems.find(item => item.slug.startsWith(CUSTOM_LINE_ITEM_VOUCHER_SLUG))
if(customLineItemWithDiscount) {
return customLineItemWithDiscount.totalPrice
}
return 0
},
couponsLimitExceeded(props){
return useCouponsLimitExceeded(props);
},
couponsLimit(props){
const couponLimit = props.cart.custom.customFieldsRaw.find(field => field.name === 'couponsLimit')
return couponLimit?.value ?? 5;
}
}
};
In styles.css and CartLikePriceDetail.txt styles and translates were added.
DiscountCodes.js was extended by computed property which map V% codes and component BasePrice
import { AVAILABLE_CODES_NAMES, CODES_STATUSES } from "../../../../../constants";
import DiscountCode from "presentation/components/DiscountCode/DiscountCode.vue";
import useAppliedCodes from "hooks/useAppliedCodes";
export default {
components: { RemoveDiscountCodeForm, BasePrice, DiscountCode },
(...)
computed: {
appliedCodes() {
const appliedCodes = useAppliedCodes(this);
return appliedCodes.length ? appliedCodes : false
}
},
(...)
}
DiscountCodes.vue template was changed a bit, added usage of BasePrice component and maping for appliedCodes from computed property
<template>
<div class="single-grand-total" v-if="appliedCodes">
<div class="single-grand-total-left col-sm-6">
<span>{{ t('appliedDiscounts') }}</span>
</div>
<div
class="single-grand-total-right col-sm-6"
data-test="discount-code-name"
>
<div v-for="code in appliedCodes" :key="code">
<discount-code :code="code" :cart="cart" :editable="editable"></discount-code>
</div>
</div>
</div>
</template>
DiscountCode.vue, DiscountCode.js and DiscountCode.css was added. This component show single code element
<style src="./DiscountCode.css" scoped></style>
<script src="./DiscountCode.js"></script>
<template>
<div class="code-container">
<b>{{code.baner || code.code}}</b>
<b class="code-gap"></b>
<b class="code-value">
<BasePrice :price="{value: {centAmount: typeof code.value == 'number' ? -code.value : code.value, fractionDigits: cart.totalPrice.fractionDigits, currencyCode: cart.totalPrice.currencyCode}}" />
</b>
<RemoveDiscountCodeForm
v-if="editable"
:cart="cart"
:code="code.code"
/>
</div>
</template>
BaseMoney.js was changed to form that allow to show string type elements on discount list.
(...)
export default {
const { n } = useI18n();
(...)
setup(props) {
(...)
const formattedMoney = computed(() => {
if (typeof props?.money?.centAmount == "number"){
return n(amount.value, 'currency', location.value);
} else {
return props?.money?.centAmount ?? '';
}
});
(...)
},
};
In DiscountCodes/style.css style was added
.code-container {
display: flex;
flex-wrap: wrap;
flex-grow: 1;
}
.code-gap {
flex-grow: 1;
}
.code-value {
color: rgb(0,222,0);
}
RemoveDiscountCodeForm.js extended logic allowed to remove codes with on click method binded in RemoveDiscountCodeForm.vue
setup(props) {
const {
returnVoucherifyCodes,
applyVoucherifyDiscount,
} = useCartTools();
const removeDiscount = () => {
const codes = returnVoucherifyCodes(props.cart)
.map(code => JSON.parse(code))
.map(code => {
if(code.code === props.code) {
return {
code: code.code,
value: code.value,
status: CODES_STATUSES.DELETED
}
}
return code
})
applyVoucherifyDiscount(codes)
};
return { removeDiscount };
},
In AddDiscountCodeForm.vue component ServeError was replaced from
<ServerError
:error="error"
v-slot="{ graphQLError }"
class="server-error"
>{{ getErrorMessage(graphQLError) }}
</ServerError>
to
<p class="message" :class="{ 'voucher-error': !codesInfo.status }">
{{codesInfo.message}}
</p>
Property
:disabled="couponsLimitExceeded"
Was added to BaseInput and Input elements.
In AddDiscountCodeForm.js there are new logic for adding V% codes in applyDiscount() and watch that handle errors in added codes.
(...)
import { ref, watch } from 'vue';
import useVuelidate from '@vuelidate/core';
import { CODES_STATUSES } from '../../../../constants';
import useCouponsLimitExceeded from "hooks/useCouponsLimitExceeded";
export default {
components: {
BaseForm,
BaseInput,
ServerError,
},
props: {
cart: {
type: Object,
required: true,
},
},
setup(props) {
const codesInfo = ref({})
const enteredCode = ref('')
const { t } = useI18n();
const {
applyVoucherifyDiscount,
returnVoucherifyCodes,
} = useCartTools();
const form = ref({});
const v = useVuelidate({
code: {}
}, form)
const applyDiscount = () => {
enteredCode.value = form.value.code;
const codes = returnVoucherifyCodes(props.cart)
.map(code => JSON.parse(code))
.filter(code => Object.values(CODES_STATUSES).includes(code.status));
form.value.code = ''
if(enteredCode.value){
return applyVoucherifyDiscount([...codes, { code: enteredCode.value, status: CODES_STATUSES.NEW }])
}else{
return applyVoucherifyDiscount([...codes])
}
};
applyDiscount();
const getErrorMessage = ({ code }) => {
if (code === 'DiscountCodeNonApplicable') {
return t('nonApplicable');
}
return t('unknownError');
};
watch(props, props => {
const codes = returnVoucherifyCodes(props.cart).map(code => JSON.parse(code))
const lastAppliedCode = codes.find(code => code.code === enteredCode.value)
if(lastAppliedCode) {
codesInfo.value = {
message: lastAppliedCode ? `${lastAppliedCode.status !== CODES_STATUSES.APPLIED && lastAppliedCode.errMsg ? lastAppliedCode.errMsg : lastAppliedCode.status}` : '',
status: lastAppliedCode.status === CODES_STATUSES.APPLIED ? true : false,
}
}
})
return {
t,
applyDiscount,
form,
codesInfo,
getErrorMessage,
v,
};
},
computed: {
couponsLimitExceeded(props) {
return useCouponsLimitExceeded(props);
}
}
};
Here was shown a discount for single product and changing in showing BasePrice. This change is related to fixed price promotions for line items.
LimeItemInfo.vue
<td class="product-name">
(...)
<b class="discounted-quantity" v-if="quantityFromCode">
{{ t('discounted') }} : {{quantityFromCode}}
</b>
</td>
<td v-if="!selectable" class="product-price">
<span class="amount" data-test="item-price">
<BasePrice :price="getPrice(lineItem)"/>
</span>
</td>
(...)
<td v-if="!selectable"
class="product-total"
data-test="line-total"
>
<span>
<BasePrice :price="total(getTotalPrice(lineItem))" />
</span>
</td>
In LimeItemInfo.js was added computed function quantityFromCode that return unit type codes applied to products
import { AVAILABLE_CODES_NAMES, CODES_TYPES } from '../../../../../constants'
import { useI18n } from 'vue-i18n';
import {getPrice, getTotalPrice} from "hooks/useFixedPrice";
export default {
(...)
setup(props, { emit }) {
(...)
const { t } = useI18n();
return {
t,
selected,
item,
...useCartTools(),
getPrice,
getTotalPrice
};
}
computed: {
quantityFromCode(props){
const codeWithFreeItem = props.lineItem.custom?.customFieldsRaw
.find(code => code.name === AVAILABLE_CODES_NAMES.APPLIED_CODES)
if(codeWithFreeItem) {
return codeWithFreeItem
.value
.map(code => JSON.parse(code))
.find(code => code.type === CODES_TYPES.UNIT)
.totalDiscountQuantity
}
return 0
}
}
}
In LimeItemInfo.txt new translate was added
en:
available: "Available"
discounted: "Discounted"
de:
available: "VerfĂĽgbar"
discounted: "Ermäßigt"
In style.css added new styles
.new-price > span{
color: rgb(0,222,0) !important;
}
To handle Promotion Tiers we add Promotion
component (src/presentation/fashion/CartDetail/CartLikePriceDetail/Promotions
). It works the way that we look for discounts in created promotion tiers in the Voucherify project, which are available for specific carts (it is done by the backend) and then we list them on the frontend, where users can add and remove them from the cart, which works similar to the way how we add typical coupons.
This component is placed in the CartLikePriceDetail.vue
component. Additionally, to make it work properly we add the AVAILABLE
code status to CODES_STATUSES
const and in src/presentation/fashion/CartDetail/CartLikePriceDetail/DiscountCodes/RemoveDiscountCodeForm/RemoveDiscountCodeForm.js
we add a new returned field which is called type
(coupons and promotions tiers are handled a little bit different in Voucherify so we have to differentiate them to use the same component for removing discounts). Moreover, there are some typical CSS/HTML changes to look it better.
On the order overview there are a few changes that allows to display fixed prices and codes summary.
In OrderOverview.js added computed fields discountValue
and appliedCodes
and functions getSubTotal
,
getPrice
were reported.
(...)
import DiscountCode from "presentation/components/DiscountCode/DiscountCode.vue";
import {getSubTotal, getPrice} from "hooks/useFixedPrice";
export default {
components: {
ShippingMethod,
BasePrice,
PaymentMethod,
DiscountCode,
// VuePerfectScrollbar,
},
(...)
setup(props, { emit }) {
(...)
return {
(...)
getSubTotal,
getPrice
};
}
computed: {
discountValue(props) {
const customLineItemWithDiscount = props.cart.customLineItems.find(item => item.slug.startsWith(CUSTOM_LINE_ITEM_VOUCHER_SLUG))
if(customLineItemWithDiscount) {
return customLineItemWithDiscount.totalPrice
}
return 0
},
appliedCodes() {
const appliedCodes = this.cart.custom?.customFieldsRaw
.filter(field => field.name === AVAILABLE_CODES_NAMES.DISCOUNT_CODES)
.reduce(customField => customField)
.value
.map(code => JSON.parse(code))
.filter(code => code.status === CODES_STATUSES.APPLIED)
return appliedCodes.length ? appliedCodes : false
}
}
},
Template OrderOverview.vue was extended by showing discounts and fixed prices.
(...)
<BasePrice :price="getPrice(lineItem)"></BasePrice>
(...)
<BasePrice :price="subTotal(getSubTotal(cart))"></BasePrice>
(...)
<div v-if="appliedCodes || appliedCodes">
<div class="mt-10"></div>
<div class="your-order-info">
<ul>
<li class="bold-text">
{{ t('code') }}
<span>{{ t('discount') }}</span>
</li>
</ul>
</div>
<div class="your-order-info order-subtotal">
<div v-for="code in appliedCodes" :key="code">
<discount-code :code="code" :cart="cart" :editable="false"></discount-code>
</div>
</div>
<div class="your-order-info order-subtotal">
<ul>
<li>
<b class="bold-text">{{ t('allDiscount') }}</b>
<span class="code-value">
<b>
<BasePrice :price="{ value: discountValue }" />
</b>
</span>
</li>
</ul>
</div>
</div>
There also was added styles and translates in OrderOverview.scss and OrderOverview.txt.
Functions that extends useCartMutation.js allowed to mark changes in codes used in cart and revalidate codes.
const applyVoucherifyDiscount = (code) =>
mutateCart(addVoucherifyDiscountCode(code));
Functions in composition/ct/useCartMutation.js which are handlers for changes in codes
import { AVAILABLE_CODES_NAMES } from '../../src/constants'
(...)
export const addVoucherifyDiscountCode = (codes) => [
{
setCustomField: {
name: AVAILABLE_CODES_NAMES,
value: JSON.stringify( codes.map(code => JSON.stringify(code)))
},
},
];
export const removeVoucherifyCode = () => [
{
setCustomField: {
name: AVAILABLE_CODES_NAMES,
},
},
];
Functions in composition/useCartTools.js for checking if V% discount codes exist and for returning it. And changing in total
function that allow to calculate total value including fixed price amount if applied.
import { AVAILABLE_CODES_NAMES } from '../src/constants'
(...)
function subTotal(cartLike) {
(...)
const totalPriceCentAmount = cartLike.lineItems.reduce(
(acc, li) => {
if(li.price.discounted) {
return acc + li.quantity * li.price.discounted.value.centAmount
}else{
return acc + li.quantity * li.price.value.centAmount
}
}, 0
);
(...)
}
(...)
const total = (lineItem) => {
if (lineItem.price.discounted) {
return {
value: {
...lineItem.price.value,
centAmount:
lineItem.price.value.centAmount *
lineItem.quantity,
},
discounted: {
value: {
...lineItem.price.discounted.value,
centAmount:
lineItem.price.discounted.value.centAmount *
lineItem.quantity,
},
},
};
}
return { value: lineItem.totalPrice };
};
(...)
const discountVoucherifyCodesExist = (cart) => {
let codeExist = false;
cart.custom.customFieldsRaw.forEach(element => {
if(element.name === AVAILABLE_CODES_NAMES.DISCOUNT_CODES && element.value.length != 0) codeExist = true;
});
return codeExist
};
const returnVoucherifyCodes = (cart) => {
let voucherifyCodes = [];
cart.custom.customFieldsRaw.forEach(element => {
if(element.name === AVAILABLE_CODES_NAMES.DISCOUNT_CODES && element.value.length != 0) voucherifyCodes = element.value;
});
return voucherifyCodes;
}
(...)
File useFixedPrice.js was added. Those functions are designed for handling fixed prices from coupons.
export function getCouponFixedPrice(custom){
if(custom?.customFieldsRaw?.length > 0){
return custom?.customFieldsRaw.filter((element) => {
return element.name === 'coupon_fixed_price';
}).map((element) => element.value)[0];
} else {
return null
}
}
export function getPrice(lineItem){
const price = {
...lineItem.price
}
const couponFixedPrice = getCouponFixedPrice(lineItem.custom);
if(couponFixedPrice){
price.discounted = {
value: {
currencyCode: lineItem.price.value.currencyCode,
fractionDigits: lineItem.price.value.fractionDigits,
}
}
price.discounted.value.centAmount = couponFixedPrice;
}
return price;
}
export function getTotalPrice(lineItem){
return {
...lineItem,
price: getPrice(lineItem)
};
}
export function getSubTotal(cart){
return {
...cart,
lineItems: cart.lineItems.map((lineItem) => getTotalPrice(lineItem))
}
}
Changes in ./composition/ct/useShippingMethods.js for proper getting shipping's methods relied on current cart state.
(...)
const query = gql`
query shippingMethods(
$cartId: String!
$locale: Locale!
) {
shippingMethodsByCart(
id: $cartId
) {
(...)
}
}
`;
const useShippingMethods = ({
(...)
const { loading, error } = useQueryFacade(query, {
(...)
onCompleted: (data) => {
if (!data) {
return;
}
setShippingMethods(data.shippingMethodsByCart);
},
fetchPolicy: 'network-only'
});
return { shippingMethods, loading, error };
};
export default useShippingMethods;
In ./composition/useShippingMethods.js there are changes for proper passing parameters to method.
import useLocale from './useLocale';
import useShippingMethods from './ct/useShippingMethods';
import {getValue} from "@/lib";
import useCart from "hooks/useCart";
export default () => {
const { locale } = useLocale();
const { cart } = useCart();
const cartId = getValue(cart).cartId;
const { total, shippingMethods, loading, error } =
useShippingMethods({
cartId,
locale
});
return {
total,
shippingMethods,
loading,
error,
};
};
/composition/useAppliedCodes.js is used for getting applied codes from cart custom fields.
import {AVAILABLE_CODES_NAMES, CODES_STATUSES} from "@/constants";
export default function useAppliedCodes(props) {
return props.cart.custom?.customFieldsRaw
.filter(field => field.name === AVAILABLE_CODES_NAMES.DISCOUNT_CODES)
.reduce(customField => customField)
.value
.map(code => JSON.parse(code))
.filter(code => code.status === CODES_STATUSES.APPLIED)
}
/composition/useCouponsLimitExceeded.js is used for checking is current applied codes exceed coupons limit.
import useAppliedCodes from "hooks/useAppliedCodes";
export default function useCouponsLimitExceeded(props) {
const couponLimit = props.cart.custom.customFieldsRaw.find(field => field.name === 'couponsLimit')
const appliedCodes = useAppliedCodes(props)
return appliedCodes.length >= (couponLimit?.value ?? 5);
}
Added new consts in constants.js
(...)
export const AVAILABLE_CODES_NAMES = {
DISCOUNT_CODES: 'discount_codes',
APPLIED_CODES: 'applied_codes',
}
export const CODES_STATUSES = {
APPLIED: 'APPLIED',
NEW: 'NEW',
DELETED: 'DELETED'
}
export const CODES_TYPES = {
UNIT: 'UNIT',
}
export const CUSTOM_LINE_ITEM_VOUCHER_SLUG = 'Voucher, '
Bug reports and pull requests are welcome through GitHub Issues.
- 2023-05-10
v4.1.1
- display banner text instead of promotion id for selected promotions - 2022-10-12
v4.1.0
- fixed clearing cart after refreshing summary page, added auto refreshing shipping methods (fix bug free shipping not showing after applying code), added direct discounts - 2022-09-09
v4.0.0
- add number of coupons limitations, adjustment for node >= 17, add info when validation fails - 2022-08-25
v3.0.2
- listing promotions on the OrderOverview page - 2022-08-25
v3.0.1
- added promotion tier handling - 2022-08-19
v3.0.0
- changed howCustom Line Item
with discount is tracked and some small fixes - 2022-08-02
v2.0.0
- Remove coupon code by changing the status toDELETED
. It allows to remove coupon from session by Commerce Tools Integration v2.0.0 or higher - 2022-07-26
v1.0.0
- Initial release
Use our contact form https://www.voucherify.io/contact-sales
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/