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