Includes: - feat: store userLanguage in checkout metadata for multi-language emails - refactor: Remove email functionality - migrated to core-extensions app - docs: Add comprehensive feature roadmap with 20 optimization features
This commit is contained in:
@@ -244,7 +244,7 @@ export default function CheckoutPage() {
|
|||||||
}
|
}
|
||||||
console.log("Step 2: Shipping method set successfully");
|
console.log("Step 2: Shipping method set successfully");
|
||||||
|
|
||||||
console.log("Step 3: Saving phone number...");
|
console.log("Step 3: Saving metadata...");
|
||||||
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||||
mutation: CHECKOUT_METADATA_UPDATE,
|
mutation: CHECKOUT_METADATA_UPDATE,
|
||||||
variables: {
|
variables: {
|
||||||
@@ -252,6 +252,8 @@ export default function CheckoutPage() {
|
|||||||
metadata: [
|
metadata: [
|
||||||
{ key: "phone", value: shippingAddress.phone },
|
{ key: "phone", value: shippingAddress.phone },
|
||||||
{ key: "shippingPhone", value: shippingAddress.phone },
|
{ key: "shippingPhone", value: shippingAddress.phone },
|
||||||
|
{ key: "userLanguage", value: locale },
|
||||||
|
{ key: "userLocale", value: locale },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -316,7 +318,16 @@ export default function CheckoutPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||||
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
|
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
|
||||||
|
// Check if checkout no longer exists
|
||||||
|
if (errorMessage.includes("Couldn't resolve to a node")) {
|
||||||
|
console.error("Checkout not found, clearing cart...");
|
||||||
|
localStorage.removeItem('cart');
|
||||||
|
localStorage.removeItem('checkoutId');
|
||||||
|
window.location.href = `/${locale}/products`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Email update failed: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
console.log("Step 1: Email updated successfully");
|
console.log("Step 1: Email updated successfully");
|
||||||
|
|
||||||
|
|||||||
@@ -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