feat: implement one-page checkout with dynamic shipping

This commit is contained in:
Unchained
2026-03-28 18:03:12 +02:00
parent 7ca756fc5a
commit bcf74e1fd1
5 changed files with 122 additions and 102 deletions

View File

@@ -129,11 +129,78 @@ export default function CheckoutPage() {
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]); const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>(""); const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
const [showShippingMethods, setShowShippingMethods] = useState(false); const [isLoadingShipping, setIsLoadingShipping] = useState(false);
const lines = getLines(); const lines = getLines();
const total = getTotal(); const total = getTotal();
// Debounced shipping method fetching
useEffect(() => {
if (!checkout) return;
// Check if address is complete enough to fetch shipping methods
const isAddressComplete =
shippingAddress.firstName &&
shippingAddress.lastName &&
shippingAddress.streetAddress1 &&
shippingAddress.city &&
shippingAddress.postalCode &&
shippingAddress.country;
if (!isAddressComplete) {
setShippingMethods([]);
return;
}
const timer = setTimeout(async () => {
setIsLoadingShipping(true);
try {
console.log("Fetching shipping methods...");
// First update the shipping address
await saleorClient.mutate<ShippingAddressUpdateResponse>({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
streetAddress2: shippingAddress.streetAddress2,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
},
},
});
// Then query for shipping methods
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
query: GET_CHECKOUT_BY_ID,
variables: { id: checkout.id },
fetchPolicy: "network-only",
});
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
console.log("Available shipping methods:", availableMethods);
setShippingMethods(availableMethods);
// Auto-select first method if none selected
if (availableMethods.length > 0 && !selectedShippingMethod) {
setSelectedShippingMethod(availableMethods[0].id);
}
} catch (err) {
console.error("Error fetching shipping methods:", err);
} finally {
setIsLoadingShipping(false);
}
}, 500); // 500ms debounce
return () => clearTimeout(timer);
}, [checkout, shippingAddress]);
useEffect(() => { useEffect(() => {
if (!checkout) { if (!checkout) {
refreshCheckout(); refreshCheckout();
@@ -189,23 +256,32 @@ export default function CheckoutPage() {
return; return;
} }
// Validate all required fields
if (!shippingAddress.email || !shippingAddress.email.includes("@")) { if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
setError(t("errorEmailRequired")); setError(t("errorEmailRequired"));
return; return;
} }
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode || !shippingAddress.phone) { if (!shippingAddress.phone || shippingAddress.phone.length < 8) {
setError(t("errorPhoneRequired"));
return;
}
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode) {
setError(t("errorFieldsRequired")); setError(t("errorFieldsRequired"));
return; return;
} }
if (!selectedShippingMethod) {
setError(t("errorSelectShipping"));
return;
}
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
// If we're showing shipping methods and one is selected, complete the order console.log("Completing order...");
if (showShippingMethods && selectedShippingMethod) {
console.log("Phase 2: Completing order with shipping method...");
console.log("Step 1: Updating billing address..."); console.log("Step 1: Updating billing address...");
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({ const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
@@ -304,93 +380,6 @@ export default function CheckoutPage() {
} else { } else {
throw new Error(t("errorCreatingOrder")); throw new Error(t("errorCreatingOrder"));
} }
} else {
// Phase 1: Update email and address, then fetch shipping methods
console.log("Phase 1: Updating email and address...");
console.log("Step 1: Updating email...");
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
mutation: CHECKOUT_EMAIL_UPDATE,
variables: {
checkoutId: checkout.id,
email: shippingAddress.email,
},
});
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
// Check if checkout no longer exists
if (errorMessage.includes("Couldn't resolve to a node")) {
console.error("Checkout not found, clearing cart...");
localStorage.removeItem('cart');
localStorage.removeItem('checkoutId');
window.location.href = `/${locale}/products`;
return;
}
throw new Error(`Email update failed: ${errorMessage}`);
}
console.log("Step 1: Email updated successfully");
console.log("Step 2: Updating shipping address...");
console.log("Shipping address data:", {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
});
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
streetAddress2: shippingAddress.streetAddress2,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
},
},
});
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
throw new Error(`Shipping address update failed: ${shippingResult.data.checkoutShippingAddressUpdate.errors[0].message}`);
}
console.log("Step 2: Shipping address updated successfully");
// Query for checkout to get available shipping methods
console.log("Step 3: Fetching shipping methods...");
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
query: GET_CHECKOUT_BY_ID,
variables: {
id: checkout.id,
},
fetchPolicy: "network-only",
});
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
console.log("Available shipping methods:", availableMethods);
if (availableMethods.length === 0) {
throw new Error(t("errorNoShippingMethods"));
}
setShippingMethods(availableMethods);
setShowShippingMethods(true);
// Track shipping step
trackCheckoutStep("shipping_method_selection", {
available_methods_count: availableMethods.length,
});
// Don't complete yet - show shipping method selection
console.log("Phase 1 complete. Waiting for shipping method selection...");
}
} catch (err: unknown) { } catch (err: unknown) {
console.error("Checkout error:", err); console.error("Checkout error:", err);
@@ -606,9 +595,17 @@ export default function CheckoutPage() {
</div> </div>
{/* Shipping Method Selection */} {/* Shipping Method Selection */}
{showShippingMethods && shippingMethods.length > 0 && (
<div className="border-b border-border pb-6"> <div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2> <h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
{isLoadingShipping ? (
<div className="flex items-center gap-2 text-foreground-muted">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" 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"></path>
</svg>
<span>{t("loadingShippingMethods")}</span>
</div>
) : shippingMethods.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{shippingMethods.map((method) => ( {shippingMethods.map((method) => (
<label <label
@@ -636,18 +633,17 @@ export default function CheckoutPage() {
</label> </label>
))} ))}
</div> </div>
{!selectedShippingMethod && ( ) : (
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p> <p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
)} )}
</div> </div>
)}
<button <button
type="submit" type="submit"
disabled={isLoading || lines.length === 0 || (showShippingMethods && !selectedShippingMethod)} disabled={isLoading || lines.length === 0 || !selectedShippingMethod}
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50" className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
> >
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")} {isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
</button> </button>
</form> </form>
</div> </div>
@@ -691,6 +687,12 @@ export default function CheckoutPage() {
<span className="text-foreground-muted">{t("subtotal")}</span> <span className="text-foreground-muted">{t("subtotal")}</span>
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span> <span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
</div> </div>
{selectedShippingMethod && (
<div className="flex justify-between">
<span className="text-foreground-muted">{t("shipping")}</span>
<span>{formatPrice(shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount || 0)}</span>
</div>
)}
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border"> <div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
<span>{t("total")}</span> <span>{t("total")}</span>
<span>{formatPrice(total)}</span> <span>{formatPrice(total)}</span>

View File

@@ -372,6 +372,11 @@
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.", "errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.", "errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.", "errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
"errorNoShippingMethods": "Keine Versandmethoden für diese Adresse verfügbar. Bitte überprüfen Sie Ihre Adresse oder kontaktieren Sie den Support.",
"errorSelectShipping": "Bitte wählen Sie eine Versandmethode.",
"errorPhoneRequired": "Bitte geben Sie eine gültige Telefonnummer ein.",
"loadingShippingMethods": "Versandoptionen werden geladen...",
"enterAddressForShipping": "Geben Sie Ihre Adresse ein, um Versandoptionen zu sehen.",
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.", "errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.", "errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
"orderConfirmed": "Bestellung bestätigt!", "orderConfirmed": "Bestellung bestätigt!",

View File

@@ -420,6 +420,9 @@
"errorFieldsRequired": "Please fill in all required fields.", "errorFieldsRequired": "Please fill in all required fields.",
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.", "errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
"errorSelectShipping": "Please select a shipping method.", "errorSelectShipping": "Please select a shipping method.",
"errorPhoneRequired": "Please enter a valid phone number.",
"loadingShippingMethods": "Loading shipping options...",
"enterAddressForShipping": "Enter your address to see shipping options.",
"errorOccurred": "An error occurred during checkout.", "errorOccurred": "An error occurred during checkout.",
"errorCreatingOrder": "Failed to create order.", "errorCreatingOrder": "Failed to create order.",
"continueToShipping": "Continue to Shipping", "continueToShipping": "Continue to Shipping",

View File

@@ -372,6 +372,11 @@
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.", "errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.", "errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.", "errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
"errorNoShippingMethods": "Aucune méthode de livraison disponible pour cette adresse. Veuillez vérifier votre adresse ou contacter le support.",
"errorSelectShipping": "Veuillez sélectionner une méthode de livraison.",
"errorPhoneRequired": "Veuillez entrer un numéro de téléphone valide.",
"loadingShippingMethods": "Chargement des options de livraison...",
"enterAddressForShipping": "Entrez votre adresse pour voir les options de livraison.",
"errorOccurred": "Une erreur s'est produite lors du paiement.", "errorOccurred": "Une erreur s'est produite lors du paiement.",
"errorCreatingOrder": "Échec de la création de la commande.", "errorCreatingOrder": "Échec de la création de la commande.",
"orderConfirmed": "Commande Confirmée!", "orderConfirmed": "Commande Confirmée!",

View File

@@ -418,6 +418,11 @@
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.", "errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
"errorEmailRequired": "Molimo unesite validnu email adresu.", "errorEmailRequired": "Molimo unesite validnu email adresu.",
"errorFieldsRequired": "Molimo popunite sva obavezna polja.", "errorFieldsRequired": "Molimo popunite sva obavezna polja.",
"errorNoShippingMethods": "Nema dostupnih načina dostave za ovu adresu. Molimo proverite adresu ili kontaktirajte podršku.",
"errorSelectShipping": "Molimo izaberite način dostave.",
"errorPhoneRequired": "Molimo unesite validan broj telefona.",
"loadingShippingMethods": "Učitavanje opcija dostave...",
"enterAddressForShipping": "Unesite adresu da vidite opcije dostave.",
"errorOccurred": "Došlo je do greške prilikom kupovine.", "errorOccurred": "Došlo je do greške prilikom kupovine.",
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.", "errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
"orderConfirmed": "Narudžbina potvrđena!", "orderConfirmed": "Narudžbina potvrđena!",