web: fix checkout (and make it load faster)

This commit is contained in:
Abdullah Atta
2024-11-08 17:06:17 +05:00
parent 4014f2a5b5
commit a5faa4acea
18 changed files with 195 additions and 143 deletions

View File

@@ -123,6 +123,7 @@ test("applying coupon should change discount & total price", async () => {
const pricing = await plan.open();
const title = await pricing.getTitle();
if (!title) continue;
await pricing.waitForPaddleFrame();
await pricing.applyCoupon("INTRO50");
planPrices[title.toLowerCase()] = roundOffPrices(await pricing.getPrices());
@@ -141,6 +142,7 @@ test("apply coupon through url", async () => {
for (const plan of ["monthly", "yearly"] as const) {
await app.checkout.goto(plan, "INTRO50");
const pricing = await app.checkout.getPricing();
await pricing.waitForPaddleFrame();
await pricing.waitForCoupon();
planPrices[plan] = roundOffPrices(await pricing.getPrices());
@@ -164,6 +166,7 @@ test("apply coupon after changing country", async () => {
const pricing = await plan.open();
const title = await pricing.getTitle();
if (!title) continue;
await pricing.waitForPaddleFrame();
await pricing.changeCountry("IN", 110001);
await pricing.applyCoupon("INTRO50");

View File

@@ -1,4 +1,4 @@
Subtotal: ₹300
Sales tax: ₹200
Discount: -₹300
Total: ₹300
Total: ₹300/mo

View File

@@ -1,4 +1,4 @@
Subtotal: ₹300
Sales tax: ₹200
Discount: -₹300
Total: ₹300
Total: ₹300/mo

View File

@@ -1,4 +1,4 @@
Subtotal: ₹300
Sales tax: ₹200
Discount: -₹300
Total: ₹300
Total: ₹300/mo

View File

@@ -1,4 +1,4 @@
Subtotal: $100
Sales tax: $0
Discount: -$100
Total: $100
Total: $100/mo

View File

@@ -1,4 +1,4 @@
Subtotal: $100
Sales tax: $0
Discount: -$100
Total: $100
Total: $100/mo

View File

@@ -1,4 +1,4 @@
Subtotal: $100
Sales tax: $0
Discount: -$100
Total: $100
Total: $100/mo

View File

@@ -1,4 +1,4 @@
Subtotal: ₹400
Sales tax: ₹300
Discount: -₹400
Total: ₹400
Total: ₹400/yr

View File

@@ -1,4 +1,4 @@
Subtotal: ₹400
Sales tax: ₹300
Discount: -₹400
Total: ₹400
Total: ₹400/yr

View File

@@ -1,4 +1,4 @@
Subtotal: ₹400
Sales tax: ₹300
Discount: -₹400
Total: ₹400
Total: ₹400/yr

View File

@@ -1,4 +1,4 @@
Subtotal: $200
Sales tax: $0
Discount: -$200
Total: $200
Total: $200/yr

View File

@@ -1,4 +1,4 @@
Subtotal: $200
Sales tax: $0
Discount: -$200
Total: $200
Total: $200/yr

View File

@@ -1,4 +1,4 @@
Subtotal: $200
Sales tax: $0
Discount: -$200
Total: $200
Total: $200/yr

View File

@@ -101,6 +101,10 @@ class PricingModel {
});
}
async waitForPaddleFrame() {
await getPaddleFrame(this.page);
}
getTitle() {
return this.title.textContent();
}

View File

@@ -68,7 +68,6 @@ function Field(props: FieldProps) {
m: "2px",
mr: "2px",
opacity: disabled ? 0.7 : 1,
pointerEvents: disabled ? "none" : "all",
...sx,
flexDirection: "column"
}}
@@ -109,6 +108,7 @@ function Field(props: FieldProps) {
sx={{
flex: 1,
...styles?.input,
pointerEvents: disabled ? "none" : "all",
":disabled": {
bg: "background-disabled"
},

View File

@@ -481,10 +481,16 @@ function SelectedPlan(props: SelectedPlanProps) {
)}
{pricingInfo ? (
<>
<Text data-test-id={`checkout-plan-country-${pricingInfo.country}`} />
{pricingInfo.coupon && (
<Text data-test-id={`checkout-plan-coupon-applied`} />
)}
{IS_TESTING ? (
<>
<span
data-test-id={`checkout-plan-country-${pricingInfo.country}`}
/>
{pricingInfo.coupon && (
<span data-test-id={`checkout-plan-coupon-applied`} />
)}
</>
) : null}
<Field
inputRef={couponInputRef}

View File

@@ -69,9 +69,15 @@ type PaddleCheckoutProps = {
coupon?: string;
};
export function PaddleCheckout(props: PaddleCheckoutProps) {
const { plan, onPriceUpdated, coupon, onCouponApplied, onCompleted, user } =
props;
const [sourceUrl, setSourceUrl] = useState<string>();
const {
plan,
onPriceUpdated,
coupon,
theme,
onCouponApplied,
onCompleted,
user
} = props;
const [isLoading, setIsLoading] = useState(true);
const appliedCouponCode = useRef<string>();
const checkoutId = useRef<string>();
@@ -81,7 +87,11 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
const reloadCheckout = useCallback(() => {
if (!checkoutRef.current) return;
setIsLoading(true);
checkoutRef.current.src = `${PADDLE_ORIGIN}/checkout/?checkout_id=${checkoutId.current}&display_mode=inline&apple_pay_enabled=false`;
checkoutRef.current.src = `${PADDLE_ORIGIN}/checkout/?checkout_id=${
checkoutId.current
}&display_mode=inline&apple_pay_enabled=${isFeatureSupported(
"applePaySupported"
)}`;
}, []);
const updatePrice = useCallback(
@@ -96,12 +106,37 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
[onPriceUpdated, plan]
);
const updateCoupon = useCallback(
async (checkoutId: string) => {
if (
appliedCouponCode.current === coupon ||
!appliedCouponCode.current === !coupon
)
return false;
if (onCouponApplied) onCouponApplied();
const checkoutData = coupon
? await applyCoupon(checkoutId, coupon).catch(() => false)
: await removeCoupon(checkoutId).catch(() => false);
if (!checkoutData) {
await updatePrice(checkoutId, true).catch(() => false);
return false;
}
appliedCouponCode.current = coupon;
return true;
},
[coupon, updatePrice, onCouponApplied]
);
useEffect(() => {
(async function () {
const url = await getCheckoutURL(props);
setSourceUrl(url);
})();
}, [props]);
createCheckout({ plan, theme, user, coupon }).then((checkoutData) => {
if (!checkoutData) return;
const pricingInfo = getPricingInfo(plan, checkoutData);
if (onPriceUpdated) onPriceUpdated(pricingInfo);
appliedCouponCode.current = pricingInfo.coupon;
checkoutId.current = checkoutData.public_checkout_id;
reloadCheckout();
});
}, [plan, theme, user]);
useEffect(() => {
async function onMessage(ev: MessageEvent<PaddleEvent>) {
@@ -110,10 +145,7 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
const { event, event_name, callback_data } = ev.data;
const { checkout } = callback_data;
if (event === PaddleEvents["Checkout.RemoveSpinner"]) {
setIsLoading(false);
return;
}
if (event === PaddleEvents["Checkout.RemoveSpinner"]) setIsLoading(false);
if (
!checkout ||
@@ -125,32 +157,30 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
return;
}
if (
event_name === PaddleEvents["Checkout.Customer.Details"] &&
!checkoutId.current
) {
submitCustomerInfo(
checkout.id,
user.email,
callback_data.user?.country || "US"
).finally(() => {
checkoutId.current = checkout.id;
reloadCheckout();
});
}
if (event_name === PaddleEvents["Checkout.Complete"]) {
onCompleted && onCompleted();
return;
}
if (event_name === PaddleEvents["Checkout.Loaded"] && checkoutId.current)
if (event_name === PaddleEvents["Checkout.Loaded"]) {
setIsLoading(false);
}
const pricingInfo = await updatePrice(checkout.id);
if (!pricingInfo) return;
const pricingInfo = getPricingInfo(plan, {
public_checkout_id: checkout.id,
ip_geo_country_code: callback_data.user?.country || "US",
items: [
{
prices: checkout.prices.customer.items,
recurring: { prices: checkout.recurring_prices.customer.items }
}
]
});
if (onPriceUpdated) onPriceUpdated(pricingInfo);
appliedCouponCode.current = pricingInfo.coupon;
checkoutId.current = checkout.id;
await updateCoupon(checkout.id);
}
window.addEventListener("message", onMessage, false);
return () => {
@@ -162,38 +192,16 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
plan,
onCompleted,
user.email,
reloadCheckout
reloadCheckout,
updateCoupon
]);
useEffect(() => {
const checkout_id = checkoutId.current;
if (
!checkout_id ||
appliedCouponCode.current === coupon ||
!appliedCouponCode.current === !coupon
)
return;
if (onCouponApplied) onCouponApplied();
(async function () {
const checkoutData = coupon
? await applyCoupon(checkout_id, coupon).catch(() => false)
: await removeCoupon(checkout_id).catch(() => false);
if (!checkoutData) {
await updatePrice(checkout_id, true);
return;
}
appliedCouponCode.current = coupon;
reloadCheckout();
})();
}, [
coupon,
onCouponApplied,
onPriceUpdated,
plan,
reloadCheckout,
updatePrice
]);
if (!checkoutId.current) return;
updateCoupon(checkoutId.current).then((result) => {
if (result) reloadCheckout();
});
}, [coupon]);
return (
<ScrollContainer
@@ -222,7 +230,6 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
scrolling="no"
frameBorder={"0"}
ref={checkoutRef}
src={sourceUrl}
allow={`payment ${PADDLE_ORIGIN} ${SUBSCRIPTION_MANAGEMENT_URL};`}
style={{
// padding: "0px 30px",
@@ -240,9 +247,13 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
);
}
async function getCheckoutURL(params: PaddleCheckoutProps) {
function getCheckoutURL(params: {
plan: PaddleCheckoutProps["plan"];
theme: PaddleCheckoutProps["theme"];
user: PaddleCheckoutProps["user"];
}) {
const { plan, theme, user } = params;
const BASE_URL = `${PADDLE_ORIGIN}/paddlejs?ccsURL=${CHECKOUT_SERVICE_ORIGIN}/create/checkout/product/${plan.id}`;
const BASE_URL = `${CHECKOUT_SERVICE_ORIGIN}/create/v2/checkout/product/${plan.id}`;
const queryParams = new URLSearchParams();
queryParams.set("product", plan.id);
queryParams.set("vendor", VENDOR_ID.toString());
@@ -264,6 +275,7 @@ async function getCheckoutURL(params: PaddleCheckoutProps) {
queryParams.set("parent_url", window.location.origin);
queryParams.set("parentURL", window.location.origin);
queryParams.set("referring_domain", window.location.hostname);
queryParams.set("locale", "en");
const fullURL = `${BASE_URL}?${queryParams.toString()}`;
return fullURL;
}
@@ -276,7 +288,7 @@ function getPricingInfo(plan: Plan, checkoutData: CheckoutData): PricingInfo {
const isRecurringDiscount = recurringPrice.discounts.length > 0;
return {
country: checkoutData.customer.country_code,
country: checkoutData.ip_geo_country_code,
currency: price.currency,
discount: {
amount: discount?.gross_discount || 0,
@@ -317,9 +329,6 @@ async function applyCoupon(
if (!response.ok) return false;
const json = (await response.json()) as CheckoutDataResponse;
await sendCheckoutEvent(checkoutId, PaddleEvents["Checkout.Coupon.Applied"]);
return json.data;
}
@@ -348,8 +357,8 @@ async function submitCustomerInfo(
});
if (!response.ok) return false;
const json = (await response.json()) as CheckoutDataResponse;
const json = (await response.json()) as CheckoutDataResponse;
return json.data;
}
@@ -361,25 +370,9 @@ async function removeCoupon(checkoutId: string): Promise<CheckoutData | false> {
});
if (!response.ok) return false;
const json = (await response.json()) as CheckoutDataResponse;
await sendCheckoutEvent(checkoutId, PaddleEvents["Checkout.Coupon.Remove"]);
return json.data;
}
async function sendCheckoutEvent(checkoutId: string, eventName: PaddleEvents) {
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/event`;
const body = { data: { event_name: eventName } };
const headers = new Headers();
headers.set("content-type", "application/json");
const response = await fetch(url, {
body: JSON.stringify(body),
headers,
method: "POST"
});
return response.ok;
}
async function getCheckoutData(
checkoutId: string
): Promise<CheckoutData | undefined> {
@@ -389,3 +382,35 @@ async function getCheckoutData(
const json = (await response.json()) as CheckoutDataResponse;
return json.data;
}
async function createCheckout(props: {
plan: PaddleCheckoutProps["plan"];
user: PaddleCheckoutProps["user"];
theme: PaddleCheckoutProps["theme"];
coupon?: PaddleCheckoutProps["coupon"];
}) {
const { plan, user, theme, coupon } = props;
const url = getCheckoutURL({ plan, user, theme });
const response = await fetch(url);
if (!response.ok) return false;
const json = (await response.json()) as CheckoutDataResponse;
let checkoutData = json.data;
const checkoutId = checkoutData.public_checkout_id;
checkoutData = await submitCustomerInfo(
checkoutId,
user.email,
json.data.ip_geo_country_code
)
.then((res) => (res ? res : checkoutData))
.catch(() => checkoutData);
if (coupon)
checkoutData = await applyCoupon(checkoutId, coupon)
.then((res) => (res ? res : checkoutData))
.catch(() => checkoutData);
return checkoutData;
}

View File

@@ -77,6 +77,7 @@ export enum PaddleEvents {
export interface CallbackData {
checkout?: Checkout;
coupon?: { coupon_code: string };
user?: {
email: string;
id: string;
@@ -86,6 +87,16 @@ export interface CallbackData {
export interface Checkout {
id?: string;
prices: {
customer: {
items: CheckoutPrices[];
};
};
recurring_prices: {
customer: {
items: CheckoutPrices[];
};
};
}
export type PaddleEvent = {
@@ -126,8 +137,11 @@ export type Discount = {
export interface Price {
gross: number;
gross_after_discount?: number;
net: number;
net_after_discount?: number;
tax: number;
tax_after_discount?: number;
currency?: string;
}
@@ -137,67 +151,67 @@ export interface CheckoutDataResponse {
export interface CheckoutData {
public_checkout_id: string;
type: string;
uuid: string;
vendor: Vendor;
display_currency: string;
charge_currency: string;
customer: Customer;
// type: string;
// uuid: string;
// vendor: Vendor;
// display_currency: string;
// charge_currency: string;
// customer: Customer;
items: Item[];
available_payment_methods: unknown[];
total: TotalPrice[];
pending_payment: boolean;
completed: boolean;
payment_method_type: null;
flagged_for_review: boolean;
// available_payment_methods: unknown[];
// total: TotalPrice[];
// pending_payment: boolean;
// completed: boolean;
// payment_method_type: null;
// flagged_for_review: boolean;
ip_geo_country_code: string;
tax: null;
name: null;
image_url: null;
message: null;
passthrough: string;
redirect_url: null;
created_at: Date;
// tax: null;
// name: null;
// image_url: null;
// message: null;
// passthrough: string;
// redirect_url: null;
// created_at: Date;
}
export interface Customer {
id: number;
email: string;
// id: number;
// email: string;
country_code: string;
postcode: null;
audience_opt_in: boolean;
// postcode: null;
// audience_opt_in: boolean;
}
export interface Item {
checkout_product_id: number;
product_id: number;
name: string;
custom_message: string;
quantity: number;
allow_quantity: boolean;
min_quantity: number;
max_quantity: number;
icon_url: string;
// checkout_product_id: number;
// product_id: number;
// name: string;
// custom_message: string;
// quantity: number;
// allow_quantity: boolean;
// min_quantity: number;
// max_quantity: number;
// icon_url: string;
prices: CheckoutPrices[];
recurring: Recurring;
webhook_url: null;
// webhook_url: null;
}
export interface CheckoutPrices {
currency: string;
unit_price: CheckoutPrice;
line_price: CheckoutPrice;
// line_price: CheckoutPrice;
discounts: CheckoutDiscount[];
tax_rate: number;
// tax_rate: number;
}
export interface CheckoutDiscount {
rank: number;
type: string;
net_discount: number;
// rank: number;
// type: string;
// net_discount: number;
gross_discount: number;
code: string;
description: string;
// description: string;
}
export interface CheckoutPrice {
@@ -212,9 +226,9 @@ export interface CheckoutPrice {
}
export interface Recurring {
period: string;
interval: number;
trial_days: number;
// period: string;
// interval: number;
// trial_days: number;
prices: CheckoutPrices[];
}