@paytree/medusa-payment-paytree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/index.d.ts +28 -0
  4. package/dist/index.js +55 -0
  5. package/dist/modules/paytree/index.d.ts +36 -0
  6. package/dist/modules/paytree/index.js +13 -0
  7. package/dist/modules/paytree/migrations/Migration20260617000000.d.ts +5 -0
  8. package/dist/modules/paytree/migrations/Migration20260617000000.js +54 -0
  9. package/dist/modules/paytree/migrations/Migration20260618000000.d.ts +5 -0
  10. package/dist/modules/paytree/migrations/Migration20260618000000.js +19 -0
  11. package/dist/modules/paytree/models/paytree-event-log.d.ts +25 -0
  12. package/dist/modules/paytree/models/paytree-event-log.js +29 -0
  13. package/dist/modules/paytree/models/paytree-setting.d.ts +23 -0
  14. package/dist/modules/paytree/models/paytree-setting.js +27 -0
  15. package/dist/modules/paytree/service.d.ts +60 -0
  16. package/dist/modules/paytree/service.js +47 -0
  17. package/dist/modules/paytree-payment/index.d.ts +8 -0
  18. package/dist/modules/paytree-payment/index.js +16 -0
  19. package/dist/modules/paytree-payment/lib/amount.d.ts +23 -0
  20. package/dist/modules/paytree-payment/lib/amount.js +40 -0
  21. package/dist/modules/paytree-payment/lib/audit.d.ts +26 -0
  22. package/dist/modules/paytree-payment/lib/audit.js +51 -0
  23. package/dist/modules/paytree-payment/lib/client.d.ts +28 -0
  24. package/dist/modules/paytree-payment/lib/client.js +81 -0
  25. package/dist/modules/paytree-payment/lib/constants.d.ts +54 -0
  26. package/dist/modules/paytree-payment/lib/constants.js +55 -0
  27. package/dist/modules/paytree-payment/lib/logger.d.ts +27 -0
  28. package/dist/modules/paytree-payment/lib/logger.js +73 -0
  29. package/dist/modules/paytree-payment/lib/methods.d.ts +69 -0
  30. package/dist/modules/paytree-payment/lib/methods.js +68 -0
  31. package/dist/modules/paytree-payment/lib/redact.d.ts +16 -0
  32. package/dist/modules/paytree-payment/lib/redact.js +60 -0
  33. package/dist/modules/paytree-payment/lib/settings-reader.d.ts +24 -0
  34. package/dist/modules/paytree-payment/lib/settings-reader.js +74 -0
  35. package/dist/modules/paytree-payment/lib/status-map.d.ts +26 -0
  36. package/dist/modules/paytree-payment/lib/status-map.js +66 -0
  37. package/dist/modules/paytree-payment/lib/types.d.ts +144 -0
  38. package/dist/modules/paytree-payment/lib/types.js +2 -0
  39. package/dist/modules/paytree-payment/service.d.ts +36 -0
  40. package/dist/modules/paytree-payment/service.js +369 -0
  41. package/docs/api-reference.md +121 -0
  42. package/docs/architecture.md +97 -0
  43. package/docs/configuration.md +62 -0
  44. package/docs/getting-started.md +111 -0
  45. package/docs/storefront-integration.md +85 -0
  46. package/integration/README.md +46 -0
  47. package/integration/admin/routes/paytree/api.ts +16 -0
  48. package/integration/admin/routes/paytree/method-icons.tsx +106 -0
  49. package/integration/admin/routes/paytree/page.tsx +18 -0
  50. package/integration/admin/routes/paytree/payments/page.tsx +240 -0
  51. package/integration/admin/routes/paytree/settings/page.tsx +214 -0
  52. package/integration/api/admin/paytree/events/route.ts +35 -0
  53. package/integration/api/admin/paytree/settings/route.ts +86 -0
  54. package/integration/api/hooks/paytree/route.ts +195 -0
  55. package/integration/api/store/paytree/methods/route.ts +28 -0
  56. package/package.json +57 -0
  57. package/storefront/README.md +25 -0
  58. package/storefront/components/payment-button.tsx +361 -0
  59. package/storefront/components/payment.tsx +356 -0
  60. package/storefront/components/paytree-method-icon.tsx +106 -0
  61. package/storefront/lib/paytree.ts +23 -0
