- Add CHECKOUT_LANGUAGE_CODE_UPDATE mutation to update checkout language - Call language code update before completing checkout - Language code (SR, EN, DE, FR) is now set on checkout before order creation - This ensures order confirmation emails are sent in the customer's language - Update step numbering in checkout flow (now 6 steps total)
773 lines
30 KiB
TypeScript
773 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { useTranslations, useLocale } from "next-intl";
|
|
import Header from "@/components/layout/Header";
|
|
import Footer from "@/components/layout/Footer";
|
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
|
import { formatPrice } from "@/lib/saleor";
|
|
import { saleorClient } from "@/lib/saleor/client";
|
|
import { useAnalytics } from "@/lib/analytics";
|
|
import {
|
|
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
|
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
|
CHECKOUT_COMPLETE,
|
|
CHECKOUT_EMAIL_UPDATE,
|
|
CHECKOUT_METADATA_UPDATE,
|
|
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
|
ORDER_METADATA_UPDATE,
|
|
CHECKOUT_LANGUAGE_CODE_UPDATE,
|
|
} from "@/lib/saleor/mutations/Checkout";
|
|
import { PaymentSection } from "./components/PaymentSection";
|
|
import { DEFAULT_PAYMENT_METHOD } from "@/lib/config/paymentMethods";
|
|
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
|
import type { Checkout } from "@/types/saleor";
|
|
|
|
interface ShippingAddressUpdateResponse {
|
|
checkoutShippingAddressUpdate?: {
|
|
checkout?: Checkout;
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
}
|
|
|
|
interface BillingAddressUpdateResponse {
|
|
checkoutBillingAddressUpdate?: {
|
|
checkout?: Checkout;
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
}
|
|
|
|
interface CheckoutCompleteResponse {
|
|
checkoutComplete?: {
|
|
order?: { id: string; number: string };
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
}
|
|
|
|
interface EmailUpdateResponse {
|
|
checkoutEmailUpdate?: {
|
|
checkout?: Checkout;
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
}
|
|
|
|
interface MetadataUpdateResponse {
|
|
updateMetadata?: {
|
|
item?: {
|
|
id: string;
|
|
metadata?: Array<{ key: string; value: string }>;
|
|
};
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
}
|
|
|
|
interface ShippingMethodUpdateResponse {
|
|
checkoutShippingMethodUpdate?: {
|
|
checkout?: Checkout;
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
}
|
|
|
|
interface CheckoutQueryResponse {
|
|
checkout?: Checkout;
|
|
}
|
|
|
|
interface ShippingMethod {
|
|
id: string;
|
|
name: string;
|
|
price: {
|
|
amount: number;
|
|
currency: string;
|
|
};
|
|
}
|
|
|
|
interface AddressForm {
|
|
firstName: string;
|
|
lastName: string;
|
|
streetAddress1: string;
|
|
streetAddress2: string;
|
|
city: string;
|
|
postalCode: string;
|
|
country: string;
|
|
phone: string;
|
|
email: string;
|
|
}
|
|
|
|
export default function CheckoutPage() {
|
|
const t = useTranslations("Checkout");
|
|
const locale = useLocale();
|
|
const router = useRouter();
|
|
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
|
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [orderComplete, setOrderComplete] = useState(false);
|
|
const [orderNumber, setOrderNumber] = useState<string | null>(null);
|
|
|
|
const [sameAsShipping, setSameAsShipping] = useState(true);
|
|
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>(DEFAULT_PAYMENT_METHOD);
|
|
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
|
|
firstName: "",
|
|
lastName: "",
|
|
streetAddress1: "",
|
|
streetAddress2: "",
|
|
city: "",
|
|
postalCode: "",
|
|
country: "RS",
|
|
phone: "",
|
|
email: "",
|
|
});
|
|
const [billingAddress, setBillingAddress] = useState<AddressForm>({
|
|
firstName: "",
|
|
lastName: "",
|
|
streetAddress1: "",
|
|
streetAddress2: "",
|
|
city: "",
|
|
postalCode: "",
|
|
country: "RS",
|
|
phone: "",
|
|
email: "",
|
|
});
|
|
|
|
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
|
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();
|
|
}
|
|
}, [checkout, refreshCheckout]);
|
|
|
|
// Track checkout started when page loads
|
|
useEffect(() => {
|
|
if (checkout) {
|
|
const lines = getLines();
|
|
const total = getTotal();
|
|
trackCheckoutStarted({
|
|
total,
|
|
currency: "RSD",
|
|
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
|
items: lines.map(line => ({
|
|
id: line.variant.id,
|
|
name: line.variant.product.name,
|
|
quantity: line.quantity,
|
|
price: line.variant.pricing?.price?.gross?.amount || 0,
|
|
})),
|
|
});
|
|
}
|
|
}, [checkout]);
|
|
|
|
// Scroll to top when order is complete
|
|
useEffect(() => {
|
|
if (orderComplete) {
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
}, [orderComplete]);
|
|
|
|
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
|
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
|
if (sameAsShipping && field !== "email") {
|
|
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
|
}
|
|
};
|
|
|
|
const handleBillingChange = (field: keyof AddressForm, value: string) => {
|
|
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handleEmailChange = (value: string) => {
|
|
setShippingAddress((prev) => ({ ...prev, email: value }));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!checkout) {
|
|
setError(t("errorNoCheckout"));
|
|
return;
|
|
}
|
|
|
|
// Validate all required fields
|
|
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
|
setError(t("errorEmailRequired"));
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (!selectedPaymentMethod) {
|
|
setError(t("errorSelectPayment"));
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
console.log("Completing order...");
|
|
|
|
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;
|
|
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 language code...");
|
|
await saleorClient.mutate({
|
|
mutation: CHECKOUT_LANGUAGE_CODE_UPDATE,
|
|
variables: {
|
|
checkoutId: checkout.id,
|
|
languageCode: locale.toUpperCase(),
|
|
},
|
|
});
|
|
console.log("Step 2: Language code updated to", locale.toUpperCase());
|
|
|
|
console.log("Step 3: Updating billing address...");
|
|
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
|
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
|
variables: {
|
|
checkoutId: checkout.id,
|
|
billingAddress: {
|
|
firstName: billingAddress.firstName,
|
|
lastName: billingAddress.lastName,
|
|
streetAddress1: billingAddress.streetAddress1,
|
|
streetAddress2: billingAddress.streetAddress2,
|
|
city: billingAddress.city,
|
|
postalCode: billingAddress.postalCode,
|
|
country: billingAddress.country,
|
|
phone: billingAddress.phone,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
|
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
|
|
}
|
|
console.log("Step 3: Billing address updated successfully");
|
|
|
|
console.log("Step 4: Setting shipping method...");
|
|
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
|
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
|
variables: {
|
|
checkoutId: checkout.id,
|
|
shippingMethodId: selectedShippingMethod,
|
|
},
|
|
});
|
|
|
|
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
|
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
|
}
|
|
console.log("Step 4: Shipping method set successfully");
|
|
|
|
console.log("Step 5: Saving metadata...");
|
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
|
mutation: CHECKOUT_METADATA_UPDATE,
|
|
variables: {
|
|
checkoutId: checkout.id,
|
|
metadata: [
|
|
{ key: "phone", value: shippingAddress.phone },
|
|
{ key: "shippingPhone", value: shippingAddress.phone },
|
|
{ key: "userLanguage", value: locale },
|
|
{ key: "userLocale", value: locale },
|
|
],
|
|
},
|
|
});
|
|
|
|
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
|
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
|
} else {
|
|
console.log("Step 5: Phone number saved successfully");
|
|
}
|
|
|
|
console.log("Step 6: Completing checkout...");
|
|
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
|
mutation: CHECKOUT_COMPLETE,
|
|
variables: {
|
|
checkoutId: checkout.id,
|
|
},
|
|
});
|
|
|
|
if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) {
|
|
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
|
|
}
|
|
|
|
const order = completeResult.data?.checkoutComplete?.order;
|
|
if (order) {
|
|
setOrderNumber(order.number);
|
|
setOrderComplete(true);
|
|
|
|
// Track order completion
|
|
const lines = getLines();
|
|
const total = getTotal();
|
|
trackOrderCompleted({
|
|
order_id: checkout.id,
|
|
order_number: order.number,
|
|
total,
|
|
currency: "RSD",
|
|
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
|
shipping_cost: shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount,
|
|
customer_email: shippingAddress.email,
|
|
});
|
|
|
|
// Identify the user
|
|
identifyUser({
|
|
profileId: shippingAddress.email,
|
|
email: shippingAddress.email,
|
|
firstName: shippingAddress.firstName,
|
|
lastName: shippingAddress.lastName,
|
|
});
|
|
} else {
|
|
throw new Error(t("errorCreatingOrder"));
|
|
}
|
|
} catch (err: unknown) {
|
|
console.error("Checkout error:", err);
|
|
|
|
if (err instanceof Error) {
|
|
if (err.name === "AbortError") {
|
|
setError("Request timed out. Please check your connection and try again.");
|
|
} else {
|
|
setError(err.message || t("errorOccurred"));
|
|
}
|
|
} else {
|
|
setError(t("errorOccurred"));
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
if (orderComplete) {
|
|
return (
|
|
<>
|
|
<Header locale={locale} />
|
|
<main className="min-h-screen">
|
|
<section className="pt-[120px] pb-20 px-4">
|
|
<div className="max-w-2xl mx-auto text-center">
|
|
<div className="mb-6">
|
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<h1 className="text-3xl font-serif mb-2">{t("orderConfirmed")}</h1>
|
|
<p className="text-foreground-muted">{t("thankYou")}</p>
|
|
</div>
|
|
|
|
{orderNumber && (
|
|
<div className="bg-background-ice p-6 rounded-lg mb-6">
|
|
<p className="text-sm text-foreground-muted mb-1">{t("orderNumber")}</p>
|
|
<p className="text-2xl font-serif">#{orderNumber}</p>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-foreground-muted mb-8">
|
|
{t("confirmationEmail")}
|
|
</p>
|
|
|
|
<Link
|
|
href={`/${locale}/products`}
|
|
className="inline-block px-8 py-3 bg-foreground text-white hover:bg-accent-dark transition-colors"
|
|
>
|
|
{t("continueShoppingBtn")}
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<div className="pt-16">
|
|
<Footer locale={locale} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Header locale={locale} />
|
|
<main className="min-h-screen">
|
|
<section className="pt-[120px] pb-20 px-4">
|
|
<div className="max-w-7xl mx-auto">
|
|
<h1 className="text-3xl font-serif mb-8">{t("checkout")}</h1>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-600 p-4 mb-6 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
<div>
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="border-b border-border pb-6">
|
|
<h2 className="text-xl font-serif mb-4">{t("contactInfo")}</h2>
|
|
<div className="grid grid-cols-1 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("email")}</label>
|
|
<input
|
|
type="email"
|
|
required
|
|
value={shippingAddress.email}
|
|
onChange={(e) => handleEmailChange(e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
placeholder="email@example.com"
|
|
/>
|
|
<p className="text-xs text-foreground-muted mt-1">{t("emailRequired")}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("phone")}</label>
|
|
<input
|
|
type="tel"
|
|
required
|
|
value={shippingAddress.phone}
|
|
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
placeholder="+381..."
|
|
/>
|
|
<p className="text-xs text-foreground-muted mt-1">{t("phoneRequired")}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-b border-border pb-6">
|
|
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("firstName")}</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={shippingAddress.firstName}
|
|
onChange={(e) => handleShippingChange("firstName", e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("lastName")}</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={shippingAddress.lastName}
|
|
onChange={(e) => handleShippingChange("lastName", e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="block text-sm font-medium mb-1">{t("country")}</label>
|
|
<select
|
|
required
|
|
value={shippingAddress.country}
|
|
onChange={(e) => handleShippingChange("country", e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
>
|
|
<option value="RS">Serbia (Srbija)</option>
|
|
<option value="BA">Bosnia and Herzegovina</option>
|
|
<option value="ME">Montenegro</option>
|
|
<option value="HR">Croatia</option>
|
|
<option value="SI">Slovenia</option>
|
|
<option value="MK">North Macedonia</option>
|
|
<option value="AL">Albania</option>
|
|
<option value="XK">Kosovo</option>
|
|
<option value="BG">Bulgaria</option>
|
|
<option value="RO">Romania</option>
|
|
<option value="HU">Hungary</option>
|
|
<option value="DE">Germany</option>
|
|
<option value="AT">Austria</option>
|
|
<option value="CH">Switzerland</option>
|
|
<option value="FR">France</option>
|
|
<option value="GB">United Kingdom</option>
|
|
<option value="US">United States</option>
|
|
<option value="CA">Canada</option>
|
|
<option value="AU">Australia</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="block text-sm font-medium mb-1">{t("streetAddress")}</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={shippingAddress.streetAddress1}
|
|
onChange={(e) => handleShippingChange("streetAddress1", e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<input
|
|
type="text"
|
|
value={shippingAddress.streetAddress2}
|
|
onChange={(e) => handleShippingChange("streetAddress2", e.target.value)}
|
|
placeholder={t("streetAddressOptional")}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("city")}</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={shippingAddress.city}
|
|
onChange={(e) => handleShippingChange("city", e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t("postalCode")}</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={shippingAddress.postalCode}
|
|
onChange={(e) => handleShippingChange("postalCode", e.target.value)}
|
|
className="w-full border border-border px-4 py-2 rounded"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-b border-border pb-6">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={sameAsShipping}
|
|
onChange={(e) => setSameAsShipping(e.target.checked)}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span>{t("billingAddressSame")}</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Shipping Method Selection */}
|
|
<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
|
|
key={method.id}
|
|
className={`flex items-center justify-between p-4 border rounded cursor-pointer transition-colors ${
|
|
selectedShippingMethod === method.id
|
|
? "border-foreground bg-background-ice"
|
|
: "border-border hover:border-foreground/50"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="radio"
|
|
name="shippingMethod"
|
|
value={method.id}
|
|
checked={selectedShippingMethod === method.id}
|
|
onChange={(e) => setSelectedShippingMethod(e.target.value)}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span className="font-medium">{method.name}</span>
|
|
</div>
|
|
<span className="text-foreground-muted">
|
|
{formatPrice(method.price.amount)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Payment Method Section */}
|
|
<PaymentSection
|
|
selectedMethodId={selectedPaymentMethod}
|
|
onSelectMethod={setSelectedPaymentMethod}
|
|
locale={locale}
|
|
channel="default-channel"
|
|
disabled={isLoading}
|
|
/>
|
|
|
|
{/* Money Back Guarantee Trust Badge */}
|
|
<div className="flex items-center justify-center gap-2 py-3 px-4 bg-green-50 rounded-lg border border-green-100">
|
|
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span className="text-sm font-medium text-green-800">{t("moneyBackGuarantee")}</span>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
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") : t("completeOrder", { total: formatPrice(total) })}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="bg-background-ice p-6 rounded-lg h-fit">
|
|
<h2 className="text-xl font-serif mb-6">{t("orderSummary")}</h2>
|
|
|
|
{lines.length === 0 ? (
|
|
<p className="text-foreground-muted">{t("yourCartEmpty")}</p>
|
|
) : (
|
|
<>
|
|
<div className="space-y-4 mb-6">
|
|
{lines.map((line) => (
|
|
<div key={line.id} className="flex gap-4">
|
|
<div className="w-16 h-16 bg-white relative flex-shrink-0">
|
|
{line.variant.product.media[0]?.url && (
|
|
<Image
|
|
src={line.variant.product.media[0].url}
|
|
alt={line.variant.product.name}
|
|
fill
|
|
sizes="64px"
|
|
className="object-cover"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="font-medium text-sm">{line.variant.product.name}</h3>
|
|
<p className="text-foreground-muted text-sm">
|
|
{t("qty")}: {line.quantity}
|
|
</p>
|
|
<p className="text-sm">
|
|
{formatPrice(line.totalPrice.gross.amount)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-4 space-y-2">
|
|
<div className="flex justify-between">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<div className="pt-16">
|
|
<Footer locale={locale} />
|
|
</div>
|
|
</>
|
|
);
|
|
} |