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

Commerce: Promo codes on checkout #77

Merged
merged 10 commits into from
Mar 29, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { cn } from '@hanzo/ui/util'

import type { FacetValueDesc, FacetsValue, LineItem } from '../../types'
import { useCommerce } from '../../service/context'
import { formatPrice } from '../../util'
import { formatCurrencyValue } from '../../util'

import FacetTogglesWidget from '../facet-values-widget'

const formatItem = (item: LineItem, withQuantity: boolean = false): string => (
`${item.titleAsOption}, ${formatPrice(item.price)}${(withQuantity && item.quantity > 0) ? ` (${item.quantity})` : ''}`
`${item.titleAsOption}, ${formatCurrencyValue(item.price)}${(withQuantity && item.quantity > 0) ? ` (${item.quantity})` : ''}`
)

const SelectCategoryAndItemWidget: React.FC<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { ItemSelector, LineItem } from '../../types'
import AddToCartWidget from '../add-to-cart-widget'
import CategoryItemRadioSelector from '../category-item-radio-selector'
import CategoryItemScrollSelector from '../category-item-scroll-selector'
import { formatPrice } from '../../util'
import { formatCurrencyValue } from '../../util'

const SelectCategoryItemCard: React.FC<React.HTMLAttributes<HTMLDivElement> & ItemSelector & {
isLoading?: boolean
Expand Down Expand Up @@ -40,7 +40,7 @@ const SelectCategoryItemCard: React.FC<React.HTMLAttributes<HTMLDivElement> & It
const item = category.products[0] as LineItem
return (
<div className={cn('flex flex-col justify-center items-center ' + (mobilePicker ? 'h-[180px] ' : 'h-auto min-h-24'), className)}>
<p className='text-lg text-center font-semibold'>{item.titleAsOption + ', ' + formatPrice(item.price)}</p>
<p className='text-lg text-center font-semibold'>{item.titleAsOption + ', ' + formatCurrencyValue(item.price)}</p>
</div>
)
}
Expand Down
6 changes: 4 additions & 2 deletions packages/commerce/components/cart-accordian.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
AccordionTrigger,
} from '@hanzo/ui/primitives'

import { formatPrice, useCommerce } from '..'
import { formatCurrencyValue, useCommerce } from '..'

import CartPanel from './cart-panel'

