Skip to content

Commit 3c4464c

Browse files
committed
feat(checkout): zero-checkout payment method for zero-total orders
1 parent 1777765 commit 3c4464c

19 files changed

Lines changed: 857 additions & 1099 deletions

File tree

package-lock.json

Lines changed: 23 additions & 1048 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/evershop/src/components/frontStore/checkout/CheckoutButton.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import { useWatch } from 'react-hook-form';
88

99
export function CheckoutButton() {
1010
const {
11-
data: { noShippingRequired, billingAddress }
11+
data: { noShippingRequired, billingAddress, grandTotal, totalQty }
1212
} = useCartState();
1313
const { form, registeredPaymentComponents } = useCheckout();
14+
// Zero-total orders don't collect a billing address; totalQty guards the
15+
// pre-sync default state (grandTotal 0 while the cart is still loading).
16+
const zeroTotal = totalQty > 0 && grandTotal.value <= 0;
1417

1518
// Watch the selected payment method
1619
const selectedPaymentMethod = useWatch({
@@ -36,7 +39,7 @@ export function CheckoutButton() {
3639
? getPaymentComponent(selectedPaymentMethod)
3740
: null;
3841

39-
if (noShippingRequired && !billingAddress) {
42+
if (noShippingRequired && !billingAddress && !zeroTotal) {
4043
return (
4144
<>
4245
<Area id="checkoutButtonBefore" />

packages/evershop/src/components/frontStore/checkout/Payment.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,19 @@ import { useWatch } from 'react-hook-form';
2222

2323
export function Payment() {
2424
const {
25-
data: { noShippingRequired, billingAddress, availablePaymentMethods },
25+
data: {
26+
noShippingRequired,
27+
billingAddress,
28+
availablePaymentMethods,
29+
grandTotal,
30+
totalQty
31+
},
2632
loadingStates: { addingBillingAddress }
2733
} = useCartState();
34+
// Zero-total orders don't collect a billing address. The totalQty guard
35+
// matters: the pre-sync default cart state has grandTotal 0, and hiding
36+
// billing for a still-loading cart would flicker the wrong way.
37+
const zeroTotal = totalQty > 0 && grandTotal.value <= 0;
2838
const { addBillingAddress } = useCartDispatch();
2939
const { updateCheckoutData } = useCheckoutDispatch();
3040
const { form } = useCheckout();
@@ -75,13 +85,15 @@ export function Payment() {
7585
</CardTitle>
7686
</CardHeader>
7787
<CardContent>
78-
<BillingAddress
79-
billingAddress={billingAddress}
80-
addBillingAddress={addBillingAddress}
81-
addingBillingAddress={addingBillingAddress}
82-
noShippingRequired={noShippingRequired}
83-
/>
84-
{(billingAddress || noShippingRequired === false) && (
88+
{!zeroTotal && (
89+
<BillingAddress
90+
billingAddress={billingAddress}
91+
addBillingAddress={addBillingAddress}
92+
addingBillingAddress={addingBillingAddress}
93+
noShippingRequired={noShippingRequired}
94+
/>
95+
)}
96+
{(billingAddress || noShippingRequired === false || zeroTotal) && (
8597
<>
8698
<Area id="checkoutPaymentMethodsBefore" />
8799
<PaymentMethods

packages/evershop/src/components/frontStore/checkout/payment/BillingAddress.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export function BillingAddress({
9393
};
9494

9595
return (
96-
<div className="billing-address-section">
96+
<div className="billing-address-section mb-6">
9797
<Item className="py-0 px-0">
9898
<ItemContent className="gap-2">
9999
<ItemTitle>{_('Billing Address')}</ItemTitle>

packages/evershop/src/components/frontStore/checkout/payment/PaymentMethods.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export function PaymentMethods({
7676
};
7777

7878
return (
79-
<div className="checkout-payment-methods mt-6">
79+
<div className="checkout-payment-methods">
8080
<Item className="px-0 py-0">
8181
<ItemContent className="gap-2">
8282
<ItemTitle>{_('Pick a payment method')}</ItemTitle>

packages/evershop/src/modules/checkout/bootstrap.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
11
import { select } from '@evershop/postgres-query-builder';
22
import { error } from '../../lib/log/logger.js';
33
import { pool } from '../../lib/postgres/connection.js';
4+
import { hookAfter } from '../../lib/util/hookable.js';
45
import { merge } from '../../lib/util/merge.js';
56
import { addFinalProcessor, addProcessor } from '../../lib/util/registry.js';
67
import { getProductsBaseQuery } from '../catalog/services/getProductsBaseQuery.js';
78
import { registerCartBaseFields } from '../checkout/services/cart/registerCartBaseFields.js';
89
import { registerCartItemBaseFields } from './services/cart/registerCartItemBaseFields.js';
910
import { sortFields } from './services/cart/sortFields.js';
11+
import type {
12+
CreateOrderContext,
13+
CreateOrderResult
14+
} from './services/orderCreator.js';
1015
import { coreShippingProvider } from './services/shipping/core/coreProvider.js';
1116
import { registerShippingProvider } from './services/shipping/registry.js';
17+
import {
18+
markZeroCheckoutOrderPaid,
19+
registerZeroCheckoutOrderValidation,
20+
registerZeroCheckoutPaymentMethod
21+
} from './services/zeroCheckout.js';
1222

1323
export default () => {
1424
addProcessor('cartFields', registerCartBaseFields, 0);
1525

1626
addProcessor('cartItemFields', registerCartItemBaseFields, 0);
1727

28+
// Built-in zero-checkout payment method: the only method a zero-total cart
29+
// accepts, hidden everywhere else. No setting — always enabled.
30+
registerZeroCheckoutPaymentMethod();
31+
registerZeroCheckoutOrderValidation();
32+
hookAfter<CreateOrderContext, CreateOrderResult>(
33+
'createOrderFunc',
34+
// Wrapped: after-hooks receive (result, ...originalArgs) and the second
35+
// argument would land in markZeroCheckoutOrderPaid's `complete` parameter.
36+
async function markZeroCheckoutOrderPaidHook(order) {
37+
await markZeroCheckoutOrderPaid(order);
38+
}
39+
);
40+
1841
// Register Core as a first-class shipping provider. Other providers
1942
// (USPS, FedEx, EasyPost, …) register themselves from their own
2043
// module bootstrap files and become siblings of Core.

packages/evershop/src/modules/checkout/graphql/types/PaymentMethod/AvailablePaymentMethod.resolvers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { getAvailablePaymentMethods } from '../../../services/index.js';
22

33
export default {
44
Cart: {
5-
availablePaymentMethods: async () => {
6-
const methods = await getAvailablePaymentMethods();
5+
availablePaymentMethods: async ({ grandTotal }) => {
6+
const methods = await getAvailablePaymentMethods({
7+
cartTotal: grandTotal
8+
});
79
return methods;
810
}
911
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { Button } from '@components/common/ui/Button.js';
2+
import { toast } from '@components/common/ui/Sonner.js';
3+
import { useCartState } from '@components/frontStore/cart/CartContext.js';
4+
import {
5+
useCheckout,
6+
useCheckoutDispatch
7+
} from '@components/frontStore/checkout/CheckoutContext.js';
8+
import { _ } from '@evershop/evershop/lib/locale/translate/_';
9+
import React, { useEffect } from 'react';
10+
11+
// Keep in sync with ZERO_CHECKOUT_CODE in
12+
// modules/checkout/services/getAvailablePaymentMethods.ts — client components
13+
// must not import server services.
14+
const ZERO_CHECKOUT_CODE = 'zero_checkout';
15+
16+
export default function ZeroCheckout() {
17+
const { checkoutSuccessUrl, orderPlaced, orderId, checkoutData, form } =
18+
useCheckout();
19+
const { registerPaymentComponent } = useCheckoutDispatch();
20+
const {
21+
data: { availablePaymentMethods }
22+
} = useCartState();
23+
24+
// The server returns exactly [zero_checkout] for a zero-total cart, so the
25+
// refreshed availability list — re-synced by CartContext after every cart
26+
// mutation — is the selection signal. Keying on the list (not the total)
27+
// avoids racing the re-sync: Payment.tsx toasts an error when the selected
28+
// code is missing from availablePaymentMethods.
29+
const methods = availablePaymentMethods || [];
30+
const zeroOnly =
31+
methods.length === 1 && methods[0].code === ZERO_CHECKOUT_CODE;
32+
33+
useEffect(() => {
34+
if (orderPlaced && checkoutData.paymentMethod === ZERO_CHECKOUT_CODE) {
35+
// Redirect to the checkout success page
36+
window.location.href = `${checkoutSuccessUrl}/${orderId}`;
37+
}
38+
}, [orderPlaced, checkoutSuccessUrl, orderId]);
39+
40+
useEffect(() => {
41+
const current = form.getValues('paymentMethod');
42+
if (zeroOnly && current !== ZERO_CHECKOUT_CODE) {
43+
form.setValue('paymentMethod', ZERO_CHECKOUT_CODE);
44+
} else if (!zeroOnly && current === ZERO_CHECKOUT_CODE) {
45+
// The total rose above 0 — clear the stale selection so the customer
46+
// picks a real payment method.
47+
form.setValue('paymentMethod', '');
48+
}
49+
}, [zeroOnly]);
50+
51+
useEffect(() => {
52+
registerPaymentComponent(ZERO_CHECKOUT_CODE, {
53+
nameRenderer: () => (
54+
<div className="flex items-center justify-between w-full">
55+
<span>{_('No payment required')}</span>
56+
</div>
57+
),
58+
formRenderer: () => (
59+
<div className="flex justify-center text-muted-foreground">
60+
<div className="w-2/3 text-center py-3">
61+
{_(
62+
'Your order total is 0 — no payment is needed to place this order.'
63+
)}
64+
</div>
65+
</div>
66+
),
67+
checkoutButtonRenderer: () => {
68+
const { checkout } = useCheckoutDispatch();
69+
const { loadingStates, orderPlaced } = useCheckout();
70+
const handleClick = async (e: React.MouseEvent) => {
71+
e.preventDefault();
72+
try {
73+
await checkout();
74+
} catch (error) {
75+
toast.error(
76+
error.message || _('Failed to place order. Please try again.')
77+
);
78+
}
79+
};
80+
81+
const isDisabled = loadingStates.placingOrder || orderPlaced;
82+
83+
return (
84+
<Button
85+
variant={'default'}
86+
size={'xl'}
87+
type="button"
88+
onClick={handleClick}
89+
disabled={isDisabled}
90+
className="w-full transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-primary"
91+
>
92+
<span className="flex items-center justify-center space-x-2">
93+
{loadingStates.placingOrder ? (
94+
<>
95+
<svg
96+
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
97+
xmlns="http://www.w3.org/2000/svg"
98+
fill="none"
99+
viewBox="0 0 24 24"
100+
>
101+
<circle
102+
className="opacity-25"
103+
cx="12"
104+
cy="12"
105+
r="10"
106+
stroke="currentColor"
107+
strokeWidth="4"
108+
></circle>
109+
<path
110+
className="opacity-75"
111+
fill="currentColor"
112+
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"
113+
></path>
114+
</svg>
115+
<span>{_('Placing Order...')}</span>
116+
</>
117+
) : orderPlaced ? (
118+
<>
119+
<svg
120+
className="w-5 h-5"
121+
fill="currentColor"
122+
viewBox="0 0 24 24"
123+
>
124+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
125+
</svg>
126+
<span>{_('Order Placed')}</span>
127+
</>
128+
) : (
129+
<>
130+
<span>{_('Place Order')}</span>
131+
<svg
132+
className="w-5 h-5"
133+
fill="currentColor"
134+
viewBox="0 0 24 24"
135+
>
136+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
137+
</svg>
138+
</>
139+
)}
140+
</span>
141+
</Button>
142+
);
143+
}
144+
});
145+
}, [registerPaymentComponent]);
146+
147+
return null;
148+
}
149+
150+
export const layout = {
151+
areaId: 'checkoutFormAfter',
152+
sortOrder: 15
153+
};

packages/evershop/src/modules/checkout/pages/frontStore/checkoutSuccess/CustomerInfo.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ interface CustomerInfoProps {
4141
city: string;
4242
address1: string;
4343
address2: string;
44-
};
44+
} | null; // zero-total orders don't collect a billing address
4545
};
4646
}
4747

@@ -105,14 +105,16 @@ export default function CustomerInfo({
105105
{paymentMethodName}
106106
</div>
107107
</div>
108-
<div>
109-
<h3 className="mb-2 text-sm font-semibold">
110-
{_('Billing Address')}
111-
</h3>
112-
<div className="text-sm text-muted-foreground">
113-
<AddressSummary address={billingAddress} />
108+
{billingAddress && (
109+
<div>
110+
<h3 className="mb-2 text-sm font-semibold">
111+
{_('Billing Address')}
112+
</h3>
113+
<div className="text-sm text-muted-foreground">
114+
<AddressSummary address={billingAddress} />
115+
</div>
114116
</div>
115-
</div>
117+
)}
116118
</div>
117119
</div>
118120
</div>

packages/evershop/src/modules/checkout/services/cart/registerCartBaseFields.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,16 @@ export function registerCartBaseFields(fields) {
624624
key: 'payment_method',
625625
resolvers: [
626626
async function resolver(paymentMethod) {
627-
const methods = await getAvailablePaymentMethods();
627+
// `grand_total` is registered by the promotion module. Without it
628+
// (no promotion module) the context stays empty and the available
629+
// list behaves exactly as before zero-checkout existed.
630+
let cartTotal;
631+
try {
632+
cartTotal = this.getData('grand_total');
633+
} catch (e) {
634+
cartTotal = undefined;
635+
}
636+
const methods = await getAvailablePaymentMethods({ cartTotal });
628637
if (
629638
paymentMethod &&
630639
methods.map((m) => m.code).includes(paymentMethod)
@@ -645,13 +654,20 @@ export function registerCartBaseFields(fields) {
645654
return null;
646655
}
647656
}
648-
]
657+
],
658+
dependencies: ['grand_total']
649659
},
650660
{
651661
key: 'payment_method_name',
652662
resolvers: [
653663
async function resolver() {
654-
const methods = await getAvailablePaymentMethods();
664+
let cartTotal;
665+
try {
666+
cartTotal = this.getData('grand_total');
667+
} catch (e) {
668+
cartTotal = undefined;
669+
}
670+
const methods = await getAvailablePaymentMethods({ cartTotal });
655671
const method = methods.find(
656672
(m) => m.code === this.getData('payment_method')
657673
);

0 commit comments

Comments
 (0)