feat: complete saleor core extensions app with React email templates
- Order confirmation, shipped, and cancelled email templates - Uses @react-email/components for professional HTML emails - Sends admin and customer notifications - Integrates with Resend for email delivery - Webhook handlers for ORDER_CREATED, ORDER_FULFILLED, ORDER_CANCELLED - Docker image optimized for production - Persistent auth data storage via PVC
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
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" : "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",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { BaseLayout } from "./BaseLayout";
|
||||
export { OrderConfirmation } from "./OrderConfirmation";
|
||||
export { OrderShipped } from "./OrderShipped";
|
||||
export { OrderCancelled } from "./OrderCancelled";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cacheExchange, createClient as urqlCreateClient, fetchExchange } from "urql";
|
||||
|
||||
export const createClient = (url: string, getAuth: () => Promise<{ token: string }>) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [
|
||||
cacheExchange,
|
||||
fetchExchange,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
|
||||
|
||||
/**
|
||||
* Saleor App can be rendered only as a Saleor Dashboard iframe.
|
||||
* All content is rendered after Dashboard exchanges auth with the app.
|
||||
* Hence, there is no reason to render app server side.
|
||||
*
|
||||
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
|
||||
* of using dynamic() calls
|
||||
*
|
||||
* You can use this wrapper selectively for some pages or remove it completely.
|
||||
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
|
||||
*/
|
||||
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Resend } from "resend";
|
||||
import { render } from "@react-email/components";
|
||||
import { OrderConfirmation, OrderShipped, OrderCancelled } from "@/emails";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const FROM_EMAIL = process.env.FROM_EMAIL || "support@mail.manoonoils.com";
|
||||
const FROM_NAME = process.env.FROM_NAME || "ManoonOils";
|
||||
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
}: {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
}) {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `${FROM_NAME} <${FROM_EMAIL}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Resend error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function formatPrice(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export async function sendOrderConfirmationEmail({
|
||||
to,
|
||||
orderData,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
to: string | string[];
|
||||
orderData: {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
items: Array<{ id: string; name: string; quantity: number; price: string }>;
|
||||
total: string;
|
||||
shippingAddress?: string;
|
||||
billingAddress?: string;
|
||||
phone?: string;
|
||||
};
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const html = await render(
|
||||
OrderConfirmation({
|
||||
...orderData,
|
||||
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
|
||||
dashboardUrl: process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com",
|
||||
isAdmin,
|
||||
})
|
||||
);
|
||||
|
||||
const subject = isAdmin
|
||||
? `🎉 New Order #${orderData.orderNumber}`
|
||||
: `Order Confirmation #${orderData.orderNumber}`;
|
||||
|
||||
return sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
export async function sendOrderShippedEmail({
|
||||
to,
|
||||
orderData,
|
||||
}: {
|
||||
to: string | string[];
|
||||
orderData: {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: Array<{ id: string; name: string; quantity: number; price: string }>;
|
||||
trackingNumber?: string;
|
||||
trackingUrl?: string;
|
||||
};
|
||||
}) {
|
||||
const html = await render(
|
||||
OrderShipped({
|
||||
...orderData,
|
||||
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
|
||||
})
|
||||
);
|
||||
|
||||
return sendEmail({
|
||||
to,
|
||||
subject: `Your Order #${orderData.orderNumber} Has Shipped!`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendOrderCancelledEmail({
|
||||
to,
|
||||
orderData,
|
||||
}: {
|
||||
to: string | string[];
|
||||
orderData: {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: Array<{ id: string; name: string; quantity: number; price: string }>;
|
||||
total: string;
|
||||
reason?: string;
|
||||
};
|
||||
}) {
|
||||
const html = await render(
|
||||
OrderCancelled({
|
||||
...orderData,
|
||||
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
|
||||
})
|
||||
);
|
||||
|
||||
return sendEmail({
|
||||
to,
|
||||
subject: `Your Order #${orderData.orderNumber} Has Been Cancelled`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTheme || !appBridgeState?.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appBridgeState.theme === "light") {
|
||||
setTheme("defaultLight");
|
||||
}
|
||||
|
||||
if (appBridgeState.theme === "dark") {
|
||||
setTheme("defaultDark");
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Text } from "@saleor/macaw-ui";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useLastOrderQuery } from "@/generated/graphql";
|
||||
|
||||
/**
|
||||
* GraphQL Code Generator scans for gql tags and generates types based on them.
|
||||
* The below query is used to generate the "useLastOrderQuery" hook.
|
||||
* If you modify it, make sure to run "pnpm run generate:app-graphql-types" to regenerate the types.
|
||||
*/
|
||||
|
||||
function generateNumberOfLinesText(lines: readonly { readonly id: string }[]) {
|
||||
if (lines.length === 0) {
|
||||
return "no lines";
|
||||
}
|
||||
|
||||
if (lines.length === 1) {
|
||||
return "1 line";
|
||||
}
|
||||
|
||||
return `${lines.length} lines`;
|
||||
}
|
||||
|
||||
export const OrderExample = () => {
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
// Using the generated hook
|
||||
const [{ data, fetching }] = useLastOrderQuery();
|
||||
const lastOrder = data?.orders?.edges[0]?.node;
|
||||
|
||||
const navigateToOrder = (id: string) => {
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: `/orders/${id}`,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection={"column"} gap={2}>
|
||||
<Text as={"h2"} size={8}>
|
||||
Fetching data
|
||||
</Text>
|
||||
|
||||
<>
|
||||
{fetching && <Text color="default2">Fetching the last order...</Text>}
|
||||
{lastOrder && (
|
||||
<>
|
||||
<Text color="default2">
|
||||
❗ The <code>orders</code> query requires the <code>MANAGE_ORDERS</code> permission.
|
||||
If you want to query other resources, make sure to update the app permissions in the{" "}
|
||||
<code>/src/pages/api/manifest.ts</code> file.
|
||||
</Text>
|
||||
<Box
|
||||
backgroundColor={"default2"}
|
||||
padding={4}
|
||||
borderRadius={4}
|
||||
borderWidth={1}
|
||||
borderStyle={"solid"}
|
||||
borderColor={"default2"}
|
||||
marginY={4}
|
||||
>
|
||||
<Text>{`The last order #${lastOrder.number}:`}</Text>
|
||||
<ul>
|
||||
<li>
|
||||
<Text>{`Contains ${generateNumberOfLinesText(lastOrder.lines)} 🛒`}</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text>{`For a total amount of ${lastOrder.total.gross.amount} ${lastOrder.total.gross.currency} 💸`}</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text>{`Ships to ${lastOrder.shippingAddress?.country.country} 📦`}</Text>
|
||||
</li>
|
||||
</ul>
|
||||
<Link onClick={() => navigateToOrder(lastOrder.id)} href={`/orders/${lastOrder.id}`}>
|
||||
See the order details →
|
||||
</Link>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{!fetching && !lastOrder && <Text color="default2">No orders found</Text>}
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import "@saleor/macaw-ui/style";
|
||||
import "../styles/globals.css";
|
||||
|
||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||
import { ThemeProvider } from "@saleor/macaw-ui";
|
||||
import { AppProps } from "next/app";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { NoSSRWrapper } from "@/lib/no-ssr-wrapper";
|
||||
import { ThemeSynchronizer } from "@/lib/theme-synchronizer";
|
||||
import { GraphQLProvider } from "@/providers/GraphQLProvider";
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||
*/
|
||||
const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||
|
||||
function NextApp({ Component, pageProps }: AppProps) {
|
||||
/**
|
||||
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const jssStyles = document.querySelector("#jss-server-side");
|
||||
if (jssStyles) {
|
||||
jssStyles?.parentElement?.removeChild(jssStyles);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NoSSRWrapper>
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<GraphQLProvider>
|
||||
<ThemeProvider>
|
||||
<ThemeSynchronizer />
|
||||
<RoutePropagator />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</GraphQLProvider>
|
||||
</AppBridgeProvider>
|
||||
</NoSSRWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default NextApp;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui";
|
||||
|
||||
import { OrderExample } from "../order-example";
|
||||
|
||||
/**
|
||||
* This is example of using AppBridge, when App is mounted in Dashboard
|
||||
* See more about AppBridge possibilities
|
||||
* https://github.com/saleor/saleor-app-sdk/blob/main/docs/app-bridge.md
|
||||
*
|
||||
* -> You can safely remove this file!
|
||||
*/
|
||||
const ActionsPage = () => {
|
||||
const { appBridge, appBridgeState } = useAppBridge();
|
||||
|
||||
const navigateToOrders = () => {
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: `/orders`,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box padding={8} display={"flex"} flexDirection={"column"} gap={6} __maxWidth={"640px"}>
|
||||
<Box>
|
||||
<Text as={"p"}>
|
||||
<b>Welcome {appBridgeState?.user?.email}!</b>
|
||||
</Text>
|
||||
<Text as={"p"}>Installing the app in the Dashboard gave it superpowers such as:</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text as={"h2"} size={8} marginBottom={2}>
|
||||
AppBridge actions
|
||||
</Text>
|
||||
<Text color="default2">
|
||||
💡 You can use AppBridge to trigger dashboard actions, such as notifications or redirects.
|
||||
</Text>
|
||||
<Box display={"flex"} gap={4} gridAutoFlow={"column"} marginY={4}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => {
|
||||
appBridge?.dispatch({
|
||||
type: "notification",
|
||||
payload: {
|
||||
status: "success",
|
||||
title: "You rock!",
|
||||
text: "This notification was triggered from Saleor App",
|
||||
actionId: "message-from-app",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Trigger notification 📤
|
||||
</Button>
|
||||
<Button variant={"secondary"} onClick={navigateToOrders}>
|
||||
Redirect to orders ➡️💰
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<OrderExample />
|
||||
<Box display="flex" flexDirection={"column"} gap={2}>
|
||||
<Text as={"h2"} size={8}>
|
||||
Webhooks
|
||||
</Text>
|
||||
<Text>
|
||||
The App Template contains an example <code>ORDER_CREATED</code> webhook under the path{" "}
|
||||
<code>src/pages/api/order-created</code>.
|
||||
</Text>
|
||||
<Text as="p">
|
||||
Create any{" "}
|
||||
<Text
|
||||
as={"a"}
|
||||
fontWeight="bold"
|
||||
size={4}
|
||||
onClick={navigateToOrders}
|
||||
cursor={"pointer"}
|
||||
color={"info1"}
|
||||
>
|
||||
Order
|
||||
</Text>{" "}
|
||||
and check your console output!
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionsPage;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { AppExtension, AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
import { orderCreatedWebhook } from "./webhooks/order-created";
|
||||
import { orderFulfilledWebhook } from "./webhooks/order-fulfilled";
|
||||
import { orderCancelledWebhook } from "./webhooks/order-cancelled";
|
||||
import { orderFilterShippingMethodsWebhook } from "./webhooks/order-filter-shipping-methods";
|
||||
|
||||
/**
|
||||
* App SDK helps with the valid Saleor App Manifest creation. Read more:
|
||||
* https://github.com/saleor/saleor-app-sdk/blob/main/docs/api-handlers.md#manifest-handler-factory
|
||||
*/
|
||||
export default createManifestHandler({
|
||||
async manifestFactory({ appBaseUrl, request, schemaVersion }) {
|
||||
/**
|
||||
* Allow to overwrite default app base url, to enable Docker support.
|
||||
*
|
||||
* See docs: https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
|
||||
*/
|
||||
const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl;
|
||||
const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl;
|
||||
|
||||
const extensionsForSaleor3_22: AppExtension[] = [
|
||||
{
|
||||
url: apiBaseURL + "/api/server-widget",
|
||||
permissions: [],
|
||||
mount: "PRODUCT_DETAILS_WIDGETS",
|
||||
label: "Product Timestamps",
|
||||
target: "WIDGET",
|
||||
options: {
|
||||
widgetTarget: {
|
||||
method: "POST",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: iframeBaseUrl+"/client-widget",
|
||||
permissions: [],
|
||||
mount: "ORDER_DETAILS_WIDGETS",
|
||||
label: "Order widget example",
|
||||
target: "WIDGET",
|
||||
options: {
|
||||
widgetTarget: {
|
||||
method: "GET",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const saleorMajor = schemaVersion && schemaVersion[0];
|
||||
const saleorMinor = schemaVersion && schemaVersion[1]
|
||||
|
||||
const is3_22 = saleorMajor === 3 && saleorMinor === 22;
|
||||
|
||||
const extensions = is3_22 ? extensionsForSaleor3_22 : [];
|
||||
|
||||
const manifest: AppManifest = {
|
||||
name: "Core Extensions",
|
||||
tokenTargetUrl: `${apiBaseURL}/api/register`,
|
||||
appUrl: iframeBaseUrl,
|
||||
permissions: [
|
||||
"MANAGE_ORDERS",
|
||||
],
|
||||
id: "saleor.core-extensions",
|
||||
version: packageJson.version,
|
||||
webhooks: [
|
||||
orderCreatedWebhook.getWebhookManifest(apiBaseURL),
|
||||
orderFulfilledWebhook.getWebhookManifest(apiBaseURL),
|
||||
orderCancelledWebhook.getWebhookManifest(apiBaseURL),
|
||||
orderFilterShippingMethodsWebhook.getWebhookManifest(apiBaseURL),
|
||||
],
|
||||
/**
|
||||
* Optionally, extend Dashboard with custom UIs
|
||||
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
|
||||
*/
|
||||
extensions: extensions,
|
||||
author: "Saleor Commerce",
|
||||
brand: {
|
||||
logo: {
|
||||
default: `${apiBaseURL}/logo.png`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return manifest;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createProtectedHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { apl } from "@/saleor-app";
|
||||
|
||||
/**
|
||||
* Will be available only from the Dashboard, and only for users with the MANAGE_ORDERS permission.
|
||||
*/
|
||||
const handler = createProtectedHandler(
|
||||
(req, res, { user, baseUrl, authData }) => {
|
||||
return res.json({
|
||||
message: "OK!",
|
||||
});
|
||||
},
|
||||
apl,
|
||||
["MANAGE_ORDERS"]
|
||||
);
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,21 @@
|
||||
// Patch fetch to force HTTPS for api.manoonoils.com
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
let url = input.toString();
|
||||
if (url.startsWith('http://api.manoonoils.com/')) {
|
||||
url = url.replace('http://', 'https://');
|
||||
input = url;
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
|
||||
export default createAppRegisterHandler({
|
||||
apl: saleorApp.apl,
|
||||
allowedSaleorUrls: [
|
||||
"https://api.manoonoils.com/graphql/",
|
||||
"http://api.manoonoils.com/graphql/",
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextApiHandler } from "next";
|
||||
import { ExtensionPOSTAttributes } from "@saleor/app-sdk/types";
|
||||
import { verifyJWT } from "@saleor/app-sdk/auth";
|
||||
import { createClient } from "@/lib/create-graphq-client";
|
||||
import { ProductTimestampsDocument } from "../../../generated/graphql";
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const { appId, accessToken, saleorApiUrl, ...contextParams } =
|
||||
req.body as ExtensionPOSTAttributes;
|
||||
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
|
||||
try {
|
||||
await verifyJWT({
|
||||
appId,
|
||||
token: accessToken,
|
||||
saleorApiUrl,
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(401).send("Not authorized");
|
||||
}
|
||||
|
||||
if (!contextParams.productId) {
|
||||
return res.status(200).send("Missing product ID");
|
||||
}
|
||||
|
||||
const client = createClient(saleorApiUrl, async () => ({ token: accessToken }));
|
||||
const productTimestamps = await client.query(ProductTimestampsDocument, {
|
||||
id: contextParams.productId,
|
||||
});
|
||||
|
||||
if (productTimestamps.data?.product) {
|
||||
return res
|
||||
.status(200)
|
||||
.send(
|
||||
`This product was created at ${productTimestamps.data.product.created} and last updated at ${productTimestamps.data.product.updatedAt}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderCancelledSubscriptionDocument,
|
||||
OrderCancelledWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { sendOrderCancelledEmail, formatPrice } from "@/lib/resend";
|
||||
|
||||
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
|
||||
name: "Order Cancelled in Saleor",
|
||||
webhookPath: "api/webhooks/order-cancelled",
|
||||
event: "ORDER_CANCELLED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderCancelledSubscriptionDocument,
|
||||
});
|
||||
|
||||
export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload, event, baseUrl, authData } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
console.error("No order data in webhook payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
console.log(`Order ${order.number} cancelled for customer: ${order.userEmail}`);
|
||||
|
||||
const items = ((order as any).lines || []).map((line: any) => ({
|
||||
id: line.id,
|
||||
name: line.variant?.product?.name || "Unknown Product",
|
||||
quantity: line.quantity,
|
||||
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
|
||||
}));
|
||||
|
||||
const orderData = {
|
||||
orderId: order.id,
|
||||
orderNumber: order.number || "Unknown",
|
||||
customerName: (order as any).shippingAddress?.firstName
|
||||
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
|
||||
: order.userEmail?.split("@")[0] || "Customer",
|
||||
items,
|
||||
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
|
||||
};
|
||||
|
||||
try {
|
||||
if (order.userEmail) {
|
||||
await sendOrderCancelledEmail({
|
||||
to: order.userEmail,
|
||||
orderData,
|
||||
});
|
||||
console.log(`Customer notification sent for cancelled order ${order.number}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderCreatedSubscriptionDocument,
|
||||
OrderCreatedWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
|
||||
|
||||
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
|
||||
name: "Order Created in Saleor",
|
||||
webhookPath: "api/webhooks/order-created",
|
||||
event: "ORDER_CREATED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderCreatedSubscriptionDocument,
|
||||
});
|
||||
|
||||
export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload, event, baseUrl, authData } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
console.error("No order data in webhook payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
|
||||
|
||||
const items = ((order as any).lines || []).map((line: any) => ({
|
||||
id: line.id,
|
||||
name: line.variant?.product?.name || "Unknown Product",
|
||||
quantity: line.quantity,
|
||||
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
|
||||
}));
|
||||
|
||||
const orderData = {
|
||||
orderId: order.id,
|
||||
orderNumber: order.number || "Unknown",
|
||||
customerEmail: order.userEmail || "",
|
||||
customerName: (order as any).shippingAddress?.firstName
|
||||
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
|
||||
: order.userEmail?.split("@")[0] || "Customer",
|
||||
items,
|
||||
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
|
||||
shippingAddress: (order as any).shippingAddress
|
||||
? `${(order as any).shippingAddress.firstName || ""} ${(order as any).shippingAddress.lastName || ""}\n${(order as any).shippingAddress.streetAddress1 || ""}\n${(order as any).shippingAddress.postalCode || ""} ${(order as any).shippingAddress.city || ""}\n${(order as any).shippingAddress.country?.country || ""}${(order as any).shippingAddress.phone ? `\nPhone: ${(order as any).shippingAddress.phone}` : ""}`
|
||||
: undefined,
|
||||
billingAddress: (order as any).billingAddress
|
||||
? `${(order as any).billingAddress.firstName || ""} ${(order as any).billingAddress.lastName || ""}\n${(order as any).billingAddress.streetAddress1 || ""}\n${(order as any).billingAddress.postalCode || ""} ${(order as any).billingAddress.city || ""}\n${(order as any).billingAddress.country?.country || ""}${(order as any).billingAddress.phone ? `\nPhone: ${(order as any).billingAddress.phone}` : ""}`
|
||||
: undefined,
|
||||
phone: (order as any).shippingAddress?.phone,
|
||||
};
|
||||
|
||||
// Send admin notification
|
||||
try {
|
||||
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
|
||||
|
||||
if (adminEmails.length > 0) {
|
||||
await sendOrderConfirmationEmail({
|
||||
to: adminEmails,
|
||||
orderData,
|
||||
isAdmin: true,
|
||||
});
|
||||
console.log(`✅ Admin notification sent for order #${order.number} to: ${adminEmails.join(", ")}`);
|
||||
} else {
|
||||
console.log("⚠️ No admin emails configured, skipping admin notification");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to send admin email:", error);
|
||||
}
|
||||
|
||||
// Send customer confirmation
|
||||
try {
|
||||
if (order.userEmail) {
|
||||
await sendOrderConfirmationEmail({
|
||||
to: order.userEmail,
|
||||
orderData,
|
||||
isAdmin: false,
|
||||
});
|
||||
console.log(`✅ Customer confirmation sent for order #${order.number} to: ${order.userEmail}`);
|
||||
} else {
|
||||
console.log("⚠️ No customer email found, skipping customer notification");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to send customer email:", error);
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
|
||||
import { FilterShippingMethods } from "@/generated/app-webhooks-types/order-filter-shipping-methods";
|
||||
import {
|
||||
OrderFilterShippingMethodsPayloadFragment,
|
||||
OrderFilterShippingMethodsSubscriptionDocument,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
|
||||
/**
|
||||
* Create abstract Webhook. It decorates handler and performs security checks under the hood.
|
||||
*
|
||||
* orderFilterShippingMethodsWebhook.getWebhookManifest() must be called in api/manifest too!
|
||||
*/
|
||||
export const orderFilterShippingMethodsWebhook =
|
||||
new SaleorSyncWebhook<OrderFilterShippingMethodsPayloadFragment>({
|
||||
name: "Order Filter Shipping Methods",
|
||||
webhookPath: "api/webhooks/order-filter-shipping-methods",
|
||||
event: "ORDER_FILTER_SHIPPING_METHODS",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderFilterShippingMethodsSubscriptionDocument,
|
||||
});
|
||||
|
||||
/**
|
||||
* Export decorated Next.js pages router handler, which adds extra context
|
||||
*/
|
||||
export default orderFilterShippingMethodsWebhook.createHandler((req, res, ctx) => {
|
||||
const {
|
||||
/**
|
||||
* Access payload from Saleor - defined above
|
||||
*/
|
||||
payload,
|
||||
/**
|
||||
* Saleor event that triggers the webhook (here - ORDER_FILTER_SHIPPING_METHODS)
|
||||
*/
|
||||
event,
|
||||
/**
|
||||
* App's URL
|
||||
*/
|
||||
baseUrl,
|
||||
/**
|
||||
* Auth data (from APL) - contains token and saleorApiUrl that can be used to construct graphQL client
|
||||
*/
|
||||
authData,
|
||||
} = ctx;
|
||||
|
||||
/**
|
||||
* Perform logic based on Saleor Event payload e.g filter shipping methods
|
||||
* This is a synchronous webhook, so you can return the response directly.
|
||||
*/
|
||||
console.log(`Filtering shipping methods for order id: ${payload.order?.id}`);
|
||||
|
||||
const response: FilterShippingMethods = {
|
||||
excluded_methods: [],
|
||||
};
|
||||
|
||||
return res.status(200).json(response);
|
||||
});
|
||||
|
||||
/**
|
||||
* Disable body parser for this endpoint, so signature can be verified
|
||||
*/
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderFulfilledSubscriptionDocument,
|
||||
OrderFulfilledWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { sendOrderShippedEmail, formatPrice } from "@/lib/resend";
|
||||
|
||||
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
|
||||
name: "Order Fulfilled in Saleor",
|
||||
webhookPath: "api/webhooks/order-fulfilled",
|
||||
event: "ORDER_FULFILLED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderFulfilledSubscriptionDocument,
|
||||
});
|
||||
|
||||
export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload, event, baseUrl, authData } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
console.error("No order data in webhook payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
console.log(`Order ${order.number} fulfilled for customer: ${order.userEmail}`);
|
||||
|
||||
const items = ((order as any).lines || []).map((line: any) => ({
|
||||
id: line.id,
|
||||
name: line.variant?.product?.name || "Unknown Product",
|
||||
quantity: line.quantity,
|
||||
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
|
||||
}));
|
||||
|
||||
const orderData = {
|
||||
orderId: order.id,
|
||||
orderNumber: order.number || "Unknown",
|
||||
customerName: (order as any).shippingAddress?.firstName
|
||||
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
|
||||
: order.userEmail?.split("@")[0] || "Customer",
|
||||
items,
|
||||
};
|
||||
|
||||
try {
|
||||
if (order.userEmail) {
|
||||
await sendOrderShippedEmail({
|
||||
to: order.userEmail,
|
||||
orderData,
|
||||
});
|
||||
console.log(`Customer notification sent for fulfilled order ${order.number}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextPage } from "next";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Text } from "@saleor/macaw-ui";
|
||||
|
||||
const ClientWidget: NextPage = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
if (!appBridgeState?.ready) {
|
||||
return <Text>Loading widget...</Text>;
|
||||
}
|
||||
|
||||
return <Text>This is a client widget 😎. Your email is {appBridgeState.user?.email}.</Text>;
|
||||
};
|
||||
|
||||
export default ClientWidget;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Button, Input, Text } from "@saleor/macaw-ui";
|
||||
import { NextPage } from "next";
|
||||
import Link from "next/link";
|
||||
import { MouseEventHandler, useEffect, useState } from "react";
|
||||
|
||||
const AddToSaleorForm = () => (
|
||||
<Box
|
||||
as={"form"}
|
||||
display={"flex"}
|
||||
alignItems={"center"}
|
||||
gap={4}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const saleorUrl = new FormData(event.currentTarget as HTMLFormElement).get("saleor-url");
|
||||
const manifestUrl = new URL("/api/manifest", window.location.origin);
|
||||
const redirectUrl = new URL(
|
||||
`/dashboard/apps/install?manifestUrl=${manifestUrl}`,
|
||||
saleorUrl as string
|
||||
).href;
|
||||
|
||||
window.open(redirectUrl, "_blank");
|
||||
}}
|
||||
>
|
||||
<Input type="url" required label="Saleor URL" name="saleor-url" />
|
||||
<Button type="submit">Add to Saleor</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* This is page publicly accessible from your app.
|
||||
* You should probably remove it.
|
||||
*/
|
||||
const IndexPage: NextPage = () => {
|
||||
const { appBridgeState, appBridge } = useAppBridge();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleLinkClick: MouseEventHandler<HTMLAnchorElement> = (e) => {
|
||||
/**
|
||||
* In iframe, link can't be opened in new tab, so Dashboard must be a proxy
|
||||
*/
|
||||
if (appBridgeState?.ready) {
|
||||
e.preventDefault();
|
||||
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
newContext: true,
|
||||
to: e.currentTarget.href,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Otherwise, assume app is accessed outside of Dashboard, so href attribute on <a> will work
|
||||
*/
|
||||
};
|
||||
|
||||
const isLocalHost = global.location.href.includes("localhost");
|
||||
|
||||
return (
|
||||
<Box padding={8}>
|
||||
<Text size={11}>Welcome to Saleor App Template (Next.js) 🚀</Text>
|
||||
<Text as={"p"} marginY={4}>
|
||||
Saleor App Template is a minimalistic boilerplate that provides a working example of a
|
||||
Saleor app.
|
||||
</Text>
|
||||
{appBridgeState?.ready && mounted && (
|
||||
<Link href="/actions">
|
||||
<Button variant="secondary">See what your app can do →</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Text as={"p"} marginTop={8}>
|
||||
Explore the App Template by visiting:
|
||||
</Text>
|
||||
<ul>
|
||||
<li>
|
||||
<code>/src/pages/api/manifest</code> - the{" "}
|
||||
<a
|
||||
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
App Manifest
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
<code>/src/pages/api/webhooks/order-created</code> - an example <code>ORDER_CREATED</code>{" "}
|
||||
webhook handler.
|
||||
</li>
|
||||
<li>
|
||||
<code>/graphql</code> - the pre-defined GraphQL queries.
|
||||
</li>
|
||||
<li>
|
||||
<code>/generated/graphql.ts</code> - the code generated for those queries by{" "}
|
||||
<a target="_blank" rel="noreferrer" href="https://the-guild.dev/graphql/codegen">
|
||||
GraphQL Code Generator
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
<Text size={8} marginTop={8} as={"h2"}>
|
||||
Resources
|
||||
</Text>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Apps documentation </Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-with-tunnels"
|
||||
>
|
||||
<Text color={"info1"}>Tunneling the app</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/saleor/app-examples"
|
||||
>
|
||||
<Text color={"info1"}>App Examples repository</Text>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/saleor/saleor-app-sdk"
|
||||
>
|
||||
<Text color={"info1"}>Saleor App SDK</Text>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://github.com/saleor/saleor-cli"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Saleor CLI</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://github.com/saleor/apps"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Saleor App Store - official apps by Saleor Team</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://macaw-ui-next.vercel.app/?path=/docs/getting-started-installation--docs"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Macaw UI - official Saleor UI library</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://nextjs.org/docs"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Next.js documentation</Text>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{mounted && !isLocalHost && !appBridgeState?.ready && (
|
||||
<>
|
||||
<Text marginBottom={4} as={"p"}>
|
||||
Install this app in your Dashboard and get extra powers!
|
||||
</Text>
|
||||
<AddToSaleorForm />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexPage;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Provider } from "urql";
|
||||
|
||||
import { createClient } from "@/lib/create-graphq-client";
|
||||
|
||||
export function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const url = appBridgeState?.saleorApiUrl!;
|
||||
|
||||
if (!url) {
|
||||
console.warn("Install the app in the Dashboard to be able to query Saleor API.");
|
||||
return <div>{props.children}</div>;
|
||||
}
|
||||
|
||||
const client = createClient(url, async () => Promise.resolve({ token: appBridgeState?.token! }));
|
||||
|
||||
return <Provider value={client} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { APL, AuthData } from "@saleor/app-sdk/APL";
|
||||
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||
import { FileAPL } from "@saleor/app-sdk/APL/file";
|
||||
|
||||
/**
|
||||
* APL wrapper that normalizes HTTP to HTTPS for auth data lookups
|
||||
*/
|
||||
class NormalizingAPL implements APL {
|
||||
private apl: FileAPL;
|
||||
|
||||
constructor(config: { fileName: string }) {
|
||||
this.apl = new FileAPL(config);
|
||||
}
|
||||
|
||||
private normalizeUrl(url: string): string {
|
||||
return url.replace(/^http:\/\//, "https://");
|
||||
}
|
||||
|
||||
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
|
||||
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
|
||||
console.log(`[NormalizingAPL] Looking up auth for: ${saleorApiUrl} -> ${normalizedUrl}`);
|
||||
return this.apl.get(normalizedUrl);
|
||||
}
|
||||
|
||||
async set(authData: AuthData): Promise<void> {
|
||||
const normalizedUrl = this.normalizeUrl(authData.saleorApiUrl);
|
||||
console.log(`[NormalizingAPL] Storing auth for: ${authData.saleorApiUrl} -> ${normalizedUrl}`);
|
||||
return this.apl.set({
|
||||
...authData,
|
||||
saleorApiUrl: normalizedUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(saleorApiUrl: string): Promise<void> {
|
||||
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
|
||||
return this.apl.delete(normalizedUrl);
|
||||
}
|
||||
|
||||
async getAll(): Promise<AuthData[]> {
|
||||
return this.apl.getAll();
|
||||
}
|
||||
}
|
||||
|
||||
export let apl: APL;
|
||||
|
||||
switch (process.env.APL) {
|
||||
default:
|
||||
apl = new NormalizingAPL({
|
||||
fileName: process.env.AUTH_DATA_FILE_PATH || "/tmp/.auth-data.json",
|
||||
});
|
||||
}
|
||||
|
||||
export const saleorApp = new SaleorApp({
|
||||
apl,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Add test setup logic here
|
||||
*
|
||||
* https://vitest.dev/config/#setupfiles
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--mu-colors-background-surface-brand-subdued);
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user