Expand All @@ -35,7 +35,7 @@ const CartAccordian: React.FC<{
</h5>
</div>
<div className='flex gap-1 items-center'>
<h5 className='text-sm sm:text-xl grow truncate'>{formatPrice(cmmc.cartTotal)}</h5>
<h5 className='text-sm sm:text-xl grow truncate'>{formatCurrencyValue(cmmc.promoAppliedCartTotal)}</h5>
<ChevronRight className="h-5 w-5 -mr-2 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
</div>
</AccordionTrigger>
Expand All @@ -46,6 +46,8 @@ const CartAccordian: React.FC<{
scrollHeightClx='h-[350px]'
itemClx='mt-2'
totalClx='sticky px-1 pr-2 border-t -bottom-[1px] bg-background'
showShipping
showPromoCode
/>
</AccordionContent>
</AccordionItem>
Expand Down
54 changes: 33 additions & 21 deletions packages/commerce/components/cart-panel/cart-line-item.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
'use client'

import React from 'react'
import Image from 'next/image'
import { observer } from 'mobx-react-lite'

import { cn } from '@hanzo/ui/util'

import type { LineItem } from '../../types'
import { formatPrice } from '../../util'
import { formatCurrencyValue } from '../../util'
import AddToCartWidget from '../add-to-cart-widget'
import { useCommerce } from '../..'

const DEF_IMG_SIZE=40

Expand All @@ -22,30 +25,39 @@ const CartLineItem: React.FC<{
item: LineItem
className?: string
imgSizePx?: number
}> = observer(({
showPromoCode?: boolean
}> = observer(({
item,
className='',
imgSizePx=DEF_IMG_SIZE
}) => (
<div className={cn('flex flex-col justify-start items-start text-sm font-sans', className)}>
<div className='flex flex-row justify-between items-center gap-2'>
{!!item.img ? (
<Image src={item.img} alt={item.title + ' image'} height={imgSizePx} width={imgSizePx} className=''/>
) : ( // placeholder so things align
<div style={{height: imgSizePx, width: imgSizePx}}/ >
)}
<div className='grow leading-tight'>{renderTitle(item.title)}</div>
</div>
<div className='flex flex-row items-center justify-between w-full'>
<div className='flex flex-row items-center'>
<AddToCartWidget className='' item={item} size='xs' buttonClx='h-8 md:h-6' ghost/>
{item.quantity > 1 && (<span className='pl-2.5'>{'@' + formatPrice(item.price)}</span>)}
imgSizePx=DEF_IMG_SIZE,
showPromoCode
}) => {

const cmmc = useCommerce()
const promoPrice = showPromoCode ? cmmc.itemPromoPrice(item) : undefined

return (
<div className={cn('flex flex-col justify-start items-start text-sm font-sans', className)}>
<div className='flex flex-row justify-between items-center gap-2'>
{!!item.img ? (
<Image src={item.img} alt={item.title + ' image'} height={imgSizePx} width={imgSizePx} className=''/>
) : ( // placeholder so things align
<div style={{height: imgSizePx, width: imgSizePx}}/>
)}
<div className='grow leading-tight'>{renderTitle(item.title)}</div>
</div>
<div className='flex flex-row items-center justify-end'>
{formatPrice(item.price * item.quantity)}
<div className='flex flex-row items-center justify-between w-full'>
<div className='flex flex-row items-center'>
<AddToCartWidget className='' item={item} size='xs' buttonClx='h-8 md:h-6' ghost/>
{item.quantity > 1 && (<span className='pl-2.5'>{'@' + formatCurrencyValue(item.price)}</span>)}
</div>
<div className='flex flex-row gap-1 items-center justify-end'>
<span className={promoPrice ? 'line-through text-muted-2' : ''}>{formatCurrencyValue(item.price * item.quantity)}</span>
{promoPrice && <span>{formatCurrencyValue(promoPrice * item.quantity)}</span>}
</div>
</div>
</div>
</div>
))
)
})

export default CartLineItem
46 changes: 39 additions & 7 deletions packages/commerce/components/cart-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { Button, ScrollArea } from '@hanzo/ui/primitives'
import { cn } from '@hanzo/ui/util'

import { useCommerce } from '../../service/context'
import { formatPrice } from '../../util'
import { formatCurrencyValue } from '../../util'
import { sendFBEvent, sendGAEvent } from '../../util/analytics'

import CartLineItem from './cart-line-item'
import PromoCode from './promo-code'

const CartPanel: React.FC<PropsWithChildren & {
/** fix size and scroll after 'scrollAfter items in teh cart. */
Expand All @@ -22,6 +23,8 @@ const CartPanel: React.FC<PropsWithChildren & {
noItemsClx?: string
totalClx?: string
buttonClx?: string
showPromoCode?: boolean
showShipping?: boolean
/** if not provided, 'checkout' button will be rendered */
handleCheckout?: () => void
}> = observer(({
Expand All @@ -36,6 +39,8 @@ const CartPanel: React.FC<PropsWithChildren & {
noItemsClx='',
totalClx='',
buttonClx='',
showPromoCode=false,
showShipping=false,
handleCheckout,
}) => {

Expand Down Expand Up @@ -69,19 +74,46 @@ const CartPanel: React.FC<PropsWithChildren & {
handleCheckout && handleCheckout()
}

const Main: React.FC = () => (<>
const Main: React.FC = observer(() => (<>
{cmmc.cartEmpty ? (
<p className={cn('text-center mt-4', noItemsClx)}>No items in cart</p>
) : (<>
{cmmc.cartItems.map((item) => (
<CartLineItem imgSizePx={imgSizePx} item={item} key={`mobile-${item.sku}`} className={cn('mb-2', itemClx)}/>
<CartLineItem
key={`mobile-${item.sku}`}
imgSizePx={imgSizePx}
item={item}
className={cn('mb-2', itemClx)}
showPromoCode={showPromoCode}
/>
))}
</>)}
<p className={cn('mt-6 text-right border-t pt-1', totalClx)}>
<span>TOTAL:&nbsp;</span>
<span className='font-semibold'>{cmmc.cartTotal === 0 ? '0' : formatPrice(cmmc.cartTotal)}</span>
{showPromoCode && <PromoCode/>}
{(showShipping || showPromoCode) && (
<div className='flex flex-col gap-1 py-2 border-t'>
<p className='flex justify-between'>
<span className='text-muted-1'>Subtotal</span>
<span className='font-semibold'>{cmmc.cartTotal === 0 ? '0' : formatCurrencyValue(cmmc.cartTotal)}</span>
</p>
{cmmc.promoAppliedCartTotal !== cmmc.cartTotal && (
<p className='flex justify-between'>
<span className='text-muted-1'>Promo Discount</span>
<span className='font-semibold'>-{formatCurrencyValue(cmmc.cartTotal - cmmc.promoAppliedCartTotal)}</span>
</p>
)}
{showShipping && (
<p className='flex justify-between'>
<span className='text-muted-1'>Shipping</span>
<span className='font-semibold'>Free Global Shipping</span>
</p>
)}
</div>
)}
<p className={cn('border-t py-2 flex justify-between', totalClx)}>
TOTAL
<span className='font-semibold'>{formatCurrencyValue(showPromoCode ? cmmc.promoAppliedCartTotal : cmmc.cartTotal)}</span>
</p>
</>)
</>))

const scrolling = (): boolean => (cmmc.cartItems.length > scrollAfter)

Expand Down
98 changes: 98 additions & 0 deletions packages/commerce/components/cart-panel/promo-code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client'

import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Check } from 'lucide-react'
import { observer } from 'mobx-react-lite'

import { Button, Form, FormControl, FormField, FormItem, Input } from '@hanzo/ui/primitives'

import { useCommerce } from '../..'
import type { Promo } from '../../types'
import getPromoFromApi from '../../util/promo-codes'

const formSchema = z.object({
code: z.string().min(1, 'Invalid code'),
})

const PromoCode = observer(() => {
const cmmc = useCommerce()
const searchParams = useSearchParams()

const [codeAccepted, setCodeAccepted] = useState<boolean>(false)

useEffect(() => {
const code = searchParams.get('code')
if (code) {
getPromoFromApi(code).then((promo?: Promo) => {
if (promo) {
form.setValue('code', code)
setCodeAccepted(true)
cmmc.setAppliedPromo(promo)
}
})
}
}, [searchParams])

useEffect(() => {
if (cmmc.appliedPromo) {
form.setValue('code', cmmc.appliedPromo.code)
setCodeAccepted(true)
}
}, [cmmc.appliedPromo])

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
code: '',
},
})

const applyPromoCode = async (values: z.infer<typeof formSchema>) => {
const { code } = values
const promo = await getPromoFromApi(code)
if (!promo) {
form.setError('code', { message: 'Invalid code' })
return
}
setCodeAccepted(true)
cmmc.setAppliedPromo(promo)
}

const removePromoCode = () => {
cmmc.setAppliedPromo(null)
setCodeAccepted(false)
}

return (
<div className='flex flex-col gap-2 border-t py-2'>
<Form {...form}>
<form onSubmit={form.handleSubmit(applyPromoCode)}>
<div className='flex gap-2 items-center'>
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem className='w-full'>
<FormControl>
<Input {...field} placeholder='Discount or invite code' onInput={removePromoCode}/>
</FormControl>
</FormItem>
)}
/>
{codeAccepted ? (
<Check/>
) : (
<Button variant='outline' className='!w-fit !min-w-0 font-inter'>Apply</Button>
)}
</div>
</form>
</Form>
</div>
)
})

