From a5faa4acea6722901eb5413bc1b436bdd50e5157 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Fri, 8 Nov 2024 17:06:17 +0500 Subject: [PATCH] web: fix checkout (and make it load faster) --- apps/web/__e2e__/checkout.test.ts | 3 + ...ly-IN-discounted-prices-Chromium-linux.txt | 2 +- ...ly-IN-discounted-prices-Chromium-win32.txt | 2 +- ...hly-IN-discounted-prices-Firefox-win32.txt | 2 +- ...nthly-discounted-prices-Chromium-linux.txt | 2 +- ...nthly-discounted-prices-Chromium-win32.txt | 2 +- ...onthly-discounted-prices-Firefox-win32.txt | 2 +- ...ly-IN-discounted-prices-Chromium-linux.txt | 2 +- ...ly-IN-discounted-prices-Chromium-win32.txt | 2 +- ...rly-IN-discounted-prices-Firefox-win32.txt | 2 +- ...early-discounted-prices-Chromium-linux.txt | 2 +- ...early-discounted-prices-Chromium-win32.txt | 2 +- ...yearly-discounted-prices-Firefox-win32.txt | 2 +- apps/web/__e2e__/models/checkout.model.ts | 4 + apps/web/src/components/field/index.tsx | 2 +- .../web/src/dialogs/buy-dialog/buy-dialog.tsx | 14 +- apps/web/src/dialogs/buy-dialog/paddle.tsx | 193 ++++++++++-------- apps/web/src/dialogs/buy-dialog/types.ts | 98 +++++---- 18 files changed, 195 insertions(+), 143 deletions(-) diff --git a/apps/web/__e2e__/checkout.test.ts b/apps/web/__e2e__/checkout.test.ts index 28a054d7d..85c65f37d 100644 --- a/apps/web/__e2e__/checkout.test.ts +++ b/apps/web/__e2e__/checkout.test.ts @@ -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"); diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-linux.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-linux.txt index fb80a9428..a5dde9acb 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-linux.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-linux.txt @@ -1,4 +1,4 @@ Subtotal: ₹300 Sales tax: ₹200 Discount: -₹300 -Total: ₹300 \ No newline at end of file +Total: ₹300/mo \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-win32.txt index fb80a9428..a5dde9acb 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Chromium-win32.txt @@ -1,4 +1,4 @@ Subtotal: ₹300 Sales tax: ₹200 Discount: -₹300 -Total: ₹300 \ No newline at end of file +Total: ₹300/mo \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Firefox-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Firefox-win32.txt index fb80a9428..a5dde9acb 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Firefox-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-IN-discounted-prices-Firefox-win32.txt @@ -1,4 +1,4 @@ Subtotal: ₹300 Sales tax: ₹200 Discount: -₹300 -Total: ₹300 \ No newline at end of file +Total: ₹300/mo \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-linux.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-linux.txt index dc7d77815..70be12204 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-linux.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-linux.txt @@ -1,4 +1,4 @@ Subtotal: $100 Sales tax: $0 Discount: -$100 -Total: $100 \ No newline at end of file +Total: $100/mo \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-win32.txt index dc7d77815..70be12204 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Chromium-win32.txt @@ -1,4 +1,4 @@ Subtotal: $100 Sales tax: $0 Discount: -$100 -Total: $100 \ No newline at end of file +Total: $100/mo \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Firefox-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Firefox-win32.txt index dc7d77815..70be12204 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Firefox-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-monthly-discounted-prices-Firefox-win32.txt @@ -1,4 +1,4 @@ Subtotal: $100 Sales tax: $0 Discount: -$100 -Total: $100 \ No newline at end of file +Total: $100/mo \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-linux.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-linux.txt index e06f69aad..0c0d65560 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-linux.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-linux.txt @@ -1,4 +1,4 @@ Subtotal: ₹400 Sales tax: ₹300 Discount: -₹400 -Total: ₹400 \ No newline at end of file +Total: ₹400/yr \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-win32.txt index e06f69aad..0c0d65560 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Chromium-win32.txt @@ -1,4 +1,4 @@ Subtotal: ₹400 Sales tax: ₹300 Discount: -₹400 -Total: ₹400 \ No newline at end of file +Total: ₹400/yr \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Firefox-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Firefox-win32.txt index e06f69aad..0c0d65560 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Firefox-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-IN-discounted-prices-Firefox-win32.txt @@ -1,4 +1,4 @@ Subtotal: ₹400 Sales tax: ₹300 Discount: -₹400 -Total: ₹400 \ No newline at end of file +Total: ₹400/yr \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-linux.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-linux.txt index fbbb62387..fb2d11bb2 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-linux.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-linux.txt @@ -1,4 +1,4 @@ Subtotal: $200 Sales tax: $0 Discount: -$200 -Total: $200 \ No newline at end of file +Total: $200/yr \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-win32.txt index fbbb62387..fb2d11bb2 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Chromium-win32.txt @@ -1,4 +1,4 @@ Subtotal: $200 Sales tax: $0 Discount: -$200 -Total: $200 \ No newline at end of file +Total: $200/yr \ No newline at end of file diff --git a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Firefox-win32.txt b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Firefox-win32.txt index fbbb62387..fb2d11bb2 100644 --- a/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Firefox-win32.txt +++ b/apps/web/__e2e__/checkout.test.ts-snapshots/checkout-yearly-discounted-prices-Firefox-win32.txt @@ -1,4 +1,4 @@ Subtotal: $200 Sales tax: $0 Discount: -$200 -Total: $200 \ No newline at end of file +Total: $200/yr \ No newline at end of file diff --git a/apps/web/__e2e__/models/checkout.model.ts b/apps/web/__e2e__/models/checkout.model.ts index 71b3be1ed..bec91cfcb 100644 --- a/apps/web/__e2e__/models/checkout.model.ts +++ b/apps/web/__e2e__/models/checkout.model.ts @@ -101,6 +101,10 @@ class PricingModel { }); } + async waitForPaddleFrame() { + await getPaddleFrame(this.page); + } + getTitle() { return this.title.textContent(); } diff --git a/apps/web/src/components/field/index.tsx b/apps/web/src/components/field/index.tsx index 51b0c805b..dde2a34c6 100644 --- a/apps/web/src/components/field/index.tsx +++ b/apps/web/src/components/field/index.tsx @@ -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" }, diff --git a/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx b/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx index 4473352bc..422e89983 100644 --- a/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx +++ b/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx @@ -481,10 +481,16 @@ function SelectedPlan(props: SelectedPlanProps) { )} {pricingInfo ? ( <> - - {pricingInfo.coupon && ( - - )} + {IS_TESTING ? ( + <> + + {pricingInfo.coupon && ( + + )} + + ) : null} (); + const { + plan, + onPriceUpdated, + coupon, + theme, + onCouponApplied, + onCompleted, + user + } = props; const [isLoading, setIsLoading] = useState(true); const appliedCouponCode = useRef(); const checkoutId = useRef(); @@ -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) { @@ -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 ( 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 { }); 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 { @@ -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; +} diff --git a/apps/web/src/dialogs/buy-dialog/types.ts b/apps/web/src/dialogs/buy-dialog/types.ts index b3e5f6c66..ee10b8493 100644 --- a/apps/web/src/dialogs/buy-dialog/types.ts +++ b/apps/web/src/dialogs/buy-dialog/types.ts @@ -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[]; }