@hanzo/commerce 5.0.0 → 5.1.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.
@@ -7,12 +7,12 @@ import { cn } from '@hanzo/ui/util'
7
7
 
8
8
  import type { FacetValueDesc, FacetsValue, LineItem } from '../../types'
9
9
  import { useCommerce } from '../../service/context'
10
- import { formatPrice } from '../../util'
10
+ import { formatCurrencyValue } from '../../util'
11
11
 
12
12
  import FacetTogglesWidget from '../facet-values-widget'
13
13
 
14
14
  const formatItem = (item: LineItem, withQuantity: boolean = false): string => (
15
- `${item.titleAsOption}, ${formatPrice(item.price)}${(withQuantity && item.quantity > 0) ? ` (${item.quantity})` : ''}`
15
+ `${item.titleAsOption}, ${formatCurrencyValue(item.price)}${(withQuantity && item.quantity > 0) ? ` (${item.quantity})` : ''}`
16
16
  )
17
17
 
18
18
  const SelectCategoryAndItemWidget: React.FC<{
@@ -10,7 +10,7 @@ import type { ItemSelector, LineItem } from '../../types'
10
10
  import AddToCartWidget from '../add-to-cart-widget'
11
11
  import CategoryItemRadioSelector from '../category-item-radio-selector'
12
12
  import CategoryItemScrollSelector from '../category-item-scroll-selector'
13
- import { formatPrice } from '../../util'
13
+ import { formatCurrencyValue } from '../../util'
14
14
 
15
15
  const SelectCategoryItemCard: React.FC<React.HTMLAttributes<HTMLDivElement> & ItemSelector & {
16
16
  isLoading?: boolean
@@ -40,7 +40,7 @@ const SelectCategoryItemCard: React.FC<React.HTMLAttributes<HTMLDivElement> & It
40
40
  const item = category.products[0] as LineItem
41
41
  return (
42
42
  <div className={cn('flex flex-col justify-center items-center ' + (mobilePicker ? 'h-[180px] ' : 'h-auto min-h-24'), className)}>
43
- <p className='text-lg text-center font-semibold'>{item.titleAsOption + ', ' + formatPrice(item.price)}</p>
43
+ <p className='text-lg text-center font-semibold'>{item.titleAsOption + ', ' + formatCurrencyValue(item.price)}</p>
44
44
  </div>
45
45
  )
46
46
  }
@@ -11,7 +11,7 @@ import {
11
11
  AccordionTrigger,
12
12
  } from '@hanzo/ui/primitives'
13
13
 
14
- import { formatPrice, useCommerce } from '..'
14
+ import { formatCurrencyValue, useCommerce } from '..'
15
15
 
16
16
  import CartPanel from './cart-panel'
17
17
 
@@ -35,7 +35,7 @@ const CartAccordian: React.FC<{
35
35
  </h5>
36
36
  </div>
37
37
  <div className='flex gap-1 items-center'>
38
- <h5 className='text-sm sm:text-xl grow truncate'>{formatPrice(cmmc.cartTotal)}</h5>
38
+ <h5 className='text-sm sm:text-xl grow truncate'>{formatCurrencyValue(cmmc.promoAppliedCartTotal)}</h5>
39
39
  <ChevronRight className="h-5 w-5 -mr-2 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
40
40
  </div>
41
41
  </AccordionTrigger>
@@ -46,6 +46,8 @@ const CartAccordian: React.FC<{
46
46
  scrollHeightClx='h-[350px]'
47
47
  itemClx='mt-2'
48
48
  totalClx='sticky px-1 pr-2 border-t -bottom-[1px] bg-background'
49
+ showShipping
50
+ showPromoCode
49
51
  />
50
52
  </AccordionContent>
51
53
  </AccordionItem>
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  import React from 'react'
2
4
  import Image from 'next/image'
3
5
  import { observer } from 'mobx-react-lite'
@@ -5,8 +7,9 @@ import { observer } from 'mobx-react-lite'
5
7
  import { cn } from '@hanzo/ui/util'
6
8
 
7
9
  import type { LineItem } from '../../types'
8
- import { formatPrice } from '../../util'
10
+ import { formatCurrencyValue } from '../../util'
9
11
  import AddToCartWidget from '../add-to-cart-widget'
12
+ import { useCommerce } from '../..'
10
13
 
11
14
  const DEF_IMG_SIZE=40
12
15
 
@@ -22,30 +25,39 @@ const CartLineItem: React.FC<{
22
25
  item: LineItem
23
26
  className?: string
24
27
  imgSizePx?: number
25
- }> = observer(({
28
+ showPromoCode?: boolean
29
+ }> = observer(({
26
30
  item,
27
31
  className='',
28
- imgSizePx=DEF_IMG_SIZE
29
- }) => (
30
- <div className={cn('flex flex-col justify-start items-start text-sm font-sans', className)}>
31
- <div className='flex flex-row justify-between items-center gap-2'>
32
- {!!item.img ? (
33
- <Image src={item.img} alt={item.title + ' image'} height={imgSizePx} width={imgSizePx} className=''/>
34
- ) : ( // placeholder so things align
35
- <div style={{height: imgSizePx, width: imgSizePx}}/ >
36
- )}
37
- <div className='grow leading-tight'>{renderTitle(item.title)}</div>
38
- </div>
39
- <div className='flex flex-row items-center justify-between w-full'>
40
- <div className='flex flex-row items-center'>
41
- <AddToCartWidget className='' item={item} size='xs' buttonClx='h-8 md:h-6' ghost/>
42
- {item.quantity > 1 && (<span className='pl-2.5'>{'@' + formatPrice(item.price)}</span>)}
32
+ imgSizePx=DEF_IMG_SIZE,
33
+ showPromoCode
34
+ }) => {
35
+
36
+ const cmmc = useCommerce()
37
+ const promoPrice = showPromoCode ? cmmc.itemPromoPrice(item) : undefined
38
+
39
+ return (
40
+ <div className={cn('flex flex-col justify-start items-start text-sm font-sans', className)}>
41
+ <div className='flex flex-row justify-between items-center gap-2'>
42
+ {!!item.img ? (
43
+ <Image src={item.img} alt={item.title + ' image'} height={imgSizePx} width={imgSizePx} className=''/>
44
+ ) : ( // placeholder so things align
45
+ <div style={{height: imgSizePx, width: imgSizePx}}/>
46
+ )}
47
+ <div className='grow leading-tight'>{renderTitle(item.title)}</div>
43
48
  </div>
44
- <div className='flex flex-row items-center justify-end'>
45
- {formatPrice(item.price * item.quantity)}
49
+ <div className='flex flex-row items-center justify-between w-full'>
50
+ <div className='flex flex-row items-center'>
51
+ <AddToCartWidget className='' item={item} size='xs' buttonClx='h-8 md:h-6' ghost/>
52
+ {item.quantity > 1 && (<span className='pl-2.5'>{'@' + formatCurrencyValue(item.price)}</span>)}
53
+ </div>
54
+ <div className='flex flex-row gap-1 items-center justify-end'>
55
+ <span className={promoPrice ? 'line-through text-muted-2' : ''}>{formatCurrencyValue(item.price * item.quantity)}</span>
56
+ {promoPrice && <span>{formatCurrencyValue(promoPrice * item.quantity)}</span>}
57
+ </div>
46
58
  </div>
47
59
  </div>
48
- </div>
49
- ))
60
+ )
61
+ })
50
62
 
51
63
  export default CartLineItem
@@ -1,15 +1,16 @@
1
1
  'use client'
2
- import React, { type PropsWithChildren } from 'react'
2
+ import React, { Suspense, type PropsWithChildren } from 'react'
3
3
  import { observer } from 'mobx-react-lite'
4
4
 
5
5
  import { Button, ScrollArea } from '@hanzo/ui/primitives'
6
6
  import { cn } from '@hanzo/ui/util'
7
7
 
8
8
  import { useCommerce } from '../../service/context'
9
- import { formatPrice } from '../../util'
9
+ import { formatCurrencyValue } from '../../util'
10
10
  import { sendFBEvent, sendGAEvent } from '../../util/analytics'
11
11
 
12
12
  import CartLineItem from './cart-line-item'
13
+ import PromoCode from './promo-code'
13
14
 
14
15
  const CartPanel: React.FC<PropsWithChildren & {
15
16
  /** fix size and scroll after 'scrollAfter items in teh cart. */
@@ -22,6 +23,8 @@ const CartPanel: React.FC<PropsWithChildren & {
22
23
  noItemsClx?: string
23
24
  totalClx?: string
24
25
  buttonClx?: string
26
+ showPromoCode?: boolean
27
+ showShipping?: boolean
25
28
  /** if not provided, 'checkout' button will be rendered */
26
29
  handleCheckout?: () => void
27
30
  }> = observer(({
@@ -36,6 +39,8 @@ const CartPanel: React.FC<PropsWithChildren & {
36
39
  noItemsClx='',
37
40
  totalClx='',
38
41
  buttonClx='',
42
+ showPromoCode=false,
43
+ showShipping=false,
39
44
  handleCheckout,
40
45
  }) => {
41
46
 
@@ -69,19 +74,46 @@ const CartPanel: React.FC<PropsWithChildren & {
69
74
  handleCheckout && handleCheckout()
70
75
  }
71
76
 
72
- const Main: React.FC = () => (<>
77
+ const Main: React.FC = observer(() => (<>
73
78
  {cmmc.cartEmpty ? (
74
79
  <p className={cn('text-center mt-4', noItemsClx)}>No items in cart</p>
75
80
  ) : (<>
76
81
  {cmmc.cartItems.map((item) => (
77
- <CartLineItem imgSizePx={imgSizePx} item={item} key={`mobile-${item.sku}`} className={cn('mb-2', itemClx)}/>
82
+ <CartLineItem
83
+ key={`mobile-${item.sku}`}
84
+ imgSizePx={imgSizePx}
85
+ item={item}
86
+ className={cn('mb-2', itemClx)}
87
+ showPromoCode={showPromoCode}
88
+ />
78
89
  ))}
79
90
  </>)}
80
- <p className={cn('mt-6 text-right border-t pt-1', totalClx)}>
81
- <span>TOTAL:&nbsp;</span>
82
- <span className='font-semibold'>{cmmc.cartTotal === 0 ? '0' : formatPrice(cmmc.cartTotal)}</span>
91
+ {showPromoCode && (<Suspense><PromoCode/></Suspense>)}
92
+ {(showShipping || showPromoCode) && (
93
+ <div className='flex flex-col gap-1 py-2 border-t'>
94
+ <p className='flex justify-between'>
95
+ <span className='text-muted-1'>Subtotal</span>
96
+ <span className='font-semibold'>{cmmc.cartTotal === 0 ? '0' : formatCurrencyValue(cmmc.cartTotal)}</span>
97
+ </p>
98
+ {cmmc.promoAppliedCartTotal !== cmmc.cartTotal && (
99
+ <p className='flex justify-between'>
100
+ <span className='text-muted-1'>Promo Discount</span>
101
+ <span className='font-semibold'>-{formatCurrencyValue(cmmc.cartTotal - cmmc.promoAppliedCartTotal)}</span>
102
+ </p>
103
+ )}
104
+ {showShipping && (
105
+ <p className='flex justify-between'>
106
+ <span className='text-muted-1'>Shipping</span>
107
+ <span className='font-semibold'>Free Global Shipping</span>
108
+ </p>
109
+ )}
110
+ </div>
111
+ )}
112
+ <p className={cn('border-t py-2 flex justify-between', totalClx)}>
113
+ TOTAL
114
+ <span className='font-semibold'>{formatCurrencyValue(showPromoCode ? cmmc.promoAppliedCartTotal : cmmc.cartTotal)}</span>
83
115
  </p>
84
- </>)
116
+ </>))
85
117
 
86
118
  const scrolling = (): boolean => (cmmc.cartItems.length > scrollAfter)
87
119
 
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useSearchParams } from 'next/navigation'
5
+ import { useForm } from 'react-hook-form'
6
+ import * as z from 'zod'
7
+ import { zodResolver } from '@hookform/resolvers/zod'
8
+ import { Check } from 'lucide-react'
9
+ import { observer } from 'mobx-react-lite'
10
+
11
+ import { Button, Form, FormControl, FormField, FormItem, Input } from '@hanzo/ui/primitives'
12
+
13
+ import { useCommerce } from '../..'
14
+ import type { Promo } from '../../types'
15
+ import getPromoFromApi from '../../util/promo-codes'
16
+
17
+ const formSchema = z.object({
18
+ code: z.string().min(1, 'Invalid code'),
19
+ })
20
+
21
+ const PromoCode = observer(() => {
22
+ const cmmc = useCommerce()
23
+ const searchParams = useSearchParams()
24
+
25
+ const [codeAccepted, setCodeAccepted] = useState<boolean>(false)
26
+
27
+ useEffect(() => {
28
+ const code = searchParams.get('code')
29
+ if (code) {
30
+ getPromoFromApi(code).then((promo?: Promo) => {
31
+ if (promo) {
32
+ form.setValue('code', code)
33
+ setCodeAccepted(true)
34
+ cmmc.setAppliedPromo(promo)
35
+ }
36
+ })
37
+ }
38
+ }, [searchParams])
39
+
40
+ useEffect(() => {
41
+ if (cmmc.appliedPromo) {
42
+ form.setValue('code', cmmc.appliedPromo.code)
43
+ setCodeAccepted(true)
44
+ }
45
+ }, [cmmc.appliedPromo])
46
+
47
+ const form = useForm<z.infer<typeof formSchema>>({
48
+ resolver: zodResolver(formSchema),
49
+ defaultValues: {
50
+ code: '',
51
+ },
52
+ })
53
+
54
+ const applyPromoCode = async (values: z.infer<typeof formSchema>) => {
55
+ const { code } = values
56
+ const promo = await getPromoFromApi(code)
57
+ if (!promo) {
58
+ form.setError('code', { message: 'Invalid code' })
59
+ return
60
+ }
61
+ setCodeAccepted(true)
62
+ cmmc.setAppliedPromo(promo)
63
+ }
64
+
65
+ const removePromoCode = () => {
66
+ cmmc.setAppliedPromo(null)
67
+ setCodeAccepted(false)
68
+ }
69
+
70
+ return (
71
+ <div className='flex flex-col gap-2 border-t py-2'>
72
+ <Form {...form}>
73
+ <form onSubmit={form.handleSubmit(applyPromoCode)}>
74
+ <div className='flex gap-2 items-center'>
75
+ <FormField
76
+ control={form.control}
77
+ name='code'
78
+ render={({ field }) => (
79
+ <FormItem className='w-full'>
80
+ <FormControl>
81
+ <Input {...field} placeholder='Discount or invite code' onInput={removePromoCode}/>
82
+ </FormControl>
83
+ </FormItem>
84
+ )}
85
+ />
86
+ {codeAccepted ? (
87
+ <Check/>
88
+ ) : (
89
+ <Button variant='outline' className='!w-fit !min-w-0 font-inter'>Apply</Button>
90
+ )}
91
+ </div>
92
+ </form>
93
+ </Form>
94
+ </div>
95
+ )
96
+ })
97
+
98
+ export default PromoCode
@@ -4,7 +4,7 @@ import { observer } from 'mobx-react-lite'
4
4
 
5
5
  import { Label, RadioGroup, RadioGroupItem } from '@hanzo/ui/primitives'
6
6
  import type { ItemSelector, LineItem, Product } from '../types'
7
- import { formatPrice } from '../util'
7
+ import { formatCurrencyValue } from '../util'
8
8
  import { cn } from '@hanzo/ui/util'
9
9
 
10
10
  const CategoryItemRadioSelector: React.FC<ItemSelector & {
@@ -29,7 +29,7 @@ const CategoryItemRadioSelector: React.FC<ItemSelector & {
29
29
  }) => (
30
30
  <div className={cn(className, itemClx)}>
31
31
  <RadioGroupItem value={item.sku} id={item.sku} />
32
- <Label htmlFor={item.sku}>{item.titleAsOption + (showPrice ? (', ' + formatPrice(item.price)) : '')}</Label>
32
+ <Label htmlFor={item.sku}>{item.titleAsOption + (showPrice ? (', ' + formatCurrencyValue(item.price)) : '')}</Label>
33
33
  </div>
34
34
  )
35
35
 
@@ -6,7 +6,7 @@ import { cn } from '@hanzo/ui/util'
6
6
  import { ScrollArea } from '@hanzo/ui/primitives'
7
7
 
8
8
  import type { ItemSelector } from '../types'
9
- import { formatPrice } from '../util'
9
+ import { formatCurrencyValue } from '../util'
10
10
 
11
11
 
12
12
  const CategoryItemScrollSelector: React.FC<ItemSelector & {
@@ -29,7 +29,7 @@ const CategoryItemScrollSelector: React.FC<ItemSelector & {
29
29
  (iRef.item?.sku === prod.sku) ? 'font-semibold text-accent' : 'text-muted',
30
30
  itemClx
31
31
  )}>
32
- {prod.titleAsOption + ', ' + formatPrice(prod.price)}
32
+ {prod.titleAsOption + ', ' + formatCurrencyValue(prod.price)}
33
33
  </div>
34
34
  </div>
35
35
  ))}
@@ -10,8 +10,9 @@ import Jcb from './card-icons/jcb'
10
10
 
11
11
  const PaymentMethods: React.FC = () => {
12
12
  return (
13
- <div className='flex gap-1 items-center text-muted-1'>
13
+ <div className='flex gap-1 items-center text-muted-1 pb-3'>
14
14
  <LockKeyhole className='w-4 h-4'/>
15
+ <span className='hidden sm:flex text-sm'>Secure payments with</span>
15
16
  <Amex className='w-9 h-5'/>
16
17
  <Discover className='w-9 h-5'/>
17
18
  <Mastercard className='w-9 h-5'/>
@@ -60,7 +60,7 @@ const PaymentStepForm: React.FC<CheckoutStepComponentProps> = observer(({
60
60
  price: item.price,
61
61
  quantity: item.quantity
62
62
  })),
63
- value: cmmc.cartTotal,
63
+ value: cmmc.promoAppliedCartTotal,
64
64
  currency: 'USD',
65
65
  payment_type: paymentInfo.paymentMethod ?? ''
66
66
  })
@@ -69,7 +69,7 @@ const PaymentStepForm: React.FC<CheckoutStepComponentProps> = observer(({
69
69
  id: item.sku,
70
70
  quantity: item.quantity
71
71
  })),
72
- value: cmmc.cartTotal,
72
+ value: cmmc.promoAppliedCartTotal,
73
73
  currency: 'USD'
74
74
  })
