UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

834 lines (833 loc) 30.7 kB
import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import "react-international-phone/style.css"; import { useLocaleContext } from "@arcblock/ux/lib/Locale/context"; import Toast from "@arcblock/ux/lib/Toast"; import { Box, Button, CircularProgress, Divider, Fade, Stack, Tooltip, Typography } from "@mui/material"; import { useMemoizedFn, useSetState } from "ahooks"; import pWaitFor from "p-wait-for"; import { useEffect, useMemo, useRef } from "react"; import { Controller, useFormContext, useWatch } from "react-hook-form"; import { joinURL } from "ufo"; import { dispatch } from "use-bus"; import isEmail from "validator/es/lib/isEmail"; import { fromUnitToToken } from "@ocap/util"; import DID from "@arcblock/ux/lib/DID"; import isEmpty from "lodash/isEmpty"; import { HelpOutline, OpenInNew } from "@mui/icons-material"; import FormInput from "../../components/input.js"; import FormLabel from "../../components/label.js"; import { usePaymentContext } from "../../contexts/payment.js"; import { useSubscription } from "../../hooks/subscription.js"; import api from "../../libs/api.js"; import { flattenPaymentMethods, formatError, formatQuantityInventory, getPrefix, getStatementDescriptor, getTokenBalanceLink, isCrossOrigin } from "../../libs/util.js"; import AddressForm from "./address.js"; import CurrencySelector from "./currency.js"; import PhoneInput from "./phone.js"; import StripeCheckout from "./stripe/index.js"; import { useMobile } from "../../hooks/mobile.js"; import { formatPhone, validatePhoneNumber } from "../../libs/phone-validator.js"; import LoadingButton from "../../components/loading-button.js"; import OverdueInvoicePayment from "../../components/over-due-invoice-payment.js"; import { saveCurrencyPreference } from "../../libs/currency.js"; import ConfirmDialog from "../../components/confirm.js"; import { getFieldValidation } from "../../libs/validator.js"; export const waitForCheckoutComplete = async (sessionId) => { let result; await pWaitFor( async () => { const { data } = await api.get(`/api/checkout-sessions/retrieve/${sessionId}`); if (data.paymentIntent && data.paymentIntent.status === "requires_action" && data.paymentIntent.last_payment_error) { throw new Error(data.paymentIntent.last_payment_error.message); } result = data; return ( // eslint-disable-next-line @typescript-eslint/return-await data.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data.checkoutSession?.payment_status) ); }, { interval: 2e3, timeout: 3 * 60 * 1e3 } ); return result; }; export const hasDidWallet = (user) => { const connected = user?.connectedAccounts || user?.extraConfigs?.connectedAccounts || []; return connected.some((x) => x.provider === "wallet"); }; const setUserFormValues = (userInfo, currentValues, setValue, options = {}) => { const { preferExisting = true, shouldValidate = false } = options; const basicFields = { customer_name: userInfo.name || userInfo.fullName, customer_email: userInfo.email, customer_phone: formatPhone(userInfo.phone) }; const addressFields = { "billing_address.state": userInfo.address?.state || userInfo.address?.province, "billing_address.line1": userInfo.address?.line1, "billing_address.line2": userInfo.address?.line2, "billing_address.city": userInfo.address?.city, "billing_address.postal_code": userInfo.address?.postal_code || userInfo.address?.postalCode, "billing_address.country": userInfo.address?.country || "us" }; if (options.showPhone) { addressFields["billing_address.country"] = userInfo.metadata?.phone?.country || userInfo.address?.country; } const allFields = { ...addressFields, ...basicFields }; Object.entries(allFields).forEach(([field, value]) => { if (!preferExisting || !currentValues[field.split(".")[0]]) { setValue(field, value, { shouldValidate }); } }); }; export default function PaymentForm({ checkoutSession, paymentMethods, paymentIntent, paymentLink, customer, onPaid, onError, // mode, action, onlyShowBtn = false, isDonation = false }) { const { t, locale } = useLocaleContext(); const { isMobile } = useMobile(); const { session, connect, payable } = usePaymentContext(); const subscription = useSubscription("events"); const formErrorPosition = "bottom"; const { control, getValues, setValue, handleSubmit, formState: { errors }, trigger } = useFormContext(); const errorRef = useRef(null); const quantityInventoryStatus = useMemo(() => { let status = true; for (const item of checkoutSession.line_items) { if (formatQuantityInventory(item.price, item.quantity)) { status = false; break; } } return status; }, [checkoutSession]); const [state, setState] = useSetState({ submitting: false, paying: false, paid: false, paymentIntent, stripeContext: void 0, customer, customerLimited: false, stripePaying: false, fastCheckoutInfo: null, creditInsufficientInfo: null }); const currencies = flattenPaymentMethods(paymentMethods); const onCheckoutComplete = useMemoizedFn(async ({ response }) => { if (response.id === checkoutSession.id && state.paid === false) { await handleConnected(); } }); useEffect(() => { if (subscription) { subscription.on("checkout.session.completed", onCheckoutComplete); } }, [subscription]); const mergeUserInfo = (customerInfo, userInfo) => { return { ...userInfo || {}, name: customerInfo?.name || customerInfo?.fullName || userInfo?.name || userInfo?.fullName, fullName: customerInfo?.name || customerInfo?.fullName || userInfo?.name || userInfo?.fullName, email: customerInfo?.email || userInfo?.email, phone: customerInfo?.phone || userInfo?.phone, address: { ...userInfo?.address || {}, ...customerInfo?.address || {}, country: customerInfo?.address?.country || userInfo?.address?.country, state: customerInfo?.address?.state || userInfo?.address?.province, line1: customerInfo?.address?.line1 || userInfo?.address?.line1, line2: customerInfo?.address?.line2 || userInfo?.address?.line2, city: customerInfo?.address?.city || userInfo?.address?.city, postal_code: customerInfo?.address?.postal_code || userInfo?.address?.postalCode }, metadata: { ...userInfo?.metadata || {}, phone: { country: customerInfo?.address?.country || userInfo?.metadata?.phone?.country, phoneNumber: customerInfo?.phone || userInfo?.metadata?.phone?.phoneNumber } } }; }; useEffect(() => { const initUserInfo = async () => { if (session?.user) { const values = getValues(); let userInfo = session.user; try { const { data: customerInfo } = await api.get("/api/customers/me?skipSummary=1&fallback=1"); userInfo = mergeUserInfo(customerInfo, userInfo); } catch (err) { userInfo = mergeUserInfo(customer || {}, userInfo); console.error(err); } setUserFormValues(userInfo, values, setValue, { preferExisting: false, showPhone: checkoutSession.phone_number_collection?.enabled }); } else { setUserFormValues( { name: "", email: "", phone: "", address: { state: "", line1: "", line2: "", city: "", postal_code: "" } }, {}, setValue, { preferExisting: false, showPhone: checkoutSession.phone_number_collection?.enabled } ); } }; if (state.submitting) { return; } initUserInfo(); }, [session?.user, checkoutSession.phone_number_collection?.enabled]); const paymentMethod = useWatch({ control, name: "payment_method" }); const paymentCurrencyId = useWatch({ control, name: "payment_currency" }); const afterUserLoggedIn = useMemoizedFn(() => { handleSubmit(onFormSubmit, onFormError)(); }); const payee = getStatementDescriptor(checkoutSession.line_items); let buttonText = ""; if (paymentLink?.donation_settings) { if (action) { buttonText = action; } else { buttonText = t("payment.checkout.donate"); } } else { buttonText = t(`payment.checkout.${checkoutSession.mode}`); } buttonText = session?.user || isDonation ? buttonText : t("payment.checkout.connect", { action: buttonText }); const method = paymentMethods.find((x) => x.id === paymentMethod); const paymentCurrency = currencies.find((x) => x.id === paymentCurrencyId); const showStake = method.type === "arcblock" && !checkoutSession.subscription_data?.no_stake; const isDonationMode = checkoutSession?.submit_type === "donate" && isDonation; const showForm = useMemo(() => { if (!session?.user) { return false; } if (method.type === "stripe") { return true; } if (checkoutSession.phone_number_collection?.enabled) { return true; } const mode = checkoutSession.billing_address_collection; if (mode === "required") { return true; } if (session?.user?.fullName && session?.user?.email) { return false; } return true; }, [session?.user, method, checkoutSession]); const handleConnected = async () => { setState({ paying: true }); try { const result = await waitForCheckoutComplete(checkoutSession.id); if (state.paid === false) { setState({ paid: true, paying: false }); onPaid(result); } } catch (err) { Toast.error(formatError(err)); } finally { setState({ paying: false }); } }; useEffect(() => { if (errorRef.current && !isEmpty(errors) && isMobile) { errorRef.current.scrollIntoView({ behavior: "smooth" }); } }, [errors, isMobile]); const onUserLoggedIn = async () => { const { data: profile } = await api.get("/api/customers/me?fallback=1&skipSummary=1"); if (profile) { const values = getValues(); const userInfo = mergeUserInfo(profile, session?.user); setUserFormValues(userInfo, values, setValue, { preferExisting: false, showPhone: checkoutSession.phone_number_collection?.enabled, shouldValidate: true }); await trigger(); } }; const handleFastCheckoutConfirm = async () => { if (!state.fastCheckoutInfo) return; setState({ fastCheckoutInfo: { ...state.fastCheckoutInfo, loading: true } }); try { const result = await api.post(`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`); if (result.data.fastPaid) { setState({ fastCheckoutInfo: null, paying: true }); await handleConnected(); } else { Toast.error(t("payment.checkout.fastPay.failed")); setState({ fastCheckoutInfo: null, paying: true }); openConnect(); } } catch (err) { console.error(err); Toast.error(formatError(err)); setState({ fastCheckoutInfo: null }); } }; const handleFastCheckoutCancel = () => { setState({ fastCheckoutInfo: null }); }; const handleCreditInsufficientClose = () => { setState({ creditInsufficientInfo: null }); }; const openConnect = () => { try { if (!["arcblock", "ethereum", "base"].includes(method.type)) { return; } setState({ paying: true }); connect.open({ locale, containerEl: void 0, action: checkoutSession.mode, prefix: joinURL(getPrefix(), "/api/did"), saveConnect: false, useSocket: isCrossOrigin() === false, extraParams: { checkoutSessionId: checkoutSession.id, sessionUserDid: session?.user?.did }, onSuccess: async () => { connect.close(); await handleConnected(); }, onClose: () => { connect.close(); setState({ submitting: false, paying: false }); }, onError: (err) => { console.error(err); setState({ submitting: false, paying: false }); onError(err); }, messages: { title: t("payment.checkout.connectModal.title", { action: buttonText }), scan: t("payment.checkout.connectModal.scan"), confirm: t("payment.checkout.connectModal.confirm"), cancel: t("payment.checkout.connectModal.cancel") } }); } catch (err) { Toast.error(formatError(err)); } }; const onFormSubmit = async (data) => { setState({ submitting: true }); try { let result; if (isDonationMode) { result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/donate-submit`, data); } else { result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, data); } setState({ paymentIntent: result.data.paymentIntent, stripeContext: result.data.stripeContext, customer: result.data.customer, submitting: false, customerLimited: false }); if (["arcblock", "ethereum", "base"].includes(method.type)) { if (paymentCurrency?.type === "credit") { if (result.data.creditSufficient === true) { setState({ fastCheckoutInfo: { open: true, loading: false, sourceType: "credit", amount: result.data.fastPayInfo?.amount || "0", payer: result.data.fastPayInfo?.payer, availableCredit: result.data.fastPayInfo?.amount || "0", balance: result.data.fastPayInfo?.token?.balance || "0" } }); } else { setState({ creditInsufficientInfo: { open: true } }); } } else if ((result.data.balance?.sufficient || result.data.delegation?.sufficient) && !isDonationMode && result.data.fastPayInfo) { setState({ fastCheckoutInfo: { open: true, loading: false, sourceType: result.data.fastPayInfo.type, amount: result.data.fastPayInfo.amount, payer: result.data.fastPayInfo.payer } }); } else { openConnect(); } } if (["stripe"].includes(method.type)) { if (result.data.stripeContext?.status === "succeeded") { setState({ paying: true }); } else { setTimeout(() => { setState({ stripePaying: true }); }, 200); } } } catch (err) { console.error(err); let shouldToast = true; if (err.response?.data?.code) { dispatch(`error.${err.response?.data?.code}`); if (err.response.data.code === "CUSTOMER_LIMITED") { shouldToast = false; setState({ customerLimited: true }); } } if (shouldToast) { Toast.error(formatError(err)); } } finally { setState({ submitting: false }); } }; const onFormError = (err) => { if (err) { console.error(err); } setState({ submitting: false }); }; const onAction = () => { if (state.submitting || state.paying) { return; } if (errorRef.current && !isEmpty(errors) && isMobile) { errorRef.current.scrollIntoView({ behavior: "smooth" }); } if (session?.user) { handleSubmit(onFormSubmit, onFormError)(); } else { if (isDonationMode) { handleSubmit(onFormSubmit, onFormError)(); return; } session?.login(() => { setState({ submitting: true }); onUserLoggedIn().then(afterUserLoggedIn).catch((err) => { console.error(err); setState({ submitting: false }); }); }); } }; const onStripeConfirm = async () => { setState({ stripePaying: false, paying: true }); await handleConnected(); }; const onStripeCancel = () => { setState({ stripePaying: false }); }; useEffect(() => { const handleKeyDown = (e) => { if (e.key === "Enter" && !state.submitting && !state.paying && !state.stripePaying && quantityInventoryStatus && payable) { onAction(); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, payable]); const balanceLink = getTokenBalanceLink(method, state.fastCheckoutInfo?.payer || ""); const FastCheckoutConfirmDialog = state.fastCheckoutInfo && /* @__PURE__ */ jsx( ConfirmDialog, { onConfirm: handleFastCheckoutConfirm, onCancel: handleFastCheckoutCancel, title: state.fastCheckoutInfo.sourceType === "credit" ? t("payment.checkout.fastPay.credit.title") : t("payment.checkout.fastPay.title"), message: state.fastCheckoutInfo.sourceType === "credit" ? /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.credit.meteringSubscriptionMessage", { available: `${fromUnitToToken(state.fastCheckoutInfo?.balance || "0", paymentCurrency?.decimal || 18).toString()} ${paymentCurrency?.symbol}` }) }) : /* @__PURE__ */ jsxs(Stack, { children: [ /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.autoPaymentReason") }), /* @__PURE__ */ jsx(Divider, { sx: { mt: 1.5, mb: 1.5 } }), /* @__PURE__ */ jsxs(Stack, { spacing: 1, children: [ /* @__PURE__ */ jsxs( Stack, { sx: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [ /* @__PURE__ */ jsx( Typography, { sx: { color: "text.primary", whiteSpace: "nowrap" }, children: t("payment.checkout.fastPay.payer") } ), /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 0.5 }, children: [ /* @__PURE__ */ jsx(DID, { did: state.fastCheckoutInfo.payer || "", compact: true, responsive: false }), balanceLink && /* @__PURE__ */ jsx(Tooltip, { title: t("payment.checkout.fastPay.balanceLink"), placement: "top", children: /* @__PURE__ */ jsx( OpenInNew, { sx: { color: "text.lighter", fontSize: "0.85rem", cursor: "pointer", "&:hover": { color: "text.primary" } }, onClick: () => { window.open(balanceLink, "_blank"); } } ) }) ] }) ] } ), /* @__PURE__ */ jsxs( Stack, { sx: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [ /* @__PURE__ */ jsx( Typography, { sx: { color: "text.primary" }, children: t("payment.checkout.fastPay.amount") } ), /* @__PURE__ */ jsxs(Typography, { children: [ fromUnitToToken(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18).toString(), " ", paymentCurrency?.symbol ] }) ] } ) ] }) ] }), loading: state.fastCheckoutInfo.loading, color: "primary" } ); const CreditInsufficientDialog = state.creditInsufficientInfo && /* @__PURE__ */ jsx( ConfirmDialog, { onConfirm: handleCreditInsufficientClose, onCancel: handleCreditInsufficientClose, title: t("payment.checkout.fastPay.credit.insufficientTitle"), message: /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.credit.insufficientMessage") }), confirm: t("common.confirm") } ); if (onlyShowBtn) { return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Box, { className: "cko-payment-submit-btn", children: /* @__PURE__ */ jsxs( Button, { variant: "contained", color: "primary", size: "large", className: "cko-submit-button", onClick: () => { if (state.submitting || state.paying) { return; } onAction(); }, fullWidth: true, disabled: state.stripePaying || !quantityInventoryStatus || !payable, children: [ (state.submitting || state.paying) && /* @__PURE__ */ jsx(CircularProgress, { size: 16, sx: { mr: 0.5, color: "primary.contrastText" } }), state.submitting || state.paying ? t("payment.checkout.processing") : buttonText ] } ) }), state.customerLimited && /* @__PURE__ */ jsx( OverdueInvoicePayment, { customerId: customer?.id || session?.user?.did, onPaid: () => { setState({ customerLimited: false }); onAction(); }, alertMessage: t("payment.customer.pastDue.alert.customMessage"), detailLinkOptions: { enabled: true, onClick: () => { setState({ customerLimited: false }); window.open( joinURL( getPrefix(), `/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}` ), "_self" ); } }, dialogProps: { open: state.customerLimited, onClose: () => setState({ customerLimited: false }), title: t("payment.customer.pastDue.alert.title") } } ), FastCheckoutConfirmDialog, CreditInsufficientDialog ] }); } return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-payment-contact", children: [ /* @__PURE__ */ jsx( Typography, { title: t("payment.checkout.paymentDetails"), sx: { color: "text.primary", fontSize: { xs: "18px", md: "24px" }, fontWeight: "700", lineHeight: "32px", mb: 2.5 }, children: t("payment.checkout.paymentDetails") } ), /* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs( Stack, { direction: "column", className: "cko-payment-methods", sx: { alignItems: "flex-start" }, children: [ /* @__PURE__ */ jsx(Stack, { direction: "row", sx: { width: "100%" }, children: /* @__PURE__ */ jsx( Controller, { name: "payment_currency", control, render: ({ field }) => /* @__PURE__ */ jsx( CurrencySelector, { value: field.value, currencies, onChange: (id, methodId) => { field.onChange(id); setValue("payment_method", methodId); saveCurrencyPreference(id, session?.user?.did); } } ) } ) }), state.stripePaying && state.stripeContext && /* @__PURE__ */ jsx( StripeCheckout, { clientSecret: state.stripeContext.client_secret, intentType: state.stripeContext.intent_type, publicKey: method.settings.stripe?.publishable_key, customer: state.customer, mode: checkoutSession.mode, onConfirm: onStripeConfirm, onCancel: onStripeCancel, returnUrl: checkoutSession?.success_url } ) ] } ) }), showForm && /* @__PURE__ */ jsxs( Stack, { direction: "column", className: "cko-payment-form", id: "cko-payment-form", spacing: 0, ref: !isEmpty(errors) ? errorRef : void 0, sx: { flex: 1, overflow: "auto", py: 1 }, children: [ /* @__PURE__ */ jsx(FormLabel, { className: "base-label", children: t("payment.checkout.customer.name") }), /* @__PURE__ */ jsx( FormInput, { name: "customer_name", variant: "outlined", errorPosition: formErrorPosition, rules: { required: t("payment.checkout.required"), ...getFieldValidation("customer_name", checkoutSession.metadata?.page_info?.field_validation, locale) } } ), /* @__PURE__ */ jsx(FormLabel, { className: "base-label", children: t("payment.checkout.customer.email") }), /* @__PURE__ */ jsx( FormInput, { name: "customer_email", variant: "outlined", errorPosition: formErrorPosition, rules: { required: t("payment.checkout.required"), validate: (x) => isEmail(x) ? true : t("payment.checkout.invalid"), ...getFieldValidation( "customer_email", checkoutSession.metadata?.page_info?.field_validation, locale ) } } ), checkoutSession.phone_number_collection?.enabled && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(FormLabel, { className: "base-label", children: t("payment.checkout.customer.phone") }), /* @__PURE__ */ jsx( PhoneInput, { name: "customer_phone", variant: "outlined", errorPosition: formErrorPosition, placeholder: "Phone number", rules: { required: t("payment.checkout.required"), validate: async (x) => { const isValid = await validatePhoneNumber(x); return isValid ? true : t("payment.checkout.invalid"); }, ...getFieldValidation( "customer_phone", checkoutSession.metadata?.page_info?.field_validation, locale ) } } ) ] }), /* @__PURE__ */ jsx( AddressForm, { mode: checkoutSession.billing_address_collection, stripe: method?.type === "stripe", sx: { marginTop: "0 !important" }, fieldValidation: checkoutSession.metadata?.page_info?.field_validation, errorPosition: formErrorPosition } ) ] } ) ] }) }), /* @__PURE__ */ jsx(Divider, { sx: { mt: 2.5, mb: 2.5 } }), /* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-payment-submit", children: [ /* @__PURE__ */ jsx(Box, { className: "cko-payment-submit-btn", children: /* @__PURE__ */ jsx( LoadingButton, { variant: "contained", color: "primary", size: "large", className: "cko-submit-button", onClick: () => { onAction(); }, fullWidth: true, loading: state.submitting || state.paying, disabled: state.stripePaying || !quantityInventoryStatus || !payable, children: state.submitting || state.paying ? t("payment.checkout.processing") : buttonText } ) }), ["subscription", "setup"].includes(checkoutSession.mode) && /* @__PURE__ */ jsx(Typography, { sx: { mt: 2.5, color: "text.lighter", fontSize: "0.7875rem", lineHeight: "0.9625rem" }, children: showStake ? t("payment.checkout.confirm.withStake", { payee }) : t("payment.checkout.confirm.withoutStake", { payee }) }), checkoutSession.metadata?.page_info?.form_purpose_description && /* @__PURE__ */ jsxs(Box, { sx: { mt: 1, display: "flex", alignItems: "center", gap: 0.5 }, children: [ /* @__PURE__ */ jsx(HelpOutline, { sx: { color: "text.lighter", fontSize: "0.75rem" } }), /* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { fontSize: "0.75rem", color: "text.lighter" }, children: locale === "zh" ? checkoutSession.metadata.page_info.form_purpose_description.zh : checkoutSession.metadata.page_info.form_purpose_description.en }) ] }) ] }) }), state.customerLimited && /* @__PURE__ */ jsx( OverdueInvoicePayment, { customerId: customer?.id || session?.user?.did, onPaid: () => { setState({ customerLimited: false }); onAction(); }, alertMessage: t("payment.customer.pastDue.alert.customMessage"), detailLinkOptions: { enabled: true, onClick: () => { setState({ customerLimited: false }); window.open( joinURL(getPrefix(), `/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}`), "_self" ); } }, dialogProps: { open: state.customerLimited, onClose: () => setState({ customerLimited: false }), title: t("payment.customer.pastDue.alert.title") } } ), FastCheckoutConfirmDialog, CreditInsufficientDialog ] }); }