feat(emails): implement transactional email system with Resend
Some checks failed
Build and Deploy / build (push) Has been cancelled
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:
35
package-lock.json
generated
35
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
"@react-email/components": "^1.0.10",
|
"@react-email/components": "^1.0.10",
|
||||||
|
"@react-email/render": "^2.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.4",
|
"framer-motion": "^12.34.4",
|
||||||
"graphql": "^16.13.1",
|
"graphql": "^16.13.1",
|
||||||
@@ -1743,23 +1744,6 @@
|
|||||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-email/components/node_modules/@react-email/render": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"html-to-text": "^9.0.5",
|
|
||||||
"prettier": "^3.5.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-email/container": {
|
"node_modules/@react-email/container": {
|
||||||
"version": "0.0.16",
|
"version": "0.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz",
|
||||||
@@ -1883,6 +1867,23 @@
|
|||||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-email/render": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
|
"prettier": "^3.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-email/row": {
|
"node_modules/@react-email/row": {
|
||||||
"version": "0.0.13",
|
"version": "0.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
"@react-email/components": "^1.0.10",
|
"@react-email/components": "^1.0.10",
|
||||||
|
"@react-email/render": "^2.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.4",
|
"framer-motion": "^12.34.4",
|
||||||
"graphql": "^16.13.1",
|
"graphql": "^16.13.1",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
CHECKOUT_COMPLETE,
|
CHECKOUT_COMPLETE,
|
||||||
CHECKOUT_EMAIL_UPDATE,
|
CHECKOUT_EMAIL_UPDATE,
|
||||||
|
CHECKOUT_METADATA_UPDATE,
|
||||||
|
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
} from "@/lib/saleor/mutations/Checkout";
|
} from "@/lib/saleor/mutations/Checkout";
|
||||||
|
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||||
import type { Checkout } from "@/types/saleor";
|
import type { Checkout } from "@/types/saleor";
|
||||||
|
|
||||||
interface ShippingAddressUpdateResponse {
|
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 {
|
interface AddressForm {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
@@ -92,6 +125,10 @@ export default function CheckoutPage() {
|
|||||||
email: "",
|
email: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||||
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||||
|
const [showShippingMethods, setShowShippingMethods] = useState(false);
|
||||||
|
|
||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
|
|
||||||
@@ -101,6 +138,13 @@ export default function CheckoutPage() {
|
|||||||
}
|
}
|
||||||
}, [checkout, refreshCheckout]);
|
}, [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) => {
|
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
||||||
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
||||||
if (sameAsShipping && field !== "email") {
|
if (sameAsShipping && field !== "email") {
|
||||||
@@ -138,81 +182,169 @@ export default function CheckoutPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
// If we're showing shipping methods and one is selected, complete the order
|
||||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
if (showShippingMethods && selectedShippingMethod) {
|
||||||
variables: {
|
console.log("Phase 2: Completing order with shipping method...");
|
||||||
checkoutId: checkout.id,
|
|
||||||
email: shippingAddress.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
console.log("Step 1: Updating billing address...");
|
||||||
throw new Error(emailResult.data.checkoutEmailUpdate.errors[0].message);
|
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||||
}
|
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
|
variables: {
|
||||||
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
checkoutId: checkout.id,
|
||||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
billingAddress: {
|
||||||
variables: {
|
firstName: billingAddress.firstName,
|
||||||
checkoutId: checkout.id,
|
lastName: billingAddress.lastName,
|
||||||
shippingAddress: {
|
streetAddress1: billingAddress.streetAddress1,
|
||||||
firstName: shippingAddress.firstName,
|
streetAddress2: billingAddress.streetAddress2,
|
||||||
lastName: shippingAddress.lastName,
|
city: billingAddress.city,
|
||||||
streetAddress1: shippingAddress.streetAddress1,
|
postalCode: billingAddress.postalCode,
|
||||||
streetAddress2: shippingAddress.streetAddress2,
|
country: billingAddress.country,
|
||||||
city: shippingAddress.city,
|
phone: billingAddress.phone,
|
||||||
postalCode: shippingAddress.postalCode,
|
},
|
||||||
country: shippingAddress.country,
|
|
||||||
phone: shippingAddress.phone,
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||||
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
|
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>({
|
console.log("Step 2: Setting shipping method...");
|
||||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||||
variables: {
|
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
checkoutId: checkout.id,
|
variables: {
|
||||||
billingAddress: {
|
checkoutId: checkout.id,
|
||||||
firstName: billingAddress.firstName,
|
shippingMethodId: selectedShippingMethod,
|
||||||
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) {
|
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||||
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
|
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>({
|
console.log("Step 3: Saving phone number...");
|
||||||
mutation: CHECKOUT_COMPLETE,
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||||
variables: {
|
mutation: CHECKOUT_METADATA_UPDATE,
|
||||||
checkoutId: checkout.id,
|
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) {
|
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
||||||
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
|
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;
|
console.log("Step 4: Completing checkout...");
|
||||||
if (order) {
|
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||||
setOrderNumber(order.number);
|
mutation: CHECKOUT_COMPLETE,
|
||||||
setOrderComplete(true);
|
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 {
|
} 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) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : null;
|
console.error("Checkout error:", err);
|
||||||
setError(errorMessage || t("errorOccurred"));
|
|
||||||
|
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -415,12 +547,49 @@ export default function CheckoutPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { OrderCancelled } from "@/emails/OrderCancelled";
|
|||||||
import { OrderPaid } from "@/emails/OrderPaid";
|
import { OrderPaid } from "@/emails/OrderPaid";
|
||||||
|
|
||||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
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 {
|
interface SaleorWebhookHeaders {
|
||||||
"saleor-event": string;
|
"saleor-event": string;
|
||||||
@@ -69,6 +70,7 @@ interface SaleorOrder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUPPORTED_EVENTS = [
|
const SUPPORTED_EVENTS = [
|
||||||
|
"ORDER_CREATED",
|
||||||
"ORDER_CONFIRMED",
|
"ORDER_CONFIRMED",
|
||||||
"ORDER_FULLY_PAID",
|
"ORDER_FULLY_PAID",
|
||||||
"ORDER_CANCELLED",
|
"ORDER_CANCELLED",
|
||||||
@@ -141,6 +143,7 @@ async function handleOrderConfirmed(order: SaleorOrder) {
|
|||||||
const customerName = getCustomerName(order);
|
const customerName = getCustomerName(order);
|
||||||
|
|
||||||
const customerEmail = order.userEmail;
|
const customerEmail = order.userEmail;
|
||||||
|
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
await sendEmailToCustomer({
|
||||||
to: customerEmail,
|
to: customerEmail,
|
||||||
@@ -168,7 +171,7 @@ async function handleOrderConfirmed(order: SaleorOrder) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
await sendEmailToAdmin({
|
||||||
subject: `New Order #${order.number} - ${customerName}`,
|
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||||
react: OrderConfirmation({
|
react: OrderConfirmation({
|
||||||
language: "en",
|
language: "en",
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
@@ -178,7 +181,11 @@ async function handleOrderConfirmed(order: SaleorOrder) {
|
|||||||
items: parseOrderItems(order.lines, currency),
|
items: parseOrderItems(order.lines, currency),
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
shippingAddress: formatAddress(order.shippingAddress),
|
shippingAddress: formatAddress(order.shippingAddress),
|
||||||
|
billingAddress: formatAddress(order.billingAddress),
|
||||||
|
phone,
|
||||||
siteUrl: SITE_URL,
|
siteUrl: SITE_URL,
|
||||||
|
dashboardUrl: DASHBOARD_URL,
|
||||||
|
isAdmin: true,
|
||||||
}),
|
}),
|
||||||
eventType: "ORDER_CONFIRMED",
|
eventType: "ORDER_CONFIRMED",
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
@@ -360,6 +367,7 @@ async function handleSaleorWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
|
case "ORDER_CREATED":
|
||||||
case "ORDER_CONFIRMED":
|
case "ORDER_CONFIRMED":
|
||||||
await handleOrderConfirmed(order);
|
await handleOrderConfirmed(order);
|
||||||
break;
|
break;
|
||||||
@@ -379,6 +387,9 @@ async function handleSaleorWebhook(
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
console.log("=== WEBHOOK RECEIVED ===");
|
||||||
|
console.log("Timestamp:", new Date().toISOString());
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const headers = request.headers;
|
const headers = request.headers;
|
||||||
|
|
||||||
@@ -388,6 +399,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const apiUrl = headers.get("saleor-api-url");
|
const apiUrl = headers.get("saleor-api-url");
|
||||||
|
|
||||||
console.log(`Received webhook: ${event} from ${domain}`);
|
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) {
|
if (!event) {
|
||||||
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function BaseLayout({ children, previewText, language, siteUrl }: BaseLay
|
|||||||
<Container style={styles.container}>
|
<Container style={styles.container}>
|
||||||
<Section style={styles.logoSection}>
|
<Section style={styles.logoSection}>
|
||||||
<Img
|
<Img
|
||||||
src={`${siteUrl}/logo.png`}
|
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||||
width="150"
|
width="150"
|
||||||
height="auto"
|
height="auto"
|
||||||
alt="ManoonOils"
|
alt="ManoonOils"
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ interface OrderConfirmationProps {
|
|||||||
items: OrderItem[];
|
items: OrderItem[];
|
||||||
total: string;
|
total: string;
|
||||||
shippingAddress?: string;
|
shippingAddress?: string;
|
||||||
|
billingAddress?: string;
|
||||||
|
phone?: string;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
|
dashboardUrl?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const translations: Record<
|
const translations: Record<
|
||||||
@@ -34,6 +38,15 @@ const translations: Record<
|
|||||||
shippingTo: string;
|
shippingTo: string;
|
||||||
questions: string;
|
questions: string;
|
||||||
thankYou: string;
|
thankYou: string;
|
||||||
|
adminTitle: string;
|
||||||
|
adminPreview: string;
|
||||||
|
adminGreeting: string;
|
||||||
|
adminMessage: string;
|
||||||
|
customerLabel: string;
|
||||||
|
customerEmailLabel: string;
|
||||||
|
billingAddressLabel: string;
|
||||||
|
phoneLabel: string;
|
||||||
|
viewDashboard: string;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
sr: {
|
sr: {
|
||||||
@@ -48,6 +61,15 @@ const translations: Record<
|
|||||||
shippingTo: "Adresa za dostavu",
|
shippingTo: "Adresa za dostavu",
|
||||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||||
thankYou: "Hvala Vam što kupujete kod nas!",
|
thankYou: "Hvala Vam što kupujete kod nas!",
|
||||||
|
adminTitle: "Nova narudžbina!",
|
||||||
|
adminPreview: "Nova narudžbina je primljena",
|
||||||
|
adminGreeting: "Čestitamo na prodaji!",
|
||||||
|
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
|
||||||
|
customerLabel: "Kupac",
|
||||||
|
customerEmailLabel: "Email kupca",
|
||||||
|
billingAddressLabel: "Adresa za naplatu",
|
||||||
|
phoneLabel: "Telefon",
|
||||||
|
viewDashboard: "Pogledaj u Dashboardu",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: "Order Confirmation",
|
title: "Order Confirmation",
|
||||||
@@ -62,6 +84,15 @@ const translations: Record<
|
|||||||
shippingTo: "Shipping address",
|
shippingTo: "Shipping address",
|
||||||
questions: "Questions? Email us at support@manoonoils.com",
|
questions: "Questions? Email us at support@manoonoils.com",
|
||||||
thankYou: "Thank you for shopping with us!",
|
thankYou: "Thank you for shopping with us!",
|
||||||
|
adminTitle: "New Order! 🎉",
|
||||||
|
adminPreview: "A new order has been received",
|
||||||
|
adminGreeting: "Congratulations on the sale!",
|
||||||
|
adminMessage: "A new order has just been placed. Details below:",
|
||||||
|
customerLabel: "Customer",
|
||||||
|
customerEmailLabel: "Customer Email",
|
||||||
|
billingAddressLabel: "Billing Address",
|
||||||
|
phoneLabel: "Phone",
|
||||||
|
viewDashboard: "View in Dashboard",
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
title: "Bestellungsbestätigung",
|
title: "Bestellungsbestätigung",
|
||||||
@@ -76,6 +107,15 @@ const translations: Record<
|
|||||||
shippingTo: "Lieferadresse",
|
shippingTo: "Lieferadresse",
|
||||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||||
thankYou: "Vielen Dank für Ihren Einkauf!",
|
thankYou: "Vielen Dank für Ihren Einkauf!",
|
||||||
|
adminTitle: "Neue Bestellung! 🎉",
|
||||||
|
adminPreview: "Eine neue Bestellung wurde erhalten",
|
||||||
|
adminGreeting: "Glückwunsch zum Verkauf!",
|
||||||
|
adminMessage: "Eine neue Bestellung wurde soeben aufgegeben. Details unten:",
|
||||||
|
customerLabel: "Kunde",
|
||||||
|
customerEmailLabel: "Kunden-E-Mail",
|
||||||
|
billingAddressLabel: "Rechnungsadresse",
|
||||||
|
phoneLabel: "Telefon",
|
||||||
|
viewDashboard: "Im Dashboard anzeigen",
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: "Confirmation de commande",
|
title: "Confirmation de commande",
|
||||||
@@ -90,6 +130,15 @@ const translations: Record<
|
|||||||
shippingTo: "Adresse de livraison",
|
shippingTo: "Adresse de livraison",
|
||||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||||
thankYou: "Merci d'avoir Magasiné avec nous!",
|
thankYou: "Merci d'avoir Magasiné avec nous!",
|
||||||
|
adminTitle: "Nouvelle commande! 🎉",
|
||||||
|
adminPreview: "Une nouvelle commande a été reçue",
|
||||||
|
adminGreeting: "Félicitations pour la vente!",
|
||||||
|
adminMessage: "Une nouvelle commande vient d'être passée. Détails ci-dessous:",
|
||||||
|
customerLabel: "Client",
|
||||||
|
customerEmailLabel: "Email du client",
|
||||||
|
billingAddressLabel: "Adresse de facturation",
|
||||||
|
phoneLabel: "Téléphone",
|
||||||
|
viewDashboard: "Voir dans le Dashboard",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,10 +151,82 @@ export function OrderConfirmation({
|
|||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
shippingAddress,
|
shippingAddress,
|
||||||
|
billingAddress,
|
||||||
|
phone,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
|
dashboardUrl,
|
||||||
|
isAdmin = false,
|
||||||
}: OrderConfirmationProps) {
|
}: OrderConfirmationProps) {
|
||||||
const t = translations[language] || translations.en;
|
const t = translations[language] || translations.en;
|
||||||
|
|
||||||
|
// For admin emails, always use English
|
||||||
|
const adminT = translations["en"];
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
return (
|
||||||
|
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
|
||||||
|
<Text style={styles.title}>{adminT.adminTitle}</Text>
|
||||||
|
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
|
||||||
|
<Text style={styles.text}>{adminT.adminMessage}</Text>
|
||||||
|
|
||||||
|
<Section style={styles.orderInfo}>
|
||||||
|
<Text style={styles.orderNumber}>
|
||||||
|
<strong>{adminT.orderNumber}:</strong> {orderNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.customerInfo}>
|
||||||
|
<strong>{adminT.customerLabel}:</strong> {customerName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.customerInfo}>
|
||||||
|
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
|
||||||
|
</Text>
|
||||||
|
{phone && (
|
||||||
|
<Text style={styles.customerInfo}>
|
||||||
|
<strong>{adminT.phoneLabel}:</strong> {phone}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.itemsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{adminT.items}</Text>
|
||||||
|
<Hr style={styles.hr} />
|
||||||
|
{items.map((item) => (
|
||||||
|
<Section key={item.id} style={styles.itemRow}>
|
||||||
|
<Text style={styles.itemName}>
|
||||||
|
{item.quantity}x {item.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
<Hr style={styles.hr} />
|
||||||
|
<Section style={styles.totalRow}>
|
||||||
|
<Text style={styles.totalLabel}>{adminT.total}:</Text>
|
||||||
|
<Text style={styles.totalValue}>{total}</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{shippingAddress && (
|
||||||
|
<Section style={styles.shippingSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
|
||||||
|
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{billingAddress && (
|
||||||
|
<Section style={styles.shippingSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
|
||||||
|
<Text style={styles.shippingAddress}>{billingAddress}</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section style={styles.buttonSection}>
|
||||||
|
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
|
||||||
|
{adminT.viewDashboard}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||||
<Text style={styles.title}>{t.title}</Text>
|
<Text style={styles.title}>{t.title}</Text>
|
||||||
@@ -187,7 +308,12 @@ const styles = {
|
|||||||
orderNumber: {
|
orderNumber: {
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
color: "#333333",
|
color: "#333333",
|
||||||
margin: "0",
|
margin: "0 0 8px 0",
|
||||||
|
},
|
||||||
|
customerInfo: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0 0 4px 0",
|
||||||
},
|
},
|
||||||
itemsSection: {
|
itemsSection: {
|
||||||
marginBottom: "20px",
|
marginBottom: "20px",
|
||||||
|
|||||||
@@ -345,6 +345,7 @@
|
|||||||
"emailRequired": "Erforderlich für Bestellbestätigung",
|
"emailRequired": "Erforderlich für Bestellbestätigung",
|
||||||
"phoneRequired": "Erforderlich für Lieferkoordination",
|
"phoneRequired": "Erforderlich für Lieferkoordination",
|
||||||
"shippingAddress": "Lieferadresse",
|
"shippingAddress": "Lieferadresse",
|
||||||
|
"shippingMethod": "Versandart",
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
"firstName": "Vorname",
|
"firstName": "Vorname",
|
||||||
"lastName": "Nachname",
|
"lastName": "Nachname",
|
||||||
|
|||||||
@@ -391,6 +391,7 @@
|
|||||||
"emailRequired": "Required for order confirmation",
|
"emailRequired": "Required for order confirmation",
|
||||||
"phoneRequired": "Required for delivery coordination",
|
"phoneRequired": "Required for delivery coordination",
|
||||||
"shippingAddress": "Shipping Address",
|
"shippingAddress": "Shipping Address",
|
||||||
|
"shippingMethod": "Shipping Method",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
"firstName": "First Name",
|
"firstName": "First Name",
|
||||||
"lastName": "Last Name",
|
"lastName": "Last Name",
|
||||||
@@ -417,8 +418,11 @@
|
|||||||
"errorNoCheckout": "No active checkout. Please try again.",
|
"errorNoCheckout": "No active checkout. Please try again.",
|
||||||
"errorEmailRequired": "Please enter a valid email address.",
|
"errorEmailRequired": "Please enter a valid email address.",
|
||||||
"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.",
|
||||||
|
"errorSelectShipping": "Please select a shipping method.",
|
||||||
"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",
|
||||||
"orderConfirmed": "Order Confirmed!",
|
"orderConfirmed": "Order Confirmed!",
|
||||||
"thankYou": "Thank you for your purchase.",
|
"thankYou": "Thank you for your purchase.",
|
||||||
"orderNumber": "Order Number",
|
"orderNumber": "Order Number",
|
||||||
|
|||||||
@@ -345,6 +345,7 @@
|
|||||||
"emailRequired": "Requis pour la confirmation de commande",
|
"emailRequired": "Requis pour la confirmation de commande",
|
||||||
"phoneRequired": "Requis pour la coordination de livraison",
|
"phoneRequired": "Requis pour la coordination de livraison",
|
||||||
"shippingAddress": "Adresse de Livraison",
|
"shippingAddress": "Adresse de Livraison",
|
||||||
|
"shippingMethod": "Méthode de livraison",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
"firstName": "Prénom",
|
"firstName": "Prénom",
|
||||||
"lastName": "Nom",
|
"lastName": "Nom",
|
||||||
|
|||||||
@@ -391,6 +391,7 @@
|
|||||||
"emailRequired": "Potrebno za potvrdu narudžbine",
|
"emailRequired": "Potrebno za potvrdu narudžbine",
|
||||||
"phoneRequired": "Potrebno za koordinaciju dostave",
|
"phoneRequired": "Potrebno za koordinaciju dostave",
|
||||||
"shippingAddress": "Adresa za dostavu",
|
"shippingAddress": "Adresa za dostavu",
|
||||||
|
"shippingMethod": "Način dostave",
|
||||||
"country": "Država",
|
"country": "Država",
|
||||||
"firstName": "Ime",
|
"firstName": "Ime",
|
||||||
"lastName": "Prezime",
|
"lastName": "Prezime",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
|
||||||
let resendClient: Resend | null = null;
|
let resendClient: Resend | null = null;
|
||||||
|
|
||||||
@@ -30,17 +31,22 @@ export async function sendEmail({
|
|||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const resend = getResendClient();
|
const resend = getResendClient();
|
||||||
const { data, error } = await resend.emails.send(
|
|
||||||
{
|
// Render React component to HTML
|
||||||
from: "ManoonOils <support@manoonoils.com>",
|
const html = await render(react, {
|
||||||
to: Array.isArray(to) ? to : [to],
|
pretty: true,
|
||||||
subject,
|
});
|
||||||
react,
|
|
||||||
text,
|
const { data, error } = await resend.emails.send({
|
||||||
tags,
|
from: "ManoonOils <support@mail.manoonoils.com>",
|
||||||
...(idempotencyKey && { idempotencyKey }),
|
replyTo: "support@manoonoils.com",
|
||||||
}
|
to: Array.isArray(to) ? to : [to],
|
||||||
);
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
tags,
|
||||||
|
...(idempotencyKey && { idempotencyKey }),
|
||||||
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Failed to send email:", error);
|
console.error("Failed to send email:", error);
|
||||||
|
|||||||
@@ -152,3 +152,24 @@ export const CHECKOUT_EMAIL_UPDATE = gql`
|
|||||||
}
|
}
|
||||||
${CHECKOUT_FRAGMENT}
|
${CHECKOUT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const CHECKOUT_METADATA_UPDATE = gql`
|
||||||
|
mutation CheckoutMetadataUpdate($checkoutId: ID!, $metadata: [MetadataInput!]!) {
|
||||||
|
updateMetadata(id: $checkoutId, input: $metadata) {
|
||||||
|
item {
|
||||||
|
... on Checkout {
|
||||||
|
id
|
||||||
|
metadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user