@coursebuilder/commerce-next
Version:
Commerce Functionality for Course Builder with Next.js
232 lines (231 loc) • 20.7 kB
JavaScript
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, };