From 5093179b440a0588f5c954ca75162d0fe12247f6 Mon Sep 17 00:00:00 2001 From: dhairyadwivedi Date: Wed, 14 Aug 2024 14:12:16 +0200 Subject: [PATCH 1/2] feat: add voucher validation --- shared/application/translations/en.json | 3 +- shared/application/translations/fr.json | 3 +- shared/application/translations/no.json | 3 +- shared/application/ui/components/voucher.tsx | 6 +++- .../use-cases/checkout/handleSaveCart.ts | 16 ++++++++-- .../use-cases/contracts/RemoteCart.ts | 10 +++++++ .../use-cases/crystallize/read/fetchCart.ts | 1 + .../read/validateVoucher.server.ts | 29 +++++++++++++++++++ .../use-cases/crystallize/write/editCart.ts | 9 ++++-- 9 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 shared/application/use-cases/crystallize/read/validateVoucher.server.ts diff --git a/shared/application/translations/en.json b/shared/application/translations/en.json index 24880b0..25b0158 100644 --- a/shared/application/translations/en.json +++ b/shared/application/translations/en.json @@ -53,7 +53,8 @@ "immutable": "You have initiated, finalized or cancelled a payment, this version of your cart is therefore locked.", "clone": "Clone previous cart", "voucherCode": "Voucher code", - "useVoucher": "Use voucher" + "useVoucher": "Use voucher", + "voucherApplied": "Voucher applied" }, "dimensions": "Dimensions", "specifications": "Manuals and specifications", diff --git a/shared/application/translations/fr.json b/shared/application/translations/fr.json index 661eaa6..fcf0dfd 100644 --- a/shared/application/translations/fr.json +++ b/shared/application/translations/fr.json @@ -53,7 +53,8 @@ "immutable": "Vous avez initié, finalisé ou annulé un paiement, cette version de votre caddie est donc bloquée.", "clone": "Cloner le caddie précédent", "voucherCode": "Bon de réduction", - "useVoucher": "Utiliser bon de réduction" + "useVoucher": "Utiliser bon de réduction", + "voucherApplied": "Bon de réduction appliqué" }, "dimensions": "Dimensions", "specifications": "Manuels et caractéristiques", diff --git a/shared/application/translations/no.json b/shared/application/translations/no.json index 6312210..3ceb2b8 100644 --- a/shared/application/translations/no.json +++ b/shared/application/translations/no.json @@ -53,7 +53,8 @@ "immutable": "Du har igangsatt, avsluttet eller kansellert en betaling, denne versjonen av handlekurven din er derfor låst.", "clone": "Klone forrige handlekurv", "voucherCode": "Rabatt code", - "useVoucher": "Bruk rabatt" + "useVoucher": "Bruk rabatt", + "voucherApplied": "Rabatt brukt" }, "dimensions": "Dimensjoner", "specifications": "Manualer og spesifikasjoner", diff --git a/shared/application/ui/components/voucher.tsx b/shared/application/ui/components/voucher.tsx index ef35c54..0b519e2 100644 --- a/shared/application/ui/components/voucher.tsx +++ b/shared/application/ui/components/voucher.tsx @@ -9,8 +9,11 @@ import { Input } from './input'; export const VoucherForm: React.FC = () => { const { cart: localCart, setVoucher } = useLocalCart(); - const { loading } = useRemoteCart(); + const { loading, remoteCart } = useRemoteCart(); const [voucherValue, setVoucherValue] = useState(localCart?.extra?.voucher ?? ''); + + const isVoucherValid = remoteCart?.context?.price?.voucherCode ? true : false; + const { _t } = useAppContext(); return ( @@ -50,6 +53,7 @@ export const VoucherForm: React.FC = () => { )} + {isVoucherValid ?
{_t('cart.voucherApplied')}
: null} ); diff --git a/shared/application/use-cases/checkout/handleSaveCart.ts b/shared/application/use-cases/checkout/handleSaveCart.ts index f4e6c35..8f0a877 100644 --- a/shared/application/use-cases/checkout/handleSaveCart.ts +++ b/shared/application/use-cases/checkout/handleSaveCart.ts @@ -1,5 +1,6 @@ import { ClientInterface } from '@crystallize/js-api-client'; import { hydrateCart } from '../crystallize/write/editCart'; +import { validateVoucher } from '../crystallize/read/validateVoucher.server'; type Deps = { apiClient: ClientInterface; @@ -13,14 +14,23 @@ export default async (body: any, { apiClient }: Deps, markets?: string[]) => { quantity: item.quantity, })); - const voucher = body.extra?.voucher?.toUpperCase() || ''; + const voucher = body.extra?.voucher?.toUpperCase(); + let validVoucher = null; + + if (voucher) { + try { + validVoucher = await validateVoucher(voucher, { apiClient }); + } catch (error) { + console.error('Voucher validation failed:', error); + } + } try { - return await hydrateCart(localCartItems, { apiClient }, cartId, markets, voucher); + return await hydrateCart(localCartItems, { apiClient }, cartId, markets, validVoucher ? voucher : ''); } catch (error: any) { if (error.message.includes('placed')) { console.log('Cart has been placed, creating a new one'); - return await hydrateCart(localCartItems, { apiClient }, undefined, markets, voucher); + return await hydrateCart(localCartItems, { apiClient }, undefined, markets, validVoucher ? voucher : ''); } throw error; } diff --git a/shared/application/use-cases/contracts/RemoteCart.ts b/shared/application/use-cases/contracts/RemoteCart.ts index 5ce3496..0a32d10 100644 --- a/shared/application/use-cases/contracts/RemoteCart.ts +++ b/shared/application/use-cases/contracts/RemoteCart.ts @@ -15,6 +15,16 @@ export type Cart = { discounts: number[]; currency: string; }; + context: { + price: { + markets?: string[]; + decimals: number; + selectedVariantIdentifier: string; + fallbackVariantIdentifiers: string[]; + compareAtVariantIdentifier: string; + voucherCode?: string; + }; + }; state: 'cart' | 'placed' | 'ordered'; orderId?: string; }; diff --git a/shared/application/use-cases/crystallize/read/fetchCart.ts b/shared/application/use-cases/crystallize/read/fetchCart.ts index 1b19d9f..ba3400b 100644 --- a/shared/application/use-cases/crystallize/read/fetchCart.ts +++ b/shared/application/use-cases/crystallize/read/fetchCart.ts @@ -14,6 +14,7 @@ export const fetchCart = async (cartId: string, { apiClient }: Deps): Promise { + const query = { + validateVoucher: { + __args: { + voucher, + }, + isValid: true, + }, + }; + + const rawQuery = jsonToGraphQLQuery({ query }); + + try { + const response = await apiClient.shopCartApi(rawQuery); + return response.validateVoucher.isValid; + } catch (exception: any) { + console.error('Failed to fetch cart', exception.message); + return { + valid: false, + }; + } +}; diff --git a/shared/application/use-cases/crystallize/write/editCart.ts b/shared/application/use-cases/crystallize/write/editCart.ts index 7e7bfde..56b6881 100644 --- a/shared/application/use-cases/crystallize/write/editCart.ts +++ b/shared/application/use-cases/crystallize/write/editCart.ts @@ -8,9 +8,10 @@ type Deps = { type Input = { items: Array<{ sku: string; quantity: number }>; - context: Record; + context: Cart['context']; id?: string; }; + export const hydrateCart = async ( items: Array<{ sku: string; quantity: number }>, { apiClient }: Deps, @@ -27,11 +28,14 @@ export const hydrateCart = async ( selectedVariantIdentifier: 'sales', fallbackVariantIdentifiers: ['default'], compareAtVariantIdentifier: 'default', - voucherCode, }, }, }; + if (voucherCode) { + input.context.price.voucherCode = voucherCode; + } + if (cartId) { input.id = cartId; } @@ -44,6 +48,7 @@ export const hydrateCart = async ( }, id: true, state: true, + context: true, items: { quantity: true, variant: { From 0995486c8950338963fd8af431a0c413ff493171 Mon Sep 17 00:00:00 2001 From: dhairyadwivedi Date: Fri, 16 Aug 2024 11:24:10 +0200 Subject: [PATCH 2/2] feat: validate voucher --- .../routes/$langstore.api.cart.voucher.tsx | 19 +++++++++++++++++++ shared/application/translations/en.json | 3 ++- shared/application/translations/fr.json | 3 ++- shared/application/translations/no.json | 3 ++- shared/application/ui/components/voucher.tsx | 19 ++++++++++++++----- shared/application/ui/hooks/useLocalCart.ts | 11 +++++++++++ .../use-cases/checkout/handleSaveCart.ts | 16 +++------------- .../use-cases/contracts/RemoteCart.ts | 6 +++--- .../use-cases/crystallize/read/fetchCart.ts | 1 - .../use-cases/crystallize/write/editCart.ts | 6 +----- .../use-cases/service-api/index.ts | 4 ++++ 11 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 frameworks/remix-run/application/src/routes/$langstore.api.cart.voucher.tsx diff --git a/frameworks/remix-run/application/src/routes/$langstore.api.cart.voucher.tsx b/frameworks/remix-run/application/src/routes/$langstore.api.cart.voucher.tsx new file mode 100644 index 0000000..f50d7d9 --- /dev/null +++ b/frameworks/remix-run/application/src/routes/$langstore.api.cart.voucher.tsx @@ -0,0 +1,19 @@ +import { ActionFunction, ActionFunctionArgs } from '@remix-run/node'; +import { getStoreFront } from '~/use-cases/storefront.server'; +import { privateJson } from '~/core/bridge/privateJson.server'; +import { getContext } from '~/use-cases/http/utils'; +import { validateVoucher } from '~/use-cases/crystallize/read/validateVoucher.server'; + +export const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { + const requestContext = getContext(request); + const { secret: storefront } = await getStoreFront(requestContext.host); + const body = await request.json(); + + const checkVoucher = await validateVoucher(body.voucher, { + apiClient: storefront.apiClient, + }); + + return privateJson({ + isValid: checkVoucher, + }); +}; diff --git a/shared/application/translations/en.json b/shared/application/translations/en.json index 25b0158..fcd704b 100644 --- a/shared/application/translations/en.json +++ b/shared/application/translations/en.json @@ -54,7 +54,8 @@ "clone": "Clone previous cart", "voucherCode": "Voucher code", "useVoucher": "Use voucher", - "voucherApplied": "Voucher applied" + "voucherApplied": "Voucher applied.", + "voucherInvalid": "Voucher is invalid." }, "dimensions": "Dimensions", "specifications": "Manuals and specifications", diff --git a/shared/application/translations/fr.json b/shared/application/translations/fr.json index fcf0dfd..b04b552 100644 --- a/shared/application/translations/fr.json +++ b/shared/application/translations/fr.json @@ -54,7 +54,8 @@ "clone": "Cloner le caddie précédent", "voucherCode": "Bon de réduction", "useVoucher": "Utiliser bon de réduction", - "voucherApplied": "Bon de réduction appliqué" + "voucherApplied": "Bon de réduction appliqué.", + "voucherInvalid": "Le bon de réduction est invalide." }, "dimensions": "Dimensions", "specifications": "Manuels et caractéristiques", diff --git a/shared/application/translations/no.json b/shared/application/translations/no.json index 3ceb2b8..96b1116 100644 --- a/shared/application/translations/no.json +++ b/shared/application/translations/no.json @@ -54,7 +54,8 @@ "clone": "Klone forrige handlekurv", "voucherCode": "Rabatt code", "useVoucher": "Bruk rabatt", - "voucherApplied": "Rabatt brukt" + "voucherApplied": "Rabatt brukt.", + "voucherInvalid": "Rabattkoden er ugyldig." }, "dimensions": "Dimensjoner", "specifications": "Manualer og spesifikasjoner", diff --git a/shared/application/ui/components/voucher.tsx b/shared/application/ui/components/voucher.tsx index 0b519e2..ed2dfb7 100644 --- a/shared/application/ui/components/voucher.tsx +++ b/shared/application/ui/components/voucher.tsx @@ -8,14 +8,21 @@ import { useRemoteCart } from '../hooks/useRemoteCart'; import { Input } from './input'; export const VoucherForm: React.FC = () => { - const { cart: localCart, setVoucher } = useLocalCart(); - const { loading, remoteCart } = useRemoteCart(); + const { cart: localCart, setVoucher, validateVoucher } = useLocalCart(); + const { loading } = useRemoteCart(); const [voucherValue, setVoucherValue] = useState(localCart?.extra?.voucher ?? ''); - - const isVoucherValid = remoteCart?.context?.price?.voucherCode ? true : false; + const [showMessage, setShowMessage] = useState(''); const { _t } = useAppContext(); + const checkVoucher = async (voucher: string) => { + if (!voucher) return; + const checkVoucher = await validateVoucher(voucher); + return checkVoucher.isValid + ? setShowMessage(_t('cart.voucherApplied')) + : setShowMessage(_t('cart.voucherInvalid')); + }; + return (
@@ -36,6 +43,7 @@ export const VoucherForm: React.FC = () => { className="bg-[#000] text-[#fff] px-2 py-1 rounded mt-5 text-center h-10" onClick={() => { setVoucher(voucherValue); + checkVoucher(voucherValue); }} > {loading && localCart?.extra?.voucher ? _t('loading') : _t('cart.useVoucher')} @@ -47,13 +55,14 @@ export const VoucherForm: React.FC = () => { onClick={() => { setVoucherValue(''); setVoucher(''); + setShowMessage(''); }} > {_t('delete')} )}
- {isVoucherValid ?
{_t('cart.voucherApplied')}
: null} + {showMessage &&

{showMessage}

}
); diff --git a/shared/application/ui/hooks/useLocalCart.ts b/shared/application/ui/hooks/useLocalCart.ts index 3d85c2f..501ffec 100644 --- a/shared/application/ui/hooks/useLocalCart.ts +++ b/shared/application/ui/hooks/useLocalCart.ts @@ -1,6 +1,8 @@ 'use client'; import { useLocalStorage, writeStorage } from '@rehooks/local-storage'; import { LocalCart } from '~/use-cases/contracts/LocalCart'; +import { ServiceAPI } from '~/use-cases/service-api'; +import { useAppContext } from '../app-context/provider'; const InitializeEmptyLocalCart = (): LocalCart => { return { @@ -18,6 +20,7 @@ export function useLocalCart() { ...cart, }); }; + const { state: appContextState } = useAppContext(); const isImmutable = () => { return cart.state === 'placed'; @@ -106,5 +109,13 @@ export function useLocalCart() { }, }); }, + validateVoucher: async (voucher: string) => { + const api = ServiceAPI({ + language: appContextState.language, + serviceApiUrl: appContextState.serviceApiUrl, + }); + + return await api.validateVoucher(voucher); + }, }; } diff --git a/shared/application/use-cases/checkout/handleSaveCart.ts b/shared/application/use-cases/checkout/handleSaveCart.ts index 8f0a877..f4e6c35 100644 --- a/shared/application/use-cases/checkout/handleSaveCart.ts +++ b/shared/application/use-cases/checkout/handleSaveCart.ts @@ -1,6 +1,5 @@ import { ClientInterface } from '@crystallize/js-api-client'; import { hydrateCart } from '../crystallize/write/editCart'; -import { validateVoucher } from '../crystallize/read/validateVoucher.server'; type Deps = { apiClient: ClientInterface; @@ -14,23 +13,14 @@ export default async (body: any, { apiClient }: Deps, markets?: string[]) => { quantity: item.quantity, })); - const voucher = body.extra?.voucher?.toUpperCase(); - let validVoucher = null; - - if (voucher) { - try { - validVoucher = await validateVoucher(voucher, { apiClient }); - } catch (error) { - console.error('Voucher validation failed:', error); - } - } + const voucher = body.extra?.voucher?.toUpperCase() || ''; try { - return await hydrateCart(localCartItems, { apiClient }, cartId, markets, validVoucher ? voucher : ''); + return await hydrateCart(localCartItems, { apiClient }, cartId, markets, voucher); } catch (error: any) { if (error.message.includes('placed')) { console.log('Cart has been placed, creating a new one'); - return await hydrateCart(localCartItems, { apiClient }, undefined, markets, validVoucher ? voucher : ''); + return await hydrateCart(localCartItems, { apiClient }, undefined, markets, voucher); } throw error; } diff --git a/shared/application/use-cases/contracts/RemoteCart.ts b/shared/application/use-cases/contracts/RemoteCart.ts index 0a32d10..dacc855 100644 --- a/shared/application/use-cases/contracts/RemoteCart.ts +++ b/shared/application/use-cases/contracts/RemoteCart.ts @@ -15,7 +15,9 @@ export type Cart = { discounts: number[]; currency: string; }; - context: { + state: 'cart' | 'placed' | 'ordered'; + orderId?: string; + context?: { price: { markets?: string[]; decimals: number; @@ -25,6 +27,4 @@ export type Cart = { voucherCode?: string; }; }; - state: 'cart' | 'placed' | 'ordered'; - orderId?: string; }; diff --git a/shared/application/use-cases/crystallize/read/fetchCart.ts b/shared/application/use-cases/crystallize/read/fetchCart.ts index ba3400b..1b19d9f 100644 --- a/shared/application/use-cases/crystallize/read/fetchCart.ts +++ b/shared/application/use-cases/crystallize/read/fetchCart.ts @@ -14,7 +14,6 @@ export const fetchCart = async (cartId: string, { apiClient }: Deps): Promise + postJson(serviceApiUrl + '/cart/voucher', { + voucher, + }), // THIS SHOULD BE REMOVED IN A REAL PROJECT sendPaidOrderWithCrystalCoin: (cart: LocalCart, customer: Partial) => sendPaidOrderWithCrystalCoin(serviceApiUrl, language, cart, customer),