75
75
  await cmmc.updateOrderPaymentInfo(id, paymentInfo)
@@ -3,14 +3,7 @@
3
3
  import React from 'react'
4
4
  import { Copy } from 'lucide-react'
5
5
 
6
- import {
7
- Button,
8
- Tabs,
9
- TabsContent,
10
- TabsList,
11
- TabsTrigger,
12
- toast
13
- } from '@hanzo/ui/primitives'
6
+ import { Button, toast } from '@hanzo/ui/primitives'
14
7
 
15
8
  import type { PaymentMethodComponentProps } from '../../../types'
16
9
 
@@ -57,51 +50,27 @@ const PayWithBankTransfer: React.FC<PaymentMethodComponentProps> = ({
57
50
  })()
58
51
  }
59
52
 
60
- const groupClx = 'border-2 bg-background border-level-2 md:bg-level-1 md:border-level-3 p-0 h-auto overflow-hidden rounded-md'
61
- const tabClx = 'text-muted data-[state=active]:text-accent data-[state=active]:bg-level-2 md:data-[state=active]:bg-level-3'
62
-
63
53
  return (
64
54
  <div className='flex flex-col gap-2 mt-6'>
65
55
  <ContactForm form={contactForm}/>
66
- <Tabs defaultValue="usd" className='w-full mx-auto max-w-[50rem]'>
67
- <TabsList className={"grid w-full grid-cols-2 max-w-[15rem] mx-auto " + groupClx}>
68
- <TabsTrigger className={tabClx} value="usd">USD</TabsTrigger>
69
- <TabsTrigger className={tabClx} value="eur">EUR/GBP</TabsTrigger>
70
- </TabsList>
71
- <TabsContent value="usd">
72
- <div className='flex flex-col gap-4 w-full'>
73
- <InfoField
74
- label='Beneficiary Bank'
75
- value={<div>Bank of America<br/>NA 222 Broadway<br/>New York, New York. 10038</div>}
76
- copyValue='Bank of America, NA 222 Broadway, New York, New York. 10038'
77
- />
78
- <InfoField
79
- label='Beneficiary'
80
- value={<div>Hanzo, Inc<br/>4811 Mastin Street<br/>Merriam, KS 66203</div>}
81
- copyValue='Hanzo, Inc, 4811 Mastin Street, Merriam, KS 66203'
82
- />
83
- <InfoField label='Routing Number - ACH' value='113000023 / 111000025' copyValue='113000023 / 111000025'/>
84
- <InfoField label='Routing Number - Wire' value='026009593' copyValue='026009593'/>
85
- <InfoField label='Routing Number - SWIFT' value='BOFAUS3N' copyValue='BOFAUS3N'/>
86
- <InfoField label='Reference' value='Lux' copyValue='Lux'/>
87
- </div>
88
- </TabsContent>
89
- <TabsContent value="eur">
90
- <div className='flex flex-col gap-4 w-full'>
91
- <InfoField
92
- label='Beneficiary Bank'
93
- value={<div>Clear Junction Limited<br/>4th floor<br/>Imperial House</div>}
94
- copyValue='Clear Junction Limited, 4th floor, Imperial House'
95
- />
96
- <InfoField label='Account Number (GBP)' value='33781813' copyValue='33781813'/>
97
- <InfoField label='Account Name' value='AiDLT Global Ltd.' copyValue='AiDLT Global Ltd.'/>
98
- <InfoField label='SWIFT/BIC' value='CLJUGB21' copyValue='CLJUGB21'/>
99
- <InfoField label='Sort Code (GBP)' value='041307' copyValue='041307'/>
100
- <InfoField label='IBAN' value='GB75 CLJU 0413 0733 78181 3' copyValue='GB75 CLJU 0413 0733 78181 3'/>
101
- <InfoField label='Reference' value='Lux' copyValue='Lux'/>
102
- </div>
103
- </TabsContent>
104
- </Tabs>
56
+ <div className='w-full mx-auto max-w-[50rem]'>
57
+ <div className='flex flex-col gap-4 w-full'>
58
+ <InfoField
59
+ label='Beneficiary Bank'
60
+ value={<div>Bank of America<br/>NA 222 Broadway<br/>New York, New York. 10038</div>}
61
+ copyValue='Bank of America, NA 222 Broadway, New York, New York. 10038'
62
+ />
63
+ <InfoField
64
+ label='Beneficiary'
65
+ value={<div>Hanzo, Inc<br/>4811 Mastin Street<br/>Merriam, KS 66203</div>}
66
+ copyValue='Hanzo, Inc, 4811 Mastin Street, Merriam, KS 66203'
67
+ />
68
+ <InfoField label='Routing Number - ACH' value='113000023 / 111000025' copyValue='113000023 / 111000025'/>
69
+ <InfoField label='Routing Number - Wire' value='026009593' copyValue='026009593'/>
70
+ <InfoField label='Routing Number - SWIFT' value='BOFAUS3N' copyValue='BOFAUS3N'/>
71
+ <InfoField label='Reference' value='Lux' copyValue='Lux'/>
72
+ </div>
73
+ </div>
105
74
  <Button onClick={payByBankTransfer} className='mx-auto w-full mt-4'>Continue</Button>
106
75
  </div>
107
76
  )