@@ -0,0 +1,361 @@
1
+ "use client"
2
+
3
+ import { Button } from "@medusajs/ui"
4
+ import { OnApproveActions, OnApproveData } from "@paypal/paypal-js"
5
+ import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"
6
+ import { useElements, useStripe } from "@stripe/react-stripe-js"
7
+ import React, { useState } from "react"
8
+ import ErrorMessage from "../error-message"
9
+ import Spinner from "@modules/common/icons/spinner"
10
+ import { placeOrder } from "@lib/data/cart"
11
+ import { HttpTypes } from "@medusajs/types"
12
+ import { isManual, isPaypal, isPaytree, isStripe } from "@lib/constants"
13
+
14
+ type PaymentButtonProps = {
15
+ cart: HttpTypes.StoreCart
16
+ "data-testid": string
17
+ }
18
+
19
+ const PaymentButton: React.FC<PaymentButtonProps> = ({
20
+ cart,
21
+ "data-testid": dataTestId,
22
+ }) => {
23
+ const notReady =
24
+ !cart ||
25
+ !cart.shipping_address ||
26
+ !cart.billing_address ||
27
+ !cart.email ||
28
+ (cart.shipping_methods?.length ?? 0) < 1
29
+
30
+ // TODO: Add this once gift cards are implemented
31
+ // const paidByGiftcard =
32
+ // cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
33
+
34
+ // if (paidByGiftcard) {
35
+ // return <GiftCardPaymentButton />
36
+ // }
37
+
38
+ const paymentSession = cart.payment_collection?.payment_sessions?.[0]
39
+
40
+ switch (true) {
41
+ case isStripe(paymentSession?.provider_id):
42
+ return (
43
+ <StripePaymentButton
44
+ notReady={notReady}
45
+ cart={cart}
46
+ data-testid={dataTestId}
47
+ />
48
+ )
49
+ case isManual(paymentSession?.provider_id):
50
+ return (
51
+ <ManualTestPaymentButton notReady={notReady} data-testid={dataTestId} />
52
+ )
53
+ case isPaypal(paymentSession?.provider_id):
54
+ return (
55
+ <PayPalPaymentButton
56
+ notReady={notReady}
57
+ cart={cart}
58
+ data-testid={dataTestId}
59
+ />
60
+ )
61
+ case isPaytree(paymentSession?.provider_id):
62
+ return (
63
+ <PaytreePaymentButton
64
+ notReady={notReady}
65
+ cart={cart}
66
+ data-testid={dataTestId}
67
+ />
68
+ )
69
+ default:
70
+ return <Button disabled>Select a payment method</Button>
71
+ }
72
+ }
73
+
74
+ const GiftCardPaymentButton = () => {
75
+ const [submitting, setSubmitting] = useState(false)
76
+
77
+ const handleOrder = async () => {
78
+ setSubmitting(true)
79
+ await placeOrder()
80
+ }
81
+
82
+ return (
83
+ <Button
84
+ onClick={handleOrder}
85
+ isLoading={submitting}
86
+ data-testid="submit-order-button"
87
+ >
88
+ Place order
89
+ </Button>
90
+ )
91
+ }
92
+
93
+ const StripePaymentButton = ({
94
+ cart,
95
+ notReady,
96
+ "data-testid": dataTestId,
97
+ }: {
98
+ cart: HttpTypes.StoreCart
99
+ notReady: boolean
100
+ "data-testid"?: string
101
+ }) => {
102
+ const [submitting, setSubmitting] = useState(false)
103
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
104
+
105
+ const onPaymentCompleted = async () => {
106
+ await placeOrder()
107
+ .catch((err) => {
108
+ setErrorMessage(err.message)
109
+ })
110
+ .finally(() => {
111
+ setSubmitting(false)
112
+ })
113
+ }
114
+
115
+ const stripe = useStripe()
116
+ const elements = useElements()
117
+ const card = elements?.getElement("card")
118
+
119
+ const session = cart.payment_collection?.payment_sessions?.find(
120
+ (s) => s.status === "pending"
121
+ )
122
+
123
+ const disabled = !stripe || !elements ? true : false
124
+
125
+ const handlePayment = async () => {
126
+ setSubmitting(true)
127
+
128
+ if (!stripe || !elements || !card || !cart) {
129
+ setSubmitting(false)
130
+ return
131
+ }
132
+
133
+ await stripe
134
+ .confirmCardPayment(session?.data.client_secret as string, {
135
+ payment_method: {
136
+ card: card,
137
+ billing_details: {
138
+ name:
139
+ cart.billing_address?.first_name +
140
+ " " +
141
+ cart.billing_address?.last_name,
142
+ address: {
143
+ city: cart.billing_address?.city ?? undefined,
144
+ country: cart.billing_address?.country_code ?? undefined,
145
+ line1: cart.billing_address?.address_1 ?? undefined,
146
+ line2: cart.billing_address?.address_2 ?? undefined,
147
+ postal_code: cart.billing_address?.postal_code ?? undefined,
148
+ state: cart.billing_address?.province ?? undefined,
149
+ },
150
+ email: cart.email,
151
+ phone: cart.billing_address?.phone ?? undefined,
152
+ },
153
+ },
154
+ })
155
+ .then(({ error, paymentIntent }) => {
156
+ if (error) {
157
+ const pi = error.payment_intent
158
+
159
+ if (
160
+ (pi && pi.status === "requires_capture") ||
161
+ (pi && pi.status === "succeeded")
162
+ ) {
163
+ onPaymentCompleted()
164
+ }
165
+
166
+ setErrorMessage(error.message || null)
167
+ return
168
+ }
169
+
170
+ if (
171
+ (paymentIntent && paymentIntent.status === "requires_capture") ||
172
+ paymentIntent.status === "succeeded"
173
+ ) {
174
+ return onPaymentCompleted()
175
+ }
176
+
177
+ return
178
+ })
179
+ }
180
+
181
+ return (
182
+ <>
183
+ <Button
184
+ disabled={disabled || notReady}
185
+ onClick={handlePayment}
186
+ size="large"
187
+ isLoading={submitting}
188
+ data-testid={dataTestId}
189
+ >
190
+ Place order
191
+ </Button>
192
+ <ErrorMessage
193
+ error={errorMessage}
194
+ data-testid="stripe-payment-error-message"
195
+ />
196
+ </>
197
+ )
198
+ }
199
+
200
+ const PayPalPaymentButton = ({
201
+ cart,
202
+ notReady,
203
+ "data-testid": dataTestId,
204
+ }: {
205
+ cart: HttpTypes.StoreCart
206
+ notReady: boolean
207
+ "data-testid"?: string
208
+ }) => {
209
+ const [submitting, setSubmitting] = useState(false)
210
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
211
+
212
+ const onPaymentCompleted = async () => {
213
+ await placeOrder()
214
+ .catch((err) => {
215
+ setErrorMessage(err.message)
216
+ })
217
+ .finally(() => {
218
+ setSubmitting(false)
219
+ })
220
+ }
221
+
222
+ const session = cart.payment_collection?.payment_sessions?.find(
223
+ (s) => s.status === "pending"
224
+ )
225
+
226
+ const handlePayment = async (
227
+ _data: OnApproveData,
228
+ actions: OnApproveActions
229
+ ) => {
230
+ actions?.order
231
+ ?.authorize()
232
+ .then((authorization) => {
233
+ if (authorization.status !== "COMPLETED") {
234
+ setErrorMessage(`An error occurred, status: ${authorization.status}`)
235
+ return
236
+ }
237
+ onPaymentCompleted()
238
+ })
239
+ .catch(() => {
240
+ setErrorMessage(`An unknown error occurred, please try again.`)
241
+ setSubmitting(false)
242
+ })
243
+ }
244
+
245
+ const [{ isPending, isResolved }] = usePayPalScriptReducer()
246
+
247
+ if (isPending) {
248
+ return <Spinner />
249
+ }
250
+
251
+ if (isResolved) {
252
+ return (
253
+ <>
254
+ <PayPalButtons
255
+ style={{ layout: "horizontal" }}
256
+ createOrder={async () => session?.data.id as string}
257
+ onApprove={handlePayment}
258
+ disabled={notReady || submitting || isPending}
259
+ data-testid={dataTestId}
260
+ />
261
+ <ErrorMessage
262
+ error={errorMessage}
263
+ data-testid="paypal-payment-error-message"
264
+ />
265
+ </>
266
+ )
267
+ }
268
+ }
269
+
270
+ const PaytreePaymentButton = ({
271
+ cart,
272
+ notReady,
273
+ "data-testid": dataTestId,
274
+ }: {
275
+ cart: HttpTypes.StoreCart
276
+ notReady: boolean
277
+ "data-testid"?: string
278
+ }) => {
279
+ const [submitting, setSubmitting] = useState(false)
280
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
281
+
282
+ const session = cart.payment_collection?.payment_sessions?.find(
283
+ (s) => s.status === "pending"
284
+ )
285
+ const paymentLink = session?.data?.payment_link as string | undefined
286
+
287
+ const handlePayment = () => {
288
+ setSubmitting(true)
289
+
290
+ if (!paymentLink) {
291
+ setErrorMessage(
292
+ "Payment link is not ready yet. Please go back to the payment step and select your method again."
293
+ )
294
+ setSubmitting(false)
295
+ return
296
+ }
297
+
298
+ // Hand off to Paytree's hosted payment page. After paying, the customer is
299
+ // redirected back to the return URL, which finalizes the order.
300
+ window.location.href = paymentLink
301
+ }
302
+
303
+ return (
304
+ <>
305
+ <Button
306
+ disabled={notReady || !paymentLink}
307
+ isLoading={submitting}
308
+ onClick={handlePayment}
309
+ size="large"
310
+ data-testid={dataTestId}
311
+ >
312
+ Pay with Paytree
313
+ </Button>
314
+ <ErrorMessage
315
+ error={errorMessage}
316
+ data-testid="paytree-payment-error-message"
317
+ />
318
+ </>
319
+ )
320
+ }
321
+
322
+ const ManualTestPaymentButton = ({ notReady }: { notReady: boolean }) => {
323
+ const [submitting, setSubmitting] = useState(false)
324
+ const [errorMessage, setErrorMessage] = useState<string | null>(null)
325
+
326
+ const onPaymentCompleted = async () => {
327
+ await placeOrder()
328
+ .catch((err) => {
329
+ setErrorMessage(err.message)
330
+ })
331
+ .finally(() => {
332
+ setSubmitting(false)
333
+ })
334
+ }
335
+
336
+ const handlePayment = () => {
337
+ setSubmitting(true)
338
+
339
+ onPaymentCompleted()
340
+ }
341
+
342
+ return (
343
+ <>
344
+ <Button
345
+ disabled={notReady}
346
+ isLoading={submitting}
347
+ onClick={handlePayment}
348
+ size="large"
349
+ data-testid="submit-order-button"
350
+ >
351
+ Place order
352
+ </Button>
353
+ <ErrorMessage
354
+ error={errorMessage}
355
+ data-testid="manual-payment-error-message"
356
+ />
357
+ </>
358
+ )
359
+ }
360
+
361
+ export default PaymentButton
@@ -0,0 +1,356 @@
1
+ "use client"
2
+
3
+ import { useCallback, useContext, useEffect, useMemo, useState } from "react"
4
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
5
+ import { RadioGroup } from "@headlessui/react"
6
+ import ErrorMessage from "@modules/checkout/components/error-message"
7
+ import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
8
+ import { Button, Container, Heading, Text, Tooltip, clx } from "@medusajs/ui"
9
+ import { CardElement } from "@stripe/react-stripe-js"
10
+ import { StripeCardElementOptions } from "@stripe/stripe-js"
11
+
12
+ import Divider from "@modules/common/components/divider"
13
+ import PaymentContainer from "@modules/checkout/components/payment-container"
14
+ import {
15
+ isStripe as isStripeFunc,
16
+ isPaytree as isPaytreeFunc,
17
+ paymentInfoMap,
18
+ } from "@lib/constants"
19
+ import { StripeContext } from "@modules/checkout/components/payment-wrapper"
20
+ import { initiatePaymentSession } from "@lib/data/cart"
21
+ import { listPaytreeMethods } from "@lib/data/paytree"
22
+ import PaytreeMethodIcon from "@modules/checkout/components/paytree-method-icon"
23
+
24
+ type PaytreeMethod = { method: string; label: string }
25
+
26
+ const Payment = ({
27
+ cart,
28
+ availablePaymentMethods,
29
+ }: {
30
+ cart: any
31
+ availablePaymentMethods: any[]
32
+ }) => {
33
+ const activeSession = cart.payment_collection?.payment_sessions?.find(
34
+ (paymentSession: any) => paymentSession.status === "pending"
35
+ )
36
+
37
+ const [isLoading, setIsLoading] = useState(false)
38
+ const [error, setError] = useState<string | null>(null)
39
+ const [cardBrand, setCardBrand] = useState<string | null>(null)
40
+ const [cardComplete, setCardComplete] = useState(false)
41
+ const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
42
+ activeSession?.provider_id ?? ""
43
+ )
44
+ const [paytreeMethods, setPaytreeMethods] = useState<PaytreeMethod[]>([])
45
+ const [paytreeMethod, setPaytreeMethod] = useState<string>(
46
+ (activeSession?.data?.method as string) ?? ""
47
+ )
48
+
49
+ const searchParams = useSearchParams()
50
+ const router = useRouter()
51
+ const pathname = usePathname()
52
+
53
+ const isOpen = searchParams.get("step") === "payment"
54
+
55
+ const isStripe = isStripeFunc(activeSession?.provider_id)
56
+ const isPaytreeSelected = isPaytreeFunc(selectedPaymentMethod)
57
+ const stripeReady = useContext(StripeContext)
58
+
59
+ const paidByGiftcard =
60
+ cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
61
+
62
+ const paymentReady =
63
+ (activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard
64
+
65
+ const useOptions: StripeCardElementOptions = useMemo(() => {
66
+ return {
67
+ style: {
68
+ base: {
69
+ fontFamily: "Inter, sans-serif",
70
+ color: "#424270",
71
+ "::placeholder": {
72
+ color: "rgb(107 114 128)",
73
+ },
74
+ },
75
+ },
76
+ classes: {
77
+ base: "pt-3 pb-1 block w-full h-11 px-4 mt-0 bg-ui-bg-field border rounded-md appearance-none focus:outline-none focus:ring-0 focus:shadow-borders-interactive-with-active border-ui-border-base hover:bg-ui-bg-field-hover transition-all duration-300 ease-in-out",
78
+ },
79
+ }
80
+ }, [])
81
+
82
+ const createQueryString = useCallback(
83
+ (name: string, value: string) => {
84
+ const params = new URLSearchParams(searchParams)
85
+ params.set(name, value)
86
+
87
+ return params.toString()
88
+ },
89
+ [searchParams]
90
+ )
91
+
92
+ const handleEdit = () => {
93
+ router.push(pathname + "?" + createQueryString("step", "payment"), {
94
+ scroll: false,
95
+ })
96
+ }
97
+
98
+ const handleSubmit = async () => {
99
+ setIsLoading(true)
100
+ try {
101
+ const shouldInputCard =
102
+ isStripeFunc(selectedPaymentMethod) && !activeSession
103
+
104
+ // Paytree re-initiates on every submit so the latest method choice is
105
+ // reflected in a fresh hosted-payment link.
106
+ if (!activeSession || isPaytreeSelected) {
107
+ await initiatePaymentSession(cart, {
108
+ provider_id: selectedPaymentMethod,
109
+ ...(isPaytreeSelected
110
+ ? { data: { method: paytreeMethod } }
111
+ : {}),
112
+ })
113
+ }
114
+
115
+ if (!shouldInputCard) {
116
+ return router.push(
117
+ pathname + "?" + createQueryString("step", "review"),
118
+ {
119
+ scroll: false,
120
+ }
121
+ )
122
+ }
123
+ } catch (err: any) {
124
+ setError(err.message)
125
+ } finally {
126
+ setIsLoading(false)
127
+ }
128
+ }
129
+
130
+ useEffect(() => {
131
+ setError(null)
132
+ }, [isOpen])
133
+
134
+ // Load the admin-curated Paytree methods once Paytree is selected.
135
+ useEffect(() => {
136
+ if (!isPaytreeSelected || paytreeMethods.length > 0) {
137
+ return
138
+ }
139
+ listPaytreeMethods().then((methods) => {
140
+ setPaytreeMethods(methods)
141
+ setPaytreeMethod((current) => current || methods[0]?.method || "")
142
+ })
143
+ }, [isPaytreeSelected, paytreeMethods.length])
144
+
145
+ return (
146
+ <div className="bg-white">
147
+ <div className="flex flex-row items-center justify-between mb-6">
148
+ <Heading
149
+ level="h2"
150
+ className={clx(
151
+ "flex flex-row text-3xl-regular gap-x-2 items-baseline",
152
+ {
153
+ "opacity-50 pointer-events-none select-none":
154
+ !isOpen && !paymentReady,
155
+ }
156
+ )}
157
+ >
158
+ Payment
159
+ {!isOpen && paymentReady && <CheckCircleSolid />}
160
+ </Heading>
161
+ {!isOpen && paymentReady && (
162
+ <Text>
163
+ <button
164
+ onClick={handleEdit}
165
+ className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
166
+ data-testid="edit-payment-button"
167
+ >
168
+ Edit
169
+ </button>
170
+ </Text>
171
+ )}
172
+ </div>
173
+ <div>
174
+ <div className={isOpen ? "block" : "hidden"}>
175
+ {!paidByGiftcard && availablePaymentMethods?.length && (
176
+ <>
177
+ <RadioGroup
178
+ value={selectedPaymentMethod}
179
+ onChange={(value: string) => setSelectedPaymentMethod(value)}
180
+ >
181
+ {availablePaymentMethods
182
+ .sort((a, b) => {
183
+ return a.provider_id > b.provider_id ? 1 : -1
184
+ })
185
+ .map((paymentMethod) => {
186
+ return (
187
+ <PaymentContainer
188
+ paymentInfoMap={paymentInfoMap}
189
+ paymentProviderId={paymentMethod.id}
190
+ key={paymentMethod.id}
191
+ selectedPaymentOptionId={selectedPaymentMethod}
192
+ />
193
+ )
194
+ })}
195
+ </RadioGroup>
196
+ {isStripe && stripeReady && (
197
+ <div className="mt-5 transition-all duration-150 ease-in-out">
198
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
199
+ Enter your card details:
200
+ </Text>
201
+
202
+ <CardElement
203
+ options={useOptions as StripeCardElementOptions}
204
+ onChange={(e) => {
205
+ setCardBrand(
206
+ e.brand &&
207
+ e.brand.charAt(0).toUpperCase() + e.brand.slice(1)
208
+ )
209
+ setError(e.error?.message || null)
210
+ setCardComplete(e.complete)
211
+ }}
212
+ />
213
+ </div>
214
+ )}
215
+
216
+ {isPaytreeSelected && (
217
+ <div className="mt-5 transition-all duration-150 ease-in-out">
218
+ <Text className="txt-medium-plus text-ui-fg-base mb-2">
219
+ Choose how you'd like to pay:
220
+ </Text>
221
+ {paytreeMethods.length === 0 ? (
222
+ <Text className="txt-medium text-ui-fg-subtle">
223
+ No payment methods are currently available.
224
+ </Text>
225
+ ) : (
226
+ <RadioGroup
227
+ value={paytreeMethod}
228
+ onChange={(value: string) => setPaytreeMethod(value)}
229
+ className="flex flex-col gap-2"
230
+ >
231
+ {paytreeMethods.map((m) => (
232
+ <RadioGroup.Option
233
+ key={m.method}
234
+ value={m.method}
235
+ className={clx(
236
+ "flex items-center gap-x-3 cursor-pointer rounded-rounded border px-4 py-3 transition-colors",
237
+ {
238
+ "border-ui-border-interactive bg-ui-bg-interactive/5":
239
+ paytreeMethod === m.method,
240
+ "border-ui-border-base hover:bg-ui-bg-subtle-hover":
241
+ paytreeMethod !== m.method,
242
+ }
243
+ )}
244
+ >
245
+ <span className="flex h-7 w-7 items-center justify-center text-ui-fg-subtle">
246
+ <PaytreeMethodIcon method={m.method} />
247
+ </span>
248
+ <Text className="txt-medium text-ui-fg-base">
249
+ {m.label}
250
+ </Text>
251
+ {paytreeMethod === m.method && (
252
+ <CheckCircleSolid className="ml-auto text-ui-fg-interactive" />
253
+ )}
254
+ </RadioGroup.Option>
255
+ ))}
256
+ </RadioGroup>
257
+ )}
258
+ </div>
259
+ )}
260
+ </>
261
+ )}
262
+
263
+ {paidByGiftcard && (
264
+ <div className="flex flex-col w-1/3">
265
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
266
+ Payment method
267
+ </Text>
268
+ <Text
269
+ className="txt-medium text-ui-fg-subtle"
270
+ data-testid="payment-method-summary"
271
+ >
272
+ Gift card
273
+ </Text>
274
+ </div>
275
+ )}
276
+
277
+ <ErrorMessage
278
+ error={error}
279
+ data-testid="payment-method-error-message"
280
+ />
281
+
282
+ <Button
283
+ size="large"
284
+ className="mt-6"
285
+ onClick={handleSubmit}
286
+ isLoading={isLoading}
287
+ disabled={
288
+ (isStripe && !cardComplete) ||
289
+ (isPaytreeSelected && !paytreeMethod) ||
290
+ (!selectedPaymentMethod && !paidByGiftcard)
291
+ }
292
+ data-testid="submit-payment-button"
293
+ >
294
+ {!activeSession && isStripeFunc(selectedPaymentMethod)
295
+ ? " Enter card details"
296
+ : "Continue to review"}
297
+ </Button>
298
+ </div>
299
+
300
+ <div className={isOpen ? "hidden" : "block"}>
301
+ {cart && paymentReady && activeSession ? (
302
+ <div className="flex items-start gap-x-1 w-full">
303
+ <div className="flex flex-col w-1/3">
304
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
305
+ Payment method
306
+ </Text>
307
+ <Text
308
+ className="txt-medium text-ui-fg-subtle"
309
+ data-testid="payment-method-summary"
310
+ >
311
+ {paymentInfoMap[selectedPaymentMethod]?.title ||
312
+ selectedPaymentMethod}
313
+ </Text>
314
+ </div>
315
+ <div className="flex flex-col w-1/3">
316
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
317
+ Payment details
318
+ </Text>
319
+ <div
320
+ className="flex gap-2 txt-medium text-ui-fg-subtle items-center"
321
+ data-testid="payment-details-summary"
322
+ >
323
+ <Container className="flex items-center h-7 w-fit p-2 bg-ui-button-neutral-hover">
324
+ {paymentInfoMap[selectedPaymentMethod]?.icon || (
325
+ <CreditCard />
326
+ )}
327
+ </Container>
328
+ <Text>
329
+ {isStripeFunc(selectedPaymentMethod) && cardBrand
330
+ ? cardBrand
331
+ : "Another step will appear"}
332
+ </Text>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ ) : paidByGiftcard ? (
337
+ <div className="flex flex-col w-1/3">
338
+ <Text className="txt-medium-plus text-ui-fg-base mb-1">
339
+ Payment method
340
+ </Text>
341
+ <Text
342
+ className="txt-medium text-ui-fg-subtle"
343
+ data-testid="payment-method-summary"
344
+ >
345
+ Gift card
346
+ </Text>
347
+ </div>
348
+ ) : null}
349
+ </div>
350
+ </div>
351
+ <Divider className="mt-8" />
352
+ </div>
353
+ )
354
+ }
355
+
356
+ export default Payment