UNPKG

@coursebuilder/commerce-next

Version:

Commerce Functionality for Course Builder with Next.js

232 lines (231 loc) 20.7 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import * as React from 'react'; import Image from 'next/image'; import { Slot } from '@radix-ui/react-slot'; import * as Switch from '@radix-ui/react-switch'; import pluralize from 'pluralize'; import Countdown from 'react-countdown'; import { buildStripeCheckoutPath } from '@coursebuilder/core/pricing/build-stripe-checkout-path'; import { formatUsd } from '@coursebuilder/core/utils/format-usd'; import { Button, Checkbox } from '@coursebuilder/ui'; import { cn } from '@coursebuilder/ui/utils/cn'; import { usePriceCheck } from './pricing-check-context'; import { PricingProvider, usePricing } from './pricing-context'; const Root = ({ children, ...props }) => { return (_jsx(PricingProvider, { ...props, children: _jsx(RootInternal, { ...props, children: children }) })); }; const RootInternal = ({ children, asChild, className, }) => { const Comp = asChild ? Slot : 'div'; const { options: { isLiveEvent }, } = usePricing(); return (_jsx(Comp, { className: cn('flex flex-col items-center', className), children: children })); }; const PricingProduct = ({ children, className, }) => { const { product, isTeamPurchaseActive, quantity, formattedPrice, userId, organizationId, options: { cancelUrl }, } = usePricing(); const isMembership = product.type === 'membership'; const checkoutPath = buildStripeCheckoutPath({ productId: formattedPrice?.id, couponId: formattedPrice?.appliedMerchantCoupon?.id, bulk: isTeamPurchaseActive, quantity, userId, upgradeFromPurchaseId: formattedPrice?.upgradeFromPurchaseId, cancelUrl, usedCouponId: formattedPrice?.usedCouponId, organizationId, }); return (_jsx("form", { className: cn('', className), action: checkoutPath, method: "POST", children: children })); }; const Details = ({ children, className, }) => { const { options: { isLiveEvent }, isPreviouslyPurchased, } = usePricing(); return isPreviouslyPurchased ? null : (_jsx("article", { className: cn('flex flex-col items-center rounded-none border-none bg-transparent pt-5', className), children: children })); }; const ProductImage = ({ className, children, }) => { const { product, options: { withImage }, } = usePricing(); return withImage ? (_jsx("div", { className: cn('relative mx-auto size-56', className), children: children || (product.fields.image && (_jsx(Image, { priority: true, src: product.fields.image.url, alt: product.fields.image.alt || product.name, quality: 100, layout: 'fill', objectFit: "contain", "aria-hidden": "true" }))) })) : null; }; const Name = ({ className, children, }) => { const { product, options: { withTitle }, } = usePricing(); return withTitle ? (_jsx("div", { className: cn('mt-3 text-balance px-5 text-center text-xl font-bold sm:text-2xl', className), children: children || product.name })) : null; }; const PriceSpinner = ({ className = 'w-8 h-8', ...rest }) => (_jsxs("svg", { className: cn('animate-spin', className), xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", ...rest, children: [_jsx("title", { children: "Loading" }), _jsx("circle", { opacity: 0.25, cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { opacity: 0.75, fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] })); const Price = ({ className, children, }) => { const { formattedPrice, status } = usePricing(); const { isDiscount } = usePriceCheck(); const appliedMerchantCoupon = formattedPrice?.appliedMerchantCoupon; const fullPrice = formattedPrice?.fullPrice; const percentOff = appliedMerchantCoupon ? Math.floor(+appliedMerchantCoupon.percentageDiscount * 100) : formattedPrice && isDiscount(formattedPrice) ? Math.floor(((formattedPrice.unitPrice - formattedPrice.calculatedPrice) / formattedPrice.unitPrice) * 100) : 0; const percentOffLabel = appliedMerchantCoupon && `${percentOff}% off of $${fullPrice}`; return (_jsx("div", { className: cn('flex flex-col items-center', className), children: children || (_jsx("div", { className: cn('mt-2 flex h-[76px] items-center justify-center gap-0.5', { 'flex h-[76px] items-center': status === 'pending', hidden: status === 'error', }), children: status === 'pending' ? (_jsxs("div", { className: "flex h-12 items-center justify-center", children: [_jsx("span", { className: "sr-only", children: "Loading price" }), _jsx(PriceSpinner, { "aria-hidden": "true", className: "h-8 w-8" })] })) : (_jsxs(_Fragment, { children: [_jsx("sup", { className: "font-heading -mt-3 text-base font-semibold opacity-60", "aria-hidden": "true", children: "US" }), _jsxs("div", { "aria-live": "polite", className: "text-foreground font-heading flex text-6xl font-bold", children: [formattedPrice?.calculatedPrice && formatUsd(formattedPrice?.calculatedPrice).dollars, _jsx("span", { className: "sup font-heading mt-1 pl-0.5 align-sub text-base text-sm opacity-60", "aria-hidden": "true", children: formattedPrice?.calculatedPrice && formatUsd(formattedPrice?.calculatedPrice).cents }), Boolean(appliedMerchantCoupon || isDiscount(formattedPrice)) && (_jsxs(_Fragment, { children: [_jsxs("div", { "aria-hidden": "true", className: "mt-1.5 flex flex-col items-center pl-3 font-normal", children: [_jsx("div", { className: "text-foreground relative flex text-2xl leading-none line-through", children: '$' + fullPrice }), _jsxs("div", { className: "text-primary text-base", children: ["Save ", percentOff, "%"] })] }), _jsxs("div", { className: "sr-only", children: [appliedMerchantCoupon?.type === 'bulk' ? (_jsx("div", { children: "Team discount." })) : null, ' ', percentOffLabel] })] }))] })] })) })) })); }; const TeamToggle = ({ className, children, }) => { const { isTeamPurchaseActive, toggleTeamPurchase } = usePricing(); const isSoldOut = usePricing().isSoldOut; if (isSoldOut) return null; return (_jsx("div", { className: cn('flex items-center justify-center gap-2 pb-3.5 text-sm', className), children: children || (_jsxs(_Fragment, { children: [_jsx("label", { className: "sr-only", htmlFor: "team-switch", children: "Buying for myself or for my team" }), _jsx("button", { className: "decoration-gray-600 underline-offset-2 transition hover:underline", role: "button", type: "button", onClick: toggleTeamPurchase, children: "For myself" }), _jsx(Switch.Root, { className: "radix-state-checked:bg-gray-200 hover:radix-state-checked:bg-gray-300/50 relative h-6 w-[47px] rounded-full border border-gray-300/50 bg-gray-200 shadow-md shadow-gray-300/30 transition hover:bg-gray-300/50 dark:border-gray-800 dark:bg-gray-950 dark:shadow-transparent dark:hover:bg-gray-900", "aria-label": isTeamPurchaseActive ? 'For my team' : 'For myself', onCheckedChange: toggleTeamPurchase, checked: isTeamPurchaseActive, id: "team-switch", children: _jsx(Switch.Thumb, { className: "radix-state-checked:translate-x-[25px] radix-state-checked:bg-blue-500 group-hover:radix-state-checked:bg-indigo-400 block h-[18px] w-[18px] translate-x-[2px] rounded-full bg-gray-500 shadow-sm shadow-gray-300/50 transition-all will-change-transform group-hover:bg-gray-300" }) }), _jsx("button", { className: "decoration-gray-600 underline-offset-2 transition hover:underline", role: "button", type: "button", onClick: toggleTeamPurchase, children: "For my team" })] })) })); }; const TeamQuantityInput = ({ className, children, label = 'Team Seats', }) => { const { product, quantity, options: { teamQuantityLimit }, updateQuantity, setMerchantCoupon, pricingData: { quantityAvailable }, isTeamPurchaseActive, isBuyingMoreSeats, } = usePricing(); return isTeamPurchaseActive || isBuyingMoreSeats ? (_jsx("div", { className: cn('mb-5 flex w-full flex-col items-center justify-center px-5 xl:px-12', className), children: children || (_jsx(_Fragment, { children: _jsxs("div", { className: "flex items-center gap-1 text-sm font-medium", children: [_jsx("label", { className: "mr-3 opacity-80", children: label }), _jsx("button", { type: "button", className: "flex h-full items-center justify-center rounded bg-gray-200/60 px-3 py-2 font-mono sm:hidden", "aria-label": "decrease seat quantity by one", onClick: () => { if (quantity === 1) return; updateQuantity(quantity - 1); }, children: "-" }), _jsx("input", { type: "number", className: "max-w-[70px] rounded-md border border-gray-200 bg-gray-200/60 py-2 pl-3 font-mono font-bold ring-blue-500 dark:border-gray-800 dark:bg-gray-950", min: 1, max: teamQuantityLimit, step: 1, onChange: (e) => { const quantity = Number(e.target.value); const newQuantity = quantity < 1 ? 1 : teamQuantityLimit && quantity > teamQuantityLimit ? teamQuantityLimit : quantity; setMerchantCoupon(undefined); updateQuantity(newQuantity); }, onKeyDown: (e) => { // don't allow decimal if (e.key === ',') { e.preventDefault(); } }, inputMode: "numeric", pattern: "[0-9]*", value: teamQuantityLimit !== 0 && teamQuantityLimit < quantity ? teamQuantityLimit : quantity, id: `${quantity}-${product.name}`, required: true }), _jsx("button", { type: "button", "aria-label": "increase seat quantity by one", className: "flex h-full items-center justify-center rounded bg-gray-200/60 px-3 py-2 font-mono sm:hidden", onClick: () => { if (quantity === 100) return; updateQuantity(quantity + 1); }, children: "+" })] }) })) })) : null; }; const BuyButton = ({ className, children, asChild, }) => { const Comp = asChild ? Slot : Button; const { formattedPrice, product, status, pricingData: { quantityAvailable }, couponId, isSoldOut, } = usePricing(); return (_jsx(Comp, { className: cn('bg-primary text-primary-foreground flex h-14 w-full items-center justify-center rounded px-4 py-4 text-center text-base font-medium ring-offset-1 transition ease-in-out disabled:cursor-not-allowed disabled:opacity-50', className), type: "submit", size: "lg", disabled: status === 'pending' || status === 'error' || isSoldOut, children: children ? children : isSoldOut ? 'Sold Out' : formattedPrice?.upgradeFromPurchaseId ? `Upgrade Now` : product?.fields.action || `Buy Now` })); }; const GuaranteeBadge = ({ className, children, }) => { const { options: { withGuaranteeBadge }, } = usePricing(); return withGuaranteeBadge ? children || (_jsx("span", { className: cn('block pt-3 text-center text-xs text-gray-600 dark:text-gray-400', className), children: "30-Day Money-Back Guarantee" })) : null; }; const LiveRefundPolicy = ({ className, children, }) => { const { options: { isLiveEvent }, } = usePricing(); return isLiveEvent ? children || (_jsx("span", { className: cn('inline-flex max-w-sm text-balance pt-3 text-center text-sm opacity-75', className), children: "Tickets to live events are non-refundable, but can be transferred" })) : null; }; const PPPToggle = ({ className, children, }) => { const { options: { isPPPEnabled }, formattedPrice, isTeamPurchaseActive, isPreviouslyPurchased, setMerchantCoupon, pricingData: { purchaseToUpgrade }, allowPurchase, activeMerchantCoupon, } = usePricing(); const { isDowngrade } = usePriceCheck(); const availablePPPCoupon = formattedPrice?.availableCoupons.find((coupon) => coupon?.type === 'ppp'); const appliedPPPCoupon = activeMerchantCoupon?.type === 'ppp' ? activeMerchantCoupon : null; const allowPurchaseWith = { pppCoupon: true, }; const getNumericValue = (value) => { if (typeof value === 'string') { return Number(value); } else if (typeof value === 'number') { return value; } else if (typeof value?.toNumber === 'function') { return value.toNumber(); } else { return 0; } }; const showPPPBox = allowPurchase && isPPPEnabled && Boolean(availablePPPCoupon || appliedPPPCoupon) && !isPreviouslyPurchased && !isDowngrade(formattedPrice) && !isTeamPurchaseActive && allowPurchaseWith?.pppCoupon; const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); if (availablePPPCoupon && !availablePPPCoupon?.country) { console.error('No country found for PPP coupon', { availablePPPCoupon }); return null; } const countryCode = availablePPPCoupon?.country || 'US'; const country = regionNames.of(countryCode); const percentageDiscount = getNumericValue(availablePPPCoupon?.percentageDiscount || 0); const percentOff = Math.floor(percentageDiscount * 100); // if we are upgrading a Core(PPP) to a Bundle(PPP) and the PPP coupon is // valid and auto-applied then we hide the checkbox to reduce confusion. const hideCheckbox = Boolean(purchaseToUpgrade); return showPPPBox ? children || (_jsxs("div", { className: cn('pt-5 text-sm', className), children: [_jsxs("div", { "data-ppp-header": "", children: [_jsxs("strong", { children: ["We noticed that you're from", ' ', _jsx("img", { className: "inline-block", src: `https://hardcore-golick-433858.netlify.app/image?code=${countryCode}`, alt: `${country} flag`, width: 18, height: 14 }), ' ', country, ". To help facilitate global learning, we are offering purchasing power parity pricing."] }), _jsxs("p", { className: "pt-3", children: ["Please note that you will only be able to view content from within", ' ', country, ", and no bonuses will be provided."] }), !hideCheckbox && (_jsx("p", { className: "pt-3", children: "If that is something that you need:" }))] }), !hideCheckbox && (_jsxs("label", { className: "mt-5 flex cursor-pointer items-center gap-2 rounded-md border border-gray-200 bg-white p-3 transition hover:bg-gray-100 dark:border-transparent dark:bg-gray-800 dark:hover:bg-gray-700/80", children: [_jsx(Checkbox, { checked: Boolean(appliedPPPCoupon), onCheckedChange: () => { if (appliedPPPCoupon) { setMerchantCoupon(undefined); } else { setMerchantCoupon(availablePPPCoupon); } } }), _jsxs("span", { className: "font-semibold", children: ["Activate ", percentOff, "% off with regional pricing"] })] }))] })) : null; }; const LiveQuantity = ({ children, className, }) => { const { product, isPreviouslyPurchased, options: { isLiveEvent }, pricingData: { quantityAvailable }, formattedPrice, isSoldOut, } = usePricing(); return isLiveEvent && !isSoldOut && quantityAvailable !== -1 ? children || (_jsx("div", { className: cn({ 'bg-foreground/20 text-foreground inline-flex items-center rounded px-2.5 pb-1.5 pt-1 text-center text-sm font-medium uppercase leading-none': !isSoldOut, 'inline-flex rounded bg-red-300/20 px-2 py-1 text-center text-sm font-medium uppercase leading-none text-red-300': isSoldOut, }, className), children: isSoldOut ? 'Sold out' : `${pluralize('spot', quantityAvailable, true)} left` })) : null; }; const Purchased = ({ children, asChild, className, }) => { const Comp = asChild ? Slot : 'div'; const { options: { isLiveEvent }, isPreviouslyPurchased, } = usePricing(); return isPreviouslyPurchased ? (_jsx(Comp, { className: cn('flex flex-col items-center', className), children: children })) : null; }; const BuyMoreSeatsToggle = ({ asChild, className, children, }) => { const Comp = asChild ? Slot : Button; const { options: { isLiveEvent }, toggleBuyingMoreSeats, isBuyingMoreSeats, } = usePricing(); return isLiveEvent ? null : (_jsx(Comp, { onClick: toggleBuyingMoreSeats, variant: "outline", type: "button", className: cn('flex flex-col items-center', className), children: children || isBuyingMoreSeats ? 'Cancel' : 'Buy More Seats' })); }; const BuyMoreSeats = ({ asChild, className, children }) => { const Comp = asChild ? Slot : 'div'; const { isBuyingMoreSeats } = usePricing(); return isBuyingMoreSeats ? (_jsx(Comp, { className: cn('flex w-full flex-col items-center', className), children: children || (_jsxs(_Fragment, { children: [_jsx(TeamQuantityInput, { label: "Quantity" }), _jsx(Price, {}), _jsx(BuyButton, { children: "Buy Additional Seats" })] })) })) : null; }; const SaleCountdown = ({ className, countdownRenderer = (props) => _jsx(CountdownRenderer, { ...props }), }) => { const { formattedPrice, product, pricingData: { quantityAvailable }, isSoldOut, } = usePricing(); if (isSoldOut) return null; return formattedPrice?.defaultCoupon?.expires ? (_jsx(Countdown, { className: cn('', className), date: formattedPrice?.defaultCoupon?.expires, renderer: (props) => countdownRenderer({ className, ...props }) })) : null; }; const CountdownRenderer = ({ days, hours, minutes, seconds, completed, className, ...rest }) => { const [screenReaderValues] = React.useState({ days, hours, minutes, seconds, }); const forScreenReader = `${screenReaderValues.days} days, ${screenReaderValues.hours} hours, ${screenReaderValues.minutes} minutes, and ${screenReaderValues.seconds} seconds`; return completed ? null : (_jsx(_Fragment, { children: _jsx("div", { className: cn('w-full', className), children: _jsxs("div", { className: "w-full text-center", children: [_jsx("p", { className: "pb-5 font-medium", children: "Hurry! Price goes up in:" }), _jsxs("div", { "aria-hidden": "true", "data-grid": "", className: "mx-auto grid max-w-[300px] grid-cols-4 items-center justify-center gap-2 tabular-nums tracking-tight", children: [_jsxs("div", { className: "flex flex-col", children: [_jsx("span", { "data-number": "days", className: "text-3xl font-medium leading-none", children: days }), _jsx("span", { "data-label": "days", className: "pt-1 text-xs font-medium uppercase tracking-wide text-gray-500", children: "days" })] }), _jsxs("div", { className: "flex flex-col", children: [_jsx("span", { "data-number": "hours", className: "text-3xl font-medium leading-none", children: hours }), _jsx("span", { "data-label": "hours", className: "pt-1 text-xs font-medium uppercase tracking-wide text-gray-500", children: "hours" })] }), _jsxs("div", { className: "flex flex-col", children: [_jsx("span", { "data-number": "minutes", className: "text-3xl font-medium leading-none", children: minutes }), _jsx("span", { "data-label": "minutes", className: "pt-1 text-xs font-medium uppercase tracking-wide text-gray-500", children: "minutes" })] }), _jsxs("div", { className: "flex flex-col", children: [_jsx("span", { "data-number": "seconds", className: "text-3xl font-medium leading-none", children: seconds }), _jsx("span", { "data-label": "seconds", className: "pt-1 text-xs font-medium uppercase tracking-wide text-gray-500", children: "seconds" })] })] }), _jsx("div", { className: "sr-only", children: forScreenReader })] }) }) })); }; const Waitlist = ({ className, children, }) => { // if no spots are available, show a waitlist form const { product, pricingData: { quantityAvailable }, formattedPrice, isSoldOut, } = usePricing(); if (!isSoldOut) return null; return _jsx("div", { className: cn('', className), children: children }); }; export { Root, PricingProduct as Product, ProductImage, Name, Details, Price, TeamToggle, TeamQuantityInput, BuyButton, GuaranteeBadge, LiveRefundPolicy, PPPToggle, LiveQuantity, Purchased, BuyMoreSeatsToggle, BuyMoreSeats, SaleCountdown, Waitlist, };