├── src
├── admin
│ ├── vite-env.d.ts
│ ├── components
│ │ ├── modals
│ │ │ ├── route-drawer
│ │ │ │ ├── index.ts
│ │ │ │ └── route-drawer.tsx
│ │ │ ├── route-modal-form
│ │ │ │ ├── index.ts
│ │ │ │ └── route-modal-form.tsx
│ │ │ ├── utilities
│ │ │ │ ├── keybound-form
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── keybound-form.tsx
│ │ │ │ └── visually-hidden
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── visually-hidden.tsx
│ │ │ ├── route-modal-provider
│ │ │ │ ├── index.ts
│ │ │ │ ├── route-modal-context.tsx
│ │ │ │ ├── use-route-modal.tsx
│ │ │ │ └── route-provider.tsx
│ │ │ ├── stacked-modal-provider
│ │ │ │ ├── index.ts
│ │ │ │ ├── stacked-modal-context.tsx
│ │ │ │ ├── use-stacked-modal.ts
│ │ │ │ └── stacked-modal-provider.tsx
│ │ │ └── route-focus-modal
│ │ │ │ └── index.tsx
│ │ ├── single-column-layout.tsx
│ │ ├── general
│ │ │ ├── container.tsx
│ │ │ ├── section-row.tsx
│ │ │ ├── header.tsx
│ │ │ ├── action-menu.tsx
│ │ │ ├── duration-input.tsx
│ │ │ └── chip-input.tsx
│ │ ├── data-table-action.tsx
│ │ └── form
│ │ │ └── form.tsx
│ ├── i18n
│ │ ├── index.ts
│ │ ├── en.json
│ │ ├── it.json
│ │ └── $schema.json
│ ├── i18next.d.ts
│ ├── lib
│ │ ├── sdk.ts
│ │ └── extended-admin.ts
│ ├── hooks
│ │ ├── use-postmark-options.tsx
│ │ ├── use-state-aware-to.tsx
│ │ ├── use-debounced-search.tsx
│ │ ├── use-postmark-templates.tsx
│ │ ├── use-combobox-data.tsx
│ │ ├── use-reminder-schedules.ts
│ │ └── use-postmark-table.tsx
│ ├── tsconfig.json
│ └── routes
│ │ └── postmark
│ │ ├── abandonded-carts
│ │ ├── page.tsx
│ │ ├── @create
│ │ │ ├── page.tsx
│ │ │ └── create-form.tsx
│ │ ├── @edit
│ │ │ └── [id]
│ │ │ │ ├── page.tsx
│ │ │ │ └── edit-form.tsx
│ │ ├── validate-templates-section.tsx
│ │ └── reminder-schedules-table.tsx
│ │ └── page.tsx
├── workflows
│ ├── index.ts
│ ├── steps
│ │ ├── default-hooks
│ │ │ └── abandoned-cart.ts
│ │ └── fetch-abandoned-carts.ts
│ ├── notification-data.ts
│ └── abandoned-carts.ts
├── types
│ ├── templates.ts
│ ├── validation.ts
│ ├── abandoned-cart-tracking.ts
│ └── reminder-schedules.ts
├── providers
│ └── postmark
│ │ ├── index.ts
│ │ └── service.ts
├── modules
│ ├── abandoned-cart
│ │ ├── service.ts
│ │ ├── models
│ │ │ └── reminder-schedules.ts
│ │ ├── index.ts
│ │ └── migrations
│ │ │ ├── Migration20251026135601.ts
│ │ │ └── .snapshot-medusa-postmark-abandoned-cart.json
│ └── postmark
│ │ ├── index.ts
│ │ ├── loaders
│ │ └── postmark-server.ts
│ │ └── service.ts
├── SUMMARY.md
├── api
│ ├── admin
│ │ └── postmark
│ │ │ ├── options
│ │ │ └── route.ts
│ │ │ ├── templates
│ │ │ ├── [id]
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ │ └── abandoned-carts
│ │ │ └── reminders
│ │ │ ├── schedules
│ │ │ ├── route.ts
│ │ │ └── [id]
│ │ │ │ └── route.ts
│ │ │ └── validate
│ │ │ └── route.ts
│ └── middlewares.ts
├── links
│ └── reminder-schedule_template.ts
├── docs
│ ├── Variables.md
│ ├── Templates.md
│ ├── Events.md
│ ├── API-Routes.md
│ ├── Reminder-Schedules.md
│ ├── Introduction.md
│ └── Configuration.md
└── jobs
│ └── abandoned-carts.ts
├── integration-tests
├── setup.js
└── medusa-config.ts
├── .github
├── dependabot.yml
└── workflows
│ ├── codeql.yml
│ ├── mdbook.yml
│ └── release-and-publish.yaml
├── tsconfig.json
├── jest.config.js
├── LICENSE
├── CHANGELOG.md
├── .gitignore
├── package.json
├── REMINDER-SCHEDULES-BEHAVIOUR.md
└── README.md
/src/admin/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | declare const __BACKEND_URL__: string;
2 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-drawer/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./route-drawer"
2 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-modal-form/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./route-modal-form"
2 |
--------------------------------------------------------------------------------
/src/admin/components/modals/utilities/keybound-form/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./keybound-form"
2 |
--------------------------------------------------------------------------------
/src/admin/components/modals/utilities/visually-hidden/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./visually-hidden"
2 |
--------------------------------------------------------------------------------
/src/workflows/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./abandoned-carts"
2 | export * from "./notification-data"
3 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-modal-provider/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./route-provider"
2 | export * from "./use-route-modal"
3 |
--------------------------------------------------------------------------------
/integration-tests/setup.js:
--------------------------------------------------------------------------------
1 | const { MetadataStorage } = require("@mikro-orm/core")
2 |
3 | process.chdir(__dirname)
4 | MetadataStorage.clear()
5 |
--------------------------------------------------------------------------------
/src/admin/components/modals/stacked-modal-provider/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./stacked-modal-provider"
2 | export * from "./use-stacked-modal"
3 |
--------------------------------------------------------------------------------
/src/types/templates.ts:
--------------------------------------------------------------------------------
1 | export type PostmarkTemplate = {
2 | Name: string
3 | Alias: string
4 | TemplateType: string
5 | LayoutTemplate: string | null
6 | TemplateId: number
7 | }
8 |
--------------------------------------------------------------------------------
/src/admin/components/modals/utilities/visually-hidden/visually-hidden.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from "react"
2 |
3 | export const VisuallyHidden = ({ children }: PropsWithChildren) => {
4 | return {children}
5 | }
6 |
--------------------------------------------------------------------------------
/src/providers/postmark/index.ts:
--------------------------------------------------------------------------------
1 | import PostmarkProviderService from "./service"
2 | import { ModuleProvider, Modules } from "@medusajs/framework/utils"
3 |
4 | export default ModuleProvider(Modules.NOTIFICATION, {
5 | services: [PostmarkProviderService],
6 | })
7 |
--------------------------------------------------------------------------------
/src/admin/i18n/index.ts:
--------------------------------------------------------------------------------
1 | import en from "./en.json" with { type: "json" }
2 | import it from "./it.json" with { type: "json" }
3 |
4 | export default {
5 | en: {
6 | postmark: en,
7 | },
8 | it: {
9 | postmark: it,
10 | },
11 | }
12 |
--------------------------------------------------------------------------------
/src/modules/abandoned-cart/service.ts:
--------------------------------------------------------------------------------
1 | import { MedusaService } from "@medusajs/framework/utils"
2 | import ReminderSchedule from "./models/reminder-schedules"
3 |
4 | class AbandonedCartModuleService extends MedusaService({
5 | ReminderSchedule,
6 | }) { }
7 |
8 | export default AbandonedCartModuleService
9 |
--------------------------------------------------------------------------------
/src/SUMMARY.md:
--------------------------------------------------------------------------------
1 | # Summary
2 |
3 | - [Introduction](docs/Introduction.md)
4 | - [Configuration](docs/Configuration.md)
5 | - [Events](docs/Events.md)
6 | - [Templates](docs/Templates.md)
7 | - [Variables](docs/Variables.md)
8 | - [Reminder Schedules](docs/Reminder-Schedules.md)
9 | - [API-Routes](docs/API-Routes.md)
10 |
--------------------------------------------------------------------------------
/src/api/admin/postmark/options/route.ts:
--------------------------------------------------------------------------------
1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
2 |
3 | export async function GET(req: MedusaRequest, res: MedusaResponse) {
4 | const postmarkModuleService = req.scope.resolve("postmarkModuleService")
5 | res.json(await postmarkModuleService.getServerId())
6 | }
7 |
--------------------------------------------------------------------------------
/src/admin/components/single-column-layout.tsx:
--------------------------------------------------------------------------------
1 | export type SingleColumnLayoutProps = {
2 | children: React.ReactNode
3 | }
4 |
5 | export const SingleColumnLayout = ({ children }: SingleColumnLayoutProps) => {
6 | return (
7 |
8 | {children}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/types/validation.ts:
--------------------------------------------------------------------------------
1 | export type ValidationResult = {
2 | templateId: string
3 | templateName?: string
4 | missingVariables?: Record
5 | providedData?: Record | null
6 | }
7 |
8 | export type ValidationResponse = {
9 | success: boolean
10 | message: string
11 | results: ValidationResult[]
12 | }
13 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | allow:
6 | - dependency-name: "@medusajs/*"
7 | groups:
8 | medusa:
9 | dependency-type: "development"
10 | patterns:
11 | - "@medusajs/*"
12 | schedule:
13 | interval: "weekly"
14 | commit-message:
15 | prefix: "npm"
16 |
--------------------------------------------------------------------------------
/src/admin/components/general/container.tsx:
--------------------------------------------------------------------------------
1 | import { Container as UiContainer, clx } from "@medusajs/ui"
2 |
3 | type ContainerProps = React.ComponentProps
4 |
5 | export const Container = (props: ContainerProps) => {
6 | return (
7 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/admin/components/modals/stacked-modal-provider/stacked-modal-context.tsx:
--------------------------------------------------------------------------------
1 | import { createContext } from "react"
2 |
3 | type StackedModalState = {
4 | getIsOpen: (id: string) => boolean
5 | setIsOpen: (id: string, open: boolean) => void
6 | register: (id: string) => void
7 | unregister: (id: string) => void
8 | }
9 |
10 | export const StackedModalContext = createContext(null)
11 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-modal-provider/route-modal-context.tsx:
--------------------------------------------------------------------------------
1 | import { createContext } from "react"
2 |
3 | type RouteModalProviderState = {
4 | handleSuccess: (path?: string) => void
5 | setCloseOnEscape: (value: boolean) => void
6 | __internal: {
7 | closeOnEscape: boolean
8 | }
9 | }
10 |
11 | export const RouteModalProviderContext =
12 | createContext(null)
13 |
--------------------------------------------------------------------------------
/src/admin/i18next.d.ts:
--------------------------------------------------------------------------------
1 | import type enTranslation from "./i18n/en.json"
2 | import type { Resources } from "@medusajs/dashboard"
3 |
4 | declare module "i18next" {
5 | interface CustomTypeOptions {
6 | fallbackNS: "translation"
7 | resources: {
8 | translation: Resources["translation"]
9 | postmark: typeof enTranslation & Resources["translation"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-modal-provider/use-route-modal.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react"
2 | import { RouteModalProviderContext } from "./route-modal-context"
3 |
4 | export const useRouteModal = () => {
5 | const context = useContext(RouteModalProviderContext)
6 |
7 | if (!context) {
8 | throw new Error("useRouteModal must be used within a RouteModalProvider")
9 | }
10 |
11 | return context
12 | }
13 |
--------------------------------------------------------------------------------
/src/admin/components/modals/stacked-modal-provider/use-stacked-modal.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from "react"
2 | import { StackedModalContext } from "./stacked-modal-context"
3 |
4 | export const useStackedModal = () => {
5 | const context = useContext(StackedModalContext)
6 |
7 | if (!context) {
8 | throw new Error(
9 | "useStackedModal must be used within a StackedModalProvider"
10 | )
11 | }
12 |
13 | return context
14 | }
15 |
--------------------------------------------------------------------------------
/src/admin/lib/sdk.ts:
--------------------------------------------------------------------------------
1 | import Medusa from "@medusajs/js-sdk"
2 | import { ExtendedAdmin } from "./extended-admin"
3 |
4 | const baseSdk = new Medusa({
5 | baseUrl: __BACKEND_URL__ || "/",
6 | auth: {
7 | type: "session",
8 | },
9 | })
10 | const extendedAdmin = new ExtendedAdmin(baseSdk.client)
11 |
12 | export const sdk = {
13 | ...baseSdk,
14 | admin: {
15 | ...baseSdk.admin,
16 | postmark: extendedAdmin.postmark,
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/modules/abandoned-cart/models/reminder-schedules.ts:
--------------------------------------------------------------------------------
1 | import { model } from "@medusajs/framework/utils"
2 |
3 | const ReminderSchedule = model.define("reminder_schedule", {
4 | id: model.id().primaryKey(),
5 | enabled: model.boolean(),
6 | template_id: model.text().unique(),
7 | delays_iso: model.array(),
8 | notify_existing: model.boolean().default(false),
9 | reset_on_cart_update: model.boolean().default(true),
10 | })
11 |
12 | export default ReminderSchedule
13 |
--------------------------------------------------------------------------------
/src/modules/abandoned-cart/index.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@medusajs/framework/utils"
2 | import AbandonedCartModuleService from "./service"
3 |
4 | declare module "@medusajs/framework/types" {
5 | interface ModuleImplementations {
6 | [ABANDONED_CART_MODULE]: AbandonedCartModuleService;
7 | }
8 | }
9 |
10 | export const ABANDONED_CART_MODULE = "postmark_abandoned_cart"
11 |
12 | export default Module(ABANDONED_CART_MODULE, {
13 | service: AbandonedCartModuleService,
14 | })
15 |
--------------------------------------------------------------------------------
/src/modules/postmark/index.ts:
--------------------------------------------------------------------------------
1 |
2 | import postmarkServerLoader from "./loaders/postmark-server";
3 | import PostmarkModuleService from "./service"
4 | import { Module } from "@medusajs/framework/utils"
5 |
6 | declare module "@medusajs/framework/types" {
7 | interface ModuleImplementations {
8 | [POSTMARK_MODULE]: PostmarkModuleService;
9 | }
10 | }
11 |
12 | export const POSTMARK_MODULE = "postmarkModuleService"
13 |
14 | export default Module(POSTMARK_MODULE, {
15 | service: PostmarkModuleService, loaders: [postmarkServerLoader]
16 | })
17 |
--------------------------------------------------------------------------------
/src/links/reminder-schedule_template.ts:
--------------------------------------------------------------------------------
1 | import { defineLink } from "@medusajs/framework/utils"
2 | import AbandonedCartModule from "../modules/abandoned-cart"
3 | import { POSTMARK_MODULE } from "../modules/postmark"
4 |
5 | export default defineLink(
6 | {
7 | linkable: AbandonedCartModule.linkable.reminderSchedule,
8 | field: "template_id",
9 | },
10 | {
11 | linkable: {
12 | serviceName: POSTMARK_MODULE,
13 | alias: "template",
14 | primaryKey: "template_id",
15 | },
16 | },
17 | {
18 | readOnly: true,
19 | }
20 | )
21 |
--------------------------------------------------------------------------------
/src/workflows/steps/default-hooks/abandoned-cart.ts:
--------------------------------------------------------------------------------
1 | import { NotificationDataWorkflowInput } from "../../notification-data"
2 |
3 | export const defaultAbandonedCartData =
4 | async (carts: NotificationDataWorkflowInput["carts"]) =>
5 | carts.flatMap(({ cart, reminders }) =>
6 | reminders.map(reminder => ({
7 | to: cart.email!,
8 | channel: "feed",
9 | template: reminder.template,
10 | data: {
11 | cart
12 | }
13 | }))
14 | )
15 |
16 |
--------------------------------------------------------------------------------
/src/admin/hooks/use-postmark-options.tsx:
--------------------------------------------------------------------------------
1 | import { sdk } from "../lib/sdk"
2 | import { useQuery } from "@tanstack/react-query"
3 |
4 | type PostmarkServerOptions = {
5 | server_id: string
6 | }
7 |
8 | export const usePostmarkOptions = () => {
9 | const { data, isPending, error } = useQuery({
10 | queryFn: () => sdk.client.fetch("/admin/postmark/options"),
11 | queryKey: ["postmark-options"],
12 | staleTime: 5 * 60 * 1000, // 5 minutes
13 | })
14 |
15 | return {
16 | options: data,
17 | isLoading: isPending,
18 | error,
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/modules/postmark/loaders/postmark-server.ts:
--------------------------------------------------------------------------------
1 | import { LoaderOptions } from "@medusajs/framework/types";
2 | import { MedusaError } from "@medusajs/framework/utils";
3 | import { asValue } from "awilix";
4 | import { ServerClient } from "postmark";
5 |
6 | export default async function postmarkServerLoader({
7 | container,
8 | options,
9 | }: LoaderOptions<{ apiKey?: string }>) {
10 | if (!options?.apiKey)
11 | throw new MedusaError(MedusaError.Types.NOT_FOUND, "Postmark API key not provided")
12 |
13 | const client = new ServerClient(options.apiKey)
14 | container.register("postmarkClient", asValue(client))
15 | }
16 |
--------------------------------------------------------------------------------
/src/admin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["."]
24 | }
25 |
--------------------------------------------------------------------------------
/integration-tests/medusa-config.ts:
--------------------------------------------------------------------------------
1 | import { loadEnv, defineConfig, Modules } from '@medusajs/framework/utils'
2 |
3 | loadEnv(process.env.NODE_ENV || 'development', process.cwd())
4 |
5 | module.exports = defineConfig({
6 | projectConfig: {
7 | databaseUrl: process.env.DATABASE_URL,
8 | http: {
9 | storeCors: process.env.STORE_CORS!,
10 | adminCors: process.env.ADMIN_CORS!,
11 | authCors: process.env.AUTH_CORS!,
12 | jwtSecret: process.env.JWT_SECRET || "supersecret",
13 | cookieSecret: process.env.COOKIE_SECRET || "supersecret",
14 | }
15 | },
16 | plugins: [
17 | {
18 | resolve: `${process.cwd()}/..`, options: {
19 | server_api: process.env.POSTMARK_API_KEY!,
20 | }
21 | },
22 | ]
23 | })
24 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/abandonded-carts/page.tsx:
--------------------------------------------------------------------------------
1 | import { defineRouteConfig } from "@medusajs/admin-sdk"
2 | import { Outlet } from "react-router-dom"
3 | import { SingleColumnLayout } from "../../../components/single-column-layout"
4 | import ReminderSchedulesTable from "./reminder-schedules-table"
5 | import { ValidateTemplatesSection } from './validate-templates-section'
6 |
7 | const AbandonedCartsPage = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
17 | export default AbandonedCartsPage
18 |
19 | export const config = defineRouteConfig({
20 | label: "Abandoned Carts" // "menuLabels.abandoned_carts",
21 | // translationNs: "postmark",
22 | })
23 |
--------------------------------------------------------------------------------
/src/admin/hooks/use-state-aware-to.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react"
2 | import { Path, useLocation } from "react-router-dom"
3 |
4 | /**
5 | * Checks if the current location has a restore_params property.
6 | * If it does, it will return a new path with the params added to it.
7 | * Otherwise, it will return the previous path.
8 | *
9 | * This is useful if the modal needs to return to the original path, with
10 | * the params that were present when the modal was opened.
11 | */
12 | export const useStateAwareTo = (prev: string | Partial) => {
13 | const location = useLocation()
14 |
15 | const to = useMemo(() => {
16 | const params = location.state?.restore_params
17 |
18 | if (!params) {
19 | return prev
20 | }
21 |
22 | return `${prev}?${params.toString()}`
23 | }, [location.state, prev])
24 |
25 | return to
26 | }
27 |
--------------------------------------------------------------------------------
/src/docs/Variables.md:
--------------------------------------------------------------------------------
1 | # Variables
2 |
3 | Template variables are the dynamic data passed to Postmark templates when sending notifications. The plugin ensures that all required variables are generated and validated before sending.
4 |
5 | ## Generation
6 | - Variables are generated by the notification data workflow based on the cart, customer, and reminder schedule context.
7 | - Each template may require different variables, which are validated before sending.
8 |
9 | ## Validation
10 | - The plugin uses Postmark's template validation endpoint to check for missing variables.
11 | - If required variables are missing, the notification is not sent and an error is logged.
12 |
13 | ## Usage
14 | - Variables are passed as the `TemplateModel` when sending templated emails through Postmark.
15 | - Ensure your templates reference only variables that are provided by the workflow.
--------------------------------------------------------------------------------
/src/admin/hooks/use-debounced-search.tsx:
--------------------------------------------------------------------------------
1 | import debounce from "lodash/debounce"
2 | import { useCallback, useEffect, useState } from "react"
3 |
4 | /**
5 | * Hook for debouncing search input
6 | * @returns searchValue, onSearchValueChange, query
7 | */
8 | export const useDebouncedSearch = () => {
9 | const [searchValue, onSearchValueChange] = useState("")
10 | const [debouncedQuery, setDebouncedQuery] = useState("")
11 |
12 | // eslint-disable-next-line react-hooks/exhaustive-deps
13 | const debouncedUpdate = useCallback(
14 | debounce((query: string) => setDebouncedQuery(query), 300),
15 | []
16 | )
17 |
18 | useEffect(() => {
19 | debouncedUpdate(searchValue)
20 |
21 | return () => debouncedUpdate.cancel()
22 | }, [searchValue, debouncedUpdate])
23 |
24 | return {
25 | searchValue,
26 | onSearchValueChange,
27 | query: debouncedQuery || undefined,
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/docs/Templates.md:
--------------------------------------------------------------------------------
1 | # Templates
2 |
3 | Templates are managed through the Postmark integration and are used to render the content of abandoned cart and other notification emails. The plugin provides CRUD operations for templates and supports validation to ensure all required variables are provided.
4 |
5 | ## Features
6 | - **List Templates**: Fetch all templates from Postmark.
7 | - **Create Template**: Add a new template to Postmark.
8 | - **Update Template**: Modify an existing template.
9 | - **Delete Template**: Remove a template from Postmark.
10 | - **Layout Templates**: Support for layout templates in Postmark.
11 | - **Validation**: Check that all required variables are present in the template model before sending.
12 |
13 | ## Template Association
14 | Each reminder schedule is linked to a specific template by `template_id`. Templates can be standard or layout types, and are validated before use in workflows.
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2024",
4 | "esModuleInterop": true,
5 | "module": "Node16",
6 | "moduleResolution": "Node16",
7 | "emitDecoratorMetadata": true,
8 | "experimentalDecorators": true,
9 | "skipLibCheck": true,
10 | "skipDefaultLibCheck": true,
11 | "declaration": true,
12 | "sourceMap": false,
13 | "inlineSourceMap": true,
14 | "outDir": "./.medusa/server",
15 | "rootDir": "./",
16 | "jsx": "react-jsx",
17 | "forceConsistentCasingInFileNames": true,
18 | "resolveJsonModule": true,
19 | "checkJs": false,
20 | "strictNullChecks": true
21 | },
22 | "ts-node": {
23 | "swc": true
24 | },
25 | "include": [
26 | "**/*",
27 | ".medusa/types/*"
28 | ],
29 | "exclude": [
30 | "node_modules",
31 | ".medusa/server",
32 | ".medusa/admin",
33 | "integration-tests",
34 | "src/admin",
35 | ".cache"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/abandonded-carts/@create/page.tsx:
--------------------------------------------------------------------------------
1 | import { Heading } from "@medusajs/ui"
2 | import { RouteDrawer } from "../../../../components/modals/route-drawer"
3 | import { CreateReminderScheduleForm } from "./create-form"
4 | import { useTranslation } from "react-i18next"
5 |
6 | const CreateAbandonedCartPage = () => {
7 | const { t } = useTranslation("postmark")
8 | return (
9 |
10 |
11 |
12 | {t("reminder_schedules.create_title")}
13 |
14 |
15 | {t("reminder_schedules.create_description")}
16 |
17 |
18 |
19 |
20 | )
21 | }
22 | export default CreateAbandonedCartPage
23 |
--------------------------------------------------------------------------------
/src/api/admin/postmark/templates/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
2 |
3 | export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
4 | const { id } = req.params
5 |
6 | if (!id) {
7 | return res.status(400).json({
8 | error: "Template ID is required"
9 | })
10 | }
11 |
12 | // Validate template ID is a number
13 | const templateId = parseInt(id as string)
14 | if (isNaN(templateId)) {
15 | return res.status(400).json({
16 | error: "Invalid template ID",
17 | message: "Template ID must be a valid number"
18 | })
19 | }
20 |
21 | const postmarkModuleService = req.scope.resolve("postmarkModuleService")
22 | const cache = req.scope.resolve("caching")
23 |
24 | await postmarkModuleService.deleteTemplate(templateId)
25 | await cache?.clear({ tags: ["PostmarkTemplate:list:*"] })
26 |
27 | res.json({ success: true, message: "Template deleted successfully" })
28 | }
29 |
--------------------------------------------------------------------------------
/src/admin/components/data-table-action.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@medusajs/ui"
2 | import { Link } from "react-router-dom"
3 |
4 | type DataTableActionProps = {
5 | label: string
6 | disabled?: boolean
7 | } & (
8 | | {
9 | to: string
10 | }
11 | | {
12 | onClick: () => void
13 | }
14 | )
15 |
16 | export const DataTableAction = ({
17 | label,
18 | disabled,
19 | ...props
20 | }: DataTableActionProps) => {
21 | const buttonProps = {
22 | size: "small" as const,
23 | disabled: disabled ?? false,
24 | type: "button" as const,
25 | variant: "secondary" as const,
26 | }
27 |
28 | if ("to" in props) {
29 | return (
30 |
33 | )
34 | }
35 |
36 | return (
37 |
40 | )
41 | }
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/docs/Events.md:
--------------------------------------------------------------------------------
1 | # Events
2 |
3 | The plugin uses event-driven workflows to automate abandoned cart notifications and template validation. Events are triggered by cart updates, schedule changes, and workflow executions.
4 |
5 | ## Main Events
6 | - **Cart Abandoned**: Triggers the abandoned cart workflow to check for eligible reminders.
7 | - **Reminder Schedule Updated**: Re-evaluates which carts are eligible for notifications.
8 | - **Template Validated**: Ensures all required variables are present before sending.
9 |
10 | # Workflows
11 |
12 | ## Abandoned Cart Workflow
13 | - Fetches eligible carts based on reminder schedules.
14 | - Determines which reminders should be sent and when.
15 | - Triggers notification sending using the associated Postmark template.
16 |
17 | ## Notification Data Workflow
18 | - Prepares the data required for each notification, including template variables.
19 | - Validates that all required variables are present for the selected template.
20 |
21 | These workflows ensure that notifications are sent reliably and only when all conditions are met.
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const { loadEnv } = require("@medusajs/utils");
2 | loadEnv("test", process.cwd());
3 |
4 | module.exports = {
5 | transform: {
6 | "^.+\\.[jt]s$": [
7 | "@swc/jest",
8 | {
9 | jsc: {
10 | parser: { syntax: "typescript", decorators: true },
11 | target: "es2021",
12 | },
13 | },
14 | ],
15 | },
16 | testEnvironment: "node",
17 | moduleFileExtensions: ["js", "ts", "json"],
18 | modulePathIgnorePatterns: ["dist/", "/.medusa/"],
19 | setupFiles: ["./integration-tests/setup.js"],
20 | };
21 |
22 | if (process.env.TEST_TYPE === "integration:http") {
23 | module.exports.testMatch = ["**/integration-tests/http/*.spec.[jt]s"];
24 | } else if (process.env.TEST_TYPE === "integration:workflows") {
25 | module.exports.testMatch = ["**/integration-tests/workflows/*.spec.[jt]s"];
26 | } else if (process.env.TEST_TYPE === "integration:modules") {
27 | module.exports.testMatch = ["**/src/modules/*/__tests__/**/*.[jt]s"];
28 | } else if (process.env.TEST_TYPE === "unit") {
29 | module.exports.testMatch = ["**/src/**/__tests__/**/*.unit.spec.[jt]s"];
30 | }
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Bram Hammer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/admin/components/modals/utilities/keybound-form/keybound-form.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | /**
4 | * A form that can only be submitted when using the meta or control key.
5 | */
6 | export const KeyboundForm = React.forwardRef<
7 | HTMLFormElement,
8 | React.FormHTMLAttributes
9 | >(({ onSubmit, onKeyDown, ...rest }, ref) => {
10 | const handleSubmit = (event: React.FormEvent) => {
11 | event.preventDefault()
12 | onSubmit?.(event)
13 | }
14 |
15 | const handleKeyDown = (event: React.KeyboardEvent) => {
16 | if (event.key === "Enter") {
17 | if (
18 | event.target instanceof HTMLTextAreaElement &&
19 | !(event.metaKey || event.ctrlKey)
20 | ) {
21 | return
22 | }
23 |
24 | event.preventDefault()
25 |
26 | if (event.metaKey || event.ctrlKey) {
27 | handleSubmit(event)
28 | }
29 | }
30 | }
31 |
32 | return (
33 |
39 | )
40 | })
41 |
42 | KeyboundForm.displayName = "KeyboundForm"
43 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-modal-provider/route-provider.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, useCallback, useMemo, useState } from "react"
2 | import { Path, useNavigate } from "react-router-dom"
3 | import { RouteModalProviderContext } from "./route-modal-context"
4 |
5 | type RouteModalProviderProps = PropsWithChildren<{
6 | prev: string | Partial
7 | }>
8 |
9 | export const RouteModalProvider = ({
10 | prev,
11 | children,
12 | }: RouteModalProviderProps) => {
13 | const navigate = useNavigate()
14 |
15 | const [closeOnEscape, setCloseOnEscape] = useState(true)
16 |
17 | const handleSuccess = useCallback(
18 | (path?: string) => {
19 | const to = path || prev
20 | navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
21 | },
22 | [navigate, prev]
23 | )
24 |
25 | const value = useMemo(
26 | () => ({
27 | handleSuccess,
28 | setCloseOnEscape,
29 | __internal: { closeOnEscape },
30 | }),
31 | [handleSuccess, setCloseOnEscape, closeOnEscape]
32 | )
33 |
34 | return (
35 |
36 | {children}
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/abandonded-carts/@edit/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Heading } from "@medusajs/ui"
2 | import { RouteDrawer } from "../../../../../components/modals/route-drawer"
3 | import { EditReminderScheduleForm } from "./edit-form"
4 | import { useReminderSchedule } from "../../../../../hooks/use-reminder-schedules"
5 | import { useParams } from "react-router-dom"
6 | import { useTranslation } from "react-i18next"
7 |
8 | const EditAbandonedCartPage = () => {
9 | const { t } = useTranslation("postmark")
10 | const { id = "" } = useParams()
11 | const { schedule, isPending } = useReminderSchedule(id)
12 | return (
13 |
14 |
15 |
16 | {t("reminder_schedules.edit_title")}
17 |
18 |
19 | {t("reminder_schedules.edit_description")}
20 |
21 |
22 | {schedule && !isPending && }
23 |
24 | )
25 | }
26 | export default EditAbandonedCartPage
27 |
28 |
--------------------------------------------------------------------------------
/src/modules/abandoned-cart/migrations/Migration20251026135601.ts:
--------------------------------------------------------------------------------
1 | import { Migration } from '@mikro-orm/migrations';
2 |
3 | export class Migration20251026135601 extends Migration {
4 |
5 | override async up(): Promise {
6 | this.addSql(`alter table if exists "reminder_schedule" drop constraint if exists "reminder_schedule_template_id_unique";`);
7 | this.addSql(`create table if not exists "reminder_schedule" ("id" text not null, "enabled" boolean not null, "template_id" text not null, "delays_iso" text[] not null, "notify_existing" boolean not null default false, "reset_on_cart_update" boolean not null default true, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "reminder_schedule_pkey" primary key ("id"));`);
8 | this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_reminder_schedule_template_id_unique" ON "reminder_schedule" (template_id) WHERE deleted_at IS NULL;`);
9 | this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_reminder_schedule_deleted_at" ON "reminder_schedule" (deleted_at) WHERE deleted_at IS NULL;`);
10 | }
11 |
12 | override async down(): Promise {
13 | this.addSql(`drop table if exists "reminder_schedule" cascade;`);
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/src/admin/components/general/section-row.tsx:
--------------------------------------------------------------------------------
1 | import { Text, clx } from "@medusajs/ui"
2 |
3 | export type SectionRowProps = {
4 | title: string
5 | value?: React.ReactNode | string | null
6 | actions?: React.ReactNode
7 | }
8 |
9 | export const SectionRow = ({ title, value, actions }: SectionRowProps) => {
10 | const isValueString = typeof value === "string" || !value
11 |
12 | return (
13 |
21 |
22 | {title}
23 |
24 | {isValueString ? (
25 |
30 | {value ?? "-"}
31 |
32 | ) : (
33 |
{value}
34 | )}
35 | {actions &&
{actions}
}
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/admin/components/modals/stacked-modal-provider/stacked-modal-provider.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, useState } from "react"
2 | import { StackedModalContext } from "./stacked-modal-context"
3 |
4 | type StackedModalProviderProps = PropsWithChildren<{
5 | onOpenChange: (open: boolean) => void
6 | }>
7 |
8 | export const StackedModalProvider = ({
9 | children,
10 | onOpenChange,
11 | }: StackedModalProviderProps) => {
12 | const [state, setState] = useState>({})
13 |
14 | const getIsOpen = (id: string) => {
15 | return state[id] || false
16 | }
17 |
18 | const setIsOpen = (id: string, open: boolean) => {
19 | setState((prevState) => ({
20 | ...prevState,
21 | [id]: open,
22 | }))
23 |
24 | onOpenChange(open)
25 | }
26 |
27 | const register = (id: string) => {
28 | setState((prevState) => ({
29 | ...prevState,
30 | [id]: false,
31 | }))
32 | }
33 |
34 | const unregister = (id: string) => {
35 | setState((prevState) => {
36 | const newState = { ...prevState }
37 | delete newState[id]
38 | return newState
39 | })
40 | }
41 |
42 | return (
43 |
51 | {children}
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/src/api/admin/postmark/abandoned-carts/reminders/schedules/route.ts:
--------------------------------------------------------------------------------
1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2 | import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
3 | import { ABANDONED_CART_MODULE } from "../../../../../../modules/abandoned-cart"
4 | import {
5 | CreateReminderSchedule
6 | } from "../../../../../../types/reminder-schedules"
7 |
8 | export async function GET(req: MedusaRequest, res: MedusaResponse) {
9 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
10 |
11 | const { data: schedules } = await query.graph(
12 | {
13 | entity: "reminder_schedule",
14 | ...req.queryConfig,
15 | },
16 | {
17 | cache: { enable: true },
18 | }
19 | )
20 |
21 | res.json({ schedules })
22 | }
23 |
24 | export async function POST(req: MedusaRequest, res: MedusaResponse) {
25 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE)
26 | const schedule = await abandonedCartModuleService.createReminderSchedules(req.validatedBody)
27 |
28 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
29 | const { data: schedules } = await query.graph({
30 | entity: "reminder_schedule",
31 | fields: [
32 | "*",
33 | "template.*",
34 | ],
35 | filters: {
36 | id: schedule.id,
37 | },
38 | })
39 |
40 | res.json({ schedules })
41 | }
42 |
--------------------------------------------------------------------------------
/src/docs/API-Routes.md:
--------------------------------------------------------------------------------
1 | # API Routes
2 |
3 | The plugin exposes several API endpoints for managing reminder schedules, templates, layouts, and sending emails through Postmark. All routes are proxied through the Medusa server for security and consistency.
4 |
5 | ## Reminder Schedules
6 | - `GET /admin/postmark/abandoned-carts/reminders/schedules` — List all reminder schedules
7 | - `GET /admin/postmark/abandoned-carts/reminders/schedules/:id` — Get a reminder schedule by id
8 | - `POST /admin/postmark/abandoned-carts/reminders/schedules` — Create a new reminder schedule
9 | - `POST /admin/postmark/abandoned-carts/reminders/schedules/:id` — Update a reminder schedule
10 | - `DELETE /admin/postmark/abandoned-carts/reminders/schedules/:id` — Delete a reminder schedule
11 |
12 | ## Templates
13 | - `GET /admin/postmark/templates` — List all templates
14 | - `GET /admin/postmark/templates/:id` — Get a template by ID
15 | - `POST /admin/postmark/templates` — Create a new template
16 | - `POST /admin/postmark/templates/:id` — Update a template
17 | - `DELETE /admin/postmark/templates/:id` — Delete a template
18 |
19 | ## Options
20 | - `GET /admin/postmark/options` — Returns configuration options about the postmark server, currently just returns the server's ID.
21 |
22 | ## Validation
23 | - `POST /admin/postmark/abandoned-carts/reminders/validate` — Validate reminder schedules against template data,to make sure that no template misses any data.
24 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ "main" ]
17 | pull_request:
18 | branches: [ "main" ]
19 | schedule:
20 | - cron: '22 9 * * 5'
21 |
22 | jobs:
23 | analyze:
24 | name: Analyze
25 | runs-on: 'ubuntu-latest'
26 | timeout-minutes: 360
27 | permissions:
28 | security-events: write
29 |
30 | strategy:
31 | fail-fast: false
32 | matrix:
33 | language: [ 'javascript-typescript' ]
34 |
35 | steps:
36 | - name: Checkout repository
37 | uses: actions/checkout@v4
38 |
39 | - name: Initialize CodeQL
40 | uses: github/codeql-action/init@v3
41 | with:
42 | languages: ${{ matrix.language }}
43 |
44 | - name: Autobuild
45 | uses: github/codeql-action/autobuild@v3
46 |
47 | - name: Perform CodeQL Analysis
48 | uses: github/codeql-action/analyze@v3
49 | with:
50 | category: "/language:${{matrix.language}}"
51 |
--------------------------------------------------------------------------------
/.github/workflows/mdbook.yml:
--------------------------------------------------------------------------------
1 | # Sample workflow for building and deploying a mdBook site to GitHub Pages
2 | #
3 | # To get started with mdBook see: https://rust-lang.github.io/mdBook/index.html
4 | #
5 | name: Deploy mdBook site to Pages
6 |
7 | on:
8 | push:
9 | branches: ["main"]
10 |
11 | workflow_dispatch:
12 |
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | concurrency:
19 | group: "pages"
20 | cancel-in-progress: false
21 |
22 | jobs:
23 | # Build job
24 | build:
25 | runs-on: ubuntu-latest
26 | env:
27 | MDBOOK_VERSION: 0.4.37
28 | steps:
29 | - uses: actions/checkout@v4
30 | - name: Install mdBook
31 | run: |
32 | curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf -y | sh
33 | rustup update
34 | cargo install --version ${MDBOOK_VERSION} mdbook
35 | - name: Setup Pages
36 | id: pages
37 | uses: actions/configure-pages@v4
38 | - name: Build with mdBook
39 | run: mdbook build
40 | - name: Upload artifact
41 | uses: actions/upload-pages-artifact@v3
42 | with:
43 | path: ./book
44 |
45 | # Deployment job
46 | deploy:
47 | environment:
48 | name: github-pages
49 | url: ${{ steps.deployment.outputs.page_url }}
50 | runs-on: ubuntu-latest
51 | needs: build
52 | steps:
53 | - name: Deploy to GitHub Pages
54 | id: deployment
55 | uses: actions/deploy-pages@v4
56 |
--------------------------------------------------------------------------------
/src/docs/Reminder-Schedules.md:
--------------------------------------------------------------------------------
1 | # Reminder Schedules
2 |
3 | Reminder schedules define when and how abandoned cart reminder emails are sent to customers. Each schedule specifies a set of delays (in ISO 8601 duration format), the Postmark template to use, and additional flags to control notification behavior.
4 |
5 | ## Fields
6 | - **id**: Unique identifier for the schedule.
7 | - **enabled**: Whether the schedule is active.
8 | - **template_id**: The Postmark template associated with this schedule.
9 | - **delays_iso**: Array of ISO 8601 durations (e.g., `['PT1H', 'P1D']`) specifying when reminders are sent after cart abandonment.
10 | - **notify_existing**: If true, carts created before a schedule update are eligible for notifications.
11 | - **reset_on_cart_update**: If true, the notification cycle restarts if the cart is updated after abandonment.
12 | - **created_at / updated_at / deleted_at**: Timestamps for schedule lifecycle management.
13 |
14 | ## Behavior
15 | - Each schedule can be enabled or disabled.
16 | - Multiple schedules can exist, each with its own template and delays.
17 | - The system ensures that reminders are not sent more than once for the same delay and cart, unless `reset_on_cart_update` is enabled and the cart is updated.
18 | - Schedules are linked to templates, and referential integrity is enforced.
19 |
20 | ## Use Case
21 | Reminder schedules are used by the abandoned cart workflow to determine which customers should receive reminder emails and when, ensuring a flexible and robust notification strategy.
--------------------------------------------------------------------------------
/src/docs/Introduction.md:
--------------------------------------------------------------------------------
1 | # medusa-plugin-postmark
2 |
3 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark)
4 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark)
5 |
6 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark/releases/)
7 | [](#license)
8 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark/issues)
9 |
10 | Notifications plugin for Medusa ecommerce server that sends transactional emails via [PostMark](https://postmarkapp.com/).
11 |
12 | ## Features
13 |
14 | - Uses the email templating features built into Postmark
15 | - You can import/use tools like [stripo.email](https://stripo.email)
16 | - The plugin is in active development. If you have any feature requests, please open an issue.
17 | - Create PDF invoices and credit notes and attach them to the email
18 | - Send out upsell emails to customers that have recently placed an order with certain collections
19 | - Send out automated abandoned cart emails to customers that have abandoned their cart (based on last updated date of cart)
20 |
--------------------------------------------------------------------------------
/src/types/abandoned-cart-tracking.ts:
--------------------------------------------------------------------------------
1 | import { CartDTO } from "@medusajs/framework/types"
2 |
3 | /**
4 | * Record of a sent notification for a specific delay in a schedule
5 | */
6 | export interface SentNotification {
7 | sent_at: string // ISO timestamp when notification was sent
8 | cart_reference_at_send: string // ISO timestamp of cart's reference time when sent
9 | }
10 |
11 | /**
12 | * Tracking data stored in cart metadata
13 | */
14 | export interface AbandonedCartTracking {
15 | sent_notifications: {
16 | [key: string]: SentNotification // Key format: "schedule_id:delay_iso"
17 | }
18 | }
19 |
20 | /**
21 | * Compute the reference timestamp for a cart
22 | * This is the most recent of: cart.created_at or any item.updated_at
23 | */
24 | export function computeCartReferenceTimestamp(cart: CartDTO): number {
25 | const timestamps = [
26 | new Date(cart.created_at!).getTime(),
27 | ...(cart.items?.map(item => new Date(item!.updated_at!).getTime()) || [])
28 | ]
29 | return Math.max(...timestamps)
30 | }
31 |
32 | /**
33 | * Get sent notification record for a specific schedule and delay
34 | */
35 | export function getSentNotification(
36 | cart: CartDTO,
37 | scheduleId: string,
38 | delayIso: string
39 | ): SentNotification | null {
40 | const tracking = cart.metadata?.abandoned_cart_tracking as AbandonedCartTracking | null
41 | if (!tracking) return null
42 |
43 | const key = `${scheduleId}:${delayIso}`
44 | return tracking.sent_notifications?.[key] || null
45 | }
46 |
--------------------------------------------------------------------------------
/src/admin/hooks/use-postmark-templates.tsx:
--------------------------------------------------------------------------------
1 | import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
2 | import { sdk } from "../lib/sdk"
3 | import { PostmarkTemplate } from "../../types/templates"
4 | import { HttpTypes } from "@medusajs/framework/types"
5 | import { FetchError } from "@medusajs/js-sdk"
6 |
7 | type TemplatesResponse = {
8 | TotalCount: number
9 | Templates: PostmarkTemplate[]
10 | }
11 |
12 | type QueryInput = HttpTypes.FindParams & {
13 | id?: string
14 | q?: string
15 | templateType?: "Standard" | "Layout"
16 | }
17 |
18 | const POSTMARK_TEMPLATES_QUERY_KEY = "postmark_templates" as const
19 |
20 | const postmarkTemplatesQueryKeys = {
21 | all: [POSTMARK_TEMPLATES_QUERY_KEY] as const,
22 | templates: (query?: Record) => [...postmarkTemplatesQueryKeys.all, "templates", query ? { query } : undefined].filter(
23 | (k) => !!k
24 | ),
25 | template: (id: string, query?: Record) => [...postmarkTemplatesQueryKeys.templates(), id, query ? { query } : undefined].filter(
26 | (k) => !!k
27 | )
28 | }
29 |
30 | export const usePostmarkTemplates = (
31 | query?: QueryInput,
32 | options?: Omit<
33 | UseQueryOptions<
34 | TemplatesResponse,
35 | FetchError,
36 | TemplatesResponse,
37 | QueryKey
38 | >,
39 | "queryFn" | "queryKey"
40 | >) => {
41 | const { data, ...rest } = useQuery({
42 | queryKey: postmarkTemplatesQueryKeys.templates(query),
43 | queryFn: () => sdk.admin.postmark.templates.list(query),
44 | ...options
45 | })
46 | return { ...data, ...rest }
47 | }
48 |
--------------------------------------------------------------------------------
/src/api/admin/postmark/templates/route.ts:
--------------------------------------------------------------------------------
1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
2 | import { Models } from "postmark"
3 |
4 | export async function GET(req: MedusaRequest, res: MedusaResponse) {
5 | // TODO: query validation
6 | const { limit: count, offset, q, order, templateType = Models.TemplateTypes.Standard } = req.query as { q: string, order: string, limit: string, offset: string, templateType: Models.TemplateTypes }
7 |
8 | const postmarkModuleService = req.scope.resolve("postmarkModuleService")
9 | const cache = req.scope.resolve("caching")
10 |
11 | const queryParams = {
12 | count: parseInt(count),
13 | offset: parseInt(offset),
14 | templateType
15 | }
16 |
17 | // Dont use cache if caching module is not enabled
18 | const cacheKey = await cache?.computeKey(req.query)
19 | let templates: Models.Templates = cacheKey ? await cache.get({ key: cacheKey }) : await postmarkModuleService.getTemplates(queryParams)
20 | if (!templates) {
21 | templates = await postmarkModuleService.getTemplates(queryParams)
22 | await cache.set({ key: cacheKey, data: templates, tags: ["PostmarkTemplate:list:*"] })
23 | }
24 | if (q)
25 | templates.Templates = templates.Templates.filter(
26 | (template) => template.Name.toLowerCase().includes(q.toLowerCase())
27 | )
28 | if (order)
29 | templates.Templates.sort((a, b) => {
30 | const ascending = !order?.startsWith("-")
31 | const field = order?.replace("-", "")
32 | if (ascending) {
33 | return a[field]?.localeCompare(b[field])
34 | } else {
35 | return b[field]?.localeCompare(a[field])
36 | }
37 | })
38 |
39 | res.json(templates)
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/src/api/admin/postmark/abandoned-carts/reminders/schedules/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2 | import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
3 | import { ABANDONED_CART_MODULE } from "../../../../../../../modules/abandoned-cart"
4 | import {
5 | UpdateReminderSchedule
6 | } from "../../../../../../../types/reminder-schedules"
7 |
8 | export async function GET(req: MedusaRequest, res: MedusaResponse) {
9 | const { id } = req.params
10 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
11 |
12 | const { data: [schedule] } = await query.graph(
13 | {
14 | entity: "reminder_schedule",
15 | ...req.queryConfig,
16 | filters: {
17 | id,
18 | },
19 | },
20 | {
21 | cache: { enable: true },
22 | }
23 | )
24 |
25 | res.json({ schedule })
26 | }
27 |
28 | export async function POST(req: MedusaRequest, res: MedusaResponse) {
29 | const { id } = req.params
30 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE)
31 | const schedule = await abandonedCartModuleService.updateReminderSchedules({ id, ...req.validatedBody })
32 |
33 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
34 | const { data: schedules } = await query.graph({
35 | entity: "reminder_schedule",
36 | fields: [
37 | "*",
38 | "template.*",
39 | ],
40 | filters: {
41 | id: schedule.id,
42 | },
43 | })
44 |
45 | res.json({ schedules })
46 | }
47 |
48 | export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
49 | const { id } = req.params
50 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE)
51 | await abandonedCartModuleService.deleteReminderSchedules(id)
52 | res.json({ success: true })
53 | }
54 |
--------------------------------------------------------------------------------
/src/docs/Configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | The plugin requires several configuration options and environment variables to function correctly.
4 |
5 | ## Environment Variables
6 | - `POSTMARK_API_KEY`: Your Postmark server token
7 | - `POSTMARK_FROM`: Default sender email address
8 | - `POSTMARK_BCC`: Default bcc email address
9 |
10 |
11 | ## Setup
12 | 1. Install the plugin and its dependencies.
13 | 2. Add the plugin to your Medusa configuration.
14 | 3. Set the required environment variables.
15 | 4. Configure reminder schedules and templates as needed.
16 |
17 | ## Example: Plugin Registration
18 |
19 | Add the plugin to your `medusa-config.ts`:
20 |
21 | ```ts
22 | defineConfig({
23 | // ...
24 | plugins: [
25 | // ...
26 | {
27 | resolve: "medusa-plugin-postmark",
28 | options: {
29 | apiKey: process.env.POSTMARK_API_KEY!,
30 | }
31 | },
32 | ]
33 | })
34 | ```
35 |
36 | ## Example: Adding the Postmark Notification Provider
37 |
38 | To use Postmark as a notification provider, add it to the `modules` property in your Medusa config:
39 |
40 | ```ts
41 | defineConfig({
42 | // ...
43 | modules: [
44 | {
45 | resolve: "@medusajs/medusa/notification",
46 | options: {
47 | providers: [
48 | {
49 | resolve: "medusa-plugin-postmark/providers/postmark",
50 | id: "postmark",
51 | options: {
52 | channels: ["email"],
53 | apiKey: process.env.POSTMARK_API_KEY!,
54 | default: {
55 | from: process.env.POSTMARK_FROM,
56 | bcc: process.env.POSTMARK_BCC,
57 | }
58 | },
59 | },
60 | ],
61 | },
62 | },
63 | ],
64 | })
65 | ```
66 |
67 | Only one provider can be set per channel (e.g., "email").
68 |
69 | ## Options
70 | - **Reminder Schedules**: Configure delays, template associations, and flags.
71 | - **Templates**: Manage templates and layouts through the admin interface or API.
72 |
73 | Refer to the other documentation sections for details on each configuration area.
74 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [4.5.1] - 2024-01-08
9 |
10 | - General: sigh... didn't add build files to the last release
11 |
12 | ## [4.5.0] - 2024-01-08
13 |
14 | - Bugfix: GiftcardService had a typo that caused an error upon startup
15 |
16 | ## [4.4.0] - 2023-11-12
17 |
18 | - Feature: Added giftcard service to the mailer
19 |
20 | ## [4.3.0] - 2023-07-14
21 |
22 | - Feature: Added country filter to convert ISO country code to country name
23 |
24 | ## [4.2.2] - 2023-07-14
25 |
26 | - Bugfix: Fix that nested variables didn't work in the if statement
27 |
28 | ## [4.2.1] - 2023-07-03
29 |
30 | - Bugfix: Renamed the attachments since IOS didn't recognize it as a pdf in some cases
31 |
32 | ## [4.2.0] - 2023-07-03
33 |
34 | - Feature: Added if statements to the pdf generator
35 | - Feature: Added config option to alter what to show when a variable can't be found or is invalid
36 |
37 | ## [4.1.0] - 2023-06-16
38 |
39 | - Bugfix: Added a check if the cart has been converted to an order. No need to send abandoned cart mails if the cart has been converted to an order.
40 |
41 | ## [4.0.2] - 2023-06-09
42 |
43 | - Bugfix: Extra check on created at since typeorm lessthen didn't filter correctly somehow
44 |
45 | ## [4.0.1] - 2023-06-09
46 |
47 | - Bugfix: First mail could be send multiple times in some occasions
48 | - Bugfix: Small piece of testcode was left in the codebase
49 |
50 | ## [4.0.0] - 2023-06-09
51 |
52 | - Feature: added automated abandoned cart mail options
53 |
54 | ## [3.1.1] - 2023-06-04
55 |
56 | - Fix: added `shipping_total_inc` to `order` object in `order.placed` event since MedusaJS can return shippign excl. tax instead of inc tax.
57 |
58 | ## [3.1.0] - 2023-06-03
59 |
60 | - First use of this changelog. Previous changes are not documented.
61 |
--------------------------------------------------------------------------------
/src/workflows/notification-data.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createWorkflow,
3 | WorkflowResponse,
4 | transform,
5 | createHook,
6 | } from "@medusajs/framework/workflows-sdk"
7 | import zod from "zod"
8 | import { defaultAbandonedCartData } from "./steps/default-hooks/abandoned-cart"
9 | import { CartDTO, CreateNotificationDTO, CustomerDTO } from "@medusajs/framework/types"
10 | import { ReminderSchedule } from "../types/reminder-schedules"
11 | import { Temporal } from "temporal-polyfill"
12 |
13 | export type NotificationDataWorkflowInput = {
14 | carts: Array<{ cart: CartDTO & { customer: CustomerDTO }, reminders: Array<{ delay: Temporal.Duration, delayIso: string, template: string, schedule: ReminderSchedule }> }>
15 | }
16 |
17 | export const notificationDataWorkflow = createWorkflow(
18 | "notification-data",
19 | function (input: NotificationDataWorkflowInput) {
20 | const notificationDataHook = createHook(
21 | "abandonedCartNotificationData",
22 | input,
23 | {
24 | resultValidator: zod.array(zod.object({
25 | to: zod.string(),
26 | channel: zod.string(),
27 | template: zod.string(),
28 | data: zod.record(zod.any()).default({}),
29 | attachments: zod.array(zod.any()).optional(),
30 | }))
31 | }
32 | )
33 | const notificationDataHookResult = notificationDataHook.getResult()
34 |
35 | const notificationData: CreateNotificationDTO[] = transform(
36 | { notificationDataHookResult, carts: input.carts },
37 | async ({ notificationDataHookResult, carts }) => {
38 | if (notificationDataHookResult)
39 | return notificationDataHookResult
40 |
41 | return await defaultAbandonedCartData(carts)
42 | }
43 | )
44 |
45 | return new WorkflowResponse({ notificationData }, { hooks: [notificationDataHook] })
46 | }
47 | )
48 |
--------------------------------------------------------------------------------
/src/jobs/abandoned-carts.ts:
--------------------------------------------------------------------------------
1 | import { MedusaContainer } from "@medusajs/framework/types"
2 | import { sendAbandonedCartsWorkflow } from "../workflows/abandoned-carts"
3 | import { defineFileConfig } from "@medusajs/framework/utils"
4 | import { ABANDONED_CART_MODULE } from "../modules/abandoned-cart"
5 |
6 | export default async function abandonedCartJob(
7 | container: MedusaContainer
8 | ) {
9 | const logger = container.resolve("logger")
10 |
11 | const limit = 100
12 | let offset = 0
13 | let totalCount = 0
14 | let abandonedCartsCount = 0
15 |
16 | const abandonedCartService = container.resolve(ABANDONED_CART_MODULE)
17 | const reminderSchedules = await abandonedCartService.listReminderSchedules({
18 | enabled: true
19 | })
20 |
21 | if (!reminderSchedules?.length) {
22 | logger.debug("medusa-plugin-postmark: No enabled reminder schedules found")
23 | return
24 | }
25 |
26 | do {
27 | try {
28 | const { result: { updatedCarts, pagination } } = await sendAbandonedCartsWorkflow(container).run({
29 | input: {
30 | reminderSchedules,
31 | pagination: { limit, offset },
32 | },
33 | })
34 | abandonedCartsCount += updatedCarts.length
35 | totalCount = pagination.totalCount ?? 0
36 | } catch (error) {
37 | logger.error(
38 | `medusa-plugin-postmark: Failed to send abandoned cart notification: ${error.message}`
39 | )
40 | }
41 |
42 | offset += limit
43 | } while (offset < totalCount)
44 |
45 | logger.debug(`medusa-plugin-postmark: Sent ${abandonedCartsCount} abandoned cart notifications`)
46 | }
47 |
48 | export const config = {
49 | name: "abandoned-cart-notification",
50 | schedule: process.env.PLUGIN_POSTMARK_ABANDONED_CART_SCHEDULE || "0 * * * *", // Run every hour by default
51 | }
52 |
53 | defineFileConfig({
54 | isDisabled: () => process.env.PLUGIN_POSTMARK_ABANDONED_CART_ENABLE === "false",
55 | })
56 |
--------------------------------------------------------------------------------
/src/api/middlewares.ts:
--------------------------------------------------------------------------------
1 | import { defineMiddlewares, validateAndTransformBody, validateAndTransformQuery } from "@medusajs/framework/http"
2 | import { createFindParams } from "@medusajs/medusa/api/utils/validators"
3 | import {
4 | CreateReminderScheduleSchema,
5 | UpdateReminderScheduleSchema
6 | } from "../types/reminder-schedules"
7 |
8 | export default defineMiddlewares({
9 | routes: [
10 | {
11 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules",
12 | methods: ["POST"],
13 | middlewares: [
14 | validateAndTransformBody(CreateReminderScheduleSchema),
15 | ],
16 | },
17 | {
18 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules",
19 | methods: ["GET"],
20 | middlewares: [
21 | validateAndTransformQuery(createFindParams(), {
22 | defaults: [
23 | "id",
24 | "template_id",
25 | "delays_iso",
26 | "enabled",
27 | "notify_existing",
28 | "reset_on_cart_update"
29 | ],
30 | isList: true,
31 | }),
32 | ],
33 | },
34 | {
35 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules/:id",
36 | methods: ["POST"],
37 | middlewares: [
38 | validateAndTransformBody(UpdateReminderScheduleSchema),
39 | ],
40 | },
41 | {
42 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules/:id",
43 | methods: ["GET"],
44 | middlewares: [
45 | validateAndTransformQuery(createFindParams(), {
46 | defaults: [
47 | "id",
48 | "template_id",
49 | "delays_iso",
50 | "enabled",
51 | "notify_existing",
52 | "reset_on_cart_update"
53 | ],
54 | isList: false,
55 | }),
56 | ],
57 | },
58 | ],
59 | })
60 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # IntelliJ project files
2 | .idea
3 | *.iml
4 | out
5 | gen
6 |
7 | # Logs
8 | logs
9 | *.log
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | lerna-debug.log*
14 | .pnpm-debug.log*
15 |
16 | # Package manager / lock files
17 | package-lock.json
18 |
19 | # Diagnostic reports (https://nodejs.org/api/report.html)
20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
21 |
22 | # Runtime data
23 | pids
24 | *.pid
25 | *.seed
26 | *.pid.lock
27 |
28 | # Directory for instrumented libs generated by jscoverage/JSCover
29 | lib-cov
30 |
31 | # Coverage directory used by tools like istanbul
32 | coverage
33 | *.lcov
34 |
35 | # nyc test coverage
36 | .nyc_output
37 |
38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
39 | .grunt
40 |
41 | # Bower dependency directory (https://bower.io/)
42 | bower_components
43 |
44 | # node-waf configuration
45 | .lock-wscript
46 |
47 | # Compiled binary addons (https://nodejs.org/api/addons.html)
48 | build/Release
49 |
50 | # Dependency directories
51 | node_modules/
52 | jspm_packages/
53 |
54 | # Snowpack dependency directory (https://snowpack.dev/)
55 | web_modules/
56 |
57 | # TypeScript cache
58 | *.tsbuildinfo
59 |
60 | # Optional npm cache directory
61 | .npm
62 |
63 | # Optional eslint cache
64 | .eslintcache
65 |
66 | # Optional stylelint cache
67 | .stylelintcache
68 |
69 | # Microbundle cache
70 | .rpt2_cache/
71 | .rts2_cache_cjs/
72 | .rts2_cache_es/
73 | .rts2_cache_umd/
74 |
75 | # Optional REPL history
76 | .node_repl_history
77 |
78 | # Output of 'npm pack'
79 | *.tgz
80 |
81 | # Yarn Integrity file
82 | .yarn-integrity
83 |
84 | # dotenv environment variable files
85 | .env
86 | .env.development.local
87 | .env.test.local
88 | .env.production.local
89 | .env.local
90 |
91 | # parcel-bundler cache (https://parceljs.org/)
92 | .cache
93 | .parcel-cache
94 |
95 | # Next.js build output
96 | .next
97 | out
98 |
99 |
100 | # Stores VSCode versions used for testing VSCode extensions
101 | .vscode-test
102 |
103 | # yarn v2
104 | .yarn/cache
105 | .yarn/unplugged
106 | .yarn/build-state.yml
107 | .yarn/install-state.gz
108 | .pnp.*
109 |
110 | .medusa
111 |
112 | /.vscode
113 |
--------------------------------------------------------------------------------
/src/admin/i18n/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./$schema.json",
3 | "templates": {
4 | "title": "Templates"
5 | },
6 | "layouts": {
7 | "title": "Layouts"
8 | },
9 | "reminder_schedules": {
10 | "title": "Reminder Schedules",
11 | "delays": "Delays",
12 | "notify_existing": "Notify Existing Carts",
13 | "notify_existing_hint": "When disabled, only carts created after the last schedule update will receive notifications",
14 | "notify_existing_warning_title": "Notify all existing carts?",
15 | "notify_existing_warning_desc": "Enabling this will notify a large number of existing abandoned carts. This action may result in a high volume of notifications being sent. Proceed carefully.",
16 | "reset_on_cart_update": "Reset on Cart Update",
17 | "reset_on_cart_update_hint": "When enabled, the notification cycle restarts if a cart is updated. When disabled, notifications continue from the original abandonment time regardless of cart updates.",
18 | "create_title": "Create schedule",
19 | "create_description": "Create a new reminder schedule for abandoned carts",
20 | "edit_title": "Edit schedule",
21 | "edit_description": "Edit the reminder schedule for abandoned carts",
22 | "delete": {
23 | "confirmation": "You are about to delete a reminder schedule. This action cannot be undone.",
24 | "successToast": "Reminder schedule was successfully deleted."
25 | }
26 | },
27 | "validate_schedules": {
28 | "title": "Validate Notification Data",
29 | "error_title": "Validation Error",
30 | "all_valid": "All Valid",
31 | "missing_data": "Missing Data ({{count}})",
32 | "validating": "Validating...",
33 | "validate_button": "Validate Templates",
34 | "unknown_template": "Unknown Template",
35 | "missing_variables": "Missing Variables",
36 | "view_provided_data": "View Provided Data"
37 | },
38 | "fields": {
39 | "unit": "Unit",
40 | "template": "Template",
41 | "layout": "Layout"
42 | },
43 | "menuLabels": {
44 | "abandoned_carts": "Abandoned Carts",
45 | "postmark_templates": "Postmark Templates"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/workflows/abandoned-carts.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createWorkflow,
3 | WorkflowResponse,
4 | transform,
5 | } from "@medusajs/framework/workflows-sdk"
6 | import { sendNotificationsStep, updateCartsStep } from "@medusajs/medusa/core-flows"
7 | import { fetchAbandonedCarts } from "./steps/fetch-abandoned-carts"
8 | import { ReminderSchedule } from "../types/reminder-schedules"
9 | import { notificationDataWorkflow } from "./notification-data"
10 | import { computeCartReferenceTimestamp } from "../types/abandoned-cart-tracking"
11 | import { deepMerge } from "@medusajs/utils"
12 |
13 | export type SendAbandonedCartsWorkflowInput = {
14 | reminderSchedules: ReminderSchedule[]
15 | pagination: { limit: number, offset: number }
16 | }
17 |
18 | export const sendAbandonedCartsWorkflow = createWorkflow(
19 | "send-abandoned-carts",
20 | function (input: SendAbandonedCartsWorkflowInput) {
21 | const { carts, totalCount } = fetchAbandonedCarts(input)
22 |
23 | const { notificationData } = notificationDataWorkflow.runAsStep({
24 | input: { carts }
25 | })
26 |
27 | sendNotificationsStep(notificationData)
28 |
29 | const updateCartsData = transform(carts, cartReminderGroups => {
30 | const now = new Date()
31 | return cartReminderGroups.map(({ cart, reminders }) => {
32 | const cartReferenceAtSend = new Date(computeCartReferenceTimestamp(cart))
33 |
34 | // Build a sent_notifications object for all reminders
35 | const sent_notifications = Object.fromEntries(
36 | reminders.map(reminder => [
37 | `${reminder.schedule.id}:${reminder.delayIso}`,
38 | {
39 | sent_at: now.toISOString(),
40 | cart_reference_at_send: cartReferenceAtSend.toISOString()
41 | }
42 | ])
43 | )
44 |
45 | return {
46 | id: cart.id,
47 | metadata: deepMerge(cart.metadata, { abandoned_cart_tracking: { sent_notifications } })
48 | }
49 | })
50 | })
51 |
52 | const updatedCarts = updateCartsStep(updateCartsData)
53 |
54 | return new WorkflowResponse({ updatedCarts, pagination: { totalCount } })
55 | }
56 | )
57 |
--------------------------------------------------------------------------------
/src/admin/i18n/it.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./$schema.json",
3 | "templates": {
4 | "title": "Template"
5 | },
6 | "layouts": {
7 | "title": "Layout"
8 | },
9 | "reminder_schedules": {
10 | "title": "Promemoria",
11 | "delays": "Ritardi",
12 | "notify_existing": "Notifica Carrelli Esistenti",
13 | "notify_existing_hint": "Quando disabilitato, solo i carrelli creati dopo l'ultimo aggiornamento del promemoria riceveranno notifiche",
14 | "notify_existing_warning_title": "Notificare tutti i carrelli esistenti?",
15 | "notify_existing_warning_desc": "Abilitando questa opzione, verrà inviata una notifica a un gran numero di carrelli abbandonati esistenti. Questa azione potrebbe comportare l'invio di un elevato volume di notifiche. Procedi con cautela.",
16 | "reset_on_cart_update": "Riavvia al Aggiornamento Carrello",
17 | "reset_on_cart_update_hint": "Quando abilitato, il ciclo di notifiche si riavvia se un carrello viene aggiornato. Quando disabilitato, le notifiche continuano dal momento originale di abbandono indipendentemente dagli aggiornamenti del carrello.",
18 | "create_title": "Crea promemoria",
19 | "create_description": "Crea un nuovo promemoria per i carrelli abbandonati",
20 | "edit_title": "Modifica promemoria",
21 | "edit_description": "Modifica il promemoria per i carrelli abbandonati",
22 | "delete": {
23 | "confirmation": "Stai per eliminare un promemoria. Questa azione non può essere annullata.",
24 | "successToast": "Il promemoria è stato eliminato con successo."
25 | }
26 | },
27 | "validate_schedules": {
28 | "title": "Valida Dati di Notifica",
29 | "error_title": "Errore di Validazione",
30 | "all_valid": "Tutto Valido",
31 | "missing_data": "Dati Mancanti ({{count}})",
32 | "validating": "Validazione in corso...",
33 | "validate_button": "Valida Template",
34 | "unknown_template": "Template Sconosciuto",
35 | "missing_variables": "Variabili Mancanti",
36 | "view_provided_data": "Visualizza Dati Forniti"
37 | },
38 | "fields": {
39 | "unit": "Unità",
40 | "template": "Template",
41 | "layout": "Layout"
42 | },
43 | "menuLabels": {
44 | "abandoned_carts": "Carrelli Abbandonati",
45 | "postmark_templates": "Template Postmark"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/admin/components/general/header.tsx:
--------------------------------------------------------------------------------
1 | import { Heading, Button, Text } from "@medusajs/ui"
2 | import React from "react"
3 | import { Link, LinkProps } from "react-router-dom"
4 | import { ActionMenu, ActionMenuProps } from "./action-menu"
5 |
6 | export type HeadingProps = {
7 | title: string
8 | subtitle?: string
9 | actions?: (
10 | | {
11 | type: "button"
12 | props: React.ComponentProps
13 | link?: LinkProps
14 | }
15 | | {
16 | type: "action-menu"
17 | props: ActionMenuProps
18 | }
19 | | {
20 | type: "custom"
21 | children: React.ReactNode
22 | }
23 | )[]
24 | }
25 |
26 | export const Header = ({
27 | title,
28 | subtitle,
29 | actions = [],
30 | }: HeadingProps) => {
31 | return (
32 |
33 |
34 | {title}
35 | {subtitle && (
36 |
37 | {subtitle}
38 |
39 | )}
40 |
41 | {actions.length > 0 && (
42 |
43 | {actions.map((action, index) => (
44 |
45 | {action.type === "button" && (
46 |
55 | )}
56 | {action.type === "action-menu" && (
57 |
58 | )}
59 | {action.type === "custom" && action.children}
60 |
61 | ))}
62 |
63 | )}
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/src/providers/postmark/service.ts:
--------------------------------------------------------------------------------
1 | import { NotificationTypes } from "@medusajs/framework/types"
2 | import { AbstractNotificationProviderService, MedusaError } from "@medusajs/framework/utils"
3 | import { ServerClient, TemplatedMessage } from "postmark"
4 |
5 | export type PostmarkProviderOptions = {
6 | apiKey: string,
7 | default: {
8 | from: string,
9 | bcc?: string
10 | }
11 | }
12 |
13 | class PostmarkProviderService extends AbstractNotificationProviderService {
14 | static identifier = "postmark"
15 | protected client: ServerClient
16 | protected options: PostmarkProviderOptions
17 |
18 | constructor(_, options: PostmarkProviderOptions) {
19 | super()
20 | this.options = options
21 | this.client = new ServerClient(options.apiKey)
22 | }
23 |
24 | async send(notification: NotificationTypes.ProviderSendNotificationDTO): Promise {
25 | const templateId = parseInt(notification.template)
26 |
27 | const sendOptions: TemplatedMessage = {
28 | From: notification.from ?? this.options.default.from,
29 | To: notification.to,
30 | TemplateId: templateId,
31 | TemplateModel: {
32 | ...notification.data
33 | }
34 | }
35 |
36 | if (this.options?.default?.bcc)
37 | sendOptions.Bcc = this.options.default.bcc
38 |
39 | if (notification.attachments?.length) {
40 | sendOptions.Attachments = notification.attachments.map((a) => {
41 | return {
42 | Content: a.content,
43 | Name: a.filename,
44 | ContentType: a.content_type ?? "",
45 | ContentID: `cid:${a.id}`,
46 | }
47 | })
48 | }
49 |
50 | return await this.client.sendEmailWithTemplate(sendOptions)
51 | .then((res) => ({ id: res.MessageID }))
52 | .catch((error) => {
53 | throw new MedusaError(MedusaError.Types.INVALID_DATA, error)
54 | })
55 | }
56 |
57 | static validateOptions(options: Record) {
58 | if (!options.apiKey) {
59 | throw new MedusaError(
60 | MedusaError.Types.INVALID_DATA,
61 | "Postmark API key is required in the provider's options."
62 | )
63 | }
64 | if (!options.default?.from) {
65 | throw new MedusaError(
66 | MedusaError.Types.INVALID_DATA,
67 | "'From' email address is required in the provider's options."
68 | )
69 | }
70 | }
71 | }
72 |
73 |
74 | export default PostmarkProviderService
75 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/page.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 | import { defineRouteConfig } from "@medusajs/admin-sdk"
3 | import { Container, DataTable, Tabs } from "@medusajs/ui"
4 | import { usePostmarkOptions } from "../../hooks/use-postmark-options"
5 | import { usePostmarkDataTable } from "../../hooks/use-postmark-table"
6 | import { DataTableAction } from "../../components/data-table-action"
7 | import { useTranslation } from "react-i18next"
8 |
9 | const PostmarkPage = () => {
10 | const { t } = useTranslation("postmark")
11 | const { options: { server_id: serverId } = {} } = usePostmarkOptions()
12 | const templatesTable = usePostmarkDataTable({ type: "template", serverId })
13 | const layoutsTable = usePostmarkDataTable({ type: "layout", serverId })
14 |
15 | const [activeTab, setActiveTab] = useState("template")
16 | const currentTable = activeTab === "template" ? templatesTable : layoutsTable
17 |
18 | const createUrl = serverId
19 | ? `https://account.postmarkapp.com/servers/${serverId}/templates/starter/new-${activeTab}`
20 | : "#"
21 |
22 | const paginationTranslations = {
23 | of: t("general.of"),
24 | results: t("general.results"),
25 | pages: t("general.pages"),
26 | prev: t("general.prev"),
27 | next: t("general.next"),
28 | }
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | {t("templates.title")}
37 | {t("layouts.title")}
38 |
39 |
40 |
41 | window.open(createUrl, "_blank")}
44 | />
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | export const config = defineRouteConfig({
56 | label: "Postmark Templates" // "menuLabels.postmark_templates",
57 | //translationNs: "postmark",
58 | })
59 |
60 | export default PostmarkPage
61 |
62 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-drawer/route-drawer.tsx:
--------------------------------------------------------------------------------
1 | import { Drawer, clx } from "@medusajs/ui"
2 | import { PropsWithChildren, useEffect, useState } from "react"
3 | import { Path, useNavigate } from "react-router-dom"
4 | import { useStateAwareTo } from "../../../hooks/use-state-aware-to"
5 | import { RouteModalForm } from "../route-modal-form"
6 | import { RouteModalProvider } from "../route-modal-provider/route-provider"
7 | import { StackedModalProvider } from "../stacked-modal-provider"
8 |
9 | type RouteDrawerProps = PropsWithChildren<{
10 | prev?: string | Partial
11 | }>
12 |
13 | const Root = ({ prev = "..", children }: RouteDrawerProps) => {
14 | const navigate = useNavigate()
15 | const [open, setOpen] = useState(false)
16 | const [stackedModalOpen, onStackedModalOpen] = useState(false)
17 |
18 | const to = useStateAwareTo(prev)
19 |
20 | /**
21 | * Open the modal when the component mounts. This
22 | * ensures that the entry animation is played.
23 | */
24 | useEffect(() => {
25 | setOpen(true)
26 |
27 | return () => {
28 | setOpen(false)
29 | onStackedModalOpen(false)
30 | }
31 | }, [])
32 |
33 | const handleOpenChange = (open: boolean) => {
34 | if (!open) {
35 | document.body.style.pointerEvents = "auto"
36 | navigate(to, { replace: true })
37 | return
38 | }
39 |
40 | setOpen(open)
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 |
53 | {children}
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | const Header = Drawer.Header
62 | const Title = Drawer.Title
63 | const Description = Drawer.Description
64 | const Body = Drawer.Body
65 | const Footer = Drawer.Footer
66 | const Close = Drawer.Close
67 | const Form = RouteModalForm
68 |
69 | /**
70 | * Drawer that is used to render a form on a separate route.
71 | *
72 | * Typically used for forms editing a resource.
73 | */
74 | export const RouteDrawer = Object.assign(Root, {
75 | Header,
76 | Title,
77 | Body,
78 | Description,
79 | Footer,
80 | Close,
81 | Form,
82 | })
83 |
--------------------------------------------------------------------------------
/src/modules/postmark/service.ts:
--------------------------------------------------------------------------------
1 | import { MedusaService } from "@medusajs/framework/utils"
2 | import { ServerClient, Models } from "postmark"
3 |
4 | export type PostmarkModuleOptions = {
5 | server_api?: string
6 | }
7 |
8 | class PostmarkModuleService extends MedusaService({}) {
9 | protected client: ServerClient
10 | protected serverId: Promise
11 |
12 | constructor({ postmarkClient }: { postmarkClient: ServerClient }) {
13 | super(arguments)
14 | this.client = postmarkClient
15 | this.serverId = this.client.getServer().then(s => s.ID)
16 | }
17 |
18 | async getServerId() {
19 | return { server_id: await this.serverId }
20 | }
21 |
22 | async getTemplates(params?: {
23 | count?: number
24 | offset?: number
25 | templateType?: Models.TemplateTypes
26 | }) {
27 | return await this.client.getTemplates(params)
28 | }
29 |
30 | async getTemplate(templateId: number) {
31 | return await this.client.getTemplate(templateId)
32 | }
33 |
34 | async deleteTemplate(templateId: number) {
35 | return await this.client.deleteTemplate(templateId)
36 | }
37 |
38 | async validateTemplate(params: {
39 | Subject: string
40 | HtmlBody: string
41 | TextBody: string
42 | TestRenderModel: Record
43 | }) {
44 | return await this.client.validateTemplate(params)
45 | }
46 |
47 | async list(filter: { template_id: string | string[] }) {
48 | const ids = Array.isArray(filter.template_id)
49 | ? filter.template_id
50 | : [filter.template_id]
51 |
52 | const templates = await Promise.all(
53 | ids.map(async (id) => {
54 | try {
55 | // Template IDs can be numeric or alias strings
56 | const numericId = parseInt(id, 10)
57 | const template = isNaN(numericId)
58 | ? await this.client.getTemplate(id)
59 | : await this.client.getTemplate(numericId)
60 |
61 | return {
62 | ...template,
63 | template_id: id,
64 | }
65 | } catch (error) {
66 | console.warn(`Template with ID ${id} not found:`, error)
67 | return null
68 | }
69 | })
70 | )
71 |
72 | return templates.filter((t) => t !== null)
73 | }
74 | }
75 |
76 |
77 | export default PostmarkModuleService
78 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-modal-form/route-modal-form.tsx:
--------------------------------------------------------------------------------
1 | import { Prompt } from "@medusajs/ui"
2 | import { PropsWithChildren } from "react"
3 | import { FieldValues, UseFormReturn } from "react-hook-form"
4 | import { useTranslation } from "react-i18next"
5 | import { useBlocker } from "react-router-dom"
6 | import { Form } from "../../form/form"
7 |
8 | type RouteModalFormProps = PropsWithChildren<{
9 | form: UseFormReturn
10 | blockSearchParams?: boolean
11 | onClose?: (isSubmitSuccessful: boolean) => void
12 | }>
13 |
14 | export const RouteModalForm = ({
15 | form,
16 | blockSearchParams: blockSearch = false,
17 | children,
18 | onClose,
19 | }: RouteModalFormProps) => {
20 | const { t } = useTranslation()
21 |
22 | const {
23 | formState: { isDirty },
24 | } = form
25 |
26 | const blocker = useBlocker(({ currentLocation, nextLocation }) => {
27 | const { isSubmitSuccessful } = nextLocation.state || {}
28 |
29 | if (isSubmitSuccessful) {
30 | onClose?.(true)
31 | return false
32 | }
33 |
34 | const isPathChanged = currentLocation.pathname !== nextLocation.pathname
35 | const isSearchChanged = currentLocation.search !== nextLocation.search
36 |
37 | if (blockSearch) {
38 | const ret = isDirty && (isPathChanged || isSearchChanged)
39 |
40 | if (!ret) {
41 | onClose?.(isSubmitSuccessful)
42 | }
43 |
44 | return ret
45 | }
46 |
47 | const ret = isDirty && isPathChanged
48 |
49 | if (!ret) {
50 | onClose?.(isSubmitSuccessful)
51 | }
52 |
53 | return ret
54 | })
55 |
56 | const handleCancel = () => {
57 | blocker?.reset?.()
58 | }
59 |
60 | const handleContinue = () => {
61 | blocker?.proceed?.()
62 | onClose?.(false)
63 | }
64 |
65 | return (
66 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/src/types/reminder-schedules.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 | import { PostmarkTemplate } from "./templates"
3 | import { Temporal } from "temporal-polyfill"
4 |
5 | /**
6 | * Base reminder schedule entity - represents the database model
7 | */
8 | export interface ReminderSchedule {
9 | id: string
10 | enabled: boolean
11 | template_id: string
12 | template?: PostmarkTemplate
13 | delays_iso: string[] // ISO 8601 duration strings (e.g., "PT1H", "PT24H", "P1D")
14 | notify_existing: boolean // Whether to notify for carts created before the last schedule update
15 | reset_on_cart_update: boolean // Whether to restart delay cycle when cart is updated
16 | created_at?: Date
17 | updated_at?: Date
18 | deleted_at?: Date | null
19 | }
20 |
21 |
22 | /**
23 | * Request payload for creating a new reminder schedule
24 | */
25 | export interface CreateReminderScheduleRequest {
26 | enabled: boolean
27 | template_id: string
28 | delays_iso: string[] // ISO 8601 duration strings
29 | notify_existing: boolean // Whether to notify for carts created before the last schedule update
30 | reset_on_cart_update: boolean // Whether to restart delay cycle when cart is updated
31 | }
32 |
33 | /**
34 | * Request payload for updating an existing reminder schedule
35 | */
36 | export interface UpdateReminderScheduleRequest {
37 | enabled?: boolean
38 | template_id?: string
39 | delays_iso?: string[] // ISO 8601 duration strings
40 | notify_existing?: boolean // Whether to notify for carts created before the last schedule update
41 | reset_on_cart_update?: boolean // Whether to restart delay cycle when cart is updated
42 | }
43 |
44 | /**
45 | * API response for listing reminder schedules
46 | */
47 | export interface ReminderScheduleListResponse {
48 | schedules: ReminderSchedule[]
49 | }
50 |
51 | /**
52 | * API response for single reminder schedule operations
53 | */
54 | export interface ReminderScheduleResponse {
55 | schedule: ReminderSchedule
56 | }
57 |
58 | /**
59 | * Zod schema for creating reminder schedules
60 | */
61 | export const CreateReminderScheduleSchema = z.object({
62 | enabled: z.boolean(),
63 | template_id: z.string().nonempty(),
64 | delays_iso: z.array(
65 | z.string().duration()
66 | ).nonempty(),
67 | notify_existing: z.boolean().default(false),
68 | reset_on_cart_update: z.boolean().default(true)
69 | })
70 |
71 | /**
72 | * Zod schema for updating reminder schedules
73 | */
74 | export const UpdateReminderScheduleSchema = z.object({
75 | enabled: z.boolean().optional(),
76 | template_id: z.string().nonempty().optional(),
77 | delays_iso: z.array(
78 | z.string().duration()
79 | ).nonempty().optional(),
80 | notify_existing: z.boolean().optional(),
81 | reset_on_cart_update: z.boolean().optional()
82 | })
83 |
84 | /**
85 | * Inferred types from Zod schemas
86 | */
87 | export type CreateReminderSchedule = z.infer
88 | export type UpdateReminderSchedule = z.infer
89 |
--------------------------------------------------------------------------------
/src/admin/components/modals/route-focus-modal/index.tsx:
--------------------------------------------------------------------------------
1 | import { FocusModal, clx } from "@medusajs/ui"
2 | import { PropsWithChildren, useEffect, useState } from "react"
3 | import { Path, useNavigate } from "react-router-dom"
4 | import { useStateAwareTo } from "../../../hooks/use-state-aware-to"
5 | import { RouteModalForm } from "../route-modal-form"
6 | import { useRouteModal } from "../route-modal-provider"
7 | import { RouteModalProvider } from "../route-modal-provider/route-provider"
8 | import { StackedModalProvider } from "../stacked-modal-provider"
9 |
10 | type RouteFocusModalProps = PropsWithChildren<{
11 | prev?: string | Partial
12 | }>
13 |
14 | const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
15 | const navigate = useNavigate()
16 | const [open, setOpen] = useState(false)
17 | const [stackedModalOpen, onStackedModalOpen] = useState(false)
18 |
19 | const to = useStateAwareTo(prev)
20 |
21 | /**
22 | * Open the modal when the component mounts. This
23 | * ensures that the entry animation is played.
24 | */
25 | useEffect(() => {
26 | setOpen(true)
27 |
28 | return () => {
29 | setOpen(false)
30 | onStackedModalOpen(false)
31 | }
32 | }, [])
33 |
34 | const handleOpenChange = (open: boolean) => {
35 | if (!open) {
36 | document.body.style.pointerEvents = "auto"
37 | navigate(to, { replace: true })
38 | return
39 | }
40 |
41 | setOpen(open)
42 | }
43 |
44 | return (
45 |
46 |
47 |
48 | {children}
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | type ContentProps = PropsWithChildren<{
56 | stackedModalOpen: boolean
57 | }>
58 |
59 | const Content = ({ stackedModalOpen, children }: ContentProps) => {
60 | const { __internal } = useRouteModal()
61 |
62 | const shouldPreventClose = !__internal.closeOnEscape
63 |
64 | return (
65 | {
69 | e.preventDefault()
70 | }
71 | : undefined
72 | }
73 | className={clx({
74 | "!bg-ui-bg-disabled !inset-x-5 !inset-y-3": stackedModalOpen,
75 | })}
76 | >
77 | {children}
78 |
79 | )
80 | }
81 |
82 | const Header = FocusModal.Header
83 | const Title = FocusModal.Title
84 | const Description = FocusModal.Description
85 | const Footer = FocusModal.Footer
86 | const Body = FocusModal.Body
87 | const Close = FocusModal.Close
88 | const Form = RouteModalForm
89 |
90 | /**
91 | * FocusModal that is used to render a form on a separate route.
92 | *
93 | * Typically used for forms creating a resource or forms that require
94 | * a lot of space.
95 | */
96 | export const RouteFocusModal = Object.assign(Root, {
97 | Header,
98 | Title,
99 | Body,
100 | Description,
101 | Footer,
102 | Close,
103 | Form,
104 | })
105 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "medusa-plugin-postmark",
3 | "version": "6.0.0",
4 | "description": "Postmark notification plugin for MedusaJS",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/Fullstak-nl/medusa-plugin-postmark"
8 | },
9 | "author": "Bram Hammer",
10 | "license": "MIT",
11 | "files": [
12 | ".medusa/server"
13 | ],
14 | "exports": {
15 | "./package.json": "./package.json",
16 | "./workflows": "./.medusa/server/src/workflows/index.js",
17 | "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
18 | ".": "./.medusa/server/src/modules/postmark/index.js",
19 | "./providers/*": "./.medusa/server/src/providers/*/index.js",
20 | "./admin": {
21 | "import": "./.medusa/server/src/admin/index.mjs",
22 | "require": "./.medusa/server/src/admin/index.js",
23 | "default": "./.medusa/server/src/admin/index.js"
24 | },
25 | "./*": "./.medusa/server/src/*.js"
26 | },
27 | "keywords": [
28 | "medusa-v2",
29 | "medusa-plugin",
30 | "medusa-plugin-integration",
31 | "medusa-plugin-notification"
32 | ],
33 | "scripts": {
34 | "build": "medusa plugin:build",
35 | "dev": "medusa plugin:develop",
36 | "prepublishOnly": "medusa plugin:build",
37 | "test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
38 | "test:integration:workflows": "TEST_TYPE=integration:workflows NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
39 | "test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
40 | "test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit"
41 | },
42 | "devDependencies": {
43 | "@medusajs/admin-sdk": "2.11.2",
44 | "@medusajs/cli": "2.11.2",
45 | "@medusajs/framework": "2.11.2",
46 | "@medusajs/icons": "2.11.2",
47 | "@medusajs/medusa": "2.11.2",
48 | "@medusajs/test-utils": "2.11.2",
49 | "@medusajs/ui": "4.0.26",
50 | "@swc/core": "1.5.7",
51 | "@swc/jest": "^0.2.36",
52 | "@types/jest": "^29.5.13",
53 | "@types/lodash": "4.17.20",
54 | "@types/node": "^20.0.0",
55 | "@types/react": "^18.3.2",
56 | "@types/react-dom": "^18.2.25",
57 | "jest": "^29.7.0",
58 | "prop-types": "^15.8.1",
59 | "react": "^18.2.0",
60 | "react-dom": "^18.2.0",
61 | "ts-node": "^10.9.2",
62 | "typescript": "^5.6.2",
63 | "vite": "^5.2.11",
64 | "yalc": "^1.0.0-pre.53"
65 | },
66 | "peerDependencies": {
67 | "@medusajs/admin-sdk": "^2.11.1",
68 | "@medusajs/cli": "^2.11.1",
69 | "@medusajs/framework": "^2.11.1",
70 | "@medusajs/icons": "^2.11.1",
71 | "@medusajs/medusa": "^2.11.1",
72 | "@medusajs/test-utils": "^2.11.1",
73 | "@medusajs/ui": "^4.0.25",
74 | "react-i18next": "*"
75 | },
76 | "dependencies": {
77 | "@formatjs/intl-durationformat": "^0.7.6",
78 | "postmark": "^4.0.5",
79 | "temporal-polyfill": "^0.3.0"
80 | },
81 | "engines": {
82 | "node": ">=20"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/REMINDER-SCHEDULES-BEHAVIOUR.md:
--------------------------------------------------------------------------------
1 | # Reminder Schedules: Behaviour Specification
2 |
3 | ## 1. Overview
4 |
5 | A **reminder schedule** defines when and how abandoned cart notifications are sent to customers. Each schedule can specify multiple delays (e.g., 1 minute, 1 hour), a single unique template, and options for handling cart updates and existing carts.
6 |
7 | ---
8 |
9 | ## 2. Notification Logic
10 |
11 | ### 2.1. Eligibility
12 | - **A cart is eligible for reminders if:**
13 | - It has an email address.
14 | - It contains at least one item.
15 |
16 | ### 2.2. Reminder Delays
17 | - Each schedule can have one or more delays (e.g., `PT1M`, `PT2H`).
18 | - A notification is sent when the time since the cart's last relevant update (see below) exceeds the delay.
19 |
20 | ### 2.3. Sending Reminders
21 | - For each eligible cart and schedule:
22 | - Only the **latest** applicable reminder is sent (if multiple delays are passed, only the largest is sent).
23 | - No duplicate notifications are sent for the same schedule/delay.
24 |
25 | #### Example
26 | > If a schedule has delays of 1 minute and 2 minutes, and a cart is abandoned for 2.5 minutes, only the 2-minute reminder is sent.
27 |
28 | ### 2.4. Outdated Notifications
29 | - If a cart becomes eligible for a longer delay, shorter (missed) reminders are **not** sent retroactively.
30 |
31 | #### Example
32 | > If a cart is abandoned for 30 minutes and the schedule is updated to add a 10-minute delay, the 10-minute reminder is **not** sent if the 30-minute one was already sent.
33 |
34 | ---
35 |
36 | ## 3. Cart Updates and Reminder Cycle
37 |
38 |
39 | ### 3.1. Resetting the Cycle
40 |
41 | - **By default** (`reset_on_cart_update: true`):
42 | - Updating cart items resets the reminder cycle for all delays.
43 | - All reminders (even those already sent) become eligible to be sent again, starting from the new update time.
44 |
45 | - If `reset_on_cart_update` is **false**:
46 | - The timer for all remaining (not-yet-sent) delays is always based on the latest item update time.
47 | - However, delays that have already been sent will **not** be sent again after an update.
48 | - This means only new, not-yet-sent delays are considered after each update, and their timer is always relative to the most recent update.
49 |
50 | #### Example (Reset = true)
51 | > Cart abandoned at 12:00, 10-min reminder sent at 12:10. If items are updated at 12:12, the 10-min reminder will be sent again at 12:22 (since all delays are re-eligible).
52 |
53 | #### Example (Reset = false)
54 | > Cart abandoned at 12:00, 10-min reminder sent at 12:10. If items are updated at 12:12, the 10-min reminder will **not** be sent again. If there is a 15-min delay, it will be sent at 12:27 (15 minutes after the latest update), but the 10-min one is never re-sent.
55 |
56 | ---
57 |
58 | ## 4. Existing Carts
59 |
60 | ### 4.1. Notifying Existing Carts
61 | - If `notify_existing` is **true**, reminders are sent to carts that were created before the schedule was last edited.
62 | - If `notify_existing` is **false**, only carts created after the schedule is edited are notified.
63 |
64 | #### Example
65 | > A cart abandoned before a new delay is added to a schedule:
66 | > - If `notify_existing: true`, it will receive reminders.
67 | > - If `notify_existing: false`, it will not.
68 |
69 | ## 5. Best Practices
70 | - Use `notify_existing` carefully to avoid spamming old carts.
71 |
72 | ---
73 |
--------------------------------------------------------------------------------
/src/admin/lib/extended-admin.ts:
--------------------------------------------------------------------------------
1 | import { Client } from "@medusajs/js-sdk"
2 | import {
3 | CreateReminderScheduleRequest,
4 | UpdateReminderScheduleRequest,
5 | ReminderScheduleListResponse,
6 | ReminderScheduleResponse
7 | } from "../../types/reminder-schedules"
8 | import { PostmarkTemplate } from "../../types/templates"
9 | import { ValidationResponse } from "../../types/validation"
10 | import { HttpTypes } from "@medusajs/framework/types"
11 |
12 |
13 | class ReminderSchedules {
14 | private client: Client
15 |
16 | constructor(client: Client) {
17 | this.client = client
18 | }
19 |
20 | async list(query?: { id?: string; q?: string } & HttpTypes.FindParams) {
21 | return this.client.fetch(
22 | "/admin/postmark/abandoned-carts/reminders/schedules",
23 | {
24 | query
25 | }
26 | )
27 | }
28 |
29 | async retrieve(id: string, query?: HttpTypes.SelectParams) {
30 | return this.client.fetch(
31 | `/admin/postmark/abandoned-carts/reminders/schedules/${id}`,
32 | {
33 | query
34 | }
35 | )
36 | }
37 |
38 | async create(data: CreateReminderScheduleRequest) {
39 | return this.client.fetch(
40 | "/admin/postmark/abandoned-carts/reminders/schedules",
41 | {
42 | method: "POST",
43 | body: data,
44 | }
45 | )
46 | }
47 |
48 | async update(id: string, data: UpdateReminderScheduleRequest) {
49 | return this.client.fetch(
50 | `/admin/postmark/abandoned-carts/reminders/schedules/${id}`,
51 | {
52 | method: "POST",
53 | body: data,
54 | }
55 | )
56 | }
57 |
58 | async delete(id: string) {
59 | return this.client.fetch<{ success: boolean }>(
60 | `/admin/postmark/abandoned-carts/reminders/schedules/${id}`,
61 | {
62 | method: "DELETE",
63 | }
64 | )
65 | }
66 |
67 | async validate() {
68 | return this.client.fetch(
69 | "/admin/postmark/abandoned-carts/reminders/validate",
70 | {
71 | method: "POST",
72 | }
73 | )
74 | }
75 | }
76 | class Templates {
77 | private client: Client
78 |
79 | constructor(client: Client) {
80 | this.client = client
81 | }
82 | async list(query?: HttpTypes.FindParams & {
83 | id?: string
84 | q?: string
85 | templateType?: "Standard" | "Layout"
86 | }) {
87 | return this.client.fetch<{ Templates: PostmarkTemplate[], TotalCount: number, offset: number, limit: number, count: number }>(
88 | "/admin/postmark/templates",
89 | {
90 | method: "GET",
91 | query
92 | }
93 | )
94 | }
95 | }
96 |
97 | class Postmark {
98 | public reminderSchedules: ReminderSchedules
99 | public templates: Templates
100 |
101 | constructor(client: Client) {
102 | this.reminderSchedules = new ReminderSchedules(client)
103 | this.templates = new Templates(client)
104 | }
105 | }
106 |
107 | export class ExtendedAdmin {
108 | public postmark: Postmark
109 |
110 | constructor(client: Client) {
111 | this.postmark = new Postmark(client)
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/.github/workflows/release-and-publish.yaml:
--------------------------------------------------------------------------------
1 | name: Release NPM Package
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: 'Release type (patch, minor, major, or specific version)'
8 | required: true
9 | default: 'patch'
10 |
11 | jobs:
12 | release:
13 | runs-on: ubuntu-latest
14 | services:
15 | postgres:
16 | image: postgres:17
17 | env:
18 | POSTGRES_USER: postgres
19 | POSTGRES_PASSWORD: postgres
20 | ports:
21 | - 5432:5432
22 | permissions:
23 | contents: write
24 | packages: write
25 | steps:
26 | - uses: actions/checkout@v3
27 | with:
28 | fetch-depth: 0
29 | token: ${{ secrets.GITHUB_TOKEN }}
30 |
31 | - name: Setup Node.js
32 | uses: actions/setup-node@v3
33 | with:
34 | node-version: '22.x'
35 | registry-url: 'https://registry.npmjs.org'
36 |
37 | # - name: Setup pnpm
38 | # uses: pnpm/action-setup@v2
39 | # with:
40 | # version: latest
41 | # run_install: false
42 |
43 | - name: Install dependencies
44 | run: yarn install --frozen-lockfile
45 |
46 | # - name: Create env file
47 | # run: |
48 | # echo 'DB_USERNAME=postgres' >> ./integration-tests/.env.test
49 | # echo 'DB_PASSWORD=postgres' >> ./integration-tests/.env.test
50 | # echo 'TOLGEE_API_URL=https://app.tolgee.io' >> ./integration-tests/.env.test
51 | # echo 'TOLGEE_API_KEY=${{ secrets.TOLGEE_API_KEY }}' >> ./integration-tests/.env.test
52 | # echo 'TOLGEE_PROJECT_ID=${{ secrets.TOLGEE_PROJECT_ID }}' >> ./integration-tests/.env.test
53 |
54 | # - name: Run tests
55 | # run: pnpm run --filter integration-tests test:integration:http
56 |
57 | - name: Build
58 | run: |
59 | yarn build
60 |
61 | - name: Configure Git
62 | run: |
63 | git config --local user.email "github-actions[bot]@users.noreply.github.com"
64 | git config --local user.name "github-actions[bot]"
65 |
66 | - name: Bump version and push
67 | id: version-bump
68 | run: |
69 | yarn version --new-version ${{ github.event.inputs.version }} -m "chore: release v%s"
70 | # Get the new version after bumping
71 | VERSION=$(node -p "require('./package.json').version")
72 |
73 | # Set version output
74 | echo "version=$VERSION" >> $GITHUB_OUTPUT
75 |
76 | # Push all changes and tags
77 | git push && git push --tags
78 |
79 | - name: Publish to NPM
80 | run: |
81 | if [[ "${{ steps.version-bump.outputs.version }}" == *"beta"* ]]; then
82 | echo "Publishing with dist-tag beta"
83 | yarn publish --tag beta
84 | elif [[ "${{ steps.version-bump.outputs.version }}" == *"alpha"* ]]; then
85 | echo "Publishing with dist-tag alpha"
86 | yarn publish --tag alpha
87 | elif [[ "${{ steps.version-bump.outputs.version }}" == *"rc"* ]]; then
88 | echo "Publishing with dist-tag rc"
89 | yarn publish --tag rc
90 | else
91 | echo "Publishing with default dist-tag latest"
92 | yarn publish
93 | fi
94 | env:
95 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
96 |
97 | - name: Create GitHub Release
98 | uses: softprops/action-gh-release@v1
99 | with:
100 | tag_name: v${{ steps.version-bump.outputs.version }}
101 | name: v${{ steps.version-bump.outputs.version }}
102 | generate_release_notes: true
103 | draft: true
104 | env:
105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
106 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # medusa-plugin-postmark
2 |
3 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark)
4 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark)
5 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark/actions/workflows/codeql.yml)
6 |
7 | [](https://www.npmjs.com/package/medusa-plugin-postmark)
8 | [](#license)
9 | [](https://github.com/Fullstak-nl/medusa-plugin-postmark/issues)
10 |
11 | > **Postmark notification plugin for Medusa v2**
12 |
13 | This plugin provides robust transactional email support for MedusaJS using [Postmark](https://postmarkapp.com/). It supports advanced workflows for abandoned cart reminders, template management, PDF attachments, and more.
14 |
15 | ---
16 |
17 | ## Features
18 |
19 | - **Transactional Emails**: Send order, customer, and workflow notifications via Postmark.
20 | - **Abandoned Cart Reminders**: Automated, configurable reminder schedules for abandoned carts.
21 | - **Template Management**: CRUD and validation for Postmark templates, including layouts.
22 | - **PDF Attachments**: Attach PDF invoices/credit notes to emails.
23 | - **Admin UI**: Manage templates, reminder schedules, and validate data from the Medusa admin panel.
24 |
25 | ---
26 |
27 | ## Installation & Setup
28 |
29 | ### 1. Install
30 |
31 | ```sh
32 | yarn add medusa-plugin-postmark
33 | npm install medusa-plugin-postmark
34 | ```
35 |
36 | ### 2. Configure Plugin
37 |
38 | Add to your `medusa-config.ts` as a notification provider and as a plugin to enable UI and abandoned carts:
39 |
40 | ```ts
41 | defineConfig({
42 | modules: [
43 | {
44 | resolve: "@medusajs/medusa/notification",
45 | options: {
46 | providers: [
47 | {
48 | resolve: "medusa-plugin-postmark/providers/postmark",
49 | id: "postmark",
50 | options: {
51 | channels: ["email"],
52 | apiKey: process.env.POSTMARK_API_KEY!,
53 | default: {
54 | from: process.env.POSTMARK_FROM,
55 | bcc: process.env.POSTMARK_BCC,
56 | },
57 | },
58 | },
59 | ],
60 | },
61 | },
62 | ],
63 |
64 | plugins: [
65 | {
66 | resolve: "medusa-plugin-postmark",
67 | options: {
68 | apiKey: process.env.POSTMARK_API_KEY!,
69 | },
70 | },
71 | ],
72 | })
73 | ```
74 |
75 | ### 3. Environment Variables
76 |
77 | - `POSTMARK_API_KEY`: Your Postmark server token
78 | - `POSTMARK_FROM`: Default sender email address (must be verified in Postmark)
79 | - `POSTMARK_BCC`: (Optional) Default BCC address
80 |
81 | ---
82 |
83 | ## Workflows
84 |
85 | - **Abandoned Cart Workflow**: Triggers reminders based on schedule and cart state.
86 | - **Template Validation**: Ensures all required variables are present before sending.
87 |
88 | ## Reminder Schedules
89 |
90 | - Define when and how reminders are sent for abandoned carts.
91 | - Use ISO 8601 durations for delays (e.g., `PT1H`, `P1D`).
92 | - Link schedules to specific Postmark templates.
93 | - Control notification behavior with flags (e.g., notify existing carts, reset on update).
94 |
95 | ## License
96 |
97 | MIT License © 2023 Bram Hammer
98 |
99 |
100 | ## Acknowledgement
101 |
102 | This plugin is originally based on medusa-plugin-sendgrid by Oliver Juhl.
103 |
--------------------------------------------------------------------------------
/src/admin/hooks/use-combobox-data.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | QueryKey,
3 | keepPreviousData,
4 | useInfiniteQuery,
5 | useQuery,
6 | } from "@tanstack/react-query"
7 | import { useDebouncedSearch } from "./use-debounced-search"
8 |
9 | type ComboboxExternalData = {
10 | offset: number
11 | limit: number
12 | count: number
13 | }
14 |
15 | type ComboboxQueryParams = {
16 | id?: string
17 | q?: string
18 | offset?: number
19 | limit?: number
20 | }
21 |
22 | export const useComboboxData = <
23 | TResponse extends ComboboxExternalData,
24 | TParams extends ComboboxQueryParams,
25 | >({
26 | queryKey,
27 | queryFn,
28 | getOptions,
29 | defaultValue,
30 | defaultValueKey,
31 | selectedValue,
32 | pageSize = 10,
33 | enabled = true,
34 | }: {
35 | queryKey: QueryKey
36 | queryFn: (params: TParams) => Promise
37 | getOptions: (data: TResponse) => { label: string; value: string }[]
38 | defaultValueKey?: keyof TParams
39 | defaultValue?: string | string[]
40 | selectedValue?: string
41 | pageSize?: number
42 | enabled?: boolean
43 | }) => {
44 | const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
45 |
46 | const queryInitialDataBy = defaultValueKey || "id"
47 | const { data: initialData } = useQuery({
48 | queryKey: [...queryKey, defaultValue].filter(Boolean) as QueryKey,
49 | queryFn: async () => {
50 | return queryFn({
51 | [queryInitialDataBy]: defaultValue,
52 | limit: Array.isArray(defaultValue) ? defaultValue.length : 1,
53 | } as TParams)
54 | },
55 | enabled: !!defaultValue && enabled,
56 | })
57 |
58 | // always load selected value in case current data dosn't contain the value
59 | const { data: selectedData } = useQuery({
60 | queryKey: [...queryKey, selectedValue].filter(Boolean) as QueryKey,
61 | queryFn: async () => {
62 | return queryFn({
63 | id: selectedValue,
64 | limit: 1,
65 | } as TParams)
66 | },
67 | enabled: !!selectedValue && enabled,
68 | })
69 |
70 | const { data, ...rest } = useInfiniteQuery({
71 | // prevent infinite query response shape beeing stored under regualr list reponse QKs
72 | queryKey: [...queryKey, "_cbx_", query].filter(Boolean) as QueryKey,
73 | queryFn: async ({ pageParam = 0 }) => {
74 | return await queryFn({
75 | q: query,
76 | limit: pageSize,
77 | offset: pageParam,
78 | } as TParams)
79 | },
80 | initialPageParam: 0,
81 | getNextPageParam: (lastPage) => {
82 | const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
83 | return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
84 | },
85 | placeholderData: keepPreviousData,
86 | enabled: enabled,
87 | })
88 |
89 | const options = data?.pages.flatMap((page) => getOptions(page)) ?? []
90 | const defaultOptions = initialData ? getOptions(initialData) : []
91 | const selectedOptions = selectedData ? getOptions(selectedData) : []
92 | /**
93 | * If there are no options and the query is empty, then the combobox should be disabled,
94 | * as there is no data to search for.
95 | */
96 | const disabled =
97 | (!rest.isPending && !options.length && !searchValue) || !enabled
98 |
99 | // make sure that the default value is included in the options
100 | if (defaultValue && defaultOptions.length && !searchValue) {
101 | defaultOptions.forEach((option) => {
102 | if (!options.find((o) => o.value === option.value)) {
103 | options.unshift(option)
104 | }
105 | })
106 | }
107 |
108 | if (selectedValue && selectedOptions.length) {
109 | selectedOptions.forEach((option) => {
110 | if (!options.find((o) => o.value === option.value)) {
111 | options.unshift(option)
112 | }
113 | })
114 | }
115 |
116 | return {
117 | options,
118 | searchValue,
119 | onSearchValueChange,
120 | disabled,
121 | ...rest,
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/admin/components/general/action-menu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DropdownMenu,
3 | IconButton,
4 | clx,
5 | } from "@medusajs/ui"
6 | import { EllipsisHorizontal } from "@medusajs/icons"
7 | import { Link } from "react-router-dom"
8 |
9 | export type Action = {
10 | icon: React.ReactNode
11 | label: string
12 | disabled?: boolean
13 | } & (
14 | | {
15 | to: string
16 | onClick?: never
17 | }
18 | | {
19 | onClick: () => void
20 | to?: never
21 | }
22 | )
23 |
24 | export type ActionGroup = {
25 | actions: Action[]
26 | }
27 |
28 | export type ActionMenuProps = {
29 | groups: ActionGroup[]
30 | }
31 |
32 | export const ActionMenu = ({ groups }: ActionMenuProps) => {
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | {groups.map((group, index) => {
42 | if (!group.actions.length) {
43 | return null
44 | }
45 |
46 | const isLast = index === groups.length - 1
47 |
48 | return (
49 |
50 | {group.actions.map((action, index) => {
51 | if (action.onClick) {
52 | return (
53 | {
57 | e.stopPropagation()
58 | action.onClick()
59 | }}
60 | className={clx(
61 | "[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
62 | {
63 | "[&_svg]:text-ui-fg-disabled": action.disabled,
64 | }
65 | )}
66 | >
67 | {action.icon}
68 | {action.label}
69 |
70 | )
71 | }
72 |
73 | return (
74 |
75 |
85 | e.stopPropagation()}>
86 | {action.icon}
87 | {action.label}
88 |
89 |
90 |
91 | )
92 | })}
93 | {!isLast && }
94 |
95 | )
96 | })}
97 |
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/src/admin/hooks/use-reminder-schedules.ts:
--------------------------------------------------------------------------------
1 | import { FetchError } from "@medusajs/js-sdk"
2 | import {
3 | UseMutationOptions,
4 | UseQueryOptions,
5 | useMutation,
6 | useQuery,
7 | useQueryClient,
8 | keepPreviousData,
9 | } from "@tanstack/react-query"
10 | import { sdk } from "../lib/sdk"
11 | import { CreateReminderScheduleRequest, ReminderScheduleListResponse, ReminderScheduleResponse, UpdateReminderScheduleRequest } from "../../types/reminder-schedules"
12 | import { HttpTypes } from "@medusajs/framework/types"
13 |
14 | const POSTMARK_REMINDER_SCHEDULES_QUERY_KEY = "postmark_reminder_schedules" as const
15 |
16 | const postmarkReminderSchedulesQueryKeys = {
17 | all: [POSTMARK_REMINDER_SCHEDULES_QUERY_KEY] as const,
18 | schedules: (query?: Record) => [...postmarkReminderSchedulesQueryKeys.all, "schedules", query ? { query } : undefined].filter(
19 | (k) => !!k
20 | ),
21 | schedule: (id: string, query?: Record) => [...postmarkReminderSchedulesQueryKeys.schedules(), id, query ? { query } : undefined].filter(
22 | (k) => !!k
23 | )
24 | }
25 |
26 | export const useReminderSchedules = (
27 | query?: HttpTypes.FindParams,
28 | options?: UseQueryOptions
29 | ) => {
30 | const { data, ...rest } = useQuery({
31 | queryFn: async () => sdk.admin.postmark.reminderSchedules.list(query),
32 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(query),
33 | placeholderData: keepPreviousData,
34 | ...options,
35 | })
36 |
37 | return { ...data, ...rest }
38 | }
39 |
40 | export const useReminderSchedule = (
41 | id: string,
42 | query?: Record,
43 | options?: UseQueryOptions
44 | ) => {
45 | const { data, ...rest } = useQuery({
46 | queryFn: async () => sdk.admin.postmark.reminderSchedules.retrieve(id, query),
47 | queryKey: postmarkReminderSchedulesQueryKeys.schedule(id, query),
48 | placeholderData: keepPreviousData,
49 | ...options,
50 | })
51 |
52 | return { ...data, ...rest }
53 | }
54 |
55 | export const useCreateReminderSchedules = (
56 | options?: UseMutationOptions<
57 | ReminderScheduleResponse,
58 | FetchError,
59 | CreateReminderScheduleRequest
60 | >
61 | ) => {
62 | const queryClient = useQueryClient()
63 |
64 | return useMutation({
65 | mutationFn: async (payload: CreateReminderScheduleRequest) => sdk.admin.postmark.reminderSchedules.create(payload),
66 | onSuccess: async (data, variables, context) => {
67 | await queryClient.invalidateQueries({
68 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(),
69 | })
70 | options?.onSuccess?.(data, variables, context)
71 | },
72 | ...options,
73 | })
74 | }
75 |
76 | export const useUpdateReminderSchedules = (
77 | id: string,
78 | options?: UseMutationOptions<
79 | ReminderScheduleResponse,
80 | FetchError,
81 | UpdateReminderScheduleRequest
82 | >
83 | ) => {
84 | const queryClient = useQueryClient()
85 |
86 | return useMutation({
87 | mutationFn: async (payload: UpdateReminderScheduleRequest) => sdk.admin.postmark.reminderSchedules.update(id, payload),
88 | onSuccess: async (data, variables, context) => {
89 | await queryClient.invalidateQueries({
90 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(),
91 | })
92 | options?.onSuccess?.(data, variables, context)
93 | },
94 | ...options,
95 | })
96 | }
97 |
98 | export const useDeleteReminderSchedules = (
99 | options?: UseMutationOptions<
100 | { success: boolean },
101 | FetchError,
102 | string
103 | >
104 | ) => {
105 | const queryClient = useQueryClient()
106 |
107 | return useMutation({
108 | mutationFn: async (id: string) => sdk.admin.postmark.reminderSchedules.delete(id),
109 | onSuccess: async (data, variables, context) => {
110 | await queryClient.invalidateQueries({
111 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(),
112 | })
113 | options?.onSuccess?.(data, variables, context)
114 | },
115 | ...options,
116 | })
117 | }
118 |
--------------------------------------------------------------------------------
/src/modules/abandoned-cart/migrations/.snapshot-medusa-postmark-abandoned-cart.json:
--------------------------------------------------------------------------------
1 | {
2 | "namespaces": [
3 | "public"
4 | ],
5 | "name": "public",
6 | "tables": [
7 | {
8 | "columns": {
9 | "id": {
10 | "name": "id",
11 | "type": "text",
12 | "unsigned": false,
13 | "autoincrement": false,
14 | "primary": false,
15 | "nullable": false,
16 | "mappedType": "text"
17 | },
18 | "enabled": {
19 | "name": "enabled",
20 | "type": "boolean",
21 | "unsigned": false,
22 | "autoincrement": false,
23 | "primary": false,
24 | "nullable": false,
25 | "mappedType": "boolean"
26 | },
27 | "template_id": {
28 | "name": "template_id",
29 | "type": "text",
30 | "unsigned": false,
31 | "autoincrement": false,
32 | "primary": false,
33 | "nullable": false,
34 | "mappedType": "text"
35 | },
36 | "delays_iso": {
37 | "name": "delays_iso",
38 | "type": "text[]",
39 | "unsigned": false,
40 | "autoincrement": false,
41 | "primary": false,
42 | "nullable": false,
43 | "mappedType": "array"
44 | },
45 | "notify_existing": {
46 | "name": "notify_existing",
47 | "type": "boolean",
48 | "unsigned": false,
49 | "autoincrement": false,
50 | "primary": false,
51 | "nullable": false,
52 | "default": "false",
53 | "mappedType": "boolean"
54 | },
55 | "reset_on_cart_update": {
56 | "name": "reset_on_cart_update",
57 | "type": "boolean",
58 | "unsigned": false,
59 | "autoincrement": false,
60 | "primary": false,
61 | "nullable": false,
62 | "default": "true",
63 | "mappedType": "boolean"
64 | },
65 | "created_at": {
66 | "name": "created_at",
67 | "type": "timestamptz",
68 | "unsigned": false,
69 | "autoincrement": false,
70 | "primary": false,
71 | "nullable": false,
72 | "length": 6,
73 | "default": "now()",
74 | "mappedType": "datetime"
75 | },
76 | "updated_at": {
77 | "name": "updated_at",
78 | "type": "timestamptz",
79 | "unsigned": false,
80 | "autoincrement": false,
81 | "primary": false,
82 | "nullable": false,
83 | "length": 6,
84 | "default": "now()",
85 | "mappedType": "datetime"
86 | },
87 | "deleted_at": {
88 | "name": "deleted_at",
89 | "type": "timestamptz",
90 | "unsigned": false,
91 | "autoincrement": false,
92 | "primary": false,
93 | "nullable": true,
94 | "length": 6,
95 | "mappedType": "datetime"
96 | }
97 | },
98 | "name": "reminder_schedule",
99 | "schema": "public",
100 | "indexes": [
101 | {
102 | "keyName": "IDX_reminder_schedule_template_id_unique",
103 | "columnNames": [],
104 | "composite": false,
105 | "constraint": false,
106 | "primary": false,
107 | "unique": false,
108 | "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_reminder_schedule_template_id_unique\" ON \"reminder_schedule\" (template_id) WHERE deleted_at IS NULL"
109 | },
110 | {
111 | "keyName": "IDX_reminder_schedule_deleted_at",
112 | "columnNames": [],
113 | "composite": false,
114 | "constraint": false,
115 | "primary": false,
116 | "unique": false,
117 | "expression": "CREATE INDEX IF NOT EXISTS \"IDX_reminder_schedule_deleted_at\" ON \"reminder_schedule\" (deleted_at) WHERE deleted_at IS NULL"
118 | },
119 | {
120 | "keyName": "reminder_schedule_pkey",
121 | "columnNames": [
122 | "id"
123 | ],
124 | "composite": false,
125 | "constraint": true,
126 | "primary": true,
127 | "unique": true
128 | }
129 | ],
130 | "checks": [],
131 | "foreignKeys": {},
132 | "nativeEnums": {}
133 | }
134 | ],
135 | "nativeEnums": {}
136 | }
137 |
--------------------------------------------------------------------------------
/src/admin/hooks/use-postmark-table.tsx:
--------------------------------------------------------------------------------
1 | import { DataTablePaginationState, DataTableSortingState, toast, useDataTable, createDataTableColumnHelper } from "@medusajs/ui"
2 | import { useState, useMemo, useCallback } from "react"
3 | import { sdk } from "../lib/sdk"
4 | import { Trash } from "@medusajs/icons"
5 | import { PostmarkTemplate } from "../../types/templates"
6 | import { useTranslation } from "react-i18next"
7 | import { usePostmarkTemplates } from "./use-postmark-templates"
8 | import { keepPreviousData } from "@tanstack/react-query"
9 |
10 | type UsePostmarkDataTableProps = {
11 | type: "template" | "layout"
12 | serverId?: string
13 | }
14 |
15 | export const usePostmarkDataTable = ({ type, serverId }: UsePostmarkDataTableProps) => {
16 | const [pagination, setPagination] = useState({
17 | pageSize: limit,
18 | pageIndex: 0,
19 | })
20 | const [search, setSearch] = useState("")
21 | const [sorting, setSorting] = useState(null)
22 |
23 | const offset = useMemo(() => {
24 | return pagination.pageIndex * limit
25 | }, [pagination])
26 |
27 | const { t } = useTranslation("postmark")
28 |
29 | const { Templates, TotalCount, isPending, refetch } = usePostmarkTemplates({
30 | templateType: type === "template" ? "Standard" : "Layout",
31 | limit: limit,
32 | offset: offset,
33 | q: search,
34 | order: sorting ? `${sorting.desc ? "-" : ""}${sorting.id}` : undefined,
35 | }, {
36 | placeholderData: keepPreviousData,
37 | })
38 |
39 | const handleDeleteTemplate = async (templateId: number) => {
40 | try {
41 | await sdk.client.fetch(
42 | `/admin/postmark/templates/${templateId}`,
43 | {
44 | method: "DELETE",
45 | headers: {
46 | "Accept": "application/json",
47 | },
48 | }
49 | )
50 |
51 | toast.success("Template deleted successfully")
52 | // Refresh the data
53 | refetch()
54 | } catch (error) {
55 | toast.error("Failed to delete template", {
56 | description: error instanceof Error ? error.message : "An unexpected error occurred",
57 | })
58 | }
59 | }
60 |
61 | const columns = useMemo(() => [
62 | columnHelper.accessor("Name", { header: t("fields.name"), enableSorting: true }),
63 | ...(type === "template"
64 | ? [columnHelper.accessor("LayoutTemplate", { header: t("fields.layout"), enableSorting: true })]
65 | : []
66 | ),
67 | columnHelper.action({
68 | actions: [
69 | {
70 | label: t("actions.delete"),
71 | icon: ,
72 | onClick: async (ctx) => {
73 | await handleDeleteTemplate(ctx.row.original.TemplateId)
74 | },
75 | },
76 | ]
77 | }),
78 | ], [t, handleDeleteTemplate])
79 |
80 |
81 | const onRowClick = useCallback(
82 | (_: unknown, row: any) => {
83 | if (!serverId) {
84 | return
85 | }
86 | const editUrl = `https://account.postmarkapp.com/servers/${serverId}/templates/${row.id}/edit`
87 | // Always open in a new tab regardless of modifier keys
88 | window.open(editUrl, "_blank", "noopener noreferrer")
89 | },
90 | [serverId]
91 | )
92 |
93 | const table = useDataTable({
94 | columns,
95 | data: Templates || [],
96 | getRowId: (row) => row.TemplateId.toString(),
97 | rowCount: TotalCount || 0,
98 | isLoading: isPending,
99 | onRowClick,
100 | pagination: {
101 | state: pagination,
102 | onPaginationChange: setPagination,
103 | },
104 | search: {
105 | state: search,
106 | onSearchChange: setSearch,
107 | },
108 | sorting: {
109 | state: sorting,
110 | onSortingChange: setSorting,
111 | },
112 | })
113 |
114 | return {
115 | table,
116 | refetch,
117 | }
118 | }
119 |
120 | const limit = 15
121 | const columnHelper = createDataTableColumnHelper()
122 |
123 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/abandonded-carts/validate-templates-section.tsx:
--------------------------------------------------------------------------------
1 | import { Container } from "../../../components/general/container"
2 | import { Header } from "../../../components/general/header"
3 | import { useMutation } from "@tanstack/react-query"
4 | import { Badge, Text, Tooltip, toast } from "@medusajs/ui"
5 | import { MedusaError } from "@medusajs/framework/utils"
6 | import { sdk } from "../../../lib/sdk"
7 | import { useTranslation } from "react-i18next"
8 | import _ from "lodash"
9 |
10 | export const ValidateTemplatesSection = () => {
11 | const { t } = useTranslation("postmark")
12 | const { mutateAsync, isPending, data } = useMutation({
13 | mutationFn: async () => {
14 | return await sdk.admin.postmark.reminderSchedules.validate()
15 | },
16 | onError: (err: MedusaError) => {
17 | toast.error(t("validate_schedules.error_title"), { description: err.message })
18 | }
19 | })
20 |
21 | return (
22 |
23 |
92 | )
93 | }
94 |
95 | // Helper function to render missing variables object as a nested structure
96 | const renderMissingVariables = (obj: Record, prefix = ""): JSX.Element[] => {
97 | const elements: JSX.Element[] = []
98 | for (const [key, value] of Object.entries(obj)) {
99 | const fullKey = prefix ? `${prefix}.${key}` : key
100 |
101 | if (value === null) {
102 | // Primitive missing value
103 | elements.push(
104 |
105 | {fullKey}
106 |
107 | )
108 | } else if (Array.isArray(value)) {
109 | // Array with missing nested values
110 | elements.push(
111 |
112 | {fullKey}[0]
113 |
114 | )
115 | if (value.length > 0 && typeof value[0] === 'object') {
116 | elements.push(...renderMissingVariables(value[0], `${fullKey}[0]`))
117 | }
118 | } else if (typeof value === 'object') {
119 | // Nested object with missing values
120 | elements.push(...renderMissingVariables(value, fullKey))
121 | }
122 | }
123 |
124 | return elements
125 | }
126 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/abandonded-carts/reminder-schedules-table.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge,
3 | createDataTableColumnHelper,
4 | DataTable,
5 | Heading,
6 | toast,
7 | useDataTable,
8 | usePrompt,
9 | } from "@medusajs/ui"
10 | import { useMemo } from "react"
11 | import { useNavigate } from "react-router-dom"
12 | import { PencilSquare, Trash } from "@medusajs/icons"
13 | import { useTranslation } from "react-i18next"
14 | import { DataTableAction } from "../../../components/data-table-action"
15 | import { ReminderSchedule } from "../../../../types/reminder-schedules"
16 | import { useDeleteReminderSchedules, useReminderSchedules } from "../../../hooks/use-reminder-schedules"
17 | import { Container } from "../../../components/general/container"
18 | import { DurationFormat } from "@formatjs/intl-durationformat"
19 | import { Temporal } from "temporal-polyfill"
20 |
21 | const ReminderSchedulesTable = () => {
22 | const { t } = useTranslation("postmark")
23 |
24 | const { schedules = [], isPending } = useReminderSchedules({ fields: "+template.*" })
25 | const columns = useColumns()
26 |
27 | const table = useDataTable({
28 | columns,
29 | data: schedules,
30 | getRowId: (row) => row.id,
31 | rowCount: schedules?.length || 0,
32 | isLoading: isPending,
33 | })
34 |
35 | return (
36 |
37 |
38 |
39 | {t("reminder_schedules.title")}
40 |
44 |
45 |
46 |
47 |
48 | )
49 | }
50 |
51 | export default ReminderSchedulesTable
52 |
53 | const columnHelper = createDataTableColumnHelper()
54 |
55 | const useColumns = () => {
56 | const { t, i18n: { language } } = useTranslation("postmark")
57 | const formatter = useMemo(() => {
58 | return new DurationFormat(language, { style: 'long' })
59 | }, [language])
60 | const navigate = useNavigate()
61 | const prompt = usePrompt()
62 |
63 | const { mutateAsync } = useDeleteReminderSchedules()
64 |
65 | const handleDelete = async (schedule: ReminderSchedule) => {
66 | const res = await prompt({
67 | title: t("general.areYouSure"),
68 | description: t("reminder_schedules.delete.confirmation"),
69 | confirmText: t("actions.delete"),
70 | cancelText: t("actions.cancel"),
71 | })
72 |
73 | if (!res) {
74 | return
75 | }
76 |
77 | await mutateAsync(schedule.id, {
78 | onSuccess: () => {
79 | toast.success(
80 | t("reminder_schedules.delete.successToast")
81 | )
82 | },
83 | onError: (e) => {
84 | toast.error(e.message)
85 | },
86 | })
87 | }
88 |
89 | const columns = useMemo(
90 | () => [
91 | columnHelper.accessor("template.Name", {
92 | header: t("fields.template"),
93 | }),
94 | columnHelper.accessor("delays_iso", {
95 | header: t("reminder_schedules.delays"),
96 | cell: ({ getValue }) => {
97 | const delays_iso = getValue()
98 | if (!delays_iso || delays_iso.length === 0) return '-'
99 |
100 | return (
101 |
102 | {delays_iso.sort((a, b) => Temporal.Duration.compare(a, b, { relativeTo: Temporal.Now.plainDateTimeISO() })).map((duration, index) => (
103 |
104 | {formatter.format(Temporal.Duration.from(duration))}
105 |
106 | ))}
107 |
108 | )
109 | }
110 | }),
111 | columnHelper.accessor("enabled", {
112 | header: t("fields.status"),
113 | cell: ({ getValue }) => {
114 | const enabled = getValue()
115 | return (
116 |
117 | {enabled ? t("statuses.enabled") : t("statuses.disabled")}
118 |
119 | )
120 | },
121 | }),
122 | columnHelper.accessor("notify_existing", {
123 | header: t("reminder_schedules.notify_existing"),
124 | cell: ({ getValue }) => {
125 | const notifyExisting = getValue()
126 | return (
127 |
128 | {notifyExisting ? t("filters.radio.yes") : t("filters.radio.no")}
129 |
130 | )
131 | },
132 | }),
133 | columnHelper.accessor("reset_on_cart_update", {
134 | header: t("reminder_schedules.reset_on_cart_update"),
135 | cell: ({ getValue }) => {
136 | const resetOnUpdate = getValue()
137 | return (
138 |
139 | {resetOnUpdate ? t("filters.radio.yes") : t("filters.radio.no")}
140 |
141 | )
142 | },
143 | }),
144 | columnHelper.action({
145 | actions: [
146 | {
147 | label: t("actions.edit"),
148 | icon: ,
149 | onClick: (ctx) => {
150 | navigate(`edit/${ctx.row.original.id}`)
151 | },
152 | },
153 | {
154 | label: t("actions.delete"),
155 | icon: ,
156 | onClick: async (ctx) => {
157 | await handleDelete(ctx.row.original)
158 | },
159 | },
160 | ],
161 | }),
162 | ],
163 | [t, navigate]
164 | )
165 | return columns
166 | }
167 |
--------------------------------------------------------------------------------
/src/admin/components/general/duration-input.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Input, Label, Select } from "@medusajs/ui"
2 | import { Plus, Trash } from "@medusajs/icons"
3 | import { useTranslation } from "react-i18next"
4 | import { forwardRef, useMemo, useCallback } from "react"
5 | import { Temporal } from "temporal-polyfill"
6 | import { DurationFormat } from "@formatjs/intl-durationformat"
7 |
8 | interface DurationInputProps {
9 | value?: string[]
10 | onChange?: (value: string[]) => void
11 | }
12 |
13 | const UNITS = ["months", "weeks", "days", "hours", "minutes"] as const
14 |
15 | export const DurationInput = forwardRef(
16 | ({ value = [], onChange }, ref) => {
17 | const { t, i18n: { language = "en" } } = useTranslation("postmark")
18 | const formatter = useMemo(() => new DurationFormat(language, { style: "long" }), [language])
19 |
20 | const durations = useMemo(() =>
21 | value.map(iso => { try { return Temporal.Duration.from(iso) } catch { return null } })
22 | .filter((d): d is Temporal.Duration => d !== null),
23 | [value]
24 | )
25 |
26 | const formatUnit = useCallback((unit: string, val: number) => {
27 | try {
28 | return formatter.format(Temporal.Duration.from({ [unit]: val }))
29 | .replace(/[\d\s]/g, "").trim()
30 | } catch {
31 | return unit
32 | }
33 | }, [formatter])
34 |
35 | const update = useCallback((updated: Temporal.Duration[]) =>
36 | onChange?.(updated.map(d => d.toString())), [onChange])
37 |
38 | const set = useCallback((i: number, unit: string, val: number) => {
39 | const updated = [...durations]
40 | updated[i] = Temporal.Duration.from({ [unit]: val })
41 | update(updated)
42 | }, [durations, update])
43 |
44 | const getUnitAndValue = useCallback((d: Temporal.Duration) => {
45 | for (const unit of UNITS) {
46 | if (d[unit]) return { unit, value: d[unit] }
47 | }
48 | return { unit: "hours", value: 1 }
49 | }, [])
50 |
51 | return (
52 |
53 | {durations.length > 0 && (
54 |
55 |
56 |
57 |
58 |
59 |
62 |
63 | )}
64 |
65 | {durations.map((duration, i) => {
66 | const { unit, value: val } = getUnitAndValue(duration)
67 |
68 | return (
69 |
70 | {i > 0 &&
}
71 |
72 |
73 | {
78 | const num = parseInt(e.target.value)
79 | if (!isNaN(num) && num > 0) set(i, unit, num)
80 | }}
81 | />
82 |
92 |
93 |
102 |
103 |
104 | )
105 | })}
106 |
107 |
117 |
118 | )
119 | }
120 | )
121 |
122 | DurationInput.displayName = "DurationInput"
123 |
--------------------------------------------------------------------------------
/src/admin/components/form/form.tsx:
--------------------------------------------------------------------------------
1 | import { InformationCircleSolid } from "@medusajs/icons"
2 | import {
3 | Hint as HintComponent,
4 | Label as LabelComponent,
5 | Text,
6 | Tooltip,
7 | clx,
8 | } from "@medusajs/ui"
9 | import { Label as RadixLabel, Slot } from "radix-ui"
10 | import React, {
11 | ReactNode,
12 | createContext,
13 | forwardRef,
14 | useContext,
15 | useId,
16 | } from "react"
17 | import {
18 | Controller,
19 | ControllerProps,
20 | FieldPath,
21 | FieldValues,
22 | FormProvider,
23 | useFormContext,
24 | useFormState,
25 | } from "react-hook-form"
26 | import { useTranslation } from "react-i18next"
27 |
28 | const Provider = FormProvider
29 |
30 | type FormFieldContextValue<
31 | TFieldValues extends FieldValues = FieldValues,
32 | TName extends FieldPath = FieldPath
33 | > = {
34 | name: TName
35 | }
36 |
37 | const FormFieldContext = createContext(
38 | {} as FormFieldContextValue
39 | )
40 |
41 | const Field = <
42 | TFieldValues extends FieldValues = FieldValues,
43 | TName extends FieldPath = FieldPath
44 | >({
45 | ...props
46 | }: ControllerProps) => {
47 | return (
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | type FormItemContextValue = {
55 | id: string
56 | }
57 |
58 | const FormItemContext = createContext(
59 | {} as FormItemContextValue
60 | )
61 |
62 | const useFormField = () => {
63 | const fieldContext = useContext(FormFieldContext)
64 | const itemContext = useContext(FormItemContext)
65 | const { getFieldState } = useFormContext()
66 |
67 | const formState = useFormState({ name: fieldContext.name })
68 | const fieldState = getFieldState(fieldContext.name, formState)
69 |
70 | if (!fieldContext) {
71 | throw new Error("useFormField should be used within a FormField")
72 | }
73 |
74 | const { id } = itemContext
75 |
76 | return {
77 | id,
78 | name: fieldContext.name,
79 | formItemId: `${id}-form-item`,
80 | formLabelId: `${id}-form-item-label`,
81 | formDescriptionId: `${id}-form-item-description`,
82 | formErrorMessageId: `${id}-form-item-message`,
83 | ...fieldState,
84 | }
85 | }
86 |
87 | const Item = forwardRef>(
88 | ({ className, ...props }, ref) => {
89 | const id = useId()
90 |
91 | return (
92 |
93 |
98 |
99 | )
100 | }
101 | )
102 | Item.displayName = "Form.Item"
103 |
104 | const Label = forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef & {
107 | optional?: boolean
108 | tooltip?: ReactNode
109 | icon?: ReactNode
110 | }
111 | >(({ className, optional = false, tooltip, icon, ...props }, ref) => {
112 | const { formLabelId, formItemId } = useFormField()
113 | const { t } = useTranslation()
114 |
115 | return (
116 |
117 |
126 | {tooltip && (
127 |
128 |
129 |
130 | )}
131 | {icon}
132 | {optional && (
133 |
134 | ({t("fields.optional")})
135 |
136 | )}
137 |
138 | )
139 | })
140 | Label.displayName = "Form.Label"
141 |
142 | const Control = forwardRef<
143 | React.ElementRef,
144 | React.ComponentPropsWithoutRef
145 | >(({ ...props }, ref) => {
146 | const {
147 | error,
148 | formItemId,
149 | formDescriptionId,
150 | formErrorMessageId,
151 | formLabelId,
152 | } = useFormField()
153 |
154 | return (
155 |
167 | )
168 | })
169 | Control.displayName = "Form.Control"
170 |
171 | const Hint = forwardRef<
172 | HTMLParagraphElement,
173 | React.HTMLAttributes
174 | >(({ className, ...props }, ref) => {
175 | const { formDescriptionId } = useFormField()
176 |
177 | return (
178 |
184 | )
185 | })
186 | Hint.displayName = "Form.Hint"
187 |
188 | const ErrorMessage = forwardRef<
189 | HTMLParagraphElement,
190 | React.HTMLAttributes
191 | >(({ className, children, ...props }, ref) => {
192 | const { error, formErrorMessageId } = useFormField()
193 | const msg = error ? String(error?.message) : children
194 |
195 | if (!msg || msg === "undefined") {
196 | return null
197 | }
198 |
199 | return (
200 |
207 | {msg}
208 |
209 | )
210 | })
211 | ErrorMessage.displayName = "Form.ErrorMessage"
212 |
213 | const Form = Object.assign(Provider, {
214 | Item,
215 | Label,
216 | Control,
217 | Hint,
218 | ErrorMessage,
219 | Field,
220 | })
221 |
222 | export { Form }
223 |
--------------------------------------------------------------------------------
/src/admin/i18n/$schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema#",
3 | "type": "object",
4 | "additionalProperties": false,
5 | "required": [
6 | "$schema",
7 | "templates",
8 | "layouts",
9 | "reminder_schedules",
10 | "validate_schedules",
11 | "fields"
12 | ],
13 | "properties": {
14 | "$schema": {
15 | "type": "string"
16 | },
17 | "templates": {
18 | "type": "object",
19 | "additionalProperties": false,
20 | "required": [
21 | "title"
22 | ],
23 | "properties": {
24 | "title": {
25 | "type": "string"
26 | }
27 | }
28 | },
29 | "layouts": {
30 | "type": "object",
31 | "additionalProperties": false,
32 | "required": [
33 | "title"
34 | ],
35 | "properties": {
36 | "title": {
37 | "type": "string"
38 | }
39 | }
40 | },
41 | "reminder_schedules": {
42 | "type": "object",
43 | "additionalProperties": false,
44 | "required": [
45 | "title",
46 | "delays",
47 | "notify_existing",
48 | "notify_existing_hint",
49 | "reset_on_cart_update",
50 | "reset_on_cart_update_hint",
51 | "notify_existing_warning_title",
52 | "notify_existing_warning_desc",
53 | "create_title",
54 | "create_description",
55 | "edit_title",
56 | "edit_description",
57 | "delete"
58 | ],
59 | "properties": {
60 | "title": {
61 | "type": "string"
62 | },
63 | "delays": {
64 | "type": "string"
65 | },
66 | "notify_existing": {
67 | "type": "string"
68 | },
69 | "notify_existing_hint": {
70 | "type": "string"
71 | },
72 | "reset_on_cart_update": {
73 | "type": "string"
74 | },
75 | "reset_on_cart_update_hint": {
76 | "type": "string"
77 | },
78 | "notify_existing_warning_title": {
79 | "type": "string"
80 | },
81 | "notify_existing_warning_desc": {
82 | "type": "string"
83 | },
84 | "create_title": {
85 | "type": "string"
86 | },
87 | "create_description": {
88 | "type": "string"
89 | },
90 | "edit_title": {
91 | "type": "string"
92 | },
93 | "edit_description": {
94 | "type": "string"
95 | },
96 | "delete": {
97 | "type": "object",
98 | "additionalProperties": false,
99 | "required": [
100 | "confirmation",
101 | "successToast"
102 | ],
103 | "properties": {
104 | "confirmation": {
105 | "type": "string"
106 | },
107 | "successToast": {
108 | "type": "string"
109 | }
110 | }
111 | }
112 | }
113 | },
114 | "validate_schedules": {
115 | "type": "object",
116 | "additionalProperties": false,
117 | "required": [
118 | "title",
119 | "error_title",
120 | "all_valid",
121 | "missing_data",
122 | "validating",
123 | "validate_button",
124 | "unknown_template",
125 | "missing_variables",
126 | "view_provided_data"
127 | ],
128 | "properties": {
129 | "title": {
130 | "type": "string"
131 | },
132 | "error_title": {
133 | "type": "string"
134 | },
135 | "all_valid": {
136 | "type": "string"
137 | },
138 | "missing_data": {
139 | "type": "string"
140 | },
141 | "validating": {
142 | "type": "string"
143 | },
144 | "validate_button": {
145 | "type": "string"
146 | },
147 | "unknown_template": {
148 | "type": "string"
149 | },
150 | "missing_variables": {
151 | "type": "string"
152 | },
153 | "view_provided_data": {
154 | "type": "string"
155 | }
156 | }
157 | },
158 | "fields": {
159 | "type": "object",
160 | "additionalProperties": false,
161 | "required": [
162 | "unit",
163 | "template",
164 | "layout"
165 | ],
166 | "properties": {
167 | "unit": {
168 | "type": "string"
169 | },
170 | "template": {
171 | "type": "string"
172 | },
173 | "layout": {
174 | "type": "string"
175 | }
176 | }
177 | },
178 | "menuLabels": {
179 | "type": "object",
180 | "additionalProperties": false,
181 | "required": [
182 | "abandoned_carts",
183 | "postmark_templates"
184 | ],
185 | "properties": {
186 | "abandoned_carts": {
187 | "type": "string"
188 | },
189 | "postmark_templates": {
190 | "type": "string"
191 | }
192 | }
193 | }
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/src/admin/components/general/chip-input.tsx:
--------------------------------------------------------------------------------
1 | import { XMarkMini } from "@medusajs/icons"
2 | import { Badge, clx } from "@medusajs/ui"
3 | import {
4 | FocusEvent,
5 | KeyboardEvent,
6 | forwardRef,
7 | useImperativeHandle,
8 | useRef,
9 | useState,
10 | } from "react"
11 |
12 | type ChipInputProps = {
13 | value?: string[]
14 | onChange?: (value: string[]) => void
15 | onBlur?: () => void
16 | name?: string
17 | disabled?: boolean
18 | allowDuplicates?: boolean
19 | showRemove?: boolean
20 | variant?: "base" | "contrast"
21 | placeholder?: string
22 | className?: string
23 | }
24 |
25 | export const ChipInput = forwardRef(
26 | (
27 | {
28 | value,
29 | onChange,
30 | onBlur,
31 | disabled,
32 | name,
33 | showRemove = true,
34 | variant = "base",
35 | allowDuplicates = false,
36 | placeholder,
37 | className,
38 | },
39 | ref
40 | ) => {
41 | const innerRef = useRef(null)
42 |
43 | const isControlledRef = useRef(typeof value !== "undefined")
44 | const isControlled = isControlledRef.current
45 |
46 | const [uncontrolledValue, setUncontrolledValue] = useState([])
47 |
48 | useImperativeHandle(
49 | ref,
50 | () => innerRef.current
51 | )
52 |
53 | const [duplicateIndex, setDuplicateIndex] = useState(null)
54 |
55 | const chips = isControlled ? (value as string[]) : uncontrolledValue
56 |
57 | // Sort chips by numerical value
58 | const sortedChips = [...chips].sort((a, b) => {
59 | const numA = parseFloat(a) || 0
60 | const numB = parseFloat(b) || 0
61 | return numA - numB
62 | })
63 |
64 | const handleAddChip = (chip: string) => {
65 | const cleanValue = chip.trim()
66 |
67 | if (!cleanValue) {
68 | return
69 | }
70 |
71 | if (!allowDuplicates && sortedChips.includes(cleanValue)) {
72 | setDuplicateIndex(sortedChips.indexOf(cleanValue))
73 |
74 | setTimeout(() => {
75 | setDuplicateIndex(null)
76 | }, 300)
77 |
78 | return
79 | }
80 |
81 | const newChips = [...chips, cleanValue]
82 | const sortedNewChips = newChips.sort((a, b) => {
83 | const numA = parseFloat(a) || 0
84 | const numB = parseFloat(b) || 0
85 | return numA - numB
86 | })
87 |
88 | onChange?.(sortedNewChips)
89 |
90 | if (!isControlled) {
91 | setUncontrolledValue(sortedNewChips)
92 | }
93 | }
94 |
95 | const handleRemoveChip = (chip: string) => {
96 | const filteredChips = chips.filter((v) => v !== chip)
97 | const sortedFilteredChips = filteredChips.sort((a, b) => {
98 | const numA = parseFloat(a) || 0
99 | const numB = parseFloat(b) || 0
100 | return numA - numB
101 | })
102 |
103 | onChange?.(sortedFilteredChips)
104 |
105 | if (!isControlled) {
106 | setUncontrolledValue(sortedFilteredChips)
107 | }
108 | }
109 |
110 | const handleBlur = (e: FocusEvent) => {
111 | onBlur?.()
112 |
113 | if (e.target.value) {
114 | handleAddChip(e.target.value)
115 | e.target.value = ""
116 | }
117 | }
118 |
119 | const handleKeyDown = (e: KeyboardEvent) => {
120 | if (e.key === "Enter" || e.key === ",") {
121 | e.preventDefault()
122 |
123 | if (!innerRef.current?.value) {
124 | return
125 | }
126 |
127 | handleAddChip(innerRef.current?.value ?? "")
128 | innerRef.current.value = ""
129 | innerRef.current?.focus()
130 | }
131 |
132 | if (e.key === "Backspace" && innerRef.current?.value === "") {
133 | handleRemoveChip(sortedChips[sortedChips.length - 1])
134 | }
135 | }
136 |
137 | return (
138 | innerRef.current?.focus()}
152 | >
153 | {sortedChips.map((v, index) => {
154 | return (
155 |
163 | {v}
164 | {showRemove && (
165 |
175 | )}
176 |
177 | )
178 | })}
179 |
194 |
195 | )
196 | }
197 | )
198 |
199 | ChipInput.displayName = "ChipInput"
200 |
--------------------------------------------------------------------------------
/src/api/admin/postmark/abandoned-carts/reminders/validate/route.ts:
--------------------------------------------------------------------------------
1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2 | import { notificationDataWorkflow } from "../../../../../../workflows/notification-data"
3 | import { ABANDONED_CART_MODULE } from "../../../../../../modules/abandoned-cart"
4 | import { ValidationResponse, ValidationResult } from "../../../../../../types/validation"
5 | import { MedusaError } from "@medusajs/framework/utils"
6 | import { Temporal } from "temporal-polyfill"
7 | import { CartDTO, CustomerDTO } from "@medusajs/framework/types"
8 |
9 | export async function POST(req: MedusaRequest, res: MedusaResponse) {
10 | const logger = req.scope.resolve("logger")
11 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE)
12 | const postmarkModuleService = req.scope.resolve("postmarkModuleService")
13 |
14 | try {
15 | // Get all reminder schedules
16 | const schedules = await abandonedCartModuleService.listReminderSchedules()
17 |
18 | if (!schedules?.length) {
19 | return res.json({
20 | success: true,
21 | message: "No reminder schedules found to validate",
22 | results: []
23 | })
24 | }
25 |
26 | const { Templates: templates } = await postmarkModuleService.getTemplates({ count: 500 })
27 |
28 | // Create mock notification data for each schedule
29 | const carts = [{
30 | cart: mockCart,
31 | reminders: schedules.flatMap((schedule) => (
32 | {
33 | delay: Temporal.Duration.from(schedule.delays_iso[0]),
34 | delayIso: schedule.delays_iso[0],
35 | template: schedule.template_id,
36 | schedule
37 | }
38 | )
39 | )
40 | }]
41 |
42 | // Run the notification data workflow with mock data
43 | const { result } = await notificationDataWorkflow(req.scope).run({
44 | input: { carts }
45 | })
46 |
47 | const notificationData = result.notificationData
48 |
49 | // Validate each notification against its template
50 | const validationResults: ValidationResult[] = []
51 |
52 | for (const notification of notificationData) {
53 | const template = templates.find(
54 | (t) => t.Alias === notification.template || t.TemplateId.toString() === notification.template
55 | )
56 |
57 | if (!template) {
58 | throw new MedusaError(MedusaError.Types.INVALID_DATA, `Template with ID or Alias '${notification.template}' not found in Postmark`)
59 | }
60 |
61 | // Use Postmark's validation endpoint
62 | // Pass an empty object to get ALL required variables in SuggestedTemplateModel
63 | const templateDetails = await postmarkModuleService.getTemplate(template.TemplateId)
64 | const { SuggestedTemplateModel } = await postmarkModuleService.validateTemplate({
65 | Subject: templateDetails.Subject || "",
66 | HtmlBody: templateDetails.HtmlBody || "",
67 | TextBody: templateDetails.TextBody || "",
68 | TestRenderModel: {}
69 | })
70 |
71 | const missingVariables = filterMissing(SuggestedTemplateModel, notification.data)
72 |
73 | if (Object.keys(missingVariables).length > 0)
74 | validationResults.push({
75 | templateId: template.TemplateId.toString(),
76 | templateName: template.Name,
77 | missingVariables,
78 | providedData: notification.data,
79 | })
80 | }
81 |
82 | const allValid = validationResults.length === 0
83 |
84 | res.json({
85 | success: allValid,
86 | message: allValid
87 | ? "All templates have the required data from the notification workflow"
88 | : "Some templates are missing required data",
89 | results: validationResults,
90 | })
91 |
92 | } catch (error) {
93 | logger.error("medusa-plugin-postmark: Error validating templates:", error)
94 | throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `Failed to validate templates: ${error instanceof Error ? error.message : "Unknown error"}`)
95 | }
96 | }
97 |
98 | /**
99 | * Recursively compares a suggested template model with provided data
100 | * Returns only the keys that are missing or have missing nested values
101 | */
102 | const filterMissing = (suggested: Record, provided: Record | undefined | null): Record => {
103 | const missing: Record = {}
104 |
105 | // If provided data is not an object, all suggested keys are missing
106 | if (!provided || typeof provided !== 'object' || Array.isArray(provided)) {
107 | const result = Object.keys(suggested).reduce((acc, key) => ({ ...acc, [key]: null }), {})
108 | return result
109 | }
110 |
111 | for (const key in suggested) {
112 | const suggestedVal = suggested[key]
113 | const providedVal = provided[key]
114 | // Check if the key exists in provided data (undefined or null means missing)
115 | const keyIsMissing = providedVal === undefined || providedVal === null
116 |
117 | if (Array.isArray(suggestedVal)) {
118 | // Handle arrays
119 | if (keyIsMissing || !Array.isArray(providedVal) || providedVal.length === 0) {
120 | missing[key] = null
121 | } else if (suggestedVal.length > 0 && typeof suggestedVal[0] === 'object' && !Array.isArray(suggestedVal[0])) {
122 | // Check nested object in array
123 | const nestedMissing = filterMissing(suggestedVal[0], providedVal[0])
124 | if (Object.keys(nestedMissing).length > 0) {
125 | missing[key] = [nestedMissing]
126 | }
127 | }
128 | } else if (suggestedVal && typeof suggestedVal === 'object') {
129 | // Handle nested objects
130 | if (keyIsMissing) {
131 | // When parent is missing, recurse with null to get full nested structure
132 | const nestedMissing = filterMissing(suggestedVal, null)
133 | missing[key] = nestedMissing
134 | } else if (typeof providedVal !== 'object' || Array.isArray(providedVal)) {
135 | // Provided value is not an object when we expect one
136 | const nestedMissing = filterMissing(suggestedVal, null)
137 | missing[key] = nestedMissing
138 | } else {
139 | // Recursively check nested properties
140 | const nestedMissing = filterMissing(suggestedVal, providedVal)
141 | if (Object.keys(nestedMissing).length > 0) {
142 | missing[key] = nestedMissing
143 | }
144 | }
145 | } else {
146 | // Handle primitive values
147 | if (keyIsMissing) {
148 | missing[key] = null
149 | }
150 | }
151 | }
152 |
153 | return missing
154 | }
155 |
156 | const mockCart = {
157 | email: "customer@example.com",
158 | created_at: new Date().toISOString(),
159 | updated_at: new Date().toISOString(),
160 | customer: {
161 | id: "cus_mock_123",
162 | email: "customer@example.com",
163 | first_name: "John",
164 | last_name: "Doe"
165 | },
166 | shipping_address: {
167 | first_name: "John",
168 | last_name: "Doe",
169 | address_1: "123 Main St",
170 | city: "New York",
171 | country_code: "US",
172 | postal_code: "10001"
173 | },
174 | items: [
175 | {
176 | id: "item_1",
177 | title: "Sample Product",
178 | quantity: 2,
179 | unit_price: 1999,
180 | thumbnail: "https://via.placeholder.com/150"
181 | },
182 | {
183 | id: "item_2",
184 | title: "Another Product",
185 | quantity: 1,
186 | unit_price: 2999,
187 | thumbnail: "https://via.placeholder.com/150"
188 | }
189 | ]
190 | } as unknown as CartDTO & { customer: CustomerDTO }
191 |
--------------------------------------------------------------------------------
/src/workflows/steps/fetch-abandoned-carts.ts:
--------------------------------------------------------------------------------
1 | import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
2 | import { ReminderSchedule } from "../../types/reminder-schedules"
3 | import { CartDTO, CustomerDTO } from "@medusajs/framework/types"
4 | import { Temporal } from "temporal-polyfill"
5 | import {
6 | computeCartReferenceTimestamp,
7 | getSentNotification,
8 | } from "../../types/abandoned-cart-tracking"
9 |
10 | type FetchAbandonedCartsStepInput = {
11 | reminderSchedules: Array
12 | pagination: { limit: number, offset: number }
13 | }
14 |
15 | /**
16 | * Represents a reminder with computed delay
17 | */
18 | interface ProcessedReminder {
19 | delay: Temporal.Duration
20 | delayIso: string // original ISO duration
21 | template: string
22 | schedule: ReminderSchedule
23 | }
24 |
25 | /**
26 | * Check if a cart should receive a specific reminder
27 | */
28 | function shouldSendReminder(
29 | cart: CartDTO,
30 | reminder: ProcessedReminder,
31 | currentReferenceInstant: Temporal.Instant,
32 | now: Temporal.Instant
33 | ): boolean {
34 | const { schedule, delayIso, delay } = reminder
35 |
36 | // Calculate time elapsed since cart's reference timestamp
37 | const elapsed = now.since(currentReferenceInstant)
38 | const currentReferenceDate = Temporal.ZonedDateTime.from(currentReferenceInstant.toZonedDateTimeISO("UTC"))
39 |
40 | // Check if enough time has passed for this delay
41 | if (Temporal.Duration.compare(elapsed, delay, { relativeTo: currentReferenceDate }) < 0) {
42 | return false
43 | }
44 |
45 | // Check if this notification was already sent
46 | const sentNotification = getSentNotification(cart, schedule.id, delayIso)
47 |
48 | if (!sentNotification) {
49 | // If reset_on_cart_update is true and the cart has been updated since any previous send, allow sending all reminders again (including missed ones)
50 | if (schedule.reset_on_cart_update) {
51 | // Check if any notification for this schedule was sent before
52 | const tracking = (typeof cart.metadata?.abandoned_cart_tracking === 'object' && cart.metadata?.abandoned_cart_tracking !== null)
53 | ? ((cart.metadata.abandoned_cart_tracking as any).sent_notifications || {})
54 | : {}
55 | const schedulePrefix = `${schedule.id}:`
56 | let anySent = false
57 | let lastCartReferenceAtSend = null
58 | for (const key of Object.keys(tracking)) {
59 | if (key.startsWith(schedulePrefix)) {
60 | anySent = true
61 | const notif = tracking[key]
62 | if (!lastCartReferenceAtSend || notif.cart_reference_at_send > lastCartReferenceAtSend) {
63 | lastCartReferenceAtSend = notif.cart_reference_at_send
64 | }
65 | }
66 | }
67 | if (anySent && lastCartReferenceAtSend) {
68 | // If cart has been updated since last send, allow sending all reminders again
69 | const cartReferenceAtSendInstant = Temporal.Instant.from(lastCartReferenceAtSend)
70 | if (Temporal.Instant.compare(currentReferenceInstant, cartReferenceAtSendInstant) > 0) {
71 | return true
72 | }
73 | }
74 | }
75 | // Never sent before - this could be a newly added delay
76 |
77 | // Prevent sending a notification for a smaller delay if a bigger one was already sent for the same schedule
78 | // Find all sent notifications for this schedule
79 | const tracking = (typeof cart.metadata?.abandoned_cart_tracking === 'object' && cart.metadata?.abandoned_cart_tracking !== null)
80 | ? ((cart.metadata.abandoned_cart_tracking as any).sent_notifications || {})
81 | : {}
82 | const schedulePrefix = `${schedule.id}:`
83 | let biggerDelaySent = false
84 | for (const key of Object.keys(tracking)) {
85 | if (key.startsWith(schedulePrefix)) {
86 | const sentDelayIso = key.slice(schedulePrefix.length)
87 | try {
88 | const sentDelay = Temporal.Duration.from(sentDelayIso)
89 | if (Temporal.Duration.compare(sentDelay, delay, { relativeTo: currentReferenceDate }) > 0) {
90 | biggerDelaySent = true
91 | break
92 | }
93 | } catch { }
94 | }
95 | }
96 | if (biggerDelaySent) {
97 | return false
98 | }
99 |
100 | if (schedule.updated_at) {
101 | const scheduleUpdatedInstant = Temporal.Instant.from(new Date(schedule.updated_at).toISOString())
102 | const cartCreatedInstant = Temporal.Instant.from(new Date(cart.created_at!).toISOString())
103 |
104 | // If notify_existing is false, only carts created after schedule update are eligible
105 | if (!schedule.notify_existing) {
106 | if (Temporal.Instant.compare(cartCreatedInstant, scheduleUpdatedInstant) < 0) {
107 | return false
108 | }
109 | // For newly added delays: cart reference must also be after schedule update
110 | if (Temporal.Instant.compare(currentReferenceInstant, scheduleUpdatedInstant) < 0) {
111 | return false
112 | }
113 | } else {
114 | // If notify_existing is true, allow carts created before schedule update
115 | // But for newly added delays, only allow if the delay existed at the time the cart became eligible
116 | // So, skip the reference check for notify_existing=true
117 | }
118 | }
119 | return true
120 | }
121 |
122 | // Already sent once - check reset_on_cart_update behavior
123 | if (schedule.reset_on_cart_update) {
124 | // Reset mode: resend if cart was updated after last send
125 | const cartReferenceAtSendInstant = Temporal.Instant.from(sentNotification.cart_reference_at_send)
126 | const cartChangedSinceLastSend = Temporal.Instant.compare(
127 | currentReferenceInstant,
128 | cartReferenceAtSendInstant
129 | ) > 0
130 |
131 | if (cartChangedSinceLastSend) {
132 | // Cart was updated, check if enough time has passed since the update
133 | return Temporal.Duration.compare(elapsed, delay, { relativeTo: currentReferenceDate }) >= 0
134 | }
135 | }
136 |
137 | // Either reset is disabled or cart hasn't changed - don't resend
138 | return false
139 | }
140 |
141 | export const fetchAbandonedCarts = createStep(
142 | "fetch-abandoned-carts",
143 | async ({ reminderSchedules, pagination }: FetchAbandonedCartsStepInput, { container }) => {
144 | const query = container.resolve("query")
145 |
146 | // Filter out disabled schedules
147 | const enabledSchedules = reminderSchedules.filter(s => s.enabled !== false)
148 | // Transform enabled reminder schedules into a flat array of reminders with delay and template
149 | const reminders: ProcessedReminder[] = enabledSchedules.flatMap(schedule =>
150 | schedule.delays_iso.map(delayIso => ({
151 | delay: Temporal.Duration.from(delayIso),
152 | delayIso,
153 | template: schedule.template_id,
154 | schedule: schedule
155 | }))
156 | ).sort((a, b) => Temporal.Duration.compare(a.delay, b.delay, { relativeTo: Temporal.Now.plainDateTimeISO() }))
157 |
158 | if (!reminders.length) {
159 | return new StepResponse({ carts: [], totalCount: 0 })
160 | }
161 |
162 | // Use the shortest delay to filter carts - we need carts old enough for at least one delay
163 | const shortestDelay = reminders[0].delay
164 | const now = Temporal.Now.instant()
165 | const cutoffInstant = now.subtract(shortestDelay)
166 |
167 | const {
168 | data: abandonedCarts,
169 | metadata,
170 | } = await query.graph({
171 | entity: "cart",
172 | fields: [
173 | "id",
174 | "email",
175 | "items.*",
176 | "metadata",
177 | "updated_at",
178 | "created_at",
179 | "customer.*",
180 | ],
181 | filters: {
182 | email: {
183 | $ne: null,
184 | },
185 | completed_at: null,
186 | },
187 | pagination: {
188 | skip: pagination.offset,
189 | take: pagination.limit,
190 | },
191 | })
192 |
193 | // Build an array of { cart, reminders: ProcessedReminder[] } for all eligible reminders for each cart
194 | const eligible: Array<{ cart: CartDTO & { customer: CustomerDTO }, reminders: ProcessedReminder[] }> = []
195 |
196 | for (const cart of abandonedCarts) {
197 | // Skip carts without items
198 | if (!cart.items?.length) {
199 | continue
200 | }
201 |
202 | // Compute the cart's reference timestamp (most recent update)
203 | const currentReferenceTimestamp = computeCartReferenceTimestamp(cart as unknown as CartDTO)
204 | const currentReferenceInstant = Temporal.Instant.fromEpochMilliseconds(currentReferenceTimestamp)
205 |
206 | // Check if cart is old enough for any reminder
207 | if (Temporal.Instant.compare(currentReferenceInstant, cutoffInstant) > 0) {
208 | continue
209 | }
210 |
211 | // For each reminder, check eligibility
212 | const eligibleReminders: ProcessedReminder[] = []
213 | // Group reminders by schedule to select one per schedule
214 | const remindersBySchedule = new Map()
215 | for (const reminder of reminders) {
216 | const scheduleId = reminder.schedule.id
217 | if (!remindersBySchedule.has(scheduleId)) {
218 | remindersBySchedule.set(scheduleId, [])
219 | }
220 | remindersBySchedule.get(scheduleId)!.push(reminder)
221 | }
222 | for (const [scheduleId, scheduleReminders] of remindersBySchedule) {
223 | let selectedReminder: ProcessedReminder | null = null
224 | for (const reminder of scheduleReminders.toReversed()) {
225 | if (shouldSendReminder(cart as unknown as CartDTO, reminder, currentReferenceInstant, now)) {
226 | selectedReminder = reminder
227 | break
228 | }
229 | }
230 | if (selectedReminder) {
231 | eligibleReminders.push(selectedReminder)
232 | }
233 | }
234 | if (eligibleReminders.length > 0) {
235 | eligible.push({ cart: cart as unknown as CartDTO & { customer: CustomerDTO }, reminders: eligibleReminders })
236 | }
237 | }
238 |
239 | const totalCount = metadata?.count ?? 0
240 | return new StepResponse({
241 | carts: eligible,
242 | totalCount
243 | })
244 | }
245 | )
246 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/abandonded-carts/@edit/[id]/edit-form.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod"
2 | import {
3 | Alert,
4 | Button,
5 | Switch,
6 | } from "@medusajs/ui"
7 | import { useForm } from "react-hook-form"
8 | import { usePrompt } from "@medusajs/ui"
9 | import { useTranslation } from "react-i18next"
10 | import { Form } from "../../../../../components/form/form"
11 | import { KeyboundForm } from "../../../../../components/modals/utilities/keybound-form"
12 | import { FetchError } from "@medusajs/js-sdk"
13 | import { useRouteModal } from "../../../../../components/modals/route-modal-provider/use-route-modal"
14 | import { RouteDrawer } from "../../../../../components/modals/route-drawer"
15 | import { ReminderSchedule, UpdateReminderSchedule, UpdateReminderScheduleSchema } from "../../../../../../types/reminder-schedules"
16 | import { useUpdateReminderSchedules } from "../../../../../hooks/use-reminder-schedules"
17 | import { DurationInput } from "../../../../../components/general/duration-input"
18 | import { useComboboxData } from "../../../../../hooks/use-combobox-data"
19 | import { sdk } from "../../../../../lib/sdk"
20 | import { Combobox } from "../../../../../components/general/combobox"
21 | const isFetchError = (error: any): error is FetchError => {
22 | return error instanceof FetchError
23 | }
24 |
25 | export const EditReminderScheduleForm = ({ schedule }: { schedule: ReminderSchedule }) => {
26 | const { t } = useTranslation("postmark")
27 | const prompt = usePrompt()
28 | const { handleSuccess } = useRouteModal()
29 |
30 | const form = useForm({
31 | defaultValues: schedule,
32 | resolver: zodResolver(UpdateReminderScheduleSchema),
33 | })
34 |
35 | const { mutateAsync, isPending } = useUpdateReminderSchedules(schedule.id)
36 |
37 | const handleSubmit = form.handleSubmit(async (values) => {
38 | await mutateAsync(values, {
39 | onSuccess: () => {
40 | handleSuccess()
41 | },
42 | onError: error => {
43 | if (isFetchError(error) && error.status === 400)
44 | form.setError("root", {
45 | type: "manual",
46 | message: error.message,
47 | })
48 |
49 | }
50 | })
51 | })
52 |
53 | const templateComboOptions = useComboboxData({
54 | queryKey: ["postmark", "templates"],
55 | queryFn: (queryParams) => sdk.admin.postmark.templates.list(queryParams),
56 | getOptions: (data) =>
57 | data.Templates.map((template) => ({
58 | label: template.Name,
59 | value: template.TemplateId.toString(),
60 | })),
61 | })
62 |
63 | return (
64 |
65 |
69 |
70 | {form.formState.errors.root && (
71 |
76 | {form.formState.errors.root.message}
77 |
78 | )}
79 |
80 |
81 |
{
85 | return (
86 |
87 |
88 | {t("fields.template")}
89 |
90 |
91 |
100 |
101 |
102 |
103 | )
104 | }}
105 | />
106 |
107 | {
111 | return (
112 |
113 |
114 | {t("reminder_schedules.delays")}
115 |
116 |
117 |
120 |
121 |
122 |
123 | )
124 | }}
125 | />
126 |
127 | {
131 | return (
132 |
133 |
134 |
135 |
140 |
141 |
142 | {t("statuses.enabled")}
143 |
144 |
145 |
146 |
147 | )
148 | }}
149 | />
150 |
151 | {
155 | const handleCheckedChange = async (checked: boolean) => {
156 | if (checked) {
157 | const confirmed = await prompt({
158 | title: t("reminder_schedules.notify_existing_warning_title"),
159 | description: t("reminder_schedules.notify_existing_warning_desc"),
160 | variant: "confirmation",
161 | })
162 | if (!confirmed) return
163 | }
164 | onChange(checked)
165 | }
166 | return (
167 |
168 |
169 |
170 |
175 |
176 |
177 | {t("reminder_schedules.notify_existing")}
178 |
179 |
180 |
181 | {t("reminder_schedules.notify_existing_hint")}
182 |
183 |
184 |
185 | )
186 | }}
187 | />
188 |
189 | {
193 | return (
194 |
195 |
196 |
197 |
202 |
203 |
204 | {t("reminder_schedules.reset_on_cart_update")}
205 |
206 |
207 |
208 | {t("reminder_schedules.reset_on_cart_update_hint")}
209 |
210 |
211 |
212 | )
213 | }}
214 | />
215 |
216 |
217 |
218 |
219 |
220 |
223 |
224 |
232 |
233 |
234 |
235 |
236 | )
237 | }
238 |
--------------------------------------------------------------------------------
/src/admin/routes/postmark/abandonded-carts/@create/create-form.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod"
2 | import {
3 | Alert,
4 | Button,
5 | Switch,
6 | } from "@medusajs/ui"
7 | import { useForm } from "react-hook-form"
8 | import { usePrompt } from "@medusajs/ui"
9 | import { useTranslation } from "react-i18next"
10 | import { Form } from "../../../../components/form/form"
11 | import { KeyboundForm } from "../../../../components/modals/utilities/keybound-form"
12 | import { FetchError } from "@medusajs/js-sdk"
13 | import { useRouteModal } from "../../../../components/modals/route-modal-provider/use-route-modal"
14 | import { RouteDrawer } from "../../../../components/modals/route-drawer"
15 | import { CreateReminderSchedule, CreateReminderScheduleSchema } from "../../../../../types/reminder-schedules"
16 | import { useCreateReminderSchedules } from "../../../../hooks/use-reminder-schedules"
17 | import { DurationInput } from "../../../../components/general/duration-input"
18 | import { useComboboxData } from "../../../../hooks/use-combobox-data"
19 | import { sdk } from "../../../../lib/sdk"
20 | import { Combobox } from "../../../../components/general/combobox"
21 | const isFetchError = (error: any): error is FetchError => {
22 | return error instanceof FetchError
23 | }
24 |
25 | export const CreateReminderScheduleForm = () => {
26 | const { t } = useTranslation("postmark")
27 | const prompt = usePrompt()
28 | const { handleSuccess } = useRouteModal()
29 |
30 | const form = useForm({
31 | resolver: zodResolver(CreateReminderScheduleSchema),
32 | defaultValues: {
33 | enabled: false,
34 | notify_existing: false,
35 | reset_on_cart_update: true,
36 | },
37 | })
38 |
39 | const { mutateAsync, isPending } = useCreateReminderSchedules()
40 |
41 | const handleSubmit = form.handleSubmit(async (values) => {
42 | await mutateAsync(values as any, {
43 | onSuccess: () => {
44 | handleSuccess()
45 | },
46 | onError: error => {
47 | if (isFetchError(error) && error.status === 400)
48 | form.setError("root", {
49 | type: "manual",
50 | message: error.message,
51 | })
52 |
53 | }
54 | })
55 | })
56 |
57 | const templateComboOptions = useComboboxData({
58 | queryKey: ["postmark", "templates"],
59 | queryFn: (queryParams) => sdk.admin.postmark.templates.list(queryParams),
60 | getOptions: (data) =>
61 | data.Templates.map((template) => ({
62 | label: template.Name,
63 | value: template.TemplateId.toString(),
64 | })),
65 | })
66 |
67 | return (
68 |
69 |
73 |
74 | {form.formState.errors.root && (
75 |
80 | {form.formState.errors.root.message}
81 |
82 | )}
83 |
84 |
85 |
{
89 | return (
90 |
91 |
92 | {t("fields.template")}
93 |
94 |
95 |
104 |
105 |
106 |
107 | )
108 | }}
109 | />
110 |
111 | {
115 | return (
116 |
117 |
118 | {t("reminder_schedules.delays")}
119 |
120 |
121 |
124 |
125 |
126 |
127 | )
128 | }}
129 | />
130 |
131 | {
135 | return (
136 |
137 |
138 |
139 |
144 |
145 |
146 | {t("statuses.enabled")}
147 |
148 |
149 |
150 |
151 | )
152 | }}
153 | />
154 |
155 | {
159 | const handleCheckedChange = async (checked: boolean) => {
160 | if (checked) {
161 | const confirmed = await prompt({
162 | title: t("reminder_schedules.notify_existing_warning_title"),
163 | description: t("reminder_schedules.notify_existing_warning_desc"),
164 | variant: "confirmation",
165 | })
166 | if (!confirmed) return
167 | }
168 | onChange(checked)
169 | }
170 | return (
171 |
172 |
173 |
174 |
179 |
180 |
181 | {t("reminder_schedules.notify_existing")}
182 |
183 |
184 |
185 | {t("reminder_schedules.notify_existing_hint")}
186 |
187 |
188 |
189 | )
190 | }}
191 | />
192 |
193 | {
197 | return (
198 |
199 |
200 |
201 |
206 |
207 |
208 | {t("reminder_schedules.reset_on_cart_update")}
209 |
210 |
211 |
212 | {t("reminder_schedules.reset_on_cart_update_hint")}
213 |
214 |
215 |
216 | )
217 | }}
218 | />
219 |
220 |
221 |
222 |
223 |
224 |
227 |
228 |
236 |
237 |
238 |
239 |
240 | )
241 | }
242 |
--------------------------------------------------------------------------------