@@ -1,11 +1,11 @@
1
1
  'use client'
2
- import React from 'react'
2
+ import React, { useEffect, useState } from 'react'
3
3
 
4
- import type { UseFormReturn } from 'react-hook-form'
5
4
  // @ts-ignore
6
5
  import { ApplePay, GooglePay, CreditCard, PaymentForm } from 'react-square-web-payments-sdk'
7
6
 
8
7
  import { ChevronRight } from 'lucide-react'
8
+ import { observer } from 'mobx-react-lite'
9
9
 
10
10
  import {
11
11
  Accordion,
@@ -14,6 +14,7 @@ import {
14
14
  AccordionTrigger,
15
15
  ApplyTypography,
16
16
  Button,
17
+ Skeleton,
17
18
  buttonVariants
18
19
  } from '@hanzo/ui/primitives'
19
20
 
@@ -27,7 +28,7 @@ import { sendFBEvent, sendGAEvent } from '../../../util/analytics'
27
28
  import ContactInfo from '../contact-form'
28
29
  import PaymentMethods from '../card-icon-row'
29
30
 
30
- const PayWithCard: React.FC<PaymentMethodComponentProps> = ({
31
+ const PayWithCard: React.FC<PaymentMethodComponentProps> = observer(({
31
32
  onDone,
32
33
  transactionStatus,
33
34
  setTransactionStatus,
@@ -42,9 +43,8 @@ const PayWithCard: React.FC<PaymentMethodComponentProps> = ({
42
43
  ) => {
43
44
  contactForm.handleSubmit(async () => {
44
45
  setTransactionStatus('paid')
45
- const res = await processSquareCardPayment(token.token, cmmc.cartTotal, verifiedBuyer.token)
46
+ const res = await processSquareCardPayment(token.token, cmmc.promoAppliedCartTotal, verifiedBuyer.token)
46
47
  if (res) {
47
- console.log(token)
48
48
  await storePaymentInfo({paymentMethod: token.details.method ?? null, processed: res})
49
49
  setTransactionStatus('confirmed')
50
50
  sendGAEvent('purchase', {
@@ -66,7 +66,7 @@ const PayWithCard: React.FC<PaymentMethodComponentProps> = ({
66
66
  quantity: item.quantity
67
67
  })),
68
68
  num_items: cmmc.cartItems.length,
69
- value: cmmc.cartTotal,
69
+ value: cmmc.promoAppliedCartTotal,
70
70
  currency: 'USD',
71
71
  })
72
72
  } else {
@@ -78,7 +78,7 @@ const PayWithCard: React.FC<PaymentMethodComponentProps> = ({
78
78
  const createVerificationDetails = () => {
79
79
  const {name, email} = contactForm.getValues()
80
80
  return {
81
- amount: cmmc.cartTotal.toFixed(2),
81
+ amount: cmmc.promoAppliedCartTotal.toFixed(2),
82
82
  billingContact: {
83
83
  givenName: name,
84
84
  email,
@@ -88,7 +88,7 @@ const PayWithCard: React.FC<PaymentMethodComponentProps> = ({
88
88
  }
89
89
  }
90
90
 
91
- const createPaymentRequest= () => ({
91
+ const createPaymentRequest = () => ({
92
92
  countryCode: "US",
93
93
  currencyCode: "USD",
94
94
  lineItems: cmmc.cartItems.map(item => ({
@@ -99,11 +99,31 @@ const PayWithCard: React.FC<PaymentMethodComponentProps> = ({
99
99
  requestBillingContact: false,
100
100
  requestShippingContact: false,
101
101
  total: {
102
- amount: cmmc.cartTotal.toFixed(2),
102
+ amount: cmmc.promoAppliedCartTotal.toFixed(2),
103
103
  label: "Total",
104
104
  },
105
105
  })
106
106
 
107
+ /**
108
+ * Reload payment form after checkout value changes (promo code applied, etc.)
109
+ * Reloading is required so that Apple Pay and Google Pay buttons are updated for new cart total.
110
+ */
111
+ const [loadingPaymentForm, setLoadingPaymentForm] = useState<boolean>(false)
112
+ useEffect(() => {
113
+ setLoadingPaymentForm(true)
114
+ const timeout = setTimeout(() => setLoadingPaymentForm(false), 1000)
115
+ return () => clearTimeout(timeout)
116
+ }, [cmmc.promoAppliedCartTotal])
117
+
118
+ if (loadingPaymentForm) {
119
+ return (
120
+ <div className='flex flex-col gap-2'>
121
+ <Skeleton className='w-full h-10' />
122
+ <Skeleton className='w-full h-10' />
123
+ </div>
124
+ )
125
+ }
126
+
107
127
  return (
108
128
  <PaymentForm
109
129
  /**
@@ -143,92 +163,75 @@ const PayWithCard: React.FC<PaymentMethodComponentProps> = ({
143
163
  <Button onClick={onDone}>Continue</Button>
144
164
  </div>
145
165
  ) : (
146
- <>
166
+ <div className='flex flex-col gap-1'>
147
167
  <GooglePay/>
148
168
  <ApplePay/>
149
169
 
150
- <div className='flex gap-2 whitespace-nowrap items-center my-0.5 sm:my-1 text-xs text-muted'>
170
+ <div className='flex gap-2 whitespace-nowrap items-center my-1 sm:my-3 text-xs text-muted'>
151
171
  <hr className='grow border'/><div className='shrink-0 mx-1'>or</div><hr className='grow border'/>
152
172
  </div>
153
173
 
154
- <Accordion type="single" collapsible className={''}>
155
- <AccordionItem value="cart" className='w-full border-b-0 data-[state=open]:border data-[state=open]:px-3 rounded '>
156
- <AccordionTrigger className={cn(
157
- buttonVariants({variant: 'outline', size: 'lg'}),
158
- '!no-underline group flex justify-between ',
159
- 'bg-transparent hover:bg-transparent border-muted-2 hover:border-muted-2',
160
- 'data-[state=open]:border-none data-[state=open]:py-0 px-4 data-[state=open]:px-1'
174
+ <PaymentMethods />
175
+
176
+ <ContactInfo form={contactForm}/>
177
+ {/* Imitates hanzo/ui Button and Input styles, I was unable to render the
178
+ hanzo/ui button outright and keeping the submit form functionality*/}
179
+ <CreditCard
180
+ style={{
181
+ '.input-container': {
182
+ borderColor: '#404040',
183
+ borderRadius: '6px',
184
+ },
185
+ '.input-container.is-focus': {
186
+ borderColor: '#ffffff',
187
+ },
188
+ '.input-container.is-error': {
189
+ borderColor: '#ff1600',
190
+ },
191
+ '.message-text': {
192
+ color: '#999999',
193
+ },
194
+ '.message-icon': {
195
+ color: '#999999',
196
+ },
197
+ '.message-text.is-error': {
198
+ color: '#ff1600',
199
+ },
200
+ '.message-icon.is-error': {
201
+ color: '#ff1600',
202
+ },
203
+ input: {
204
+ backgroundColor: 'transparent',
205
+ color: '#FFFFFF',
206
+ fontSize: '15px',
207
+ fontFamily: 'helvetica neue, sans-serif',
208
+ },
209
+ 'input::placeholder': {
210
+ color: '#999999',
211
+ },
212
+ 'input.is-error': {
213
+ color: '#ff1600',
214
+ },
215
+ }}
216
+ render={(Button: any) => (
217
+ <Button className={cn(
218
+ 'items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
219
+ 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
220
+ '!bg-primary !text-primary-fg hover:!bg-primary-hover font-nav whitespace-nowrap not-typography h-10 py-2 px-4',
221
+ '!text-sm rounded-md lg:min-w-[220px] sm:min-w-[220px] flex'
161
222
  )}>
162
- <div className='font-sans text-base mr-2 text-muted'>CC</div>
163
- <PaymentMethods />
164
- <div>
165
- <ChevronRight className="h-5 w-5 -mr-2 text-muted shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90 hidden group-data-[state=open]:block" />
166
- </div>
167
- </AccordionTrigger>
168
- <AccordionContent className='data-[state=open]:mb-4 data-[state=open]:mt-1'>
169
- <ContactInfo form={contactForm}/>
170
- {/* Imitates hanzo/ui Button and Input styles, I was unable to render the
171
- hanzo/ui button outright and keeping the submit form functionality*/}
172
- <CreditCard
173
- style={{
174
- '.input-container': {
175
- borderColor: '#404040',
176
- borderRadius: '6px',
177
- },
178
- '.input-container.is-focus': {
179
- borderColor: '#ffffff',
180
- },
181
- '.input-container.is-error': {
182
- borderColor: '#ff1600',
183
- },
184
- '.message-text': {
185
- color: '#999999',
186
- },
187
- '.message-icon': {
188
- color: '#999999',
189
- },
190
- '.message-text.is-error': {
191
- color: '#ff1600',
192
- },
193
- '.message-icon.is-error': {
194
- color: '#ff1600',
195
- },
196
- input: {
197
- backgroundColor: 'transparent',
198
- color: '#FFFFFF',
199
- fontSize: '15px',
200
- fontFamily: 'helvetica neue, sans-serif',
201
- },
202
- 'input::placeholder': {
203
- color: '#999999',
204
- },
205
- 'input.is-error': {
206
- color: '#ff1600',
207
- },
208
- }}
209
- render={(Button: any) => (
210
- <Button className={cn(
211
- 'items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
212
- 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
213
- '!bg-primary !text-primary-fg hover:!bg-primary-hover font-nav whitespace-nowrap not-typography h-10 py-2 px-4',
214
- '!text-sm rounded-md lg:min-w-[220px] sm:min-w-[220px] flex'
215
- )}>
216
- Pay
217
- </Button>
218
- )}
219
- />
220
- {transactionStatus === 'error' && (
221
- <p className='mx-auto text-destructive'>There was an error processing your payment.</p>
222
- )}
223
- </AccordionContent>
224
- </AccordionItem>
225
- </Accordion>
226
-
227
- </>
223
+ Pay
224
+ </Button>
225
+ )}
226
+ />
227
+ {transactionStatus === 'error' && (
228
+ <p className='mx-auto text-destructive'>There was an error processing your payment.</p>
229
+ )}
230
+ </div>
228
231
  )}
229
232
  </ApplyTypography>
230
233
  </PaymentForm>
231
234
  )
232
- }
235
+ })
233
236
 
234
237
  export default PayWithCard
@@ -64,7 +64,7 @@ const PayWithCrypto: React.FC<PaymentMethodComponentProps> = observer(({
64
64
  .then(res => res.json())
65
65
  .then((exchangeRate) => {
66
66
  const oneUsdInWei = (10**18) / exchangeRate.data.amount
67
- const usdAmountInWei = oneUsdInWei * cmmc.cartTotal
67
+ const usdAmountInWei = oneUsdInWei * cmmc.promoAppliedCartTotal
68
68
  setAmount(usdAmountInWei)
69
69
  setLoadingPrice(false)
70
70
  })
@@ -77,7 +77,7 @@ const PayWithCrypto: React.FC<PaymentMethodComponentProps> = observer(({
77
77
  const interval = setInterval(fetchPrice, 30000)
78
78
 
79
79
  return () => clearInterval(interval)
80
- }, [cmmc.cartTotal])
80
+ }, [cmmc.promoAppliedCartTotal])
81
81
 
82
82
  const sendPayment = async (ether: number) => {
83
83
  contactForm.handleSubmit(async () => {
@@ -13,7 +13,7 @@ import {
13
13
  import type { ImageDef } from '@hanzo/ui/types'
14
14
  import { cn } from '@hanzo/ui/util'
15
15
 
16
- import { formatPrice } from '../util'
16
+ import { formatCurrencyValue } from '../util'
17
17
  import type { LineItem } from '../types'
18
18
  import { Icons } from './Icons'
19
19
 
@@ -66,7 +66,7 @@ const ProductCard: React.FC<ProductCardProps> = ({
66
66
  <CardContent className='grid gap-2.5 p-4'>
67
67
  <CardTitle className='text-sm sm:text-base flex flex-col justify-start items-center line-clap-3'>
68
68
  {item.title.split(', ').map((e, i) => (<p key={i}>{e}</p>))}
69
- <p className='mt-1 font-semibold'>{formatPrice(item.price)}</p>
69
+ <p className='mt-1 font-semibold'>{formatCurrencyValue(item.price)}</p>
70
70
  </CardTitle>
71
71
  </CardContent>
72
72
  <CardFooter className='p-4 flex flex-row justify-center'>
@@ -7,7 +7,7 @@ import { cn } from '@hanzo/ui/util'
7
7
  import { Skeleton } from '@hanzo/ui/primitives'
8
8
 
9
9
  import type { ItemSelector } from '../types'
10
- import { formatPrice } from '../util'
10
+ import { formatCurrencyValue } from '../util'
11
11
  import { Icons } from './Icons'
12
12
 
13
13
  import AddToCartWidget from './add-to-cart-widget'
@@ -132,7 +132,7 @@ const SelectCategoryItemPanel: React.FC<
132
132
  </h3>
133
133
  {selectedItemRef.item?.sku ? (
134
134
  <h6 className='text-center font-semibold'>
135
- {(soleOption ? '' : (selectedItemRef.item.titleAsOption + ': ')) + formatPrice(selectedItemRef.item.price)}
135
+ {(soleOption ? '' : (selectedItemRef.item.titleAsOption + ': ')) + formatCurrencyValue(selectedItemRef.item.price)}
136
136
  </h6>
137
137
  ) : ''}
138
138
  </div>
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from './service/context'
2
2
  export * from './components'
3
3
  export type { StandaloneServiceOptions as ServiceOptions } from './service/impls/standalone/standalone-service'
4
- export { useSyncSkuParamWithCurrentItem, getFacetValuesMutator, formatPrice } from './util'
4
+ export { useSyncSkuParamWithCurrentItem, getFacetValuesMutator, formatCurrencyValue } from './util'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/commerce",
3
- "version": "5.0.0",
3
+ "version": "5.1.1",
4
4
  "description": "Library with shopping cart components.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
@@ -29,6 +29,7 @@ const CommerceServiceProvider: React.FC<PropsWithChildren & {
29
29
  options
30
30
  }) => {
31
31
 
32
+ // TODO: Inject Promo fixture here
32
33
  const serviceRef = useRef<CommerceService>(getServiceSingleton(productsByCategory, rootFacet, options))
33
34
  return (
34
35
  <CommerceServiceContext.Provider value={serviceRef.current}>
@@ -14,7 +14,8 @@ import type {
14
14
  Category,
15
15
  LineItem,
16
16
  FacetsValue,
17
- FacetValueDesc
17
+ FacetValueDesc,
18
+ Promo
18
19
  } from '../../../types'
19
20
 
20
21
  import {
@@ -43,6 +44,7 @@ class StandaloneService
43
44
  private _categoryMap = new Map<string, Category>()
44
45
  private _rootFacet: FacetValueDesc
45
46
  private _selectedFacets: FacetsValue = {}
47
+ private _promo: Promo | null = null
46
48
 
47
49
  private _options : StandaloneServiceOptions
48
50
  private _currentItem: ActualLineItem | undefined = undefined
@@ -73,23 +75,28 @@ class StandaloneService
73
75
  makeObservable<
74
76
  StandaloneService,
75
77
  '_selectedFacets' |
76
- '_currentItem'
78
+ '_currentItem' |
79
+ '_promo'
77
80
  >(this, {
78
81
  _selectedFacets : observable.deep,
79
82
  _currentItem: observable,
83
+ _promo: observable,
80
84
  })
81
85
 
82
86
  makeObservable(this, {
83
87
  cartItems: computed,
84
88
  cartQuantity: computed,
85
- cartTotal: computed,
89
+ cartTotal: computed,
90
+ promoAppliedCartTotal: computed,
86
91
  cartEmpty: computed,
87
92
  specifiedItems: computed,
88
93
  specifiedCategories: computed,
89
94
  setCurrentItem: action,
90
95
  currentItem: computed,
91
96
  item: computed,
92
- facetsValue: computed
97
+ facetsValue: computed,
98
+ appliedPromo: computed,
99
+ setAppliedPromo: action,
93
100
  /* NOT setFacets. It implements it's action mechanism */
94
101
  })
95
102
  }
@@ -181,6 +188,43 @@ class StandaloneService
181
188
  )
182
189
  }
183
190
 
191
+ _promoValue_unsafe(value: number): number {
192
+ if (this._promo!.type === 'percent') {
193
+ return value * (1 - this._promo!.value / 100)
194
+ }
195
+ return value - this._promo!.value
196
+ }
197
+
198
+ get promoAppliedCartTotal(): number {
199
+ if (!this._promo) {
200
+ return this.cartTotal
201
+ }
202
+
203
+ if (!this._promo.skus) {
204
+ return this._promoValue_unsafe(this.cartTotal)
205
+ }
206
+
207
+ let total = this.cartItems.reduce(
208
+ (total, item) => {
209
+ const itemPrice = this._promo!.skus!.includes(item.sku) ?
210
+ this._promoValue_unsafe(item.price)
211
+ :
212
+ item.price
213
+ return total + itemPrice * item.quantity
214
+ },
215
+ 0
216
+ )
217
+
218
+ return total
219
+ }
220
+
221
+ itemPromoPrice(item: LineItem): number | undefined {
222
+ if (this._promo && (!this._promo.skus || this._promo.skus.includes(item.sku) )) {
223
+ return this._promoValue_unsafe(item.price)
224
+ }
225
+ return undefined
226
+ }
227
+
184
228
  get cartQuantity(): number {
185
229
  return this.cartItems.reduce(
186
230
  (total, item) => (total + item.quantity),
@@ -188,6 +232,14 @@ class StandaloneService
188
232
  )
189
233
  }
190
234
 
235
+ get appliedPromo(): Promo | null {
236
+ return this._promo
237
+ }
238
+
239
+ setAppliedPromo(promo: Promo | null): void {
240
+ this._promo = promo
241
+ }
242
+
191
243
  setCurrentItem(skuToFind: string | undefined): boolean {
192
244
 
193
245
  if (skuToFind === undefined || skuToFind.length === 0) {
package/types/checkout.ts CHANGED
@@ -37,12 +37,11 @@ interface PaymentMethodDesc {
37
37
  Comp: ComponentType<PaymentMethodComponentProps>
38
38
  }
39
39
 
40
-
41
40
  export {
42
41
  type TransactionStatus,
43
42
  type CheckoutStepComponentProps,
44
43
  type CheckoutStep,
45
44
  type PaymentMethodComponentProps,
46
45
  type PaymentMethodDesc,
47
- type ContactFormType
46
+ type ContactFormType,
48
47
  }
@@ -1,6 +1,7 @@
1
1
  import type { LineItem, ObsLineItemRef } from './line-item'
2
2
  import type { FacetValueDesc, FacetsValue } from './facet'
3
3
  import type Category from './category'
4
+ import type Promo from './promo'
4
5
 
5
6
  interface CommerceService extends ObsLineItemRef {
6
7
 
@@ -8,10 +9,17 @@ interface CommerceService extends ObsLineItemRef {
8
9
  get cartItems(): LineItem[]
9
10
  /** Total of all quantities of all items in cart */
10
11
  get cartQuantity(): number
11
- /** Total of all prices * quantities of items in cart */
12
+ /** Total of all prices x quantities of items in cart */
12
13
  get cartTotal(): number
13
14
 
15
+ get promoAppliedCartTotal(): number
16
+
14
17
  get cartEmpty(): boolean
18
+
19
+ get appliedPromo(): Promo | null
20
+ setAppliedPromo(promo: Promo | null): void
21
+ /** returns the price with promo applied, of undefined if no promo or promo does not apply */
22
+ itemPromoPrice(item: LineItem): number | undefined
15
23
 
16
24
  getCartCategorySubtotal(categoryId: string): number
17
25
 
package/types/index.ts CHANGED
@@ -3,6 +3,7 @@ export type { default as Category } from './category'
3
3
  export type { default as CommerceService } from './commerce-service'
4
4
  export type { default as ItemSelector } from './item-selector'
5
5
  export type { default as Product } from './product'
6
+ export type { default as Promo } from './promo'
6
7
 
7
8
  export * from './checkout'
8
9
  export * from './line-item'
package/types/promo.ts ADDED
@@ -0,0 +1,10 @@
1
+ interface Promo {
2
+ type: 'percent' | 'amount'
3
+ code: string
4
+ value: number,
5
+ skus?: string[]
6
+ }
7
+
8
+ export {
9
+ type Promo as default
10
+ }
package/util/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { StringMutator, CommerceService } from '../types'
1
+ import type { StringMutator, CommerceService, Promo } from '../types'
2
2
 
3
3
  export function toTitleCase(str: string) {
4
4
  return str.replace(
@@ -20,7 +20,7 @@ export function unslugify(str: string) {
20
20
  }
21
21
 
22
22
 
23
- export function formatPrice(price: number): string {
23
+ export function formatCurrencyValue(price: number): string {
24
24
  const str = price.toLocaleString('en-US', {
25
25
  style: 'currency',
26
26
  currency: 'USD',
@@ -28,6 +28,7 @@ export function formatPrice(price: number): string {
28
28
  return (str.endsWith('.00')) ? str.replace('.00', '') : str
29
29
  }
30
30
 
31
+
31
32
  export const getFacetValuesMutator = (level: number, cmmc: CommerceService): StringMutator => {
32
33
 
33
34
  const setLevel = (value: string, level: number ): void => {
@@ -0,0 +1,46 @@
1
+ 'use server'
2
+
3
+ import type { Promo } from '../types'
4
+
5
+ const PROMO_CODES: Promo[] = [
6
+ {
7
+ type: 'percent',
8
+ code: 'ANGEL',
9
+ value: 20
10
+ },
11
+ {
12
+ type: 'percent',
13
+ code: 'NASSER',
14
+ value: 20
15
+ },
16
+ {
17
+ type: 'percent',
18
+ code: 'DARA',
19
+ value: 20
20
+ },
21
+ {
22
+ type: 'percent',
23
+ code: 'ROSEY',
24
+ value: 20
25
+ },
26
+ {
27
+ type: 'percent',
28
+ code: 'IYKYK',
29
+ value: 20
30
+ },
31
+ {
32
+ type: 'percent',
33
+ code: 'GENESIS',
34
+ value: 99,
35
+ skus: ['LXM-PS-PS']
36
+ }
37
+ ]
38
+
39
+ // TODO: implement a real API call
40
+ const getPromoFromApi = async (code: string) => {
41
+ return PROMO_CODES.find(promo => promo.code === code)
42
+ }
43
+
44
+ export {
45
+ getPromoFromApi as default
46
+ }