export default PromoCode
4 changes: 2 additions & 2 deletions packages/commerce/components/category-item-radio-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { observer } from 'mobx-react-lite'

import { Label, RadioGroup, RadioGroupItem } from '@hanzo/ui/primitives'
import type { ItemSelector, LineItem, Product } from '../types'
import { formatPrice } from '../util'
import { formatCurrencyValue } from '../util'
import { cn } from '@hanzo/ui/util'

const CategoryItemRadioSelector: React.FC<ItemSelector & {
Expand All @@ -29,7 +29,7 @@ const CategoryItemRadioSelector: React.FC<ItemSelector & {
}) => (
<div className={cn(className, itemClx)}>
<RadioGroupItem value={item.sku} id={item.sku} />
<Label htmlFor={item.sku}>{item.titleAsOption + (showPrice ? (', ' + formatPrice(item.price)) : '')}</Label>
<Label htmlFor={item.sku}>{item.titleAsOption + (showPrice ? (', ' + formatCurrencyValue(item.price)) : '')}</Label>
</div>
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { cn } from '@hanzo/ui/util'
import { ScrollArea } from '@hanzo/ui/primitives'

import type { ItemSelector } from '../types'
import { formatPrice } from '../util'
import { formatCurrencyValue } from '../util'


const CategoryItemScrollSelector: React.FC<ItemSelector & {
Expand All @@ -29,7 +29,7 @@ const CategoryItemScrollSelector: React.FC<ItemSelector & {
(iRef.item?.sku === prod.sku) ? 'font-semibold text-accent' : 'text-muted',
itemClx
)}>
{prod.titleAsOption + ', ' + formatPrice(prod.price)}
{prod.titleAsOption + ', ' + formatCurrencyValue(prod.price)}
</div>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import Jcb from './card-icons/jcb'

const PaymentMethods: React.FC = () => {
return (
<div className='flex gap-1 items-center text-muted-1'>
<div className='flex gap-1 items-center text-muted-1 pb-3'>
<LockKeyhole className='w-4 h-4'/>
<span className='hidden sm:flex text-sm'>Secure payments with</span>
<Amex className='w-9 h-5'/>
<Discover className='w-9 h-5'/>
<Mastercard className='w-9 h-5'/>
Expand Down
4 changes: 2 additions & 2 deletions packages/commerce/components/payment-step-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const PaymentStepForm: React.FC<CheckoutStepComponentProps> = observer(({
price: item.price,
quantity: item.quantity
})),
value: cmmc.cartTotal,
value: cmmc.promoAppliedCartTotal,
currency: 'USD',
payment_type: paymentInfo.paymentMethod ?? ''
})
Expand All @@ -69,7 +69,7 @@ const PaymentStepForm: React.FC<CheckoutStepComponentProps> = observer(({
id: item.sku,
quantity: item.quantity
})),
value: cmmc.cartTotal,
value: cmmc.promoAppliedCartTotal,
currency: 'USD'
})
await cmmc.updateOrderPaymentInfo(id, paymentInfo)
Expand Down
Loading
Loading