refactor: Remove email functionality - migrated to core-extensions app
Removed: - Webhook handlers (src/app/api/webhooks/saleor/) - Email templates (src/emails/) - OrderNotificationService (src/lib/services/) Emails now handled by saleor-core-extensions service Manifest: https://core-extensions.manoonoils.com/api/manifest
This commit is contained in:
@@ -1,295 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||
import { analyticsService } from "@/lib/services/AnalyticsService";
|
||||
|
||||
// Saleor webhook payload interfaces (snake_case)
|
||||
interface SaleorLineItemPayload {
|
||||
id: string;
|
||||
product_name: string;
|
||||
variant_name?: string;
|
||||
quantity: number;
|
||||
total_price_gross_amount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface SaleorAddressPayload {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
street_address_1?: string;
|
||||
street_address_2?: string;
|
||||
city?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface SaleorOrderPayload {
|
||||
id: string;
|
||||
number: number;
|
||||
user_email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
billing_address?: SaleorAddressPayload;
|
||||
shipping_address?: SaleorAddressPayload;
|
||||
lines: SaleorLineItemPayload[];
|
||||
total_gross_amount: string;
|
||||
shipping_price_gross_amount?: string;
|
||||
channel: { currency_code: string };
|
||||
currency?: string;
|
||||
language_code?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Internal camelCase interfaces
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
productName: string;
|
||||
variantName?: string;
|
||||
quantity: number;
|
||||
totalPrice: {
|
||||
gross: { amount: number; currency: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface OrderAddress {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
streetAddress1?: string;
|
||||
streetAddress2?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
number: string;
|
||||
userEmail: string;
|
||||
user?: { firstName?: string; lastName?: string };
|
||||
billingAddress?: OrderAddress;
|
||||
shippingAddress?: OrderAddress;
|
||||
lines: OrderItem[];
|
||||
total: { gross: { amount: number; currency: string } };
|
||||
languageCode?: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
const SUPPORTED_EVENTS = [
|
||||
"ORDER_CREATED",
|
||||
"ORDER_CONFIRMED",
|
||||
"ORDER_FULLY_PAID",
|
||||
"ORDER_CANCELLED",
|
||||
"ORDER_FULFILLED",
|
||||
];
|
||||
|
||||
// Convert Saleor payload to internal format
|
||||
function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
|
||||
return {
|
||||
id: payload.id,
|
||||
number: String(payload.number),
|
||||
userEmail: payload.user_email,
|
||||
user: payload.first_name || payload.last_name ? {
|
||||
firstName: payload.first_name,
|
||||
lastName: payload.last_name,
|
||||
} : undefined,
|
||||
billingAddress: payload.billing_address ? {
|
||||
firstName: payload.billing_address.first_name,
|
||||
lastName: payload.billing_address.last_name,
|
||||
streetAddress1: payload.billing_address.street_address_1,
|
||||
streetAddress2: payload.billing_address.street_address_2,
|
||||
city: payload.billing_address.city,
|
||||
postalCode: payload.billing_address.postal_code,
|
||||
country: payload.billing_address.country,
|
||||
phone: payload.billing_address.phone,
|
||||
} : undefined,
|
||||
shippingAddress: payload.shipping_address ? {
|
||||
firstName: payload.shipping_address.first_name,
|
||||
lastName: payload.shipping_address.last_name,
|
||||
streetAddress1: payload.shipping_address.street_address_1,
|
||||
streetAddress2: payload.shipping_address.street_address_2,
|
||||
city: payload.shipping_address.city,
|
||||
postalCode: payload.shipping_address.postal_code,
|
||||
country: payload.shipping_address.country,
|
||||
phone: payload.shipping_address.phone,
|
||||
} : undefined,
|
||||
lines: payload.lines.map((line) => ({
|
||||
id: line.id,
|
||||
productName: line.product_name,
|
||||
variantName: line.variant_name,
|
||||
quantity: line.quantity,
|
||||
totalPrice: {
|
||||
gross: {
|
||||
amount: parseInt(line.total_price_gross_amount),
|
||||
currency: line.currency || payload.channel.currency_code,
|
||||
},
|
||||
},
|
||||
})),
|
||||
total: {
|
||||
gross: {
|
||||
amount: parseInt(payload.total_gross_amount),
|
||||
currency: payload.channel.currency_code,
|
||||
},
|
||||
},
|
||||
languageCode: payload.language_code?.toUpperCase(),
|
||||
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract tracking number from metadata
|
||||
function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
|
||||
if (!order.metadata) return {};
|
||||
|
||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||
|
||||
return {
|
||||
trackingNumber: trackingMeta?.value,
|
||||
trackingUrl: trackingUrlMeta?.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract cancellation reason from metadata
|
||||
function getCancellationReason(order: Order): string | undefined {
|
||||
if (!order.metadata) return undefined;
|
||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||
return reasonMeta?.value;
|
||||
}
|
||||
|
||||
// Webhook handlers
|
||||
async function handleOrderConfirmed(order: Order, eventType: string) {
|
||||
const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
||||
|
||||
// Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
|
||||
if (eventType === "ORDER_CONFIRMED") {
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
// Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
|
||||
// This prevents duplicate tracking when both events fire for the same order
|
||||
analyticsService.trackOrderReceived({
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
total: order.total.gross.amount,
|
||||
currency: order.total.gross.currency,
|
||||
itemCount,
|
||||
customerEmail: order.userEmail,
|
||||
eventType,
|
||||
});
|
||||
|
||||
analyticsService.trackRevenue({
|
||||
amount: order.total.gross.amount,
|
||||
currency: order.total.gross.currency,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Send admin notification for both events
|
||||
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||
}
|
||||
|
||||
async function handleOrderFulfilled(order: Order) {
|
||||
const { trackingNumber, trackingUrl } = getTrackingInfo(order);
|
||||
|
||||
await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
|
||||
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
|
||||
}
|
||||
|
||||
async function handleOrderCancelled(order: Order) {
|
||||
const reason = getCancellationReason(order);
|
||||
|
||||
await orderNotificationService.sendOrderCancelled(order, reason);
|
||||
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
|
||||
}
|
||||
|
||||
async function handleOrderFullyPaid(order: Order) {
|
||||
await orderNotificationService.sendOrderPaid(order);
|
||||
await orderNotificationService.sendOrderPaidToAdmin(order);
|
||||
}
|
||||
|
||||
// Main webhook processor
|
||||
async function processWebhook(event: string, order: Order) {
|
||||
console.log(`Processing webhook event: ${event} for order ${order.id}`);
|
||||
|
||||
switch (event) {
|
||||
case "ORDER_CREATED":
|
||||
case "ORDER_CONFIRMED":
|
||||
await handleOrderConfirmed(order, event);
|
||||
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 {
|
||||
console.log("=== WEBHOOK RECEIVED ===");
|
||||
console.log("Timestamp:", new Date().toISOString());
|
||||
|
||||
const body = await request.json();
|
||||
const headers = request.headers;
|
||||
|
||||
const event = headers.get("saleor-event") as string;
|
||||
const domain = headers.get("saleor-domain");
|
||||
|
||||
console.log(`Received webhook: ${event} from ${domain}`);
|
||||
|
||||
// Parse payload
|
||||
let orderPayload: SaleorOrderPayload | null = null;
|
||||
if (Array.isArray(body) && body.length > 0) {
|
||||
orderPayload = body[0] as SaleorOrderPayload;
|
||||
} else if (body.data && Array.isArray(body.data)) {
|
||||
orderPayload = body.data[0] as SaleorOrderPayload;
|
||||
}
|
||||
|
||||
if (!orderPayload) {
|
||||
console.error("No order found in webhook payload");
|
||||
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log("Order:", {
|
||||
id: orderPayload.id,
|
||||
number: orderPayload.number,
|
||||
email: orderPayload.user_email,
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedEvent = event.toUpperCase();
|
||||
|
||||
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
|
||||
console.log(`Event ${event} not supported, skipping`);
|
||||
return NextResponse.json({ success: true, message: "Event not supported" });
|
||||
}
|
||||
|
||||
const order = convertPayloadToOrder(orderPayload);
|
||||
await processWebhook(normalizedEvent, order);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function formatPrice(amount: number, currency: string): string {
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(amount);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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;
|
||||
siteUrl: 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, siteUrl }: 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://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.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,
|
||||
},
|
||||
};
|
||||
@@ -1,237 +0,0 @@
|
||||
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;
|
||||
siteUrl: 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,
|
||||
siteUrl,
|
||||
}: OrderCancelledProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<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={siteUrl} 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",
|
||||
},
|
||||
};
|
||||
@@ -1,394 +0,0 @@
|
||||
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;
|
||||
billingAddress?: string;
|
||||
phone?: string;
|
||||
siteUrl: string;
|
||||
dashboardUrl?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
adminTitle: string;
|
||||
adminPreview: string;
|
||||
adminGreeting: string;
|
||||
adminMessage: string;
|
||||
customerLabel: string;
|
||||
customerEmailLabel: string;
|
||||
billingAddressLabel: string;
|
||||
phoneLabel: string;
|
||||
viewDashboard: 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!",
|
||||
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: {
|
||||
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!",
|
||||
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: {
|
||||
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!",
|
||||
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: {
|
||||
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!",
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderConfirmation({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
shippingAddress,
|
||||
billingAddress,
|
||||
phone,
|
||||
siteUrl,
|
||||
dashboardUrl,
|
||||
isAdmin = false,
|
||||
}: OrderConfirmationProps) {
|
||||
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 (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<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={siteUrl} 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 0 8px 0",
|
||||
},
|
||||
customerInfo: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 4px 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",
|
||||
},
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
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;
|
||||
siteUrl: 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,
|
||||
siteUrl,
|
||||
}: OrderPaidProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<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={siteUrl} 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",
|
||||
},
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
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;
|
||||
siteUrl: 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,
|
||||
siteUrl,
|
||||
}: OrderShippedProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<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",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export { BaseLayout } from "./BaseLayout";
|
||||
export { OrderConfirmation } from "./OrderConfirmation";
|
||||
export { OrderShipped } from "./OrderShipped";
|
||||
export { OrderCancelled } from "./OrderCancelled";
|
||||
export { OrderPaid } from "./OrderPaid";
|
||||
@@ -1,357 +0,0 @@
|
||||
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";
|
||||
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
|
||||
|
||||
// Translation helper for email subjects
|
||||
function getOrderConfirmationSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Potvrda narudžbine #${orderNumber}`,
|
||||
de: `Bestellbestätigung #${orderNumber}`,
|
||||
fr: `Confirmation de commande #${orderNumber}`,
|
||||
en: `Order Confirmation #${orderNumber}`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
function getOrderShippedSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Vaša narudžbina #${orderNumber} je poslata!`,
|
||||
de: `Ihre Bestellung #${orderNumber} wurde versendet!`,
|
||||
fr: `Votre commande #${orderNumber} a été expédiée!`,
|
||||
en: `Your Order #${orderNumber} Has Shipped!`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
function getOrderCancelledSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Vaša narudžbina #${orderNumber} je otkazana`,
|
||||
de: `Ihre Bestellung #${orderNumber} wurde storniert`,
|
||||
fr: `Votre commande #${orderNumber} a été annulée`,
|
||||
en: `Your Order #${orderNumber} Has Been Cancelled`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
function getOrderPaidSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Plaćanje za narudžbinu #${orderNumber} je primljeno!`,
|
||||
de: `Zahlung für Bestellung #${orderNumber} erhalten!`,
|
||||
fr: `Paiement reçu pour la commande #${orderNumber}!`,
|
||||
en: `Payment Received for Order #${orderNumber}!`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
// Interfaces
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
productName: string;
|
||||
variantName?: string;
|
||||
quantity: number;
|
||||
totalPrice: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface OrderAddress {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
streetAddress1?: string;
|
||||
streetAddress2?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
number: string;
|
||||
userEmail: string;
|
||||
user?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
billingAddress?: OrderAddress;
|
||||
shippingAddress?: OrderAddress;
|
||||
lines: OrderItem[];
|
||||
total: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
languageCode?: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
interface OrderEmailItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
class OrderNotificationService {
|
||||
private static instance: OrderNotificationService;
|
||||
|
||||
static getInstance(): OrderNotificationService {
|
||||
if (!OrderNotificationService.instance) {
|
||||
OrderNotificationService.instance = new OrderNotificationService();
|
||||
}
|
||||
return OrderNotificationService.instance;
|
||||
}
|
||||
|
||||
private parseOrderItems(lines: OrderItem[], currency: string): OrderEmailItem[] {
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
private formatAddress(address?: OrderAddress): string {
|
||||
if (!address) return "";
|
||||
const parts = [
|
||||
address.firstName,
|
||||
address.lastName,
|
||||
address.streetAddress1,
|
||||
address.streetAddress2,
|
||||
address.city,
|
||||
address.postalCode,
|
||||
address.country,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
private getCustomerName(order: Order): string {
|
||||
if (order.user?.firstName || order.user?.lastName) {
|
||||
return `${order.user.firstName || ""} ${order.user.lastName || ""}`.trim();
|
||||
}
|
||||
if (order.shippingAddress?.firstName || order.shippingAddress?.lastName) {
|
||||
return `${order.shippingAddress.firstName || ""} ${order.shippingAddress.lastName || ""}`.trim();
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
private getCustomerLanguage(order: Order): string {
|
||||
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
||||
SR: "sr",
|
||||
EN: "en",
|
||||
DE: "de",
|
||||
FR: "fr",
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
async sendOrderConfirmation(order: Order): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderConfirmationSubject(language, order.number),
|
||||
react: OrderConfirmation({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: this.formatAddress(order.shippingAddress),
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-confirmed/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderConfirmationToAdmin(order: Order): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||
react: OrderConfirmation({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: this.formatAddress(order.shippingAddress),
|
||||
billingAddress: this.formatAddress(order.billingAddress),
|
||||
phone,
|
||||
siteUrl: SITE_URL,
|
||||
dashboardUrl: DASHBOARD_URL,
|
||||
isAdmin: true,
|
||||
}),
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderShipped(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderShippedSubject(language, order.number),
|
||||
react: OrderShipped({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-fulfilled/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderShippedToAdmin(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Shipped #${order.number} - ${customerName}`,
|
||||
react: OrderShipped({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
eventType: "ORDER_FULFILLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderCancelled(order: Order, reason?: string): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderCancelledSubject(language, order.number),
|
||||
react: OrderCancelled({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-cancelled/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderCancelledToAdmin(order: Order, reason?: string): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
||||
react: OrderCancelled({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
eventType: "ORDER_CANCELLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderPaid(order: Order): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderPaidSubject(language, order.number),
|
||||
react: OrderPaid({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-paid/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderPaidToAdmin(order: Order): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
|
||||
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: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
eventType: "ORDER_FULLY_PAID",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orderNotificationService = OrderNotificationService.getInstance();
|
||||
export default orderNotificationService;
|
||||
Reference in New Issue
Block a user