feat: complete saleor core extensions app with React email templates

- Order confirmation, shipped, and cancelled email templates
- Uses @react-email/components for professional HTML emails
- Sends admin and customer notifications
- Integrates with Resend for email delivery
- Webhook handlers for ORDER_CREATED, ORDER_FULFILLED, ORDER_CANCELLED
- Docker image optimized for production
- Persistent auth data storage via PVC
This commit is contained in:
Unchained
2026-03-27 05:21:56 +02:00
commit 33fb9a8452
76 changed files with 209115 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"mcp-saleor": {
"command": "npx",
"args": ["mcp-graphql"],
"env": {
"SCHEMA":"../graphql/schema.graphql"
}
}
}
}
@@ -0,0 +1,17 @@
---
description: Integrating with Saleor API
globs:
alwaysApply: false
---
# Verify Schema
Whenever the user is asking for integration with Saleor API, verify the [schema.graphql](mdc:graphql/schema.graphql) to match the queries, mutations and inputs required by Saleor API.
# GraphQL Files
Whenever the user requires some GraphQL query/mutation, write them to a .graphql file in "/graphql" folder. Queries go to "/query", mutations go to "/mutations" etc.
# Generating Types
When you add a new GraphQL file, make sure to regenerate the types using the "generate" command from [package.json](mdc:package.json). It may take some time for the TS Server to detect the changes.
+15
View File
@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:22
ENV PNPM_HOME="/home/node/.pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
# This is needed so pnpm-store volume is created with correct permissions (see docker-compose.yml)
RUN mkdir -p /app/.pnpm-store
RUN chown -R node:node /app/.pnpm-store
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
RUN chown -R node:node /home/node/.pnpm
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Saleor app template",
"dockerComposeFile": "docker-compose.yml",
"service": "saleor-app-template",
"workspaceFolder": "/app",
"forwardPorts": [3000],
"portsAttributes": {
"3000": {
"label": "Saleor app"
}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"GraphQL.vscode-graphql-syntax",
"GraphQL.vscode-graphql",
"streetsidesoftware.code-spell-checker"
]
}
}
}
+14
View File
@@ -0,0 +1,14 @@
services:
saleor-app-template:
image: saleor-app-template
command: sleep infinity # keeps docker container running
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- "..:/app"
- "pnpm-store:/app/.pnpm-store"
volumes:
pnpm-store:
driver: local
+8
View File
@@ -0,0 +1,8 @@
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
#
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
#
# If developped with tunnels, set this empty, it will fallback to address the app is reached from (default port 3000).
APP_IFRAME_BASE_URL=
APP_API_BASE_URL=
+12
View File
@@ -0,0 +1,12 @@
{
"extends": ["next", "plugin:@saleor/saleor-app/recommended", "prettier"],
"plugins": ["simple-import-sort"],
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"import/order": "off", // to avoid conflicts with simple-import-sort
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
}
}
+1
View File
@@ -0,0 +1 @@
generated/** linguist-generated=true
+13
View File
@@ -0,0 +1,13 @@
# Copilot Instructions
## Verify Schema
Whenever the user is asking for integration with Saleor API, verify the [schema.graphql](../graphql/schema.graphql) to match the queries, mutations and inputs required by Saleor API.
## GraphQL Files
Whenever the user requires some GraphQL query/mutation, write them to a .graphql file in [/graphql](../graphql) folder. Queries go to "/query", mutations go to "/mutations" etc.
## Generating Types
When you add a new GraphQL file, make sure to regenerate the types using the "generate" command from [package.json](../package.json). It may take some time for the TS Server to detect the changes.
+46
View File
@@ -0,0 +1,46 @@
import { spawnSync } from "node:child_process";
const {
stdout: branchesDiffer,
stderr,
status,
} = spawnSync("git", ["log", "main..canary"], {
encoding: "utf8",
});
if (status !== 0) {
console.error("Fail reading branches diff");
console.error(stderr);
process.exit(1);
}
if (branchesDiffer === "") {
console.log("Branches canary and main have no different commits");
process.exit(0);
} else if (branchesDiffer.length > 0) {
const result = spawnSync(
"gh",
[
"pr",
"create",
"-B",
"main",
"-H",
"canary",
"--title",
"Merge canary to main",
"--body",
"Merge canary to main, to trigger a prod release",
],
{}
);
if (result.status === 0) {
console.log("Successfully opened a PR");
process.exit(0);
} else {
console.error("Error trying to open a PR");
console.error(result.stderr);
process.exit(1);
}
}
+16
View File
@@ -0,0 +1,16 @@
name: Assign PR to creator
on:
pull_request:
types: [opened]
jobs:
assign_creator:
runs-on: ubuntu-22.04
permissions:
pull-requests: write
steps:
- name: Assign PR to creator
uses: toshimaru/auto-author-assign@ebd30f10fb56e46eb0759a14951f36991426fed0 # v2.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
+48
View File
@@ -0,0 +1,48 @@
name: Check Licenses
on:
pull_request:
types:
- opened
- synchronize
# Labels are needed to handle external contributors
- labeled
- unlabeled
paths:
# Self
- ".github/workflows/check-licenses.yaml"
# Python Ecosystem
- "**/pyproject.toml"
- "**/setup.py"
- "**/requirements*.txt"
- "**/Pipfile.lock"
- "**/poetry.lock"
# JS/TS Ecosystem
- "**/package.json"
- "**/pnpm-lock.yaml"
- "**/package-lock.json"
jobs:
default:
permissions:
contents: read
pull-requests: write
uses: saleor/saleor-internal-actions/.github/workflows/run-license-check.yaml@v1
with:
# List of ecosystems to scan.
ecosystems: >-
python
javascript
# Grant rules (https://github.com/anchore/grant/blob/4362dc22cf5ea9baeccfa59b2863879afe0c30d7/README.md#usage)
rules: |
# Explicitly allow LGPL as "*GPL*" rule will cause to reject them otherwise.
- pattern: "*lgpl*"
name: "allow-lgpl"
mode: "allow"
reason: "LGPL is allowed."
- pattern: "*gpl*"
name: "deny-gpl"
mode: "deny"
reason: "GPL licenses are not compatible with BSD-3-Clause"
- pattern: "*proprietary*"
name: "deny-proprietary"
mode: "deny"
+29
View File
@@ -0,0 +1,29 @@
name: QA
on: [pull_request]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
with:
run_install: false
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Check types
run: pnpm check-types
- name: Check for changes in generated files
run: |
pnpm generate
git diff --name-status --exit-code .
- name: Test
run: pnpm test
- name: Build project
run: pnpm build
@@ -0,0 +1,25 @@
# Branches with names starting with `example` are meant to be long living branches used as
# documentation/usage example.
# After accepting the branch, the main PR should be closed
name: Example branch management
on: pull_request
jobs:
example-branches-checks:
if: ${{ startsWith(github.head_ref, 'example') }}
name: Checks
runs-on: ubuntu-22.04
permissions:
pull-requests: write
steps:
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
name: Set example label
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
labels: |
example
- name: Example branches are not meant to be merged
run: |
echo "Example branches are not meant to be merged"
exit 1
+50
View File
@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
.envfile
.saleor-app-auth.json
# vercel
.vercel
# typescript
*.tsbuildinfo
.auth_token
#editor
!.vscode/mcp.json
.idea
# Sentry
.sentryclirc
.env
+3
View File
@@ -0,0 +1,3 @@
strict-peer-dependencies=false
auto-install-peers=true
manage-package-manager-versions=true
+1
View File
@@ -0,0 +1 @@
22.11
+5
View File
@@ -0,0 +1,5 @@
.next
saleor/api.tsx
pnpm-lock.yaml
graphql/schema.graphql
generated
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": false,
"printWidth": 100
}
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"mcp-saleor": {
"command": "npx",
"args": ["mcp-graphql"],
"env": {
"SCHEMA":"../graphql/schema.graphql"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
* @saleor/js
+30
View File
@@ -0,0 +1,30 @@
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run generate && npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+37
View File
@@ -0,0 +1,37 @@
BSD 3-Clause License
Copyright (c) 2020-2022, Saleor Commerce
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------
Unless stated otherwise, artwork included in this distribution is licensed
under the Creative Commons Attribution 4.0 International License.
You can learn more about the permitted use by visiting
https://creativecommons.org/licenses/by/4.0/
+112
View File
@@ -0,0 +1,112 @@
<div align="center">
<img width="150" alt="saleor-app-template" src="https://user-images.githubusercontent.com/4006792/215185065-4ef2eda4-ca71-48cc-b14b-c776e0b491b6.png">
</div>
<div align="center">
<h1>Saleor App Template</h1>
</div>
<div align="center">
<p>Bare-bones boilerplate for writing Saleor Apps with Next.js.</p>
</div>
<div align="center">
<a href="https://saleor.io/">Website</a>
<span> | </span>
<a href="https://docs.saleor.io/docs/3.x/">Docs</a>
<span> | </span>
<a href="https://githubbox.com/saleor/saleor-app-template">CodeSandbox</a>
</div>
> [!TIP]
> Questions or issues? Check our [discord](https://discord.gg/H52JTZAtSH) channel for help.
### What is Saleor App
Saleor App is the fastest way of extending Saleor with custom logic using [asynchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks) and [synchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/synchronous-webhooks/key-concepts) webhooks (and vast Saleor's API). In most cases, creating an App consists of two tasks:
- Writing webhook's code executing your custom logic.
- Developing configuration UI to be displayed in Saleor Dashboard via specialized view (designated in the App's manifest).
### What's included?
- 🚀 Communication between Saleor instance and Saleor App
- 📖 Manifest with webhooks using custom query
### Why Next.js
You can use any preferred technology to create Saleor Apps, but Next.js is among the most efficient for two reasons. The first is the simplicity of maintaining your API endpoints/webhooks and your apps' configuration React front-end in a single, well-organized project. The second reason is the ease and quality of local development and deployment.
### Learn more about Apps
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
## Development
#### Running app locally in development containers
The easiest way of running app for local development is to use [development containers](https://containers.dev/).
If you have Visual Studio Code follow their [guide](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container) on how to open existing folder in container.
Development container only creates container, you still need to start the server.
Development container will have port opened:
1. `3000` - were app dev server will listen to requests
### Requirements
Before you start, make sure you have installed:
- [Node.js 22](https://nodejs.org/en/)
- [pnpm 9](https://pnpm.io/)
1. Install the dependencies by running:
```
pnpm install
```
2. Start the local server with:
```
pnpm dev
```
3. Expose local environment using tunnel:
Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
4. Install the application in your dashboard:
If you use Saleor Cloud or your local server is exposed, you can install your app by following this link:
```
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
```
This template host manifest at `/api/manifest`
You can also install application using GQL or command line. Follow the guide [how to install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) to learn more.
### Generated schema and typings
This project uses a `generate` npm script command to:
- Generate GraphQL schema and typed functions from Saleor's GraphQL endpoint.
- Generate types for Saleor sync webhook responses from JSON schema
Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the GraphQL / JSON schema changes.
To generate GraphQL types we are using [GraphQL Codegen](https://www.graphql-code-generator.com/). For generating types from JSON schema we use [json-schema-to-typescript](https://www.npmjs.com/package/json-schema-to-typescript).
### Storing registration data - APL
During the registration process, Saleor API passes the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
To store this data, app-template use a different [APL interfaces](https://docs.saleor.io/developer/extending/apps/developing-apps/app-sdk/apl).
The choice of the APL is made using the `APL` environment variable. If the value is not set, FileAPL is used. Available choices:
- `file`: no additional setup is required. Good choice for local development. It can't be used for multi tenant-apps or be deployed (not intended for production)
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. It can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
If you want to use your own database, you can implement your own APL. [Check the documentation to read more](https://docs.saleor.io/developer/extending/apps/developing-apps/app-sdk/apl).
+55
View File
@@ -0,0 +1,55 @@
import { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "./graphql/schema.graphql",
documents: ["./graphql/**/*.graphql"],
generates: {
"./generated/graphql.ts": {
plugins: [
{
add: {
content:
"type JSONValue = string | number | boolean | null | { [key: string]: JSONValue } | JSONValue[];",
},
},
"typescript",
"typescript-operations",
"urql-introspection",
{
"typescript-urql": {
documentVariablePrefix: "Untyped",
fragmentVariablePrefix: "Untyped",
},
},
"typed-document-node",
],
config: {
dedupeFragments: true,
defaultScalarType: "unknown",
immutableTypes: true,
strictScalars: true,
skipTypename: true,
scalars: {
_Any: "unknown",
Date: "string",
DateTime: "string",
Decimal: "number",
Minute: "number",
GenericScalar: "JSONValue",
JSON: "JSONValue",
JSONString: "string",
Metadata: "Record<string, string>",
PositiveDecimal: "number",
Upload: "unknown",
UUID: "string",
WeightScalar: "number",
Day: "string",
Hour: "number",
PositiveInt: "number",
},
},
},
},
};
export default config;
@@ -0,0 +1,18 @@
/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type Id = string;
export type Reason = string;
export type ExcludedMethods = ExcludedShippingMethodSchema[];
export interface FilterShippingMethods {
excluded_methods?: ExcludedMethods;
}
export interface ExcludedShippingMethodSchema {
id: Id;
reason?: Reason;
}
+105671
View File
File diff suppressed because one or more lines are too long
+36858
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
fragment OrderCancelledWebhookPayload on OrderCancelled {
order {
id
number
userEmail
user {
email
firstName
lastName
}
status
}
}
+86
View File
@@ -0,0 +1,86 @@
fragment OrderCreatedWebhookPayload on OrderCreated {
order {
id
number
created
status
userEmail
languageCode
channel {
slug
}
user {
email
firstName
lastName
}
shippingAddress {
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country {
country
}
phone
}
billingAddress {
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country {
country
}
phone
}
lines {
id
quantity
unitPrice {
gross {
amount
currency
}
}
totalPrice {
gross {
amount
currency
}
}
variant {
name
sku
product {
name
media {
url
}
}
}
}
subtotal {
gross {
amount
currency
}
}
shippingPrice {
gross {
amount
currency
}
}
total {
gross {
amount
currency
}
}
}
}
@@ -0,0 +1,9 @@
fragment OrderFilterShippingMethodsPayload on OrderFilterShippingMethods {
order {
id
}
shippingMethods {
id
name
}
}
+18
View File
@@ -0,0 +1,18 @@
fragment OrderFulfilledWebhookPayload on OrderFulfilled {
order {
id
number
userEmail
user {
email
firstName
lastName
}
status
fulfillments {
id
status
created
}
}
}
View File
+29
View File
@@ -0,0 +1,29 @@
query LastOrder {
orders(first: 1) {
edges {
node {
id
number
created
user {
firstName
lastName
}
shippingAddress {
country {
country
}
}
total {
gross {
amount
currency
}
}
lines {
id
}
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
query ProductTimestamps($id: ID) {
product(id: $id) {
created
updatedAt
}
}
+37797
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
subscription OrderCancelledSubscription {
event {
...OrderCancelledWebhookPayload
}
}
@@ -0,0 +1,5 @@
subscription OrderCreatedSubscription {
event {
...OrderCreatedWebhookPayload
}
}
@@ -0,0 +1,5 @@
subscription OrderFilterShippingMethodsSubscription {
event {
...OrderFilterShippingMethodsPayload
}
}
@@ -0,0 +1,5 @@
subscription OrderFulfilledSubscription {
event {
...OrderFulfilledWebhookPayload
}
}
+23
View File
@@ -0,0 +1,23 @@
import path from "path";
import { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
reactStrictMode: true,
webpack: (config) => {
// When using `pnpm link` for local SDK development, webpack may resolve
// react/react-dom from the linked package's node_modules (different version),
// causing the "two Reacts" problem. Force resolution to this project's copy.
config.resolve = {
...config.resolve,
alias: {
...config.resolve?.alias,
react: path.resolve("./node_modules/react"),
"react-dom": path.resolve("./node_modules/react-dom"),
},
};
return config;
},
};
export default config;
+15848
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
{
"name": "saleor-app-template",
"version": "1.0.0",
"private": true,
"license": "(BSD-3-Clause AND CC-BY-4.0)",
"type": "module",
"scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_config_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"test": "vitest",
"check-types": "tsc --noEmit",
"generate": "npm run generate:app-graphql-types && npm run generate:app-webhooks-types",
"generate:app-graphql-types": "graphql-codegen",
"generate:app-webhooks-types": "tsx ./scripts/generate-app-webhooks-types.ts"
},
"config": {
"saleor": {
"schemaVersion": "3.22"
}
},
"engines": {
"npm": ">=10.0.0 <11.0.0",
"node": ">=22.0.0 <23.0.0",
"pnpm": ">=10.0.0 <11.0.0"
},
"dependencies": {
"@react-email/components": "^0.0.19",
"@saleor/app-sdk": "1.5.0",
"@saleor/macaw-ui": "1.4.1",
"@urql/exchange-auth": "^1.0.0",
"next": "15.5.9",
"react": "18.3.1",
"react-dom": "18.3.1",
"resend": "^6.9.4",
"urql": "^4.0.2"
},
"packageManager": "pnpm@10.28.1",
"devDependencies": {
"@graphql-codegen/add": "3.2.0",
"@graphql-codegen/cli": "3.3.1",
"@graphql-codegen/introspection": "3.0.1",
"@graphql-codegen/schema-ast": "^3.0.1",
"@graphql-codegen/typed-document-node": "4.0.1",
"@graphql-codegen/typescript": "3.0.4",
"@graphql-codegen/typescript-operations": "3.0.4",
"@graphql-codegen/typescript-urql": "^3.7.3",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "^3.2.0",
"@saleor/eslint-plugin-saleor-app": "^0.1.2",
"@types/node": "^18.11.18",
"@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4",
"@vitejs/plugin-react": "4.2.1",
"eslint": "8.31.0",
"eslint-config-next": "13.1.2",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"graphql": "^16.8.1",
"graphql-tag": "^2.12.6",
"jsdom": "^20.0.3",
"json-schema-to-typescript": "^15.0.4",
"prettier": "^2.8.2",
"tsx": "4.20.3",
"typescript": "5.0.4",
"vite": "5.2.10",
"vite-tsconfig-paths": "5.1.4",
"vitest": "1.5.2"
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix",
"*.{js,ts,tsx,css,md,json}": "prettier --write"
}
}
+9898
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
onlyBuiltDependencies:
- esbuild
- sharp
blockExoticSubdeps: true
minimumReleaseAge: 1440 # 24h
trustPolicy: no-downgrade
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

+61
View File
@@ -0,0 +1,61 @@
import { writeFileSync } from "node:fs";
import { compile } from "json-schema-to-typescript";
const schemaFileNames = [
// List of all Saleor webhook response schemas - uncomment those you need
// "CheckoutCalculateTaxes",
// "CheckoutFilterShippingMethods",
// "ListStoredPaymentMethods",
// "OrderCalculateTaxes",
"OrderFilterShippingMethods",
// "PaymentGatewayInitializeSession",
// "PaymentGatewayInitializeTokenizationSession",
// "ShippingListMethodsForCheckout",
// "ShippingListMethodsForOrder",
// "StoredPaymentMethodDeleteRequested",
// "TransactionCancelationRequested",
// "TransactionChargeRequested",
// "TransactionInitializeSession",
// "TransactionProcessSession",
// "TransactionRefundRequested",
];
const path = "https://raw.githubusercontent.com/saleor/saleor/main/saleor/json_schemas/";
const convertToKebabCase = (fileName: string): string => {
return fileName
.replace(/([A-Z])/g, "-$1")
.toLowerCase()
.replace(/^-/, "");
};
const schemaMapping = schemaFileNames.map((fileName) => ({
fileName: convertToKebabCase(fileName),
url: `${path}${fileName}.json`,
}));
async function main() {
await Promise.all(
schemaMapping.map(async ({ fileName, url }) => {
const res = await fetch(url);
const fetchedSchema = await res.json();
const compiledTypes = await compile(fetchedSchema, fileName, {
additionalProperties: false,
});
writeFileSync(`./generated/app-webhooks-types/${fileName}.ts`, compiledTypes);
})
);
}
try {
console.log("Fetching JSON schemas from Saleor GitHub repository...");
await main();
console.log("Successfully generated TypeScript files from JSON schemas.");
} catch (error) {
console.error(`Error generating webhook response types: ${error}`);
process.exit(1);
}
+98
View File
@@ -0,0 +1,98 @@
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
interface BaseLayoutProps {
children: React.ReactNode;
previewText: string;
language: string;
siteUrl: string;
}
const translations: Record<string, { footer: string; company: string }> = {
sr: {
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
company: "ManoonOils",
},
en: {
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
company: "ManoonOils",
},
de: {
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
company: "ManoonOils",
},
fr: {
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
company: "ManoonOils",
},
};
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
const t = translations[language] || translations.en;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.logoSection}>
<Img
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
width="150"
height="auto"
alt="ManoonOils"
style={styles.logo}
/>
</Section>
{children}
<Section style={styles.footer}>
<Text style={styles.footerText}>{t.footer}</Text>
</Section>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
backgroundColor: "#f6f6f6",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
},
container: {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "40px 20px",
maxWidth: "600px",
},
logoSection: {
textAlign: "center" as const,
marginBottom: "30px",
},
logo: {
margin: "0 auto",
},
footer: {
marginTop: "40px",
paddingTop: "20px",
borderTop: "1px solid #e0e0e0",
},
footerText: {
color: "#666666",
fontSize: "12px",
textAlign: "center" as const,
},
};
+237
View File
@@ -0,0 +1,237 @@
import { Button, Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: string;
}
interface OrderCancelledProps {
language?: string;
orderId: string;
orderNumber: string;
customerName: string;
items: OrderItem[];
total: string;
reason?: string;
siteUrl: string;
}
const translations: Record<
string,
{
title: string;
preview: string;
greeting: string;
orderCancelled: string;
items: string;
total: string;
reason: string;
questions: string;
}
> = {
sr: {
title: "Vaša narudžbina je otkazana",
preview: "Vaša narudžbina je otkazana",
greeting: "Poštovani {name},",
orderCancelled:
"Vaša narudžbina je otkazana. Ako niste zatražili otkazivanje, molimo kontaktirajte nas što pre.",
items: "Artikli",
total: "Ukupno",
reason: "Razlog",
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
},
en: {
title: "Your Order Has Been Cancelled",
preview: "Your order has been cancelled",
greeting: "Dear {name},",
orderCancelled:
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
items: "Items",
total: "Total",
reason: "Reason",
questions: "Questions? Email us at support@manoonoils.com",
},
de: {
title: "Ihre Bestellung wurde storniert",
preview: "Ihre Bestellung wurde storniert",
greeting: "Sehr geehrte/r {name},",
orderCancelled:
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
items: "Artikel",
total: "Gesamt",
reason: "Grund",
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
},
fr: {
title: "Votre commande a été annulée",
preview: "Votre commande a été annulée",
greeting: "Cher(e) {name},",
orderCancelled:
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
items: "Articles",
total: "Total",
reason: "Raison",
questions: "Questions? Écrivez-nous à support@manoonoils.com",
},
};
export function OrderCancelled({
language = "en",
orderId,
orderNumber,
customerName,
items,
total,
reason,
siteUrl,
}: OrderCancelledProps) {
const t = translations[language] || translations.en;
return (
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
<Text style={styles.title}>{t.title}</Text>
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
<Text style={styles.text}>{t.orderCancelled}</Text>
<Section style={styles.orderInfo}>
<Text style={styles.orderNumber}>
<strong>Order Number:</strong> {orderNumber}
</Text>
{reason && (
<Text style={styles.reason}>
<strong>{t.reason}:</strong> {reason}
</Text>
)}
</Section>
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{t.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
<Hr style={styles.hr} />
<Section style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.total}:</Text>
<Text style={styles.totalValue}>{total}</Text>
</Section>
</Section>
<Section style={styles.buttonSection}>
<Button href={siteUrl} style={styles.button}>
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
</Button>
</Section>
<Text style={styles.questions}>{t.questions}</Text>
</BaseLayout>
);
}
const styles = {
title: {
fontSize: "24px",
fontWeight: "bold" as const,
color: "#dc2626",
marginBottom: "20px",
},
greeting: {
fontSize: "16px",
color: "#333333",
marginBottom: "10px",
},
text: {
fontSize: "14px",
color: "#666666",
marginBottom: "20px",
},
orderInfo: {
backgroundColor: "#fef2f2",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
orderNumber: {
fontSize: "14px",
color: "#333333",
margin: "0 0 5px 0",
},
reason: {
fontSize: "14px",
color: "#991b1b",
margin: "0",
},
itemsSection: {
marginBottom: "20px",
},
sectionTitle: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "10px",
},
hr: {
borderColor: "#e0e0e0",
margin: "10px 0",
},
itemRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
itemName: {
fontSize: "14px",
color: "#666666",
margin: "0",
textDecoration: "line-through",
},
itemPrice: {
fontSize: "14px",
color: "#666666",
margin: "0",
textDecoration: "line-through",
},
totalRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
totalLabel: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#666666",
margin: "0",
},
totalValue: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#666666",
margin: "0",
textDecoration: "line-through",
},
buttonSection: {
textAlign: "center" as const,
marginBottom: "20px",
},
button: {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 30px",
borderRadius: "4px",
fontSize: "14px",
fontWeight: "bold" as const,
textDecoration: "none",
},
questions: {
fontSize: "14px",
color: "#666666",
},
};
+340
View File
@@ -0,0 +1,340 @@
import { Button, Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: string;
}
interface OrderConfirmationProps {
language?: string;
orderId: string;
orderNumber: string;
customerEmail: string;
customerName: string;
items: OrderItem[];
total: string;
shippingAddress?: string;
billingAddress?: string;
phone?: string;
siteUrl: string;
dashboardUrl?: string;
isAdmin?: boolean;
}
const translations: Record<
string,
{
title: string;
preview: string;
greeting: string;
orderReceived: string;
orderNumber: string;
items: string;
quantity: string;
total: string;
shippingTo: string;
questions: string;
thankYou: string;
adminTitle: string;
adminPreview: string;
adminGreeting: string;
adminMessage: string;
customerLabel: string;
customerEmailLabel: string;
billingAddressLabel: string;
phoneLabel: string;
viewDashboard: string;
}
> = {
sr: {
title: "Potvrda narudžbine",
preview: "Vaša narudžbina je potvrđena",
greeting: "Poštovani {name},",
orderReceived: "Zahvaljujemo se na Vašoj narudžbini! Primili smo je i sada je u pripremi.",
orderNumber: "Broj narudžbine",
items: "Artikli",
quantity: "Količina",
total: "Ukupno",
shippingTo: "Adresa za dostavu",
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
thankYou: "Hvala Vam što kupujete kod nas!",
adminTitle: "Nova narudžbina!",
adminPreview: "Nova narudžbina je primljena",
adminGreeting: "Čestitamo na prodaji!",
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
customerLabel: "Kupac",
customerEmailLabel: "Email kupca",
billingAddressLabel: "Adresa za naplatu",
phoneLabel: "Telefon",
viewDashboard: "Pogledaj u Dashboardu",
},
en: {
title: "Order Confirmation",
preview: "Your order has been confirmed",
greeting: "Dear {name},",
orderReceived:
"Thank you for your order! We have received it and it is now being processed.",
orderNumber: "Order number",
items: "Items",
quantity: "Quantity",
total: "Total",
shippingTo: "Shipping address",
questions: "Questions? Email us at support@manoonoils.com",
thankYou: "Thank you for shopping with us!",
adminTitle: "New Order! 🎉",
adminPreview: "A new order has been received",
adminGreeting: "Congratulations on the sale!",
adminMessage: "A new order has just been placed. Details below:",
customerLabel: "Customer",
customerEmailLabel: "Customer Email",
billingAddressLabel: "Billing Address",
phoneLabel: "Phone",
viewDashboard: "View in Dashboard",
},
};
export function OrderConfirmation({
language = "en",
orderId,
orderNumber,
customerEmail,
customerName,
items,
total,
shippingAddress,
billingAddress,
phone,
siteUrl,
dashboardUrl,
isAdmin = false,
}: OrderConfirmationProps) {
const t = translations[language] || translations.en;
const adminT = translations["en"];
if (isAdmin) {
return (
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
<Text style={styles.title}>{adminT.adminTitle}</Text>
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
<Text style={styles.text}>{adminT.adminMessage}</Text>
<Section style={styles.orderInfo}>
<Text style={styles.orderNumber}>
<strong>{adminT.orderNumber}:</strong> {orderNumber}
</Text>
<Text style={styles.customerInfo}>
<strong>{adminT.customerLabel}:</strong> {customerName}
</Text>
<Text style={styles.customerInfo}>
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
</Text>
{phone && (
<Text style={styles.customerInfo}>
<strong>{adminT.phoneLabel}:</strong> {phone}
</Text>
)}
</Section>
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{adminT.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
<Hr style={styles.hr} />
<Section style={styles.totalRow}>
<Text style={styles.totalLabel}>{adminT.total}:</Text>
<Text style={styles.totalValue}>{total}</Text>
</Section>
</Section>
{shippingAddress && (
<Section style={styles.shippingSection}>
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
</Section>
)}
{billingAddress && (
<Section style={styles.shippingSection}>
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
<Text style={styles.shippingAddress}>{billingAddress}</Text>
</Section>
)}
<Section style={styles.buttonSection}>
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
{adminT.viewDashboard}
</Button>
</Section>
</BaseLayout>
);
}
return (
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
<Text style={styles.title}>{t.title}</Text>
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
<Text style={styles.text}>{t.orderReceived}</Text>
<Section style={styles.orderInfo}>
<Text style={styles.orderNumber}>
<strong>{t.orderNumber}:</strong> {orderNumber}
</Text>
</Section>
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{t.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
<Hr style={styles.hr} />
<Section style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.total}:</Text>
<Text style={styles.totalValue}>{total}</Text>
</Section>
</Section>
{shippingAddress && (
<Section style={styles.shippingSection}>
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
</Section>
)}
<Section style={styles.buttonSection}>
<Button href={siteUrl} style={styles.button}>
{language === "sr" ? "Pogledajte narudžbinu" : "View Order"}
</Button>
</Section>
<Text style={styles.questions}>{t.questions}</Text>
<Text style={styles.thankYou}>{t.thankYou}</Text>
</BaseLayout>
);
}
const styles = {
title: {
fontSize: "24px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "20px",
},
greeting: {
fontSize: "16px",
color: "#333333",
marginBottom: "10px",
},
text: {
fontSize: "14px",
color: "#666666",
marginBottom: "20px",
},
orderInfo: {
backgroundColor: "#f9f9f9",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
orderNumber: {
fontSize: "14px",
color: "#333333",
margin: "0 0 8px 0",
},
customerInfo: {
fontSize: "14px",
color: "#333333",
margin: "0 0 4px 0",
},
itemsSection: {
marginBottom: "20px",
},
sectionTitle: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "10px",
},
hr: {
borderColor: "#e0e0e0",
margin: "10px 0",
},
itemRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
itemName: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
itemPrice: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
totalRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
totalLabel: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
margin: "0",
},
totalValue: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
margin: "0",
},
shippingSection: {
marginBottom: "20px",
},
shippingAddress: {
fontSize: "14px",
color: "#666666",
margin: "0",
},
buttonSection: {
textAlign: "center" as const,
marginBottom: "20px",
},
button: {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 30px",
borderRadius: "4px",
fontSize: "14px",
fontWeight: "bold" as const,
textDecoration: "none",
},
questions: {
fontSize: "14px",
color: "#666666",
marginBottom: "10px",
},
thankYou: {
fontSize: "14px",
fontWeight: "bold" as const,
color: "#1a1a1a",
},
};
+193
View File
@@ -0,0 +1,193 @@
import { Button, Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: string;
}
interface OrderShippedProps {
language?: string;
orderId: string;
orderNumber: string;
customerName: string;
items: OrderItem[];
trackingNumber?: string;
trackingUrl?: string;
siteUrl: string;
}
const translations: Record<
string,
{
title: string;
preview: string;
greeting: string;
orderShipped: string;
tracking: string;
items: string;
questions: string;
}
> = {
sr: {
title: "Vaša narudžbina je poslata!",
preview: "Vaša narudžbina je na putu",
greeting: "Poštovani {name},",
orderShipped:
"Odlične vesti! Vaša narudžbina je poslata i uskoro će stići na vašu adresu.",
tracking: "Praćenje pošiljke",
items: "Artikli",
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
},
en: {
title: "Your Order Has Shipped!",
preview: "Your order is on its way",
greeting: "Dear {name},",
orderShipped:
"Great news! Your order has been shipped and will arrive at your address soon.",
tracking: "Track your shipment",
items: "Items",
questions: "Questions? Email us at support@manoonoils.com",
},
de: {
title: "Ihre Bestellung wurde versendet!",
preview: "Ihre Bestellung ist unterwegs",
greeting: "Sehr geehrte/r {name},",
orderShipped:
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
tracking: "Sendung verfolgen",
items: "Artikel",
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
},
fr: {
title: "Votre commande a été expédiée!",
preview: "Votre commande est en route",
greeting: "Cher(e) {name},",
orderShipped:
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
tracking: "Suivre votre envoi",
items: "Articles",
questions: "Questions? Écrivez-nous à support@manoonoils.com",
},
};
export function OrderShipped({
language = "en",
orderId,
orderNumber,
customerName,
items,
trackingNumber,
trackingUrl,
siteUrl,
}: OrderShippedProps) {
const t = translations[language] || translations.en;
return (
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
<Text style={styles.title}>{t.title}</Text>
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
<Text style={styles.text}>{t.orderShipped}</Text>
{trackingNumber && (
<Section style={styles.trackingSection}>
<Text style={styles.sectionTitle}>{t.tracking}</Text>
{trackingUrl ? (
<Button href={trackingUrl} style={styles.trackingButton}>
{trackingNumber}
</Button>
) : (
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
)}
</Section>
)}
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{t.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
</Section>
<Text style={styles.questions}>{t.questions}</Text>
</BaseLayout>
);
}
const styles = {
title: {
fontSize: "24px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "20px",
},
greeting: {
fontSize: "16px",
color: "#333333",
marginBottom: "10px",
},
text: {
fontSize: "14px",
color: "#666666",
marginBottom: "20px",
},
trackingSection: {
backgroundColor: "#f9f9f9",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
sectionTitle: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "10px",
},
trackingNumber: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
trackingButton: {
backgroundColor: "#000000",
color: "#ffffff",
padding: "10px 20px",
borderRadius: "4px",
fontSize: "14px",
textDecoration: "none",
},
itemsSection: {
marginBottom: "20px",
},
hr: {
borderColor: "#e0e0e0",
margin: "10px 0",
},
itemRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
itemName: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
itemPrice: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
questions: {
fontSize: "14px",
color: "#666666",
},
};
+4
View File
@@ -0,0 +1,4 @@
export { BaseLayout } from "./BaseLayout";
export { OrderConfirmation } from "./OrderConfirmation";
export { OrderShipped } from "./OrderShipped";
export { OrderCancelled } from "./OrderCancelled";
+10
View File
@@ -0,0 +1,10 @@
import { cacheExchange, createClient as urqlCreateClient, fetchExchange } from "urql";
export const createClient = (url: string, getAuth: () => Promise<{ token: string }>) =>
urqlCreateClient({
url,
exchanges: [
cacheExchange,
fetchExchange,
],
});
+19
View File
@@ -0,0 +1,19 @@
import dynamic from "next/dynamic";
import React, { PropsWithChildren } from "react";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
/**
* Saleor App can be rendered only as a Saleor Dashboard iframe.
* All content is rendered after Dashboard exchanges auth with the app.
* Hence, there is no reason to render app server side.
*
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
* of using dynamic() calls
*
* You can use this wrapper selectively for some pages or remove it completely.
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
*/
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});
+130
View File
@@ -0,0 +1,130 @@
import { Resend } from "resend";
import { render } from "@react-email/components";
import { OrderConfirmation, OrderShipped, OrderCancelled } from "@/emails";
const resend = new Resend(process.env.RESEND_API_KEY);
const FROM_EMAIL = process.env.FROM_EMAIL || "support@mail.manoonoils.com";
const FROM_NAME = process.env.FROM_NAME || "ManoonOils";
export async function sendEmail({
to,
subject,
html,
}: {
to: string | string[];
subject: string;
html: string;
}) {
const { data, error } = await resend.emails.send({
from: `${FROM_NAME} <${FROM_EMAIL}>`,
to,
subject,
html,
});
if (error) {
console.error("Resend error:", error);
throw error;
}
return data;
}
export function formatPrice(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
export async function sendOrderConfirmationEmail({
to,
orderData,
isAdmin = false,
}: {
to: string | string[];
orderData: {
orderId: string;
orderNumber: string;
customerEmail: string;
customerName: string;
items: Array<{ id: string; name: string; quantity: number; price: string }>;
total: string;
shippingAddress?: string;
billingAddress?: string;
phone?: string;
};
isAdmin?: boolean;
}) {
const html = await render(
OrderConfirmation({
...orderData,
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
dashboardUrl: process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com",
isAdmin,
})
);
const subject = isAdmin
? `🎉 New Order #${orderData.orderNumber}`
: `Order Confirmation #${orderData.orderNumber}`;
return sendEmail({ to, subject, html });
}
export async function sendOrderShippedEmail({
to,
orderData,
}: {
to: string | string[];
orderData: {
orderId: string;
orderNumber: string;
customerName: string;
items: Array<{ id: string; name: string; quantity: number; price: string }>;
trackingNumber?: string;
trackingUrl?: string;
};
}) {
const html = await render(
OrderShipped({
...orderData,
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
})
);
return sendEmail({
to,
subject: `Your Order #${orderData.orderNumber} Has Shipped!`,
html,
});
}
export async function sendOrderCancelledEmail({
to,
orderData,
}: {
to: string | string[];
orderData: {
orderId: string;
orderNumber: string;
customerName: string;
items: Array<{ id: string; name: string; quantity: number; price: string }>;
total: string;
reason?: string;
};
}) {
const html = await render(
OrderCancelled({
...orderData,
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
})
);
return sendEmail({
to,
subject: `Your Order #${orderData.orderNumber} Has Been Cancelled`,
html,
});
}
+24
View File
@@ -0,0 +1,24 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui";
import { useEffect } from "react";
export function ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (appBridgeState.theme === "light") {
setTheme("defaultLight");
}
if (appBridgeState.theme === "dark") {
setTheme("defaultDark");
}
}, [appBridgeState?.theme, setTheme]);
return null;
}
+86
View File
@@ -0,0 +1,86 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Text } from "@saleor/macaw-ui";
import Link from "next/link";
import { useLastOrderQuery } from "@/generated/graphql";
/**
* GraphQL Code Generator scans for gql tags and generates types based on them.
* The below query is used to generate the "useLastOrderQuery" hook.
* If you modify it, make sure to run "pnpm run generate:app-graphql-types" to regenerate the types.
*/
function generateNumberOfLinesText(lines: readonly { readonly id: string }[]) {
if (lines.length === 0) {
return "no lines";
}
if (lines.length === 1) {
return "1 line";
}
return `${lines.length} lines`;
}
export const OrderExample = () => {
const { appBridge } = useAppBridge();
// Using the generated hook
const [{ data, fetching }] = useLastOrderQuery();
const lastOrder = data?.orders?.edges[0]?.node;
const navigateToOrder = (id: string) => {
appBridge?.dispatch(
actions.Redirect({
to: `/orders/${id}`,
})
);
};
return (
<Box display="flex" flexDirection={"column"} gap={2}>
<Text as={"h2"} size={8}>
Fetching data
</Text>
<>
{fetching && <Text color="default2">Fetching the last order...</Text>}
{lastOrder && (
<>
<Text color="default2">
The <code>orders</code> query requires the <code>MANAGE_ORDERS</code> permission.
If you want to query other resources, make sure to update the app permissions in the{" "}
<code>/src/pages/api/manifest.ts</code> file.
</Text>
<Box
backgroundColor={"default2"}
padding={4}
borderRadius={4}
borderWidth={1}
borderStyle={"solid"}
borderColor={"default2"}
marginY={4}
>
<Text>{`The last order #${lastOrder.number}:`}</Text>
<ul>
<li>
<Text>{`Contains ${generateNumberOfLinesText(lastOrder.lines)} 🛒`}</Text>
</li>
<li>
<Text>{`For a total amount of ${lastOrder.total.gross.amount} ${lastOrder.total.gross.currency} 💸`}</Text>
</li>
<li>
<Text>{`Ships to ${lastOrder.shippingAddress?.country.country} 📦`}</Text>
</li>
</ul>
<Link onClick={() => navigateToOrder(lastOrder.id)} href={`/orders/${lastOrder.id}`}>
See the order details
</Link>
</Box>
</>
)}
{!fetching && !lastOrder && <Text color="default2">No orders found</Text>}
</>
</Box>
);
};
+46
View File
@@ -0,0 +1,46 @@
import "@saleor/macaw-ui/style";
import "../styles/globals.css";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { ThemeProvider } from "@saleor/macaw-ui";
import { AppProps } from "next/app";
import { useEffect } from "react";
import { NoSSRWrapper } from "@/lib/no-ssr-wrapper";
import { ThemeSynchronizer } from "@/lib/theme-synchronizer";
import { GraphQLProvider } from "@/providers/GraphQLProvider";
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
function NextApp({ Component, pageProps }: AppProps) {
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<GraphQLProvider>
<ThemeProvider>
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
</ThemeProvider>
</GraphQLProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);
}
export default NextApp;
+89
View File
@@ -0,0 +1,89 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Button, Text } from "@saleor/macaw-ui";
import { OrderExample } from "../order-example";
/**
* This is example of using AppBridge, when App is mounted in Dashboard
* See more about AppBridge possibilities
* https://github.com/saleor/saleor-app-sdk/blob/main/docs/app-bridge.md
*
* -> You can safely remove this file!
*/
const ActionsPage = () => {
const { appBridge, appBridgeState } = useAppBridge();
const navigateToOrders = () => {
appBridge?.dispatch(
actions.Redirect({
to: `/orders`,
})
);
};
return (
<Box padding={8} display={"flex"} flexDirection={"column"} gap={6} __maxWidth={"640px"}>
<Box>
<Text as={"p"}>
<b>Welcome {appBridgeState?.user?.email}!</b>
</Text>
<Text as={"p"}>Installing the app in the Dashboard gave it superpowers such as:</Text>
</Box>
<Box>
<Text as={"h2"} size={8} marginBottom={2}>
AppBridge actions
</Text>
<Text color="default2">
💡 You can use AppBridge to trigger dashboard actions, such as notifications or redirects.
</Text>
<Box display={"flex"} gap={4} gridAutoFlow={"column"} marginY={4}>
<Button
variant={"secondary"}
onClick={() => {
appBridge?.dispatch({
type: "notification",
payload: {
status: "success",
title: "You rock!",
text: "This notification was triggered from Saleor App",
actionId: "message-from-app",
},
});
}}
>
Trigger notification 📤
</Button>
<Button variant={"secondary"} onClick={navigateToOrders}>
Redirect to orders 💰
</Button>
</Box>
</Box>
<OrderExample />
<Box display="flex" flexDirection={"column"} gap={2}>
<Text as={"h2"} size={8}>
Webhooks
</Text>
<Text>
The App Template contains an example <code>ORDER_CREATED</code> webhook under the path{" "}
<code>src/pages/api/order-created</code>.
</Text>
<Text as="p">
Create any{" "}
<Text
as={"a"}
fontWeight="bold"
size={4}
onClick={navigateToOrders}
cursor={"pointer"}
color={"info1"}
>
Order
</Text>{" "}
and check your console output!
</Text>
</Box>
</Box>
);
};
export default ActionsPage;
+89
View File
@@ -0,0 +1,89 @@
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { AppExtension, AppManifest } from "@saleor/app-sdk/types";
import packageJson from "@/package.json";
import { orderCreatedWebhook } from "./webhooks/order-created";
import { orderFulfilledWebhook } from "./webhooks/order-fulfilled";
import { orderCancelledWebhook } from "./webhooks/order-cancelled";
import { orderFilterShippingMethodsWebhook } from "./webhooks/order-filter-shipping-methods";
/**
* App SDK helps with the valid Saleor App Manifest creation. Read more:
* https://github.com/saleor/saleor-app-sdk/blob/main/docs/api-handlers.md#manifest-handler-factory
*/
export default createManifestHandler({
async manifestFactory({ appBaseUrl, request, schemaVersion }) {
/**
* Allow to overwrite default app base url, to enable Docker support.
*
* See docs: https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
*/
const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl;
const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl;
const extensionsForSaleor3_22: AppExtension[] = [
{
url: apiBaseURL + "/api/server-widget",
permissions: [],
mount: "PRODUCT_DETAILS_WIDGETS",
label: "Product Timestamps",
target: "WIDGET",
options: {
widgetTarget: {
method: "POST",
},
},
},
{
url: iframeBaseUrl+"/client-widget",
permissions: [],
mount: "ORDER_DETAILS_WIDGETS",
label: "Order widget example",
target: "WIDGET",
options: {
widgetTarget: {
method: "GET",
},
},
},
]
const saleorMajor = schemaVersion && schemaVersion[0];
const saleorMinor = schemaVersion && schemaVersion[1]
const is3_22 = saleorMajor === 3 && saleorMinor === 22;
const extensions = is3_22 ? extensionsForSaleor3_22 : [];
const manifest: AppManifest = {
name: "Core Extensions",
tokenTargetUrl: `${apiBaseURL}/api/register`,
appUrl: iframeBaseUrl,
permissions: [
"MANAGE_ORDERS",
],
id: "saleor.core-extensions",
version: packageJson.version,
webhooks: [
orderCreatedWebhook.getWebhookManifest(apiBaseURL),
orderFulfilledWebhook.getWebhookManifest(apiBaseURL),
orderCancelledWebhook.getWebhookManifest(apiBaseURL),
orderFilterShippingMethodsWebhook.getWebhookManifest(apiBaseURL),
],
/**
* Optionally, extend Dashboard with custom UIs
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
*/
extensions: extensions,
author: "Saleor Commerce",
brand: {
logo: {
default: `${apiBaseURL}/logo.png`,
},
},
};
return manifest;
},
});
+17
View File
@@ -0,0 +1,17 @@
import { createProtectedHandler } from "@saleor/app-sdk/handlers/next";
import { apl } from "@/saleor-app";
/**
* Will be available only from the Dashboard, and only for users with the MANAGE_ORDERS permission.
*/
const handler = createProtectedHandler(
(req, res, { user, baseUrl, authData }) => {
return res.json({
message: "OK!",
});
},
apl,
["MANAGE_ORDERS"]
);
export default handler;
+21
View File
@@ -0,0 +1,21 @@
// Patch fetch to force HTTPS for api.manoonoils.com
const originalFetch = global.fetch;
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
let url = input.toString();
if (url.startsWith('http://api.manoonoils.com/')) {
url = url.replace('http://', 'https://');
input = url;
}
return originalFetch(input, init);
};
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "@/saleor-app";
export default createAppRegisterHandler({
apl: saleorApp.apl,
allowedSaleorUrls: [
"https://api.manoonoils.com/graphql/",
"http://api.manoonoils.com/graphql/",
],
});
+41
View File
@@ -0,0 +1,41 @@
import { NextApiHandler } from "next";
import { ExtensionPOSTAttributes } from "@saleor/app-sdk/types";
import { verifyJWT } from "@saleor/app-sdk/auth";
import { createClient } from "@/lib/create-graphq-client";
import { ProductTimestampsDocument } from "../../../generated/graphql";
const handler: NextApiHandler = async (req, res) => {
const { appId, accessToken, saleorApiUrl, ...contextParams } =
req.body as ExtensionPOSTAttributes;
res.setHeader("Content-Type", "text/plain");
try {
await verifyJWT({
appId,
token: accessToken,
saleorApiUrl,
});
} catch (e) {
return res.status(401).send("Not authorized");
}
if (!contextParams.productId) {
return res.status(200).send("Missing product ID");
}
const client = createClient(saleorApiUrl, async () => ({ token: accessToken }));
const productTimestamps = await client.query(ProductTimestampsDocument, {
id: contextParams.productId,
});
if (productTimestamps.data?.product) {
return res
.status(200)
.send(
`This product was created at ${productTimestamps.data.product.created} and last updated at ${productTimestamps.data.product.updatedAt}`
);
}
};
export default handler;
+64
View File
@@ -0,0 +1,64 @@
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderCancelledSubscriptionDocument,
OrderCancelledWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderCancelledEmail, formatPrice } from "@/lib/resend";
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
name: "Order Cancelled in Saleor",
webhookPath: "api/webhooks/order-cancelled",
event: "ORDER_CANCELLED",
apl: saleorApp.apl,
query: OrderCancelledSubscriptionDocument,
});
export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
const { payload, event, baseUrl, authData } = ctx;
const order = payload.order;
if (!order) {
console.error("No order data in webhook payload");
return res.status(200).end();
}
console.log(`Order ${order.number} cancelled for customer: ${order.userEmail}`);
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
};
try {
if (order.userEmail) {
await sendOrderCancelledEmail({
to: order.userEmail,
orderData,
});
console.log(`Customer notification sent for cancelled order ${order.number}`);
}
} catch (error) {
console.error("Failed to send email:", error);
}
return res.status(200).end();
});
export const config = {
api: {
bodyParser: false,
},
};
+94
View File
@@ -0,0 +1,94 @@
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderCreatedSubscriptionDocument,
OrderCreatedWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
name: "Order Created in Saleor",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CREATED",
apl: saleorApp.apl,
query: OrderCreatedSubscriptionDocument,
});
export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
const { payload, event, baseUrl, authData } = ctx;
const order = payload.order;
if (!order) {
console.error("No order data in webhook payload");
return res.status(200).end();
}
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerEmail: order.userEmail || "",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
shippingAddress: (order as any).shippingAddress
? `${(order as any).shippingAddress.firstName || ""} ${(order as any).shippingAddress.lastName || ""}\n${(order as any).shippingAddress.streetAddress1 || ""}\n${(order as any).shippingAddress.postalCode || ""} ${(order as any).shippingAddress.city || ""}\n${(order as any).shippingAddress.country?.country || ""}${(order as any).shippingAddress.phone ? `\nPhone: ${(order as any).shippingAddress.phone}` : ""}`
: undefined,
billingAddress: (order as any).billingAddress
? `${(order as any).billingAddress.firstName || ""} ${(order as any).billingAddress.lastName || ""}\n${(order as any).billingAddress.streetAddress1 || ""}\n${(order as any).billingAddress.postalCode || ""} ${(order as any).billingAddress.city || ""}\n${(order as any).billingAddress.country?.country || ""}${(order as any).billingAddress.phone ? `\nPhone: ${(order as any).billingAddress.phone}` : ""}`
: undefined,
phone: (order as any).shippingAddress?.phone,
};
// Send admin notification
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderConfirmationEmail({
to: adminEmails,
orderData,
isAdmin: true,
});
console.log(`✅ Admin notification sent for order #${order.number} to: ${adminEmails.join(", ")}`);
} else {
console.log("⚠️ No admin emails configured, skipping admin notification");
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
}
// Send customer confirmation
try {
if (order.userEmail) {
await sendOrderConfirmationEmail({
to: order.userEmail,
orderData,
isAdmin: false,
});
console.log(`✅ Customer confirmation sent for order #${order.number} to: ${order.userEmail}`);
} else {
console.log("⚠️ No customer email found, skipping customer notification");
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
}
return res.status(200).end();
});
export const config = {
api: {
bodyParser: false,
},
};
@@ -0,0 +1,67 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { FilterShippingMethods } from "@/generated/app-webhooks-types/order-filter-shipping-methods";
import {
OrderFilterShippingMethodsPayloadFragment,
OrderFilterShippingMethodsSubscriptionDocument,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
/**
* Create abstract Webhook. It decorates handler and performs security checks under the hood.
*
* orderFilterShippingMethodsWebhook.getWebhookManifest() must be called in api/manifest too!
*/
export const orderFilterShippingMethodsWebhook =
new SaleorSyncWebhook<OrderFilterShippingMethodsPayloadFragment>({
name: "Order Filter Shipping Methods",
webhookPath: "api/webhooks/order-filter-shipping-methods",
event: "ORDER_FILTER_SHIPPING_METHODS",
apl: saleorApp.apl,
query: OrderFilterShippingMethodsSubscriptionDocument,
});
/**
* Export decorated Next.js pages router handler, which adds extra context
*/
export default orderFilterShippingMethodsWebhook.createHandler((req, res, ctx) => {
const {
/**
* Access payload from Saleor - defined above
*/
payload,
/**
* Saleor event that triggers the webhook (here - ORDER_FILTER_SHIPPING_METHODS)
*/
event,
/**
* App's URL
*/
baseUrl,
/**
* Auth data (from APL) - contains token and saleorApiUrl that can be used to construct graphQL client
*/
authData,
} = ctx;
/**
* Perform logic based on Saleor Event payload e.g filter shipping methods
* This is a synchronous webhook, so you can return the response directly.
*/
console.log(`Filtering shipping methods for order id: ${payload.order?.id}`);
const response: FilterShippingMethods = {
excluded_methods: [],
};
return res.status(200).json(response);
});
/**
* Disable body parser for this endpoint, so signature can be verified
*/
export const config = {
api: {
bodyParser: false,
},
};
+63
View File
@@ -0,0 +1,63 @@
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderFulfilledSubscriptionDocument,
OrderFulfilledWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderShippedEmail, formatPrice } from "@/lib/resend";
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
name: "Order Fulfilled in Saleor",
webhookPath: "api/webhooks/order-fulfilled",
event: "ORDER_FULFILLED",
apl: saleorApp.apl,
query: OrderFulfilledSubscriptionDocument,
});
export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
const { payload, event, baseUrl, authData } = ctx;
const order = payload.order;
if (!order) {
console.error("No order data in webhook payload");
return res.status(200).end();
}
console.log(`Order ${order.number} fulfilled for customer: ${order.userEmail}`);
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
};
try {
if (order.userEmail) {
await sendOrderShippedEmail({
to: order.userEmail,
orderData,
});
console.log(`Customer notification sent for fulfilled order ${order.number}`);
}
} catch (error) {
console.error("Failed to send email:", error);
}
return res.status(200).end();
});
export const config = {
api: {
bodyParser: false,
},
};
+15
View File
@@ -0,0 +1,15 @@
import { NextPage } from "next";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Text } from "@saleor/macaw-ui";
const ClientWidget: NextPage = () => {
const { appBridgeState } = useAppBridge();
if (!appBridgeState?.ready) {
return <Text>Loading widget...</Text>;
}
return <Text>This is a client widget 😎. Your email is {appBridgeState.user?.email}.</Text>;
};
export default ClientWidget;
+208
View File
@@ -0,0 +1,208 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Button, Input, Text } from "@saleor/macaw-ui";
import { NextPage } from "next";
import Link from "next/link";
import { MouseEventHandler, useEffect, useState } from "react";
const AddToSaleorForm = () => (
<Box
as={"form"}
display={"flex"}
alignItems={"center"}
gap={4}
onSubmit={(event) => {
event.preventDefault();
const saleorUrl = new FormData(event.currentTarget as HTMLFormElement).get("saleor-url");
const manifestUrl = new URL("/api/manifest", window.location.origin);
const redirectUrl = new URL(
`/dashboard/apps/install?manifestUrl=${manifestUrl}`,
saleorUrl as string
).href;
window.open(redirectUrl, "_blank");
}}
>
<Input type="url" required label="Saleor URL" name="saleor-url" />
<Button type="submit">Add to Saleor</Button>
</Box>
);
/**
* This is page publicly accessible from your app.
* You should probably remove it.
*/
const IndexPage: NextPage = () => {
const { appBridgeState, appBridge } = useAppBridge();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleLinkClick: MouseEventHandler<HTMLAnchorElement> = (e) => {
/**
* In iframe, link can't be opened in new tab, so Dashboard must be a proxy
*/
if (appBridgeState?.ready) {
e.preventDefault();
appBridge?.dispatch(
actions.Redirect({
newContext: true,
to: e.currentTarget.href,
})
);
}
/**
* Otherwise, assume app is accessed outside of Dashboard, so href attribute on <a> will work
*/
};
const isLocalHost = global.location.href.includes("localhost");
return (
<Box padding={8}>
<Text size={11}>Welcome to Saleor App Template (Next.js) 🚀</Text>
<Text as={"p"} marginY={4}>
Saleor App Template is a minimalistic boilerplate that provides a working example of a
Saleor app.
</Text>
{appBridgeState?.ready && mounted && (
<Link href="/actions">
<Button variant="secondary">See what your app can do </Button>
</Link>
)}
<Text as={"p"} marginTop={8}>
Explore the App Template by visiting:
</Text>
<ul>
<li>
<code>/src/pages/api/manifest</code> - the{" "}
<a
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest"
target="_blank"
rel="noreferrer"
>
App Manifest
</a>
.
</li>
<li>
<code>/src/pages/api/webhooks/order-created</code> - an example <code>ORDER_CREATED</code>{" "}
webhook handler.
</li>
<li>
<code>/graphql</code> - the pre-defined GraphQL queries.
</li>
<li>
<code>/generated/graphql.ts</code> - the code generated for those queries by{" "}
<a target="_blank" rel="noreferrer" href="https://the-guild.dev/graphql/codegen">
GraphQL Code Generator
</a>
.
</li>
</ul>
<Text size={8} marginTop={8} as={"h2"}>
Resources
</Text>
<ul>
<li>
<a
onClick={handleLinkClick}
target="_blank"
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts"
rel="noreferrer"
>
<Text color={"info1"}>Apps documentation </Text>
</a>
</li>
<li>
<a
onClick={handleLinkClick}
target="_blank"
rel="noreferrer"
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-with-tunnels"
>
<Text color={"info1"}>Tunneling the app</Text>
</a>
</li>
<li>
<a
onClick={handleLinkClick}
target="_blank"
rel="noreferrer"
href="https://github.com/saleor/app-examples"
>
<Text color={"info1"}>App Examples repository</Text>
</a>
</li>
<li>
<a
onClick={handleLinkClick}
target="_blank"
rel="noreferrer"
href="https://github.com/saleor/saleor-app-sdk"
>
<Text color={"info1"}>Saleor App SDK</Text>
</a>
</li>
<li>
<a
onClick={handleLinkClick}
target="_blank"
href="https://github.com/saleor/saleor-cli"
rel="noreferrer"
>
<Text color={"info1"}>Saleor CLI</Text>
</a>
</li>
<li>
<a
onClick={handleLinkClick}
target="_blank"
href="https://github.com/saleor/apps"
rel="noreferrer"
>
<Text color={"info1"}>Saleor App Store - official apps by Saleor Team</Text>
</a>
</li>
<li>
<a
onClick={handleLinkClick}
target="_blank"
href="https://macaw-ui-next.vercel.app/?path=/docs/getting-started-installation--docs"
rel="noreferrer"
>
<Text color={"info1"}>Macaw UI - official Saleor UI library</Text>
</a>
</li>
<li>
<a
onClick={handleLinkClick}
target="_blank"
href="https://nextjs.org/docs"
rel="noreferrer"
>
<Text color={"info1"}>Next.js documentation</Text>
</a>
</li>
</ul>
{mounted && !isLocalHost && !appBridgeState?.ready && (
<>
<Text marginBottom={4} as={"p"}>
Install this app in your Dashboard and get extra powers!
</Text>
<AddToSaleorForm />
</>
)}
</Box>
);
};
export default IndexPage;
+19
View File
@@ -0,0 +1,19 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { PropsWithChildren } from "react";
import { Provider } from "urql";
import { createClient } from "@/lib/create-graphq-client";
export function GraphQLProvider(props: PropsWithChildren<{}>) {
const { appBridgeState } = useAppBridge();
const url = appBridgeState?.saleorApiUrl!;
if (!url) {
console.warn("Install the app in the Dashboard to be able to query Saleor API.");
return <div>{props.children}</div>;
}
const client = createClient(url, async () => Promise.resolve({ token: appBridgeState?.token! }));
return <Provider value={client} {...props} />;
}
+55
View File
@@ -0,0 +1,55 @@
import { APL, AuthData } from "@saleor/app-sdk/APL";
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
import { FileAPL } from "@saleor/app-sdk/APL/file";
/**
* APL wrapper that normalizes HTTP to HTTPS for auth data lookups
*/
class NormalizingAPL implements APL {
private apl: FileAPL;
constructor(config: { fileName: string }) {
this.apl = new FileAPL(config);
}
private normalizeUrl(url: string): string {
return url.replace(/^http:\/\//, "https://");
}
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
console.log(`[NormalizingAPL] Looking up auth for: ${saleorApiUrl} -> ${normalizedUrl}`);
return this.apl.get(normalizedUrl);
}
async set(authData: AuthData): Promise<void> {
const normalizedUrl = this.normalizeUrl(authData.saleorApiUrl);
console.log(`[NormalizingAPL] Storing auth for: ${authData.saleorApiUrl} -> ${normalizedUrl}`);
return this.apl.set({
...authData,
saleorApiUrl: normalizedUrl,
});
}
async delete(saleorApiUrl: string): Promise<void> {
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
return this.apl.delete(normalizedUrl);
}
async getAll(): Promise<AuthData[]> {
return this.apl.getAll();
}
}
export let apl: APL;
switch (process.env.APL) {
default:
apl = new NormalizingAPL({
fileName: process.env.AUTH_DATA_FILE_PATH || "/tmp/.auth-data.json",
});
}
export const saleorApp = new SaleorApp({
apl,
});
+6
View File
@@ -0,0 +1,6 @@
/**
* Add test setup logic here
*
* https://vitest.dev/config/#setupfiles
*/
export {};
+12
View File
@@ -0,0 +1,12 @@
a {
text-decoration: none;
}
code {
background-color: var(--mu-colors-background-surface-brand-subdued);
}
html, body {
margin: 0;
padding: 0;
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/generated/*": ["generated/*"],
"@/package.json": ["package.json"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+17
View File
@@ -0,0 +1,17 @@
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
passWithNoTests: true,
environment: "jsdom",
setupFiles: "./src/setup-tests.ts",
css: false,
alias: {
"@/": new URL("./src/", import.meta.url).pathname,
},
},
});