feat(emails): implement transactional email system with Resend
Some checks failed
Build and Deploy / build (push) Has been cancelled

- Add Resend email integration with @react-email/render
- Create email templates: OrderConfirmation, OrderShipped, OrderCancelled, OrderPaid
- Implement webhook handler for ORDER_CREATED and other events
- Add multi-language support for customer emails
- Admin emails in English with order details
- Update checkout page with auto-scroll on order completion
- Configure DASHBOARD_URL environment variable
This commit is contained in:
Unchained
2026-03-25 14:13:34 +02:00
parent ef83538d0b
commit 5576946829
12 changed files with 446 additions and 96 deletions

View File

@@ -15,7 +15,10 @@ import {
CHECKOUT_BILLING_ADDRESS_UPDATE,
CHECKOUT_COMPLETE,
CHECKOUT_EMAIL_UPDATE,
CHECKOUT_METADATA_UPDATE,
CHECKOUT_SHIPPING_METHOD_UPDATE,
} from "@/lib/saleor/mutations/Checkout";
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
import type { Checkout } from "@/types/saleor";
interface ShippingAddressUpdateResponse {
@@ -46,6 +49,36 @@ interface EmailUpdateResponse {
};
}
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;
@@ -92,6 +125,10 @@ export default function CheckoutPage() {
email: "",
});
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
const [showShippingMethods, setShowShippingMethods] = useState(false);
const lines = getLines();
const total = getTotal();
@@ -101,6 +138,13 @@ export default function CheckoutPage() {
}
}, [checkout, refreshCheckout]);
// 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") {
@@ -138,81 +182,169 @@ export default function CheckoutPage() {
setError(null);
try {
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
mutation: CHECKOUT_EMAIL_UPDATE,
variables: {
checkoutId: checkout.id,
email: shippingAddress.email,
},
});
// 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...");
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
throw new Error(emailResult.data.checkoutEmailUpdate.errors[0].message);
}
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,
console.log("Step 1: 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 (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
}
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 1: Billing address updated successfully");
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,
console.log("Step 2: Setting shipping method...");
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
variables: {
checkoutId: checkout.id,
shippingMethodId: selectedShippingMethod,
},
},
});
});
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
}
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 2: Shipping method set successfully");
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
mutation: CHECKOUT_COMPLETE,
variables: {
checkoutId: checkout.id,
},
});
console.log("Step 3: Saving phone number...");
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 },
],
},
});
if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) {
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
}
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 3: Phone number saved successfully");
}
const order = completeResult.data?.checkoutComplete?.order;
if (order) {
setOrderNumber(order.number);
setOrderComplete(true);
console.log("Step 4: 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);
} else {
throw new Error(t("errorCreatingOrder"));
}
} else {
throw new Error(t("errorCreatingOrder"));
// 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) {
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
}
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);
// Don't complete yet - show shipping method selection
console.log("Phase 1 complete. Waiting for shipping method selection...");
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : null;
setError(errorMessage || t("errorOccurred"));
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);
}
@@ -415,12 +547,49 @@ export default function CheckoutPage() {
</label>
</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="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>
{!selectedShippingMethod && (
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
)}
</div>
)}
<button
type="submit"
disabled={isLoading || lines.length === 0}
disabled={isLoading || lines.length === 0 || (showShippingMethods && !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) })}
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
</button>
</form>
</div>

View File

@@ -7,6 +7,7 @@ import { OrderCancelled } from "@/emails/OrderCancelled";
import { OrderPaid } from "@/emails/OrderPaid";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
interface SaleorWebhookHeaders {
"saleor-event": string;
@@ -69,6 +70,7 @@ interface SaleorOrder {
}
const SUPPORTED_EVENTS = [
"ORDER_CREATED",
"ORDER_CONFIRMED",
"ORDER_FULLY_PAID",
"ORDER_CANCELLED",
@@ -141,6 +143,7 @@ async function handleOrderConfirmed(order: SaleorOrder) {
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
await sendEmailToCustomer({
to: customerEmail,
@@ -168,7 +171,7 @@ async function handleOrderConfirmed(order: SaleorOrder) {
});
await sendEmailToAdmin({
subject: `New Order #${order.number} - ${customerName}`,
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
react: OrderConfirmation({
language: "en",
orderId: order.id,
@@ -178,7 +181,11 @@ async function handleOrderConfirmed(order: SaleorOrder) {
items: parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
shippingAddress: formatAddress(order.shippingAddress),
billingAddress: formatAddress(order.billingAddress),
phone,
siteUrl: SITE_URL,
dashboardUrl: DASHBOARD_URL,
isAdmin: true,
}),
eventType: "ORDER_CONFIRMED",
orderId: order.id,
@@ -360,6 +367,7 @@ async function handleSaleorWebhook(
}
switch (event) {
case "ORDER_CREATED":
case "ORDER_CONFIRMED":
await handleOrderConfirmed(order);
break;
@@ -379,6 +387,9 @@ async function handleSaleorWebhook(
export async function POST(request: NextRequest) {
try {
console.log("=== WEBHOOK RECEIVED ===");
console.log("Timestamp:", new Date().toISOString());
const body = await request.json();
const headers = request.headers;
@@ -388,6 +399,14 @@ export async function POST(request: NextRequest) {
const apiUrl = headers.get("saleor-api-url");
console.log(`Received webhook: ${event} from ${domain}`);
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
console.log("Payload keys:", Object.keys(body));
if (body.order) {
console.log("Order ID:", body.order.id);
console.log("Order number:", body.order.number);
console.log("User email:", body.order.userEmail);
}
if (!event) {
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });