feat: implement one-page checkout with dynamic shipping
This commit is contained in:
@@ -129,11 +129,78 @@ export default function CheckoutPage() {
|
||||
|
||||
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||
const [showShippingMethods, setShowShippingMethods] = useState(false);
|
||||
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
||||
|
||||
const lines = getLines();
|
||||
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(() => {
|
||||
if (!checkout) {
|
||||
refreshCheckout();
|
||||
@@ -189,23 +256,32 @@ export default function CheckoutPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all required fields
|
||||
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
||||
setError(t("errorEmailRequired"));
|
||||
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"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedShippingMethod) {
|
||||
setError(t("errorSelectShipping"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// If we're showing shipping methods and one is selected, complete the order
|
||||
if (showShippingMethods && selectedShippingMethod) {
|
||||
console.log("Phase 2: Completing order with shipping method...");
|
||||
console.log("Completing order...");
|
||||
|
||||
console.log("Step 1: Updating billing address...");
|
||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||
@@ -304,93 +380,6 @@ export default function CheckoutPage() {
|
||||
} else {
|
||||
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) {
|
||||
console.error("Checkout error:", err);
|
||||
|
||||
@@ -606,9 +595,17 @@ export default function CheckoutPage() {
|
||||
</div>
|
||||
|
||||
{/* Shipping Method Selection */}
|
||||
{showShippingMethods && shippingMethods.length > 0 && (
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||
<div className="border-b border-border pb-6">
|
||||
<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">
|
||||
{shippingMethods.map((method) => (
|
||||
<label
|
||||
@@ -636,18 +633,17 @@ export default function CheckoutPage() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!selectedShippingMethod && (
|
||||
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
|
||||
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -691,6 +687,12 @@ export default function CheckoutPage() {
|
||||
<span className="text-foreground-muted">{t("subtotal")}</span>
|
||||
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||
</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">
|
||||
<span>{t("total")}</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||
"orderConfirmed": "Bestellung bestätigt!",
|
||||
|
||||
@@ -420,6 +420,9 @@
|
||||
"errorFieldsRequired": "Please fill in all required fields.",
|
||||
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Failed to create order.",
|
||||
"continueToShipping": "Continue to Shipping",
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||
"orderConfirmed": "Commande Confirmée!",
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||
"orderConfirmed": "Narudžbina potvrđena!",
|
||||
|
||||
Reference in New Issue
Block a user