feat: Add Saleor webhook handler with Resend email integration
- Add Resend SDK for transactional emails - Create React Email templates for order events: - OrderConfirmation - OrderShipped - OrderCancelled - OrderPaid - Multi-language support (SR, EN, DE, FR) - Customer emails in their language - Admin emails in English to me@hytham.me and tamara@hytham.me - Webhook handler at /api/webhooks/saleor - Supports: ORDER_CONFIRMED, ORDER_FULLY_PAID, ORDER_CANCELLED, ORDER_FULFILLED - Add GraphQL mutation to create webhooks in Saleor - Add Resend API key to .env.local
This commit is contained in:
411
src/app/api/webhooks/saleor/route.ts
Normal file
411
src/app/api/webhooks/saleor/route.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||
import { OrderConfirmation } from "@/emails/OrderConfirmation";
|
||||
import { OrderShipped } from "@/emails/OrderShipped";
|
||||
import { OrderCancelled } from "@/emails/OrderCancelled";
|
||||
import { OrderPaid } from "@/emails/OrderPaid";
|
||||
|
||||
interface SaleorWebhookHeaders {
|
||||
"saleor-event": string;
|
||||
"saleor-domain": string;
|
||||
"saleor-signature"?: string;
|
||||
"saleor-api-url": string;
|
||||
}
|
||||
|
||||
interface SaleorLineItem {
|
||||
id: string;
|
||||
productName: string;
|
||||
variantName?: string;
|
||||
quantity: number;
|
||||
quantityUnit?: string;
|
||||
totalPrice: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface SaleorAddress {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
streetAddress1?: string;
|
||||
streetAddress2?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface SaleorOrder {
|
||||
id: string;
|
||||
number: string;
|
||||
userEmail: string;
|
||||
user?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
};
|
||||
billingAddress?: SaleorAddress;
|
||||
shippingAddress?: SaleorAddress;
|
||||
lines: SaleorLineItem[];
|
||||
total: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
shippingPrice?: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
languageCode?: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
const SUPPORTED_EVENTS = [
|
||||
"ORDER_CONFIRMED",
|
||||
"ORDER_FULLY_PAID",
|
||||
"ORDER_CANCELLED",
|
||||
"ORDER_FULFILLED",
|
||||
];
|
||||
|
||||
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
||||
SR: "sr",
|
||||
EN: "en",
|
||||
DE: "de",
|
||||
FR: "fr",
|
||||
};
|
||||
|
||||
function getCustomerLanguage(order: SaleorOrder): string {
|
||||
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
||||
return LANGUAGE_CODE_MAP[order.languageCode];
|
||||
}
|
||||
if (order.metadata) {
|
||||
const langMeta = order.metadata.find((m) => m.key === "language");
|
||||
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
||||
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
||||
}
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
function formatPrice(amount: number, currency: string): string {
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
function formatAddress(address?: SaleorAddress): string {
|
||||
if (!address) return "";
|
||||
const parts = [
|
||||
address.firstName,
|
||||
address.lastName,
|
||||
address.streetAddress1,
|
||||
address.streetAddress2,
|
||||
address.postalCode,
|
||||
address.city,
|
||||
address.country,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function getCustomerName(order: SaleorOrder): string {
|
||||
if (order.user?.firstName) {
|
||||
return `${order.user.firstName}${order.user.lastName ? ` ${order.user.lastName}` : ""}`;
|
||||
}
|
||||
if (order.billingAddress?.firstName) {
|
||||
return `${order.billingAddress.firstName}${order.billingAddress.lastName ? ` ${order.billingAddress.lastName}` : ""}`;
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
function parseOrderItems(lines: SaleorLineItem[], currency: string) {
|
||||
return lines.map((line) => ({
|
||||
id: line.id,
|
||||
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
||||
quantity: line.quantity,
|
||||
price: formatPrice(line.totalPrice.gross.amount, currency),
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleOrderConfirmed(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Potvrda narudžbine #${order.number}`
|
||||
: language === "de"
|
||||
? `Bestellbestätigung #${order.number}`
|
||||
: language === "fr"
|
||||
? `Confirmation de commande #${order.number}`
|
||||
: `Order Confirmation #${order.number}`,
|
||||
react: OrderConfirmation({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: formatAddress(order.shippingAddress),
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-confirmed/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `New Order #${order.number} - ${customerName}`,
|
||||
react: OrderConfirmation({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: formatAddress(order.shippingAddress),
|
||||
}),
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrderFulfilled(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
let trackingNumber: string | undefined;
|
||||
let trackingUrl: string | undefined;
|
||||
|
||||
if (order.metadata) {
|
||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||
if (trackingMeta) {
|
||||
trackingNumber = trackingMeta.value;
|
||||
}
|
||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||
if (trackingUrlMeta) {
|
||||
trackingUrl = trackingUrlMeta.value;
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Vaša narudžbina #${order.number} je poslata!`
|
||||
: language === "de"
|
||||
? `Ihre Bestellung #${order.number} wurde versendet!`
|
||||
: language === "fr"
|
||||
? `Votre commande #${order.number} a été expédiée!`
|
||||
: `Your Order #${order.number} Has Shipped!`,
|
||||
react: OrderShipped({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-fulfilled/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Shipped #${order.number} - ${customerName}`,
|
||||
react: OrderShipped({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
}),
|
||||
eventType: "ORDER_FULFILLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrderCancelled(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
let reason: string | undefined;
|
||||
if (order.metadata) {
|
||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||
if (reasonMeta) {
|
||||
reason = reasonMeta.value;
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Vaša narudžbina #${order.number} je otkazana`
|
||||
: language === "de"
|
||||
? `Ihre Bestellung #${order.number} wurde storniert`
|
||||
: language === "fr"
|
||||
? `Votre commande #${order.number} a été annulée`
|
||||
: `Your Order #${order.number} Has Been Cancelled`,
|
||||
react: OrderCancelled({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-cancelled/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
||||
react: OrderCancelled({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
}),
|
||||
eventType: "ORDER_CANCELLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrderFullyPaid(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Plaćanje za narudžbinu #${order.number} je primljeno!`
|
||||
: language === "de"
|
||||
? `Zahlung für Bestellung #${order.number} erhalten!`
|
||||
: language === "fr"
|
||||
? `Paiement reçu pour la commande #${order.number}!`
|
||||
: `Payment Received for Order #${order.number}!`,
|
||||
react: OrderPaid({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-paid/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||
react: OrderPaid({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
}),
|
||||
eventType: "ORDER_FULLY_PAID",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaleorWebhook(
|
||||
event: string,
|
||||
payload: { order: SaleorOrder }
|
||||
) {
|
||||
const { order } = payload;
|
||||
|
||||
console.log(`Processing webhook event: ${event} for order ${order?.id}`);
|
||||
|
||||
if (!order || !order.id) {
|
||||
console.error("No order in payload");
|
||||
throw new Error("No order in payload");
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case "ORDER_CONFIRMED":
|
||||
await handleOrderConfirmed(order);
|
||||
break;
|
||||
case "ORDER_FULFILLED":
|
||||
await handleOrderFulfilled(order);
|
||||
break;
|
||||
case "ORDER_CANCELLED":
|
||||
await handleOrderCancelled(order);
|
||||
break;
|
||||
case "ORDER_FULLY_PAID":
|
||||
await handleOrderFullyPaid(order);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unsupported event: ${event}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const headers = request.headers;
|
||||
|
||||
const event = headers.get("saleor-event") as string;
|
||||
const domain = headers.get("saleor-domain");
|
||||
const signature = headers.get("saleor-signature");
|
||||
const apiUrl = headers.get("saleor-api-url");
|
||||
|
||||
console.log(`Received webhook: ${event} from ${domain}`);
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!SUPPORTED_EVENTS.includes(event)) {
|
||||
console.log(`Event ${event} not supported, skipping`);
|
||||
return NextResponse.json({ success: true, message: "Event not supported" });
|
||||
}
|
||||
|
||||
const payload = body;
|
||||
|
||||
await handleSaleorWebhook(event, payload);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Webhook processing error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
message: "Saleor webhook endpoint is active",
|
||||
supportedEvents: SUPPORTED_EVENTS,
|
||||
});
|
||||
}
|
||||
97
src/emails/BaseLayout.tsx
Normal file
97
src/emails/BaseLayout.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface BaseLayoutProps {
|
||||
children: React.ReactNode;
|
||||
previewText: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const translations: Record<string, { footer: string; company: string }> = {
|
||||
sr: {
|
||||
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
en: {
|
||||
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
de: {
|
||||
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
fr: {
|
||||
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
};
|
||||
|
||||
export function BaseLayout({ children, previewText, language }: BaseLayoutProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.container}>
|
||||
<Section style={styles.logoSection}>
|
||||
<Img
|
||||
src="https://manoonoils.com/logo.png"
|
||||
width="150"
|
||||
height="auto"
|
||||
alt="ManoonOils"
|
||||
style={styles.logo}
|
||||
/>
|
||||
</Section>
|
||||
{children}
|
||||
<Section style={styles.footer}>
|
||||
<Text style={styles.footerText}>{t.footer}</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#f6f6f6",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "40px 20px",
|
||||
maxWidth: "600px",
|
||||
},
|
||||
logoSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "30px",
|
||||
},
|
||||
logo: {
|
||||
margin: "0 auto",
|
||||
},
|
||||
footer: {
|
||||
marginTop: "40px",
|
||||
paddingTop: "20px",
|
||||
borderTop: "1px solid #e0e0e0",
|
||||
},
|
||||
footerText: {
|
||||
color: "#666666",
|
||||
fontSize: "12px",
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
};
|
||||
235
src/emails/OrderCancelled.tsx
Normal file
235
src/emails/OrderCancelled.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderCancelledProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderCancelled: string;
|
||||
items: string;
|
||||
total: string;
|
||||
reason: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Vaša narudžbina je otkazana",
|
||||
preview: "Vaša narudžbina je otkazana",
|
||||
greeting: "Poštovani {name},",
|
||||
orderCancelled:
|
||||
"Vaša narudžbina je otkazana. Ako niste zatražili otkazivanje, molimo kontaktirajte nas što pre.",
|
||||
items: "Artikli",
|
||||
total: "Ukupno",
|
||||
reason: "Razlog",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Been Cancelled",
|
||||
preview: "Your order has been cancelled",
|
||||
greeting: "Dear {name},",
|
||||
orderCancelled:
|
||||
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
|
||||
items: "Items",
|
||||
total: "Total",
|
||||
reason: "Reason",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde storniert",
|
||||
preview: "Ihre Bestellung wurde storniert",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderCancelled:
|
||||
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
|
||||
items: "Artikel",
|
||||
total: "Gesamt",
|
||||
reason: "Grund",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été annulée",
|
||||
preview: "Votre commande a été annulée",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderCancelled:
|
||||
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
|
||||
items: "Articles",
|
||||
total: "Total",
|
||||
reason: "Raison",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderCancelled({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
reason,
|
||||
}: OrderCancelledProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderCancelled}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>Order Number:</strong> {orderNumber}
|
||||
</Text>
|
||||
{reason && (
|
||||
<Text style={styles.reason}>
|
||||
<strong>{t.reason}:</strong> {reason}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.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}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href="https://manoonoils.com" style={styles.button}>
|
||||
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#dc2626",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#fef2f2",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 5px 0",
|
||||
},
|
||||
reason: {
|
||||
fontSize: "14px",
|
||||
color: "#991b1b",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
266
src/emails/OrderConfirmation.tsx
Normal file
266
src/emails/OrderConfirmation.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderConfirmationProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
shippingAddress?: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderReceived: string;
|
||||
orderNumber: string;
|
||||
items: string;
|
||||
quantity: string;
|
||||
total: string;
|
||||
shippingTo: string;
|
||||
questions: string;
|
||||
thankYou: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Potvrda narudžbine",
|
||||
preview: "Vaša narudžbina je potvrđena",
|
||||
greeting: "Poštovani {name},",
|
||||
orderReceived: "Zahvaljujemo se na Vašoj narudžbini! Primili smo je i sada je u pripremi.",
|
||||
orderNumber: "Broj narudžbine",
|
||||
items: "Artikli",
|
||||
quantity: "Količina",
|
||||
total: "Ukupno",
|
||||
shippingTo: "Adresa za dostavu",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
thankYou: "Hvala Vam što kupujete kod nas!",
|
||||
},
|
||||
en: {
|
||||
title: "Order Confirmation",
|
||||
preview: "Your order has been confirmed",
|
||||
greeting: "Dear {name},",
|
||||
orderReceived:
|
||||
"Thank you for your order! We have received it and it is now being processed.",
|
||||
orderNumber: "Order number",
|
||||
items: "Items",
|
||||
quantity: "Quantity",
|
||||
total: "Total",
|
||||
shippingTo: "Shipping address",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
thankYou: "Thank you for shopping with us!",
|
||||
},
|
||||
de: {
|
||||
title: "Bestellungsbestätigung",
|
||||
preview: "Ihre Bestellung wurde bestätigt",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderReceived:
|
||||
"Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und sie wird nun bearbeitet.",
|
||||
orderNumber: "Bestellnummer",
|
||||
items: "Artikel",
|
||||
quantity: "Menge",
|
||||
total: "Gesamt",
|
||||
shippingTo: "Lieferadresse",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
thankYou: "Vielen Dank für Ihren Einkauf!",
|
||||
},
|
||||
fr: {
|
||||
title: "Confirmation de commande",
|
||||
preview: "Votre commande a été confirmée",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderReceived:
|
||||
"Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.",
|
||||
orderNumber: "Numéro de commande",
|
||||
items: "Articles",
|
||||
quantity: "Quantité",
|
||||
total: "Total",
|
||||
shippingTo: "Adresse de livraison",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
thankYou: "Merci d'avoir Magasiné avec nous!",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderConfirmation({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
shippingAddress,
|
||||
}: OrderConfirmationProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderReceived}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>{t.orderNumber}:</strong> {orderNumber}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.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}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
{shippingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
|
||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href="https://manoonoils.com" style={styles.button}>
|
||||
{language === "sr"
|
||||
? "Pogledajte narudžbinu"
|
||||
: language === "de"
|
||||
? "Bestellung ansehen"
|
||||
: language === "fr"
|
||||
? "Voir la commande"
|
||||
: "View Order"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
<Text style={styles.thankYou}>{t.thankYou}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
shippingSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
shippingAddress: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
thankYou: {
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
};
|
||||
251
src/emails/OrderPaid.tsx
Normal file
251
src/emails/OrderPaid.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderPaidProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderPaid: string;
|
||||
items: string;
|
||||
total: string;
|
||||
nextSteps: string;
|
||||
nextStepsText: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Plaćanje je primljeno!",
|
||||
preview: "Vaša uplata je zabeležena",
|
||||
greeting: "Poštovani {name},",
|
||||
orderPaid:
|
||||
"Plaćanje za vašu narudžbinu je primljeno. Hvala vam! Narudžbina će uskoro biti spremna za slanje.",
|
||||
items: "Artikli",
|
||||
total: "Ukupno",
|
||||
nextSteps: "Šta dalje?",
|
||||
nextStepsText:
|
||||
"Primićete još jedan email kada vaša narudžbina bude poslata. Možete očekivati dostavu u roku od 3-5 radnih dana.",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Payment Received!",
|
||||
preview: "Your payment has been recorded",
|
||||
greeting: "Dear {name},",
|
||||
orderPaid:
|
||||
"Payment for your order has been received. Thank you! Your order will be prepared for shipping soon.",
|
||||
items: "Items",
|
||||
total: "Total",
|
||||
nextSteps: "What's next?",
|
||||
nextStepsText:
|
||||
"You will receive another email when your order ships. You can expect delivery within 3-5 business days.",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Zahlung erhalten!",
|
||||
preview: "Ihre Zahlung wurde verbucht",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderPaid:
|
||||
"Zahlung für Ihre Bestellung ist eingegangen. Vielen Dank! Ihre Bestellung wird bald für den Versand vorbereitet.",
|
||||
items: "Artikel",
|
||||
total: "Gesamt",
|
||||
nextSteps: "Was kommt als nächstes?",
|
||||
nextStepsText:
|
||||
"Sie erhalten eine weitere E-Mail, wenn Ihre Bestellung versandt wird. Die Lieferung erfolgt innerhalb von 3-5 Werktagen.",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Paiement reçu!",
|
||||
preview: "Votre paiement a été enregistré",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderPaid:
|
||||
"Le paiement de votre commande a été reçu. Merci! Votre commande sera bientôt prête à être expédiée.",
|
||||
items: "Articles",
|
||||
total: "Total",
|
||||
nextSteps: "Et ensuite?",
|
||||
nextStepsText:
|
||||
"Vous recevrez un autre email lorsque votre commande sera expédiée. Vous pouvez vous attendre à une livraison dans 3-5 jours ouvrables.",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderPaid({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
}: OrderPaidProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderPaid}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>Order Number:</strong> {orderNumber}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.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}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.nextSteps}>
|
||||
<Text style={styles.nextStepsTitle}>{t.nextSteps}</Text>
|
||||
<Text style={styles.nextStepsText}>{t.nextStepsText}</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href="https://manoonoils.com" style={styles.button}>
|
||||
{language === "sr" ? "Nastavite kupovinu" : "Continue Shopping"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#16a34a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#f0fdf4",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
nextSteps: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
nextStepsTitle: {
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "5px",
|
||||
},
|
||||
nextStepsText: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
191
src/emails/OrderShipped.tsx
Normal file
191
src/emails/OrderShipped.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderShippedProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
trackingNumber?: string;
|
||||
trackingUrl?: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderShipped: string;
|
||||
tracking: string;
|
||||
items: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Vaša narudžbina je poslata!",
|
||||
preview: "Vaša narudžbina je na putu",
|
||||
greeting: "Poštovani {name},",
|
||||
orderShipped:
|
||||
"Odlične vesti! Vaša narudžbina je poslata i uskoro će stići na vašu adresu.",
|
||||
tracking: "Praćenje pošiljke",
|
||||
items: "Artikli",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Shipped!",
|
||||
preview: "Your order is on its way",
|
||||
greeting: "Dear {name},",
|
||||
orderShipped:
|
||||
"Great news! Your order has been shipped and will arrive at your address soon.",
|
||||
tracking: "Track your shipment",
|
||||
items: "Items",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde versendet!",
|
||||
preview: "Ihre Bestellung ist unterwegs",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderShipped:
|
||||
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
|
||||
tracking: "Sendung verfolgen",
|
||||
items: "Artikel",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été expédiée!",
|
||||
preview: "Votre commande est en route",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderShipped:
|
||||
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
|
||||
tracking: "Suivre votre envoi",
|
||||
items: "Articles",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderShipped({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
}: OrderShippedProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderShipped}</Text>
|
||||
|
||||
{trackingNumber && (
|
||||
<Section style={styles.trackingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.tracking}</Text>
|
||||
{trackingUrl ? (
|
||||
<Button href={trackingUrl} style={styles.trackingButton}>
|
||||
{trackingNumber}
|
||||
</Button>
|
||||
) : (
|
||||
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.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>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
trackingSection: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
trackingNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
trackingButton: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "10px 20px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
textDecoration: "none",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
5
src/emails/index.ts
Normal file
5
src/emails/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { BaseLayout } from "./BaseLayout";
|
||||
export { OrderConfirmation } from "./OrderConfirmation";
|
||||
export { OrderShipped } from "./OrderShipped";
|
||||
export { OrderCancelled } from "./OrderCancelled";
|
||||
export { OrderPaid } from "./OrderPaid";
|
||||
100
src/lib/resend.ts
Normal file
100
src/lib/resend.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Resend } from "resend";
|
||||
|
||||
let resendClient: Resend | null = null;
|
||||
|
||||
function getResendClient(): Resend {
|
||||
if (!resendClient) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error("RESEND_API_KEY environment variable is not set");
|
||||
}
|
||||
resendClient = new Resend(process.env.RESEND_API_KEY);
|
||||
}
|
||||
return resendClient;
|
||||
}
|
||||
|
||||
export const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
tags,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
react: React.ReactNode;
|
||||
text?: string;
|
||||
tags?: { name: string; value: string }[];
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
const resend = getResendClient();
|
||||
const { data, error } = await resend.emails.send(
|
||||
{
|
||||
from: "ManoonOils <support@manoonoils.com>",
|
||||
to: Array.isArray(to) ? to : [to],
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
tags,
|
||||
...(idempotencyKey && { idempotencyKey }),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendEmailToCustomer({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
language,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
to: string;
|
||||
subject: string;
|
||||
react: React.ReactNode;
|
||||
text?: string;
|
||||
language: string;
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
const tag = `customer-${language}`;
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
tags: [{ name: "type", value: tag }],
|
||||
idempotencyKey,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendEmailToAdmin({
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
eventType,
|
||||
orderId,
|
||||
}: {
|
||||
subject: string;
|
||||
react: React.ReactNode;
|
||||
text?: string;
|
||||
eventType: string;
|
||||
orderId: string;
|
||||
}) {
|
||||
return sendEmail({
|
||||
to: ADMIN_EMAILS,
|
||||
subject: `[Admin] ${subject}`,
|
||||
react,
|
||||
text,
|
||||
tags: [{ name: "type", value: "admin-notification" }],
|
||||
idempotencyKey: `admin-${eventType}/${orderId}`,
|
||||
});
|
||||
}
|
||||
77
src/lib/saleor/create-webhooks.graphql
Normal file
77
src/lib/saleor/create-webhooks.graphql
Normal file
@@ -0,0 +1,77 @@
|
||||
mutation CreateSaleorWebhooks {
|
||||
orderConfirmedWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Confirmed"
|
||||
targetUrl: "https://manoonoils.com/api/webhooks/saleor"
|
||||
events: [ORDER_CONFIRMED]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
orderPaidWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Paid"
|
||||
targetUrl: "https://manoonoils.com/api/webhooks/saleor"
|
||||
events: [ORDER_FULLY_PAID]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
orderCancelledWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Cancelled"
|
||||
targetUrl: "https://manoonoils.com/api/webhooks/saleor"
|
||||
events: [ORDER_CANCELLED]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
orderFulfilledWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Fulfilled"
|
||||
targetUrl: "https://manoonoils.com/api/webhooks/saleor"
|
||||
events: [ORDER_FULFILLED]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user