我們今天來看
什麼情況下結帳的話面會出現不一樣的元件
當你選購的方案為「訂閱方案」且價格為「0 元代幣」
這時候你點選「立即訂閱」時他會跳出下面這個 Modal
這邊的邏輯稍微複雜
因為他和結帳頁的邏輯是一樣的
簡單來說,他會先從你的 localstorage 取得你的「shipping」、「invoice」和「payment」
再來會根據你點擊購買的課程,去取得課程的相關資訊和 GA 所要使用的資料
再來會初始化你的付款資訊
最後會打 API 給後端確認取得訂單資訊
沒問題後即可以填寫資訊,完成訂單
如果有必填的沒填,會滾動至沒填的欄位,使用的是 useRef 的方式
明天我們來看結帳頁
今天的程式碼如下:
const CheckoutProductModal: React.VFC<CheckoutProductModalProps> = ({
defaultProductId,
warningText,
startedAt,
shippingMethods,
productQuantity,
isFieldsValidate,
renderInvoice,
renderTrigger,
renderProductSelector,
renderTerms,
setIsModalDisable,
setIsOrderCheckLoading,
}) => {
const { formatMessage } = useIntl()
const history = useHistory()
const { isOpen, onOpen, onClose } = useDisclosure()
const checkoutOpened = useRef(false)
const [checkoutProductId] = useQueryParam('checkoutProductId', StringParam)
const { enabledModules, settings, id: appId, currencyId: appCurrencyId } = useApp()
const { currentMemberId, isAuthenticating, authToken } = useAuth()
const { member: currentMember } = useMember(currentMemberId || '')
const { memberCreditCards } = useMemberCreditCards(currentMemberId || '')
const [quantity, setQuantity] = useState(1)
useEffect(() => {
if (!checkoutOpened.current && checkoutProductId === defaultProductId) {
checkoutOpened.current = true
onOpen()
}
}, [checkoutProductId])
useEffect(() => {
if (productQuantity !== undefined) {
setQuantity(productQuantity)
}
}, [productQuantity])
const sessionStorageKey = `lodestar.sharing_code.${defaultProductId}`
const [sharingCode = window.sessionStorage.getItem(sessionStorageKey)] = useQueryParam('sharing', StringParam)
sharingCode && window.sessionStorage.setItem(sessionStorageKey, sharingCode)
const cachedCartInfo = useMemo<{
shipping: ShippingProps | null
invoice: InvoiceProps | null
payment: PaymentProps | null
contactInfo: ContactInfo | null
}>(() => {
const defaultCartInfo = {
shipping: null,
invoice: {
name: currentMember?.name || '',
phone: currentMember?.phone || '',
email: currentMember?.email || '',
},
payment: null,
contactInfo: {
name: currentMember?.name || '',
phone: currentMember?.phone || '',
email: currentMember?.email || '',
},
}
try {
const cachedShipping = localStorage.getItem('kolable.cart.shipping')
const cachedInvoice = localStorage.getItem('kolable.cart.invoice')
const cachedPayment = localStorage.getItem('kolable.cart.payment.perpetual')
cachedCartInfo.shipping = cachedShipping && JSON.parse(cachedShipping)
cachedCartInfo.invoice = cachedInvoice && JSON.parse(cachedInvoice).value
cachedCartInfo.payment = cachedPayment && JSON.parse(cachedPayment)
} catch {}
return defaultCartInfo
}, [currentMember?.name, currentMember?.email, currentMember?.phone])
// checkout
const [productId, setProductId] = useState(defaultProductId)
const { target: productTarget } = useSimpleProduct({ id: productId, startedAt })
const { type, target } = getResourceByProductId(productId)
const { resourceCollection } = useResourceCollection([`${appId}:${type}:${target}`])
// tracking
const tracking = useTracking()
// cart information
const memberCartInfo: {
shipping?: ShippingProps | null
invoice?: InvoiceProps | null
payment?: PaymentProps | null
} = {
shipping: currentMember?.shipping,
invoice: currentMember?.invoice,
payment: currentMember?.payment,
}
const [shipping, setShipping] = useState<ShippingProps>({
name: '',
phone: '',
address: '',
shippingMethod: 'home-delivery',
specification: '',
storeId: '',
storeName: '',
...memberCartInfo.shipping,
...cachedCartInfo.shipping,
})
const [invoice, setInvoice] = useState<InvoiceProps>({
name: '',
phone: '',
email: currentMember?.email || '',
...memberCartInfo.invoice,
...cachedCartInfo.invoice,
})
const [payment, setPayment] = useState<PaymentProps | null | undefined>()
const [isApproved, setIsApproved] = useState(settings['checkout.approvement'] !== 'true')
useEffect(() => {
setIsApproved(settings['checkout.approvement'] !== 'true')
}, [settings])
useEffect(() => {
if (currentMember) {
setInvoice(prev => ({ ...prev, ...cachedCartInfo.invoice }))
}
}, [cachedCartInfo.invoice])
const initialPayment = useMemo(
() =>
(productTarget?.isSubscription
? {
gateway: settings['payment.subscription.default_gateway'] || 'tappay',
method: 'credit',
}
: {
gateway: settings['payment.perpetual.default_gateway'] || 'spgateway',
method: settings['payment.perpetual.default_gateway_method'] || 'credit',
...memberCartInfo.payment,
...cachedCartInfo.payment,
}) as PaymentProps,
[productTarget?.isSubscription, settings, memberCartInfo.payment, cachedCartInfo.payment],
)
useEffect(() => {
if (typeof productTarget?.isSubscription === 'boolean') {
setPayment(initialPayment)
}
}, [productTarget?.isSubscription, initialPayment])
const shippingRef = useRef<HTMLDivElement | null>(null)
const invoiceRef = useRef<HTMLDivElement | null>(null)
const referrerRef = useRef<HTMLDivElement | null>(null)
const groupBuyingRef = useRef<HTMLDivElement | null>(null)
const paymentMethodRef = useRef<HTMLDivElement | null>(null)
const contactInfoRef = useRef<HTMLDivElement | null>(null)
const [discountId, setDiscountId] = useState('')
useEffect(() => {
if (
productTarget?.currencyId === 'LSC' &&
defaultProductId !== undefined &&
defaultProductId.includes('MerchandiseSpec_')
) {
setDiscountId('Coin')
}
}, [productTarget, defaultProductId])
const [groupBuying, setGroupBuying] = useState<{
memberIds: string[]
withError: boolean
}>({ memberIds: [], withError: false })
const { totalPrice, placeOrder, check, orderChecking, orderPlacing } = useCheck({
productIds: [productId],
discountId,
shipping: productTarget?.isPhysical
? shipping
: productId.startsWith('MerchandiseSpec_')
? { address: currentMember?.email }
: null,
options: {
[productId]: {
startedAt,
from: window.location.pathname,
sharingCode,
groupBuyingPartnerIds: groupBuying.memberIds,
quantity: quantity,
},
},
})
const { TPDirect } = useTappay()
const toast = useToast()
const [isValidating, setIsValidating] = useState(false)
const [referrerEmail, setReferrerEmail] = useState('')
const [tpCreditCard, setTpCreditCard] = useState<TPCreditCard | null>(null)
const [errorContactFields, setErrorContactFields] = useState<string[]>([])
const { memberId: referrerId, validateStatus: referrerStatus } = useMemberValidation(referrerEmail)
const updateMemberMetadata = useUpdateMemberMetadata()
const isCreditCardReady = Boolean(memberCreditCards.length > 0 || tpCreditCard?.canGetPrime)
const [isCoinMerchandise, setIsCoinMerchandise] = useState(false)
const [isCoinsEnough, setIsCoinsEnough] = useState(true)
const { remainingCoins } = useMemberCoinsRemaining(currentMemberId || '')
useEffect(() => {
if (check.orderProducts.length === 0) {
setIsOrderCheckLoading?.(true)
setIsModalDisable?.(true)
} else if (
check.orderProducts.length === 1 &&
check.orderProducts[0].options?.currencyId === 'LSC' &&
check.orderProducts[0].productId.includes('MerchandiseSpec_')
) {
setIsOrderCheckLoading?.(false)
setIsCoinMerchandise(true)
if (
check.orderProducts[0].options?.currencyPrice !== undefined &&
remainingCoins !== undefined &&
productQuantity !== undefined &&
check.orderProducts[0].options.currencyPrice * productQuantity > remainingCoins
) {
setIsCoinsEnough(false)
setIsModalDisable?.(true)
} else {
setIsCoinsEnough(true)
setIsModalDisable?.(false)
}
}
}, [check, productQuantity, remainingCoins, setIsModalDisable, setIsOrderCheckLoading])
if (isAuthenticating) {
return renderTrigger?.({ isLoading: true })
}
if (currentMember === null) {
return renderTrigger?.({ isLoginAlert: true })
}
if (productTarget === null || payment === undefined) {
return renderTrigger?.({ isLoading: isAuthenticating, disable: true })
}
const handleSubmit = async () => {
!isValidating && setIsValidating(true)
let isValidShipping = false
let isValidInvoice = false
if (isFieldsValidate) {
;({ isValidInvoice, isValidShipping } = isFieldsValidate({ invoice, shipping }))
} else {
isValidShipping = !productTarget.isPhysical || validateShipping(shipping)
isValidInvoice = Number(settings['feature.invoice.disable'])
? true
: Number(settings['feature.invoice_member_info_input.disable'])
? validateInvoice(invoice).filter(v => !['name', 'phone', 'email'].includes(v)).length === 0
: validateInvoice(invoice).length === 0
}
if (totalPrice > 0 && payment === null) {
paymentMethodRef.current?.scrollIntoView({ behavior: 'smooth' })
return
}
if (!isValidShipping) {
shippingRef.current?.scrollIntoView({ behavior: 'smooth' })
return
} else if ((totalPrice > 0 || productTarget.discountDownPrice) && !isValidInvoice) {
invoiceRef.current?.scrollIntoView({ behavior: 'smooth' })
return
}
if (referrerStatus === 'error') {
referrerRef.current?.scrollIntoView({ behavior: 'smooth' })
}
if (referrerEmail && referrerStatus !== 'success') {
if (referrerStatus === 'error') {
referrerRef.current?.scrollIntoView({ behavior: 'smooth' })
}
return
}
if (groupBuying.withError) {
groupBuyingRef.current?.scrollIntoView({ behavior: 'smooth' })
return
}
if (totalPrice <= 0 && settings['feature.contact_info.enabled'] === '1') {
const errorFields = validateContactInfo(invoice)
if (errorFields.length !== 0) {
setErrorContactFields(errorFields)
contactInfoRef.current?.scrollIntoView({ behavior: 'smooth' })
return
}
}
if (!isCoinsEnough) {
toast({
title: formatMessage(checkoutMessages.message.notEnoughCoins),
status: 'error',
duration: 3000,
position: 'top',
})
return
}
if (settings['tracking.fb_pixel_id']) {
ReactPixel.track('AddToCart', {
content_name: productTarget.title || productId,
value: totalPrice,
currency: 'TWD',
})
}
if (settings['tracking.ga_id']) {
ReactGA.plugin.execute('ec', 'addProduct', {
id: productId,
name: productTarget.title || productId,
category: productId.split('_')[0] || 'Unknown',
price: `${totalPrice}`,
quantity: '1',
currency: 'TWD',
})
ReactGA.plugin.execute('ec', 'setAction', 'add')
ReactGA.ga('send', 'event', 'UX', 'click', 'add to cart')
}
// free subscription should bind card first
if (productTarget.isSubscription && totalPrice <= 0 && memberCreditCards.length === 0) {
await new Promise((resolve, reject) => {
const clientBackUrl = new URL(window.location.href)
clientBackUrl.searchParams.append('checkoutProductId', productId)
TPDirect.card.getPrime(({ status, card: { prime } }: { status: number; card: { prime: string } }) => {
axios({
method: 'POST',
url: `${process.env.REACT_APP_API_BASE_ROOT}/payment/credit-cards`,
withCredentials: true,
data: {
prime,
cardHolder: {
name: currentMember.name,
email: currentMember.email,
phoneNumber: currentMember.phone || '0987654321',
},
clientBackUrl,
},
headers: { authorization: `Bearer ${authToken}` },
})
.then(({ data: { code, result } }) => {
if (code === 'SUCCESS') {
resolve(result.memberCreditCardId)
} else if (code === 'REDIRECT') {
window.location.assign(result)
}
reject(code)
})
.catch(reject)
})
})
}
placeOrder(
productTarget.isSubscription ? 'subscription' : 'perpetual',
{
...invoice,
referrerEmail: referrerEmail || undefined,
},
payment,
)
.then(taskId =>
// sync cart info
updateMemberMetadata({
variables: {
memberId: currentMember.id,
metadata: {
invoice,
shipping,
payment,
},
memberPhones: invoice.phone ? [{ member_id: currentMember.id, phone: invoice.phone }] : [],
},
}).then(() => taskId),
)
.then(taskId => history.push(`/tasks/order/${taskId}`))
.catch(() => {})
}
return (
<>
{renderTrigger({
onOpen,
onProductChange: productId => setProductId(productId),
isLoading: isAuthenticating,
isSubscription: productTarget.isSubscription,
disable:
(productTarget.endedAt ? new Date(productTarget.endedAt) < new Date(now()) : false) ||
(productTarget.expiredAt ? new Date(productTarget.expiredAt) < new Date(now()) : false),
})}
<CommonModal
title={<StyledTitle className="mb-4">{formatMessage(checkoutMessages.title.cart)}</StyledTitle>}
isOpen={isOpen}
isFullWidth
onClose={() => {
onClose()
const resource = resourceCollection.filter(notEmpty).length > 0 && resourceCollection[0]
resource && tracking.removeFromCart(resource)
}}
>
<div className="mb-4">
<ProductItem
id={productId}
startedAt={startedAt}
variant={
settings['custom.project.plan_price_style'] === 'hidden' && productId.startsWith('ProjectPlan_')
? undefined
: 'checkout'
}
quantity={quantity}
onChange={value => typeof value === 'number' && setQuantity(value)}
/>
</div>
{settings['feature.contact_info.enabled'] === '1' && totalPrice === 0 && (
<Box ref={contactInfoRef} mb="3">
<ContactInfoInput value={invoice} onChange={v => setInvoice(v)} errorContactFields={errorContactFields} />
</Box>
)}
{renderProductSelector && (
<div className="mb-5">
{renderProductSelector({ productId, onProductChange: productId => setProductId(productId) })}
</div>
)}
{!!warningText && <StyledWarningText>{warningText}</StyledWarningText>}
{productTarget.isPhysical && (
<div ref={shippingRef}>
<ShippingInput
value={shipping}
onChange={value => setShipping(value)}
shippingMethods={shippingMethods}
isValidating={isValidating}
/>
</div>
)}
{enabledModules.group_buying && !!productTarget.groupBuyingPeople && productTarget.groupBuyingPeople > 1 && (
<div ref={groupBuyingRef}>
<StyledBlockTitle className="mb-3">{formatMessage(checkoutMessages.label.groupBuying)}</StyledBlockTitle>
<OrderedList className="mb-4">
<StyledListItem>{formatMessage(checkoutMessages.text.groupBuyingDescription1)}</StyledListItem>
<StyledListItem>{formatMessage(checkoutMessages.text.groupBuyingDescription2)}</StyledListItem>
<StyledListItem>
{formatMessage(checkoutMessages.text.groupBuyingDescription3, { modal: <GroupBuyingRuleModal /> })}
</StyledListItem>
</OrderedList>
<CheckoutGroupBuyingForm
title={productTarget.title || ''}
partnerCount={productTarget.groupBuyingPeople - 1}
onChange={value => setGroupBuying(value)}
/>
</div>
)}
{totalPrice > 0 && productTarget.isSubscription === false && (
<div className="mb-5" ref={paymentMethodRef}>
<PaymentSelector value={payment} onChange={v => setPayment(v)} isValidating={isValidating} />
</div>
)}
{totalPrice <= 0 && productTarget.isSubscription && (
<>
{memberCreditCards[0]?.cardInfo?.['last_four'] ? (
<Box borderWidth="1px" borderRadius="lg" w="100%" p={4}>
<span>
{formatMessage(checkoutMessages.label.creditLastFour)}:{memberCreditCards[0].cardInfo['last_four']}
</span>
</Box>
) : (
<TapPayForm onUpdate={setTpCreditCard} />
)}
</>
)}
{((totalPrice > 0 && productTarget?.currencyId !== 'LSC' && productTarget.productType !== 'MerchandiseSpec') ||
productTarget.discountDownPrice) && (
<>
<div ref={invoiceRef} className="mb-5">
{renderInvoice?.({ invoice, setInvoice, isValidating }) ||
(settings['feature.invoice.disable'] !== '1' && (
<InvoiceInput
value={invoice}
onChange={value => setInvoice(value)}
isValidating={isValidating}
shouldSameToShippingCheckboxDisplay={productTarget.isPhysical}
/>
))}
</div>
<div className="mb-3">
<DiscountSelectionCard check={check} value={discountId} onChange={setDiscountId} />
</div>
</>
)}
{enabledModules.referrer && productTarget.currencyId !== undefined && productTarget.currencyId !== 'LSC' && (
<div className="row mb-3" ref={referrerRef}>
<div className="col-12">
<StyledTitle className="mb-2">{formatMessage(commonMessages.label.referrer)}</StyledTitle>
</div>
<div className="col-12 col-lg-6">
<CheckoutProductReferrerInput
referrerId={referrerId}
referrerStatus={referrerStatus}
onEmailSet={email => setReferrerEmail(email)}
/>
</div>
</div>
)}
{settings['checkout.approvement'] === 'true' && (
<div className="my-4">
<StyledCheckbox
className="mr-2"
size="lg"
colorScheme="primary"
isChecked={isApproved}
onChange={() => setIsApproved(prev => !prev)}
/>
<StyledLabel>{formatMessage(checkoutMessages.label.approved)}</StyledLabel>
<StyledApprovementBox
className="mt-2"
dangerouslySetInnerHTML={{ __html: settings['checkout.approvement_content'] }}
/>
</div>
)}
<Divider className="mb-3" />
{renderTerms && (
<StyledCheckoutBlock className="mb-5">
<div className="mb-2">{renderTerms()}</div>
</StyledCheckoutBlock>
)}
{settings['custom.project.plan_price_style'] === 'hidden' &&
productId.startsWith('ProjectPlan_') ? null : orderChecking ? (
<SkeletonText noOfLines={4} spacing="5" />
) : (
<>
<StyledCheckoutBlock className="mb-5">
{check.orderProducts.map(orderProduct => (
<CheckoutProductItem
key={orderProduct.name}
name={orderProduct.name}
price={
orderProduct.productId.includes('MerchandiseSpec_') && orderProduct.options?.currencyId === 'LSC'
? orderProduct.options.currencyPrice || orderProduct.price
: orderProduct.price
}
quantity={quantity}
saleAmount={Number((orderProduct.options?.amount || 1) / quantity)}
defaultProductId={defaultProductId}
currencyId={orderProduct.options?.currencyId || appCurrencyId}
/>
))}
{check.orderDiscounts.map((orderDiscount, idx) => (
<CheckoutProductItem
key={orderDiscount.name}
name={orderDiscount.name}
price={
check.orderProducts[0]?.productId.includes('MerchandiseSpec_') &&
check.orderProducts[0].options?.currencyId === 'LSC'
? -orderDiscount.options?.coins
: -orderDiscount.price
}
currencyId={productTarget.currencyId}
/>
))}
{check.shippingOption && (
<CheckoutProductItem
name={formatMessage(
checkoutMessages.shipping[camelCase(check.shippingOption.id) as ShippingOptionIdType],
)}
price={check.shippingOption.fee}
/>
)}
</StyledCheckoutBlock>
<StyledCheckoutPrice className="mb-3">
{!isCoinMerchandise || isCoinsEnough ? (
<PriceLabel listPrice={totalPrice} />
) : (
`${settings['coin.unit'] || check.orderProducts[0].options?.currencyId} ${formatMessage(
checkoutMessages.message.notEnough,
)}`
)}
</StyledCheckoutPrice>
</>
)}
<StyledSubmitBlock className="text-right">
<Button
variant="outline"
onClick={() => {
onClose()
const resource = resourceCollection.filter(notEmpty).length > 0 && resourceCollection[0]
resource && tracking.removeFromCart(resource)
}}
className="mr-3"
>
{formatMessage(commonMessages.ui.cancel)}
</Button>
<Button
colorScheme="primary"
isLoading={orderPlacing}
onClick={handleSubmit}
disabled={
(totalPrice === 0 && productTarget.isSubscription && !isCreditCardReady) ||
isApproved === false ||
!isCoinsEnough
}
>
{productTarget.isSubscription
? formatMessage(commonMessages.button.subscribeNow)
: formatMessage(checkoutMessages.button.cartSubmit)}
</Button>
</StyledSubmitBlock>
</CommonModal>
</>
)
}