Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
648 changes: 19 additions & 629 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
"dependencies": {
"@next/third-parties": "^16.1.6",
"@sentry/nextjs": "^10.38.0",
"@spree/next": "^0.8.1",
"@spree/sdk": "^0.8.1",
"@spree/next": "^0.8.2",
"@spree/sdk": "^0.8.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@testing-library/dom": "^10.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
Expand Down
42 changes: 30 additions & 12 deletions src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { getMarketCountries, resolveMarket } from "@/lib/data/markets";
import {
completeCheckoutOrder,
completeCheckoutPaymentSession,
createCheckoutPayment,
} from "@/lib/data/payment";
import { extractBasePath } from "@/lib/utils/path";

Expand Down Expand Up @@ -392,23 +393,40 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
}
};

// Handle payment completion (called by PaymentStep after Stripe confirms)
const handlePaymentComplete = async (paymentSessionId: string) => {
// Handle payment completion (called by PaymentStep after gateway confirms)
const handlePaymentComplete = async (
paymentSessionId: string | null,
paymentMethodId: string,
) => {
if (!order) return;

setError(null);

try {
// Complete the payment session on the backend
const sessionResult = await completeCheckoutPaymentSession(
order.id,
paymentSessionId,
);

if (!sessionResult.success) {
setError(sessionResult.error || "Failed to complete payment session");
setProcessing(false);
return;
if (paymentSessionId) {
// Session-based methods (Stripe, etc.) — complete the payment session
const sessionResult = await completeCheckoutPaymentSession(
order.id,
paymentSessionId,
);

if (!sessionResult.success) {
setError(sessionResult.error || "Failed to complete payment session");
setProcessing(false);
return;
}
} else {
// Non-session methods (Check, Bank Transfer, etc.) — create a payment record
const paymentResult = await createCheckoutPayment(
order.id,
paymentMethodId,
);

if (!paymentResult.success) {
setError(paymentResult.error || "Failed to create payment");
setProcessing(false);
return;
}
}

try {
Expand Down
61 changes: 11 additions & 50 deletions src/components/checkout/AddressSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,38 @@
"use client";

import type { Address, Country, State } from "@spree/sdk";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { AddressFormData } from "@/lib/utils/address";
import { AddressFormFields } from "./AddressFormFields";

interface AddressSelectorProps {
savedAddresses: Address[];
selectedAddressId: string | null;
currentAddress: AddressFormData;
countries: Country[];
states: State[];
loadingStates: boolean;
onChange: (field: keyof AddressFormData, value: string) => void;
onSelectSavedAddress: (address: Address) => void;
onSelectNew: () => void;
onEditAddress?: (address: Address) => void;
idPrefix: string;
}

export function AddressSelector({
savedAddresses,
selectedAddressId,
currentAddress,
countries,
states,
loadingStates,
onChange,
onSelectSavedAddress,
onSelectNew,
onEditAddress,
idPrefix,
}: AddressSelectorProps) {
// Derive selected address from current form data — no useEffect needed
const selectedAddressId = useMemo((): string => {
if (savedAddresses.length === 0) return "new";
const match = savedAddresses.find(
(addr) =>
addr.address1 === currentAddress.address1 &&
addr.city === currentAddress.city &&
addr.zipcode === currentAddress.zipcode &&
addr.country_iso === currentAddress.country_iso,
);
if (match) return match.id;
return "new";
}, [
savedAddresses,
currentAddress.address1,
currentAddress.city,
currentAddress.zipcode,
currentAddress.country_iso,
]);

const handleSelectAddress = (addressId: string) => {
if (addressId === "new") {
// Clear form for new address
onChange("firstname", "");
onChange("lastname", "");
onChange("address1", "");
onChange("address2", "");
onChange("city", "");
onChange("zipcode", "");
onChange("phone", "");
onChange("company", "");
onChange("country_iso", "");
onChange("state_abbr", "");
onChange("state_name", "");
} else {
const selectedAddress = savedAddresses.find((a) => a.id === addressId);
if (selectedAddress) {
onSelectSavedAddress(selectedAddress);
}
}
};

const showForm = selectedAddressId === "new" || savedAddresses.length === 0;
const showForm = !selectedAddressId || savedAddresses.length === 0;

return (
<div className="space-y-4">
Expand All @@ -95,8 +56,8 @@ export function AddressSelector({
name={`${idPrefix}-address-selection`}
value={address.id}
checked={selectedAddressId === address.id}
onChange={() => handleSelectAddress(address.id)}
className="mt-1 h-4 w-4 text-primary border-gray-300 focus:[outline:1px_solid_black]"
onChange={() => onSelectSavedAddress(address)}
className="mt-1 h-4 w-4 text-primary border-gray-300"
/>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">
Expand Down Expand Up @@ -136,7 +97,7 @@ export function AddressSelector({
))}
<label
className={`flex items-center p-4 border rounded-xl cursor-pointer transition-colors ${
selectedAddressId === "new"
!selectedAddressId
? "border-gray-300 bg-gray-50"
: "border-gray-200 hover:border-gray-300"
}`}
Expand All @@ -145,9 +106,9 @@ export function AddressSelector({
type="radio"
name={`${idPrefix}-address-selection`}
value="new"
checked={selectedAddressId === "new"}
onChange={() => handleSelectAddress("new")}
className="h-4 w-4 text-primary border-gray-300 focus:[outline:1px_solid_black]"
checked={!selectedAddressId}
onChange={onSelectNew}
className="h-4 w-4 text-primary border-gray-300"
/>
<span className="ml-3 text-sm font-medium text-gray-900">
Use a different address
Expand Down
34 changes: 30 additions & 4 deletions src/components/checkout/AddressStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
import {
type AddressFormData,
addressToFormData,
emptyAddress,
formDataToAddress,
} from "@/lib/utils/address";
import { AddressEditModal } from "./AddressEditModal";
Expand Down Expand Up @@ -51,6 +52,21 @@ export function AddressStep({
const [shipAddress, setShipAddress] = useState<AddressFormData>(() =>
addressToFormData(order.ship_address),
);
const [selectedSavedAddressId, setSelectedSavedAddressId] = useState<
string | null
>(() => {
// Check if the current order address matches a saved address
if (initialSavedAddresses.length === 0 || !order.ship_address) return null;
const formData = addressToFormData(order.ship_address);
const match = initialSavedAddresses.find(
(addr) =>
addr.address1 === formData.address1 &&
addr.city === formData.city &&
addr.zipcode === formData.zipcode &&
addr.country_iso === formData.country_iso,
);
return match?.id ?? null;
});
Comment on lines +55 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This saved-address inference can pick the wrong address.

The initializer only matches address1, city, zipcode, and country_iso. Two saved addresses can share those fields but differ in address2, state, company, phone, or even recipient name, and Lines 100-103 will then submit the wrong ship_address_id. Prefer the saved-address ID when it exists, or fall back to a fully normalized address comparison.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/checkout/AddressStep.tsx` around lines 55 - 69, The
saved-address inference in the selectedSavedAddressId state initializer can pick
the wrong saved address because it only compares address1, city, zipcode, and
country_iso; update the initializer to first prefer and return
order.ship_address.id (or ship_address_id) when present, and only if no explicit
saved ID exists fall back to a stricter comparison using
addressToFormData(order.ship_address) against initialSavedAddresses that
includes address2, state/region, company, phone, and recipient names (or perform
a normalized deep-equality of the form data) before returning match?.id; ensure
this logic lives where selectedSavedAddressId is initialized so subsequent
submission (ship_address_id usage) uses the explicit saved ID when available.

const [shipStates, setShipStates] = useState<State[]>([]);
const [isPendingShip, startTransitionShip] = useTransition();
const [savedAddresses, setSavedAddresses] = useState(initialSavedAddresses);
Expand Down Expand Up @@ -81,13 +97,15 @@ export function AddressStep({

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
email,
ship_address: formDataToAddress(shipAddress),
});
if (selectedSavedAddressId) {
onSubmit({ email, ship_address_id: selectedSavedAddressId });
} else {
onSubmit({ email, ship_address: formDataToAddress(shipAddress) });
}
};

const updateShipAddress = (field: keyof AddressFormData, value: string) => {
setSelectedSavedAddressId(null);
setShipAddress((prev) => {
const updated = { ...prev, [field]: value };
// Clear state when country changes
Expand All @@ -100,9 +118,15 @@ export function AddressStep({
};

const handleSelectSavedAddress = (address: Address) => {
setSelectedSavedAddressId(address.id);
setShipAddress(addressToFormData(address));
};

const handleSelectNew = () => {
setSelectedSavedAddressId(null);
setShipAddress({ ...emptyAddress });
};

const handleSaveEditedAddress = async (data: AddressParams, id?: string) => {
if (!id || !onUpdateSavedAddress) {
throw new Error("Cannot update address");
Expand Down Expand Up @@ -167,12 +191,14 @@ export function AddressStep({
{isAuthenticated && savedAddresses.length > 0 ? (
<AddressSelector
savedAddresses={savedAddresses}
selectedAddressId={selectedSavedAddressId}
currentAddress={shipAddress}
countries={countries}
states={shipStates}
loadingStates={isPendingShip}
onChange={updateShipAddress}
onSelectSavedAddress={handleSelectSavedAddress}
onSelectNew={handleSelectNew}
onEditAddress={
onUpdateSavedAddress
? (address) => setEditingAddress(address)
Expand Down
2 changes: 1 addition & 1 deletion src/components/checkout/DeliveryStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function DeliveryStep({
handleRateChange(shipment.id, rate.id)
}
disabled={processing}
className="w-4 h-4 text-primary border-gray-300 focus:[outline:1px_solid_black]"
className="w-4 h-4 text-primary border-gray-300"
/>
<div className="ml-3">
<span className="block text-sm font-medium text-gray-900">
Expand Down
63 changes: 63 additions & 0 deletions src/components/checkout/PaymentMethodSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import type { PaymentMethod } from "@spree/sdk";
import type { ReactNode } from "react";

interface PaymentMethodSelectorProps {
paymentMethods: PaymentMethod[];
selectedMethodId: string;
onSelect: (methodId: string) => void;
disabled?: boolean;
/** Render gateway content inline below the selected method */
renderContent?: (method: PaymentMethod) => ReactNode;
}

export function PaymentMethodSelector({
paymentMethods,
selectedMethodId,
onSelect,
disabled,
renderContent,
}: PaymentMethodSelectorProps) {
return (
<div className="space-y-3">
{paymentMethods.map((method) => {
const isSelected = selectedMethodId === method.id;

return (
<div
key={method.id}
className={`rounded-xl border transition-colors ${
isSelected
? "border-gray-600 bg-gray-50"
: "border-gray-200 hover:border-gray-300"
} ${disabled ? "opacity-50 pointer-events-none" : ""}`}
>
<label className="flex items-center gap-3 p-4 cursor-pointer">
<input
type="radio"
name="payment_method"
checked={isSelected}
onChange={() => onSelect(method.id)}
disabled={disabled}
className="w-4 h-4 text-primary border-gray-300"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900">
{method.name}
</span>
{method.description && (
<p className="text-sm text-gray-500">{method.description}</p>
)}
</div>
</label>

{isSelected && renderContent && (
<div className="px-4 pb-4 pt-0">{renderContent(method)}</div>
)}
</div>
);
})}
</div>
);
}
Loading
Loading