Compare commits

...

1 commit

Author SHA1 Message Date
Krzysztof Wolski
de12370c46 POC WIP 2023-09-18 09:28:01 +02:00
130 changed files with 50796 additions and 305 deletions

View file

@ -0,0 +1,9 @@
APP_DEBUG=info
# 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 default Next's localhost:3000
# https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
APP_IFRAME_BASE_URL=
APP_API_BASE_URL=

View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

3
apps/data-exporter/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Sentry Auth Token
.sentryclirc

View file

@ -0,0 +1,19 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
plugins:
- typescript
- typescript-operations
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

View file

@ -0,0 +1,364 @@
# saleor-app-products-feed
## 1.12.0
### Minor Changes
- 261e9d1: Added additional images attribute to the feed for media uploaded to the product.
### Patch Changes
- 23e71bc: Fix typo on button label
- 261e9d1: Improved default resolution of the submitted images. Was: 500px, now it's 1024px.
Users can now configure the size in the app configuration.
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- @saleor/apps-shared@1.9.0
- @saleor/apps-ui@1.2.0
## 1.11.4
### Patch Changes
- 2a1385b: Plaintext EditorJS renderer has been moved to the shared package.
- a81f061: Updated Macaw to pre-127
- fcc37e7: Remove clsx package from the projects no longer using it.
- Updated dependencies [2a1385b]
- Updated dependencies [a81f061]
- Updated dependencies [fcc37e7]
- @saleor/apps-shared@1.8.1
- @saleor/react-hook-form-macaw@0.2.5
- @saleor/apps-ui@1.1.8
## 1.11.3
### Patch Changes
- 8b3d961: Updated Macaw UI to pre.118
- Updated dependencies [8b3d961]
- Updated dependencies [c50797e]
- @saleor/react-hook-form-macaw@0.2.4
- @saleor/apps-shared@1.8.0
- @saleor/apps-ui@1.1.7
## 1.11.2
### Patch Changes
- 3002354: Added error logging for exceptions thrown at tRPC routes.
## 1.11.1
### Patch Changes
- bb504d9: Fixed error on loading configuration created in the previous version of the app.
- bb504d9: Fixed issue with saving changes in the title template form.
- bb504d9: Improved error logging in the tRPC API.
## 1.11.0
### Minor Changes
- 6a84b4b: Changed format of product URLs to Handlebars. Previous format can still be parsed, so this change does not affect existing configurations.
Old format: `http://example.com/{variantId}`
New format: `http://example.com/{{variant.id}}`
- 6a84b4b: Added item title customization using Handlebars.
- fc5e639: Feed format has been changed to leverage Product Group ID field:
- Product ID: feed items use SKU if available, product variant ID is used otherwise
- Product Group ID: product ID is used for all the items
- 0b0297e: Updated pricing attributes according to the Google guidelines.
Was:
- Price: base or discounted price
Now:
- Price: always the base price. Attribute skipped if amount is equal to 0.
- Sale price: discounted price. Attribute skipped if value is the same as base price
- aece073: Added configuration for choosing which product attributes should be used for generating Google Product Feed. Supported feed attributes: Brand, Color, Size, Material, Pattern.
### Patch Changes
- 261957f: Updated dependencies: vite, vitest, eslint, prettier, dotenv, turbo, syncpack, changesets, lint staged
- Updated dependencies [261957f]
- @saleor/react-hook-form-macaw@0.2.3
- @saleor/apps-shared@1.7.6
- @saleor/apps-ui@1.1.6
## 1.10.3
### Patch Changes
- 07999ea: Changed input type for region in the AWS credentials section. Region can be now selected from the list, instead of text input.
- 43d7e47: Added validation for AWS credentials. If provided configuration for S3 Bucket is invalid, it won't be saved.
## 1.10.2
### Patch Changes
- 2fab86b: Updated graphql package to 16.7.1 and @graphql-codegen related dependencies to the latest version.
- aa6fec1: Updated Macaw UI to pre-106
- Updated dependencies [aa6fec1]
- Updated dependencies [aa6fec1]
- @saleor/react-hook-form-macaw@0.2.2
- @saleor/apps-shared@1.7.5
- @saleor/apps-ui@1.1.5
## 1.10.1
### Patch Changes
- 70cb741: Update Zod to 3.21.4
- e7c2d3a: Updated and ESLint dependencies
- 3c6cd4c: Updated the @saleor/app-sdk package to version 0.41.1.
- 6210447: Updated tRPC packages to 10.34.0
- 6210447: Updated @tanstack/react-query 4.29.19
- Updated dependencies [70cb741]
- Updated dependencies [e7c2d3a]
- Updated dependencies [3c6cd4c]
- Updated dependencies [6210447]
- @saleor/react-hook-form-macaw@0.2.1
- @saleor/apps-shared@1.7.4
- @saleor/apps-ui@1.1.4
## 1.10.0
### Minor Changes
- a1f083c: Filled "about" field in App Manifest. Dashboard will display it in app details page now.
- 47102ba: Added additional ENV variables (see each app's .env.example), that can overwrite app base URL. This change allows easy apps development using Docker
### Patch Changes
- 2d77bca: Updated Next.js to 13.4.8
- 6299e06: Update @saleor/app-sdk to 0.41.0
- Updated dependencies [2d77bca]
- Updated dependencies [6299e06]
- @saleor/apps-shared@1.7.3
- @saleor/apps-ui@1.1.3
- @saleor/react-hook-form-macaw@0.2.0
## 1.9.0
### Minor Changes
- 1dead1e: Included dedicated logo and attached it to App's manifest. From Saleor 3.15 the logo will be visible in the Dashboard during and after installation.
- 3462cc3: Improved helper text in S3 form - region field. Now it should be more explicit that only region code (like "eu-west-1") should be provided.
- e4497b9: Added test-id attributes to several meaningful elements. Now quering in automated tests are more stable
- e4497b9: Make Google Categories mapping lazy loaded, so the page loads immediately and forms add with a delay. Previously the page was stuck due to large chunk size
### Patch Changes
- 860bac4: Updated @saleor/app-sdk to 0.40.1
- a1ad70e: Updated configuration and dependencies of GraphQL client - urql.
All applications use now unified config for creating the client. Also unused related packages has been removed.
- ec68ed5: Updated Sentry package and config. Set Sentry release to match package.json version. Now Sentry will use semver version instead a commit
- cb6ee29: Updated dependencies
- Updated dependencies [f96563f]
- Updated dependencies [f96563f]
- Updated dependencies [860bac4]
- Updated dependencies [a1ad70e]
- Updated dependencies [cb6ee29]
- Updated dependencies [a1ad70e]
- @saleor/react-hook-form-macaw@0.2.0
- @saleor/apps-ui@1.1.2
- @saleor/apps-shared@1.7.2
## 1.8.1
### Patch Changes
- a8834a1: Unified graphql version to 16.6
- a8834a1: Unified graphql codegen packages
- a8834a1: Removed unnecessary duplicated dependencies from apps and moved them to shared and root (types, eslint rules)
- a8834a1: Updated dev dependencies - Typescript, Eslint and Turborepo
- 928c727: Updated @saleor/macaw-ui to 0.8.0-pre.95. This version introduces change in spacing scale, so there may be slight changes in spacing
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [928c727]
- @saleor/react-hook-form-macaw@0.1.2
- @saleor/apps-shared@1.7.1
- @saleor/apps-ui@1.1.1
## 1.8.0
### Minor Changes
- 182bdd6: This version introduces major update of the App UI:
- Added Macaw UI / next
- Rebuilt app's UI to match modern Saleor guidelines
- Moved S3 form to be configured once, globally
- Added tests and refactored App configuration/metadata (incompatible with previous version)
- Product-related webhooks are now refreshing cache responsible on indexing large databases
App was not officially released yet, hence only minor update is introduced
### Patch Changes
- 0c2fc65: Update dev dependencies - Vite and Vitest. These changes will not affect runtime Apps, but can affect tests and builds
- Updated dependencies [0c2fc65]
- Updated dependencies [b75a664]
- @saleor/react-hook-form-macaw@0.1.1
- @saleor/apps-shared@1.7.0
## 1.7.1
### Patch Changes
- 6e69f4f: Update app-sdk to 0.39.1
- Updated dependencies [6e69f4f]
- @saleor/apps-shared@1.6.1
## 1.7.0
### Minor Changes
- 0c8717a: Generated feed XML can now be uploaded to the s3 bucket.
Feed generation is now much faster thanks to implemented caching.
- 4801803: Added Sentry config. If Sentry is configured in ENV, it will use default Sentry configuration for Next.js to send errors to the Sentry
### Patch Changes
- ce8d9de: Product description in the feed is now a plaintext instead of JSON.
- Updated dependencies [23b5c70]
- @saleor/apps-shared@1.6.0
## 1.6.2
### Patch Changes
- c406318: Updated dep @saleor/app-sdk to 0.38.0
- 2de2a40: Query for the product details run now in paralell to speed up overall feed generation
- Updated dependencies [c406318]
- @saleor/apps-shared@1.5.1
## 1.6.1
### Patch Changes
- 8b22b1c: Restored Pino logger packages to each app, to fix failing logs in development. Also updated .env.example to contain up to date APP_LOG_LEVEL variable
## 1.6.0
### Minor Changes
- 830cfe9: Changed APP_DEBUG env to APP_LOG_LEVEL
### Patch Changes
- 830cfe9: Replaced internal logger implementation with shared logger
- Updated dependencies [830cfe9]
- @saleor/apps-shared@1.5.0
## 1.5.0
### Minor Changes
- 57f6d41: Updated Manifest to contain up to date support, privacy, homepage and author fields
### Patch Changes
- 2c0df91: Added lint:fix script, so `eslint --fix` can be run deliberately
- e167e72: Update next.js to 13.3.0
- 74174c4: Updated @saleor/app-sdk to 0.37.3
- 2e51890: Update next.js to 13.3.0
- 2e51890: Update @saleor/app-sdk to 0.37.2
- 2e51890: Use useDashboardNotification hook from shared package, instead of direct AppBridge usage
- Updated dependencies [2c0df91]
- Updated dependencies [e167e72]
- Updated dependencies [74174c4]
- Updated dependencies [2e51890]
- Updated dependencies [2e51890]
- Updated dependencies [2e51890]
- @saleor/apps-shared@1.4.0
## 1.4.3
### Patch Changes
- eca52ad: Replace "export default" with named exports
- @saleor/apps-shared@1.3.0
## 1.4.2
### Patch Changes
- a811d30: Added default cache of feed file to 5 minutes. It can be overwritten by env variable.
## 1.4.1
### Patch Changes
- 5fad97c: Update the UI to the common theme
- f58043f: Add Google Products Category mapping
## 1.4.0
### Minor Changes
- 7cb3b89: Added "author" field to the Manifest, set it to Saleor Commerce, so Dashboard can display it too
### Patch Changes
- 7cb3b89: Replace apps to avoid AppPermission (use Permission for client permissions) and authData.domain (use saleorApiUrl)
- 7cb3b89: Updated @saleor/app-sdk to 0.37.1
## 1.3.2
### Patch Changes
- e93a4dc: Updated GraphQL Code Generator package
## 1.3.1
### Patch Changes
- dca82bb: Update app-sdk to pre-0.34.0. Update Async Webhooks to use new API
## 1.3.0
### Minor Changes
- 2d23480: Remove TitleBar component from apps, because it is moved to Dashboard, outside of iframe context
### Patch Changes
- Updated dependencies [2d23480]
- @saleor/apps-shared@1.3.0
## 1.2.0
### Minor Changes
- 289b42f: Breaking change for app maintainers: VercelAPL can no longer be set for the app since it's deprecated and will be removed in app-sdk 0.30.0. As a replacement, we recommend using Upstash APL or implementing your own.
Read more about APLs: https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
### Patch Changes
- 4e4257d: Ensure initially selected channel will not crash if no channels exist
## 1.1.0
### Minor Changes
- 1c9b2c4: Change public app names to be more readable
### Patch Changes
- ca3a030: Add products feed app
- Updated dependencies [5fc88ed]
- @saleor/apps-shared@1.2.0

View file

@ -0,0 +1,114 @@
<div align="center">
<h1>Products feed</h1>
</div>
<div align="center">
<p>Share products data with the feed aggregators</p>
</div>
## About Saleor Invoices app
- Create Google Merchant Feed XML
## Development
### Requirements
Before you start, make sure you have installed:
- [Node.js](https://nodejs.org/en/)
- [pnpm](https://pnpm.io/)
- [Saleor CLI](https://docs.saleor.io/docs/3.x/cli) - optional, but recommended
### With CLI
The easiest way to set up a Saleor app is by using the Saleor CLI.
[Saleor CLI](https://github.com/saleor/saleor-cli) is designed to save you from the repetitive chores around Saleor development, including creating Apps. It will take the burden of spawning new apps locally, connecting them with Saleor environments, and establishing a tunnel for local development in seconds.
[Full Saleor CLI reference](https://docs.saleor.io/docs/3.x/developer/cli)
If you don't have a (free developer) Saleor Cloud account, create one with the following command:
```
saleor register
```
Now you're ready to create your first App:
```
saleor app create [your-app-name]
```
In this step, Saleor CLI will:
- clone this repository to the specified folder
- install dependencies
- ask you whether you'd like to install the app in the selected Saleor environment
- create `.env` file
- start the app in development mode
Having your app ready, the final thing you want to establish is a tunnel with your Saleor environment. Go to your app's directory first and run:
```
saleor app tunnel
```
Your local application should be available now to the outside world (Saleor instance) for accepting all the events via webhooks.
A quick note: the next time you come back to your project, it is enough to launch your app in a standard way (and then launch your tunnel as described earlier):
```
pnpm dev
```
### Without CLI
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 application at 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
Commands `build` and `dev` would generate schema and typed functions using Saleor's GraphQL endpoint. Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the schema changes.
[Learn more](https://www.graphql-code-generator.com/) about GraphQL code generation.
### Storing registration data - APL
During registration process Saleor API pass 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://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
The choice of the APL is done using `APL` environment variable. If value is not set, FileAPL is used. Available choices:
- `file`: no additional setup is required. Good choice for local development. 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. 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://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
### Environment variables
- `FEED_CACHE_MAX_AGE`: Amount of seconds the the response will be cached for. Default time is 5 minutes.

View file

@ -0,0 +1,5 @@
fragment AttributeWithMappingFragment on Attribute {
id
name
slug
}

View file

@ -0,0 +1,11 @@
fragment CategoryWithMappingFragment on Category {
id
parent{
name
parent{
name
}
}
name
googleCategoryId: metafield(key: "google_category_id")
}

View file

@ -0,0 +1,68 @@
fragment GoogleFeedProductVariant on ProductVariant {
id
name
sku
pricing {
priceUndiscounted{
gross {
currency
amount
}
}
price {
gross {
currency
amount
}
}
}
quantityAvailable
attributes {
attribute {
id
}
values {
value
name
}
}
product {
id
name
slug
description
seoDescription
media{
id
alt
url(size: $imageSize)
type
}
variants{
id
media{
id
alt
url(size: $imageSize)
type
}
}
attributes{
attribute{
id
}
values{
value
name
}
}
thumbnail(size: $imageSize) {
url
}
category {
id
name
googleCategoryId: metafield(key: "google_category_id")
}
}
}

View file

@ -0,0 +1,8 @@
fragment ProductVariantWebhookPayload on ProductVariant {
channel
channelListings {
channel {
slug
}
}
}

View file

@ -0,0 +1,8 @@
fragment ProductWebhookPayload on Product {
channel
channelListings {
channel {
slug
}
}
}

View file

@ -0,0 +1,7 @@
mutation UpdateCategoryMapping($id: ID!, $googleCategoryId: String!) {
updateMetadata(id: $id, input: {key: "google_category_id", value: $googleCategoryId}) {
errors {
message
}
}
}

View file

@ -0,0 +1,9 @@
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}

View file

@ -0,0 +1,13 @@
query FetchAttributesWithMapping($cursor: String){
attributes(first: 100, after: $cursor){
pageInfo{
hasNextPage
endCursor
}
edges{
node{
...AttributeWithMappingFragment
}
}
}
}

View file

@ -0,0 +1,13 @@
query FetchCategoriesWithMapping($cursor: String){
categories(first: 100, after: $cursor){
pageInfo{
hasNextPage
endCursor
}
edges{
node{
...CategoryWithMappingFragment
}
}
}
}

View file

@ -0,0 +1,9 @@
query FetchProductCursors($first:Int!, $after: String, $channel: String!){
productVariants(first:$first, after: $after, channel: $channel){
pageInfo{
hasNextPage
startCursor
endCursor
}
}
}

View file

@ -0,0 +1,13 @@
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!, $imageSize: Int = 1024){
productVariants(first:$first, after: $after, channel: $channel){
pageInfo{
hasNextPage
endCursor
}
edges{
node{
...GoogleFeedProductVariant
}
}
}
}

View file

@ -0,0 +1,6 @@
query ShopDetails {
shop{
name
description
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
subscription ProductCreated {
event {
... on ProductCreated {
product {
...ProductWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductDeleted {
event {
... on ProductDeleted {
product {
...ProductWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductUpdated {
event {
... on ProductUpdated {
product {
...ProductWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductVariantCreated {
event {
... on ProductVariantCreated {
productVariant {
...ProductVariantWebhookPayload
}
}
}
}

View file

@ -0,0 +1,10 @@
subscription ProductVariantDeleted {
event {
... on ProductVariantDeleted {
productVariant {
...ProductVariantWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductVariantUpdated {
event {
... on ProductVariantUpdated {
productVariant {
...ProductVariantWebhookPayload
}
}
}
}

5
apps/data-exporter/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -0,0 +1,28 @@
const { withSentryConfig } = require("@sentry/nextjs");
const isSentryPropertiesInEnvironment =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
};
const configWithSentry = withSentryConfig(
nextConfig,
{
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
widenClientFileUpload: true,
transpileClientSDK: true,
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
}
);
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;

View file

@ -0,0 +1,69 @@
{
"name": "saleor-app-data-exporter",
"version": "0.0.0",
"scripts": {
"build": "pnpm generate && next build",
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"lint": "next lint",
"lint:fix": "eslint --fix .",
"start": "next start",
"test": "vitest"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.332.0",
"@hookform/resolvers": "^3.3.1",
"@saleor/app-sdk": "0.43.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.127",
"@saleor/react-hook-form-macaw": "workspace:*",
"@sentry/nextjs": "7.67.0",
"@tanstack/react-query": "4.29.19",
"@trpc/client": "10.38.1",
"@trpc/next": "10.38.1",
"@trpc/react-query": "10.38.1",
"@trpc/server": "10.38.1",
"@urql/exchange-auth": "^2.1.4",
"@vitejs/plugin-react": "4.0.4",
"fast-xml-parser": "^4.0.15",
"graphql": "16.7.1",
"graphql-tag": "^2.12.6",
"handlebars": "^4.7.7",
"jsdom": "^20.0.3",
"next": "13.4.8",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.9",
"react-is": "^18.2.0",
"urql": "^4.0.4",
"usehooks-ts": "^2.9.1",
"vite": "4.4.8",
"vitest": "0.34.1",
"zod": "3.21.4"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.0",
"@graphql-codegen/introspection": "4.0.0",
"@graphql-codegen/schema-ast": "4.0.0",
"@graphql-codegen/typed-document-node": "5.0.1",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-codegen/typescript-urql": "3.7.3",
"@graphql-typed-document-node/core": "3.2.0",
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.5",
"@types/react-dom": "18.2.5",
"eslint": "8.46.0",
"eslint-config-saleor": "workspace:*",
"typescript": "5.1.6"
},
"private": true,
"saleor": {
"schemaVersion": "3.10"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -0,0 +1,37 @@
/*
* This file configures the initialization of Sentry on the client.
* The config you add here will be used whenever a users loads a page in their browser.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
/*
* This sets the sample rate to be 10%. You may want this to be 100% while
* in development and sample at a lower rate in production
*/
replaysSessionSampleRate: 0.1,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
new Sentry.Replay({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,21 @@
/*
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
* The config you add here will be used whenever one of the edge features is loaded.
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,20 @@
/*
* This file configures the initialization of Sentry on the server.
* The config you add here will be used whenever the server handles a request.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,11 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { createGraphQLClient } from "@saleor/apps-shared";
export const GraphqlClientFactory = {
fromAuthData(authData: Pick<AuthData, "token" | "saleorApiUrl">) {
return createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
},
};

View file

@ -0,0 +1,95 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client, gql } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
gql`
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}
`;
gql`
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}
`;
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (client: Client) => {
/*
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
* We recommend it for production, because all values are encrypted.
* If your use case require plain text values, you can use MetadataManager.
*/
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
});
};

View file

@ -0,0 +1,25 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui/next";
import { useEffect } from "react";
// todo move to shared
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;
}

View file

@ -0,0 +1,18 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export class AppConfigMetadataManager {
private readonly metadataKey = "app-config-v1";
constructor(private settingsManager: SettingsManager) {}
get() {
return this.settingsManager.get(this.metadataKey);
}
set(stringMetadata: string) {
return this.settingsManager.set({
key: this.metadataKey,
value: stringMetadata,
});
}
}

View file

@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import { AppConfig, RootConfig } from "./app-config";
const exampleChannelConfig: RootConfig["channelConfig"] = {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
},
},
};
const exampleS3Config: RootConfig["s3"] = {
accessKeyId: "example-access-key",
bucketName: "example-bucket-name",
region: "eu-west-1",
secretAccessKey: "example-secret-key",
};
const exampleAttributeMappingConfig: RootConfig["attributeMapping"] = {
brandAttributeIds: ["brand-attribute-1"],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
};
const exampleTitleTemplate: RootConfig["titleTemplate"] =
"Example {{ variant.product.name }} - {{ variant.name }}";
const exampleImageSize: RootConfig["imageSize"] = 1024;
const exampleConfiguration: RootConfig = {
channelConfig: exampleChannelConfig,
s3: exampleS3Config,
attributeMapping: exampleAttributeMappingConfig,
titleTemplate: exampleTitleTemplate,
imageSize: exampleImageSize,
};
describe("AppConfig", function () {
describe("Construction", () => {
it("Constructs configuration with default values, when empty object is passed as initial data", () => {
const instance = new AppConfig();
expect(instance.getRootConfig()).toEqual({
channelConfig: {},
s3: null,
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
imageSize: 1024,
});
});
it("Constructs configuration, when valid initial state is passed", () => {
const instance = new AppConfig(exampleConfiguration);
expect(instance.getRootConfig()).toEqual(exampleConfiguration);
});
it("Fill attribute mapping, image size and title template with default values, when initial data are lacking those fields", () => {
const configurationWithoutMapping = structuredClone(exampleConfiguration);
// @ts-expect-error: Simulating data before the migration
delete configurationWithoutMapping.attributeMapping;
// @ts-expect-error
delete configurationWithoutMapping.titleTemplate;
// @ts-expect-error
delete configurationWithoutMapping.imageSize;
const instance = new AppConfig(configurationWithoutMapping as any); // Casting used to prevent TS from reporting an error
expect(instance.getRootConfig()).toEqual({
...exampleConfiguration,
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
imageSize: 1024,
});
});
it("Fails construction if invalid state provided", () => {
expect(
() =>
new AppConfig({
// @ts-expect-error
foo: "bar",
}),
).toThrow();
});
it("Parses from string", () => {
const instance1 = new AppConfig({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
const serialized = instance1.serialize();
const instance2 = AppConfig.parse(serialized);
expect(instance2.getRootConfig()).toEqual({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
});
});
describe("getters", () => {
const instance = new AppConfig({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
},
},
},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: ["size-id"],
},
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
imageSize: 1024,
});
it("getRootConfig returns root config data", () => {
expect(instance.getRootConfig()).toEqual({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
},
},
},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: ["size-id"],
},
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
imageSize: 1024,
});
});
it("getUrlsForChannel gets data for given channel or undefined if doesn't exist", () => {
expect(instance.getUrlsForChannel("test")).toEqual({
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
});
expect(instance.getUrlsForChannel("not-existing")).toBeUndefined();
});
it("getS3Config gets s3 data", () => {
expect(instance.getS3Config()).toEqual({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
});
it("getAttributeMapping gets attribute data", () => {
expect(instance.getAttributeMapping()).toEqual({
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: ["size-id"],
});
});
});
describe("setters", () => {
it("setS3 sets valid config to s3 key and rejects invalid config", () => {
const instance = new AppConfig();
instance.setS3({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
expect(instance.getS3Config()).toEqual({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
// @ts-expect-error
expect(() => instance.setS3({ foo: "bar" })).toThrowError();
});
it("setChannelUrls sets valid config to channelConfig[channelSlug] and rejects invalid config", () => {
const instance = new AppConfig();
instance.setChannelUrls("test", {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
});
expect(instance.getUrlsForChannel("test")).toEqual({
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
});
// @ts-expect-error
expect(() => instance.setChannelUrls("channel", "foo")).toThrowError();
});
});
it("Serializes to string", () => {
const instance = new AppConfig();
instance.setS3({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
const serialized = instance.serialize();
/**
* Only way to check if serialization works is to deserialize. Order of serialized fields is not given so string cant be asserted.
* JSON.parse can be used but its testing implementation details
*/
expect(AppConfig.parse(serialized).getS3Config()).toEqual({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
});
});

View file

@ -0,0 +1,170 @@
import { createLogger } from "@saleor/apps-shared";
import { z } from "zod";
const imageSizeFieldSchema = z.coerce.number().gte(256).default(1024);
export const imageSizeInputSchema = z.object({
imageSize: imageSizeFieldSchema,
});
export type ImageSizeInput = z.infer<typeof imageSizeInputSchema>;
const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");
export const titleTemplateInputSchema = z.object({
titleTemplate: titleTemplateFieldSchema,
});
export type TitleTemplateInput = z.infer<typeof titleTemplateInputSchema>;
const attributeMappingSchema = z.object({
brandAttributeIds: z.array(z.string()).default([]),
colorAttributeIds: z.array(z.string()).default([]),
sizeAttributeIds: z.array(z.string()).default([]),
materialAttributeIds: z.array(z.string()).default([]),
patternAttributeIds: z.array(z.string()).default([]),
});
const s3ConfigSchema = z.object({
bucketName: z.string().min(1),
secretAccessKey: z.string().min(1),
accessKeyId: z.string().min(1),
region: z.string().min(1),
});
const urlConfigurationSchema = z.object({
storefrontUrl: z.string().min(1).url(),
productStorefrontUrl: z.string().min(1).url(),
});
const rootAppConfigSchema = z.object({
s3: s3ConfigSchema.nullable(),
titleTemplate: titleTemplateFieldSchema
.optional()
.default(titleTemplateFieldSchema.parse(undefined)),
imageSize: imageSizeFieldSchema.optional().default(imageSizeFieldSchema.parse(undefined)),
attributeMapping: attributeMappingSchema
.nullable()
.optional()
.default(attributeMappingSchema.parse({})),
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
});
export const AppConfigSchema = {
root: rootAppConfigSchema,
s3Bucket: s3ConfigSchema,
channelUrls: urlConfigurationSchema,
attributeMapping: attributeMappingSchema,
};
export type RootConfig = z.infer<typeof rootAppConfigSchema>;
export type ChannelUrlsConfig = z.infer<typeof AppConfigSchema.channelUrls>;
const logger = createLogger({ name: "AppConfig" });
export class AppConfig {
private rootData: RootConfig = {
channelConfig: {},
s3: null,
attributeMapping: attributeMappingSchema.parse({}),
titleTemplate: titleTemplateFieldSchema.parse(undefined),
imageSize: imageSizeFieldSchema.parse(undefined),
};
constructor(initialData?: RootConfig) {
if (initialData) {
try {
this.rootData = rootAppConfigSchema.parse(initialData);
} catch (e) {
logger.error(e, "Could not parse initial data");
throw new Error("Can't load the configuration");
}
}
}
static parse(serializedSchema: string) {
return new AppConfig(JSON.parse(serializedSchema));
}
getRootConfig() {
return this.rootData;
}
serialize() {
return JSON.stringify(this.rootData);
}
setS3(s3Config: z.infer<typeof s3ConfigSchema>) {
try {
this.rootData.s3 = s3ConfigSchema.parse(s3Config);
return this;
} catch (e) {
logger.info(e, "Invalid S3 config provided");
throw new Error("Invalid S3 config provided");
}
}
setAttributeMapping(attributeMapping: z.infer<typeof attributeMappingSchema>) {
try {
this.rootData.attributeMapping = attributeMappingSchema.parse(attributeMapping);
return this;
} catch (e) {
logger.info(e, "Invalid mapping config provided");
throw new Error("Invalid mapping config provided");
}
}
setChannelUrls(channelSlug: string, urlsConfig: z.infer<typeof urlConfigurationSchema>) {
try {
const parsedConfig = urlConfigurationSchema.parse(urlsConfig);
this.rootData.channelConfig[channelSlug] = {
storefrontUrls: parsedConfig,
};
return this;
} catch (e) {
logger.info(e, "Invalid channels config provided");
throw new Error("Invalid channels config provided");
}
}
getUrlsForChannel(channelSlug: string) {
try {
return this.rootData.channelConfig[channelSlug].storefrontUrls;
} catch (e) {
return undefined;
}
}
getS3Config() {
return this.rootData.s3;
}
getAttributeMapping() {
return this.rootData.attributeMapping;
}
setTitleTemplate(titleTemplate: z.infer<typeof titleTemplateFieldSchema>) {
this.rootData.titleTemplate = titleTemplate;
return this;
}
getTitleTemplate() {
return this.rootData.titleTemplate;
}
setImageSize(imageSize: z.infer<typeof imageSizeFieldSchema>) {
this.rootData.imageSize = imageSize;
return this;
}
getImageSize() {
return this.rootData.imageSize;
}
}

View file

@ -0,0 +1,231 @@
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { createLogger } from "@saleor/apps-shared";
import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
import { AppConfigSchema, imageSizeInputSchema, titleTemplateInputSchema } from "./app-config";
import { z } from "zod";
import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
import { TRPCError } from "@trpc/server";
import { AttributeFetcher } from "./attribute-fetcher";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { prepareExampleVariantData } from "./prepare-example-variant-data";
export const appConfigurationRouter = router({
/**
* Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query
*/
fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig } }) => {
logger.debug("Fetching configuration");
try {
const configuration = await getConfig();
logger.debug("Configuration fetched");
return configuration.getRootConfig();
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch the configuration",
});
}
}),
testS3BucketConfiguration: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.s3Bucket)
.mutation(async ({ ctx: { saleorApiUrl }, input }) => {
const logger = createLogger({ saleorApiUrl: saleorApiUrl });
logger.debug("Validate the credentials");
const s3Client = createS3ClientFromConfiguration(input);
try {
await checkBucketAccess({
bucketName: input.bucketName,
s3Client,
});
logger.debug("Verification succeeded");
} catch {
logger.debug("Validation failed");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Could not access the S3 bucket using the provided credentials",
});
}
}),
setS3BucketConfiguration: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.s3Bucket)
.mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => {
const logger = createLogger({ saleorApiUrl: saleorApiUrl });
logger.debug("Validate credentials");
const s3Client = createS3ClientFromConfiguration(input);
try {
await checkBucketAccess({
bucketName: input.bucketName,
s3Client,
});
} catch {
logger.debug("Validation failed");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Could not access the S3 bucket using the provided credentials",
});
}
logger.debug("Credentials validated, saving");
const config = await getConfig();
config.setS3(input);
await appConfigMetadataManager.set(config.serialize());
logger.debug("Config saved");
return null;
}),
setChannelsUrls: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(
z.object({
channelSlug: z.string(),
urls: AppConfigSchema.channelUrls,
}),
)
.mutation(
async ({
ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger },
input,
}) => {
const config = await getConfig();
/**
* TODO Check if this has to run, once its cached, it should be invalidated by webhooks only.
*
* But this operation isn't expensive and users will not continuously save this form
*/
await updateCacheForConfigurations({
client: apiClient,
channelsSlugs: [input.channelSlug],
saleorApiUrl: saleorApiUrl,
});
logger.debug({ channel: input.channelSlug }, "Updated cache for channel");
config.setChannelUrls(input.channelSlug, input.urls);
await appConfigMetadataManager.set(config.serialize());
logger.debug("Saved config");
return null;
},
),
setAttributeMapping: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.attributeMapping)
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
logger.debug("Setting attribute mapping");
const config = await getConfig();
config.setAttributeMapping(input);
await appConfigMetadataManager.set(config.serialize());
logger.debug("Attribute map set");
return null;
}),
getAttributes: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.query(async ({ ctx: { logger, apiClient } }) => {
const fetcher = new AttributeFetcher(apiClient);
const result = await fetcher.fetchAllAttributes().catch((e) => {
logger.error(e, "Can't fetch the attributes");
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch the attributes",
});
});
logger.debug("Returning attributes");
return result;
}),
setImageSize: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(imageSizeInputSchema)
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
logger.debug("Setting image size");
const config = await getConfig();
config.setImageSize(input.imageSize);
await appConfigMetadataManager.set(config.serialize());
logger.debug("image size set");
return null;
}),
setTitleTemplate: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(titleTemplateInputSchema)
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
logger.debug("Setting title template");
const config = await getConfig();
// Test render to prevent saving invalid template
try {
renderHandlebarsTemplate({
data: {},
template: input.titleTemplate,
});
} catch (err) {
logger.debug({ error: err }, "Template render failed");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Submitted template is invalid",
});
}
config.setTitleTemplate(input.titleTemplate);
await appConfigMetadataManager.set(config.serialize());
logger.debug("Template title set");
return null;
}),
renderTemplate: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(titleTemplateInputSchema)
.mutation(async ({ ctx: { getConfig, logger }, input }) => {
logger.debug(input, "renderTemplate called");
const config = await getConfig();
try {
const title = renderHandlebarsTemplate({
data: prepareExampleVariantData({
attributeMapping: config.getAttributeMapping(),
}),
template: input.titleTemplate,
});
logger.debug("Title rendered succeeded");
return { title };
} catch (err) {
logger.debug({ error: err }, "Template render failed");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Submitted template is invalid",
});
}
}),
});

View file

@ -0,0 +1,49 @@
import { Client } from "urql";
import {
AttributeWithMappingFragmentFragment,
FetchAttributesWithMappingDocument,
} from "../../../generated/graphql";
export class AttributeFetcher {
constructor(private apiClient: Pick<Client, "query">) {}
private async fetchRecursivePage(
accumulator: AttributeWithMappingFragmentFragment[],
cursor?: string
): Promise<AttributeWithMappingFragmentFragment[]> {
const result = await this.apiClient
.query(FetchAttributesWithMappingDocument, {
cursor,
})
.toPromise();
if (result.error) {
throw new Error(result.error.message);
}
if (!result.data) {
// todo sentry
throw new Error("Empty attributes data");
}
accumulator = [...accumulator, ...(result.data.attributes?.edges.map((c) => c.node) ?? [])];
const hasNextPage = result.data.attributes?.pageInfo.hasNextPage;
const endCursor = result.data.attributes?.pageInfo.endCursor;
if (hasNextPage && endCursor) {
return this.fetchRecursivePage(accumulator, endCursor);
} else {
return accumulator;
}
}
/**
* Fetches all attribute pages - standard page is max 100 items
*/
async fetchAllAttributes(): Promise<AttributeWithMappingFragmentFragment[]> {
let attributes: AttributeWithMappingFragmentFragment[] = [];
return this.fetchRecursivePage(attributes, undefined);
}
}

View file

@ -0,0 +1,137 @@
import { AppConfigSchema, RootConfig } from "./app-config";
import { useForm } from "react-hook-form";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo } from "react";
import { Multiselect } from "@saleor/react-hook-form-macaw";
import { zodResolver } from "@hookform/resolvers/zod";
import { trpcClient } from "../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
import { AttributeWithMappingFragmentFragment } from "../../../generated/graphql";
type AttributeMappingConfiguration = Exclude<RootConfig["attributeMapping"], null>;
type Props = {
initialData: AttributeMappingConfiguration;
attributes: AttributeWithMappingFragmentFragment[];
onSubmit(data: AttributeMappingConfiguration): Promise<void>;
};
export const AttributeMappingConfigurationForm = (props: Props) => {
const { handleSubmit, control } = useForm<AttributeMappingConfiguration>({
defaultValues: props.initialData,
resolver: zodResolver(AppConfigSchema.attributeMapping),
});
const options = props.attributes.map((a) => ({ value: a.id, label: a.name || a.id })) || [];
return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit((data) => {
props.onSubmit(data);
})}
>
<Multiselect
control={control}
name="brandAttributeIds"
label="Brand attributes"
options={options}
/>
<Multiselect
control={control}
name="colorAttributeIds"
label="Color attributes"
options={options}
/>
<Multiselect
control={control}
name="materialAttributeIds"
label="Material attributes"
options={options}
/>
<Multiselect
control={control}
name="patternAttributeIds"
label="Pattern attributes"
options={options}
/>
<Multiselect
control={control}
name="sizeAttributeIds"
label="Size attributes"
options={options}
/>
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button type="submit" variant="primary">
Save mapping
</Button>
</Box>
</Box>
);
};
export const ConnectedAttributeMappingForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const { data: attributes, isLoading: isAttributesLoading } =
trpcClient.appConfiguration.getAttributes.useQuery();
const { data, isLoading: isConfigurationLoading } = trpcClient.appConfiguration.fetch.useQuery();
const isLoading = isAttributesLoading || isConfigurationLoading;
const { mutate } = trpcClient.appConfiguration.setAttributeMapping.useMutation({
onSuccess() {
notifySuccess("Success", "Updated attribute mapping");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});
const handleSubmit = useCallback(
async (data: AttributeMappingConfiguration) => {
mutate(data);
},
[mutate]
);
const formData: AttributeMappingConfiguration = useMemo(() => {
if (data?.attributeMapping) {
return data.attributeMapping;
}
return {
colorAttributeIds: [],
sizeAttributeIds: [],
brandAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
};
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
}
const showForm = !isLoading && attributes?.length;
return (
<>
{showForm ? (
<AttributeMappingConfigurationForm
onSubmit={handleSubmit}
initialData={formData}
attributes={attributes}
/>
) : (
<Box>Loading</Box>
)}
</>
);
};

View file

@ -0,0 +1,106 @@
import { Accordion, Box, Button, Divider, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw";
import { trpcClient } from "../trpc/trpc-client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AppConfigSchema, ChannelUrlsConfig } from "./app-config";
import { FeedPreviewCard } from "./feed-preview-card";
import { useDashboardNotification } from "@saleor/apps-shared";
const ChannelConfigForm = ({ channelSlug, ...props }: PropsWithBox<{ channelSlug: string }>) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: appConfig } = trpcClient.appConfiguration.fetch.useQuery();
const channelConfig = appConfig?.channelConfig[channelSlug];
const { mutate } = trpcClient.appConfiguration.setChannelsUrls.useMutation({
onSuccess() {
notifySuccess("Success");
},
onError() {
notifyError("Failed saving configuration.", "Refresh the page and try again");
},
});
const { control, handleSubmit } = useForm<ChannelUrlsConfig>({
resolver: zodResolver(AppConfigSchema.channelUrls),
defaultValues: {
productStorefrontUrl: channelConfig?.storefrontUrls.productStorefrontUrl ?? "",
storefrontUrl: channelConfig?.storefrontUrls.storefrontUrl ?? "",
},
});
return (
<Box
onSubmit={handleSubmit((data) => {
mutate({
urls: data,
channelSlug,
});
})}
as={"form"}
display={"grid"}
gap={3}
{...props}
>
<Text variant={"heading"} as={"h2"} marginBottom={1.5}>
Configure channel URLs
</Text>
<Input
label={"Storefront URL"}
placeholder={"https://myshop.com"}
helperText={"Public address of your storefront"}
name={"storefrontUrl"}
control={control}
/>
<Input
label={"Storefront product URL"}
placeholder={"https://myshop.com/product/{productSlug}"}
name={"productStorefrontUrl"}
control={control}
helperText={
"Public address of your storefront product page. Use placeholder tags to inject dynamic product data"
}
/>
<Box display={"flex"} justifyContent={"flex-end"}>
<Button type={"submit"} __width={"fit-content"}>
Save channel settings
</Button>
</Box>
</Box>
);
};
export const ChannelsConfigAccordion = () => {
const { data, isLoading } = trpcClient.channels.fetch.useQuery();
if (isLoading) {
return <Text>Loading...</Text>;
}
return (
<Accordion display={"grid"} gap={5}>
{data?.map((channel) => (
<Accordion.Item
key={channel.id}
value={channel.id}
borderColor={"neutralHighlight"}
borderWidth={1}
borderBottomStyle={"solid"}
paddingBottom={5}
>
<Accordion.Trigger>
<Text>{channel.name}</Text>
<Accordion.TriggerButton />
</Accordion.Trigger>
<Accordion.Content>
<ChannelConfigForm margin={5} channelSlug={channel.slug} />
<Divider />
<FeedPreviewCard channelSlug={channel.slug} margin={5} marginTop={9} />
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
);
};

View file

@ -0,0 +1,27 @@
import { Client, gql } from "urql";
import { FetchChannelsDocument } from "../../../../generated/graphql";
gql`
fragment Channel on Channel {
name
id
slug
}
query FetchChannels {
channels {
...Channel
}
}
`;
export class ChannelsFetcher {
constructor(private client: Client) {}
fetchChannels() {
return this.client
.query(FetchChannelsDocument, {})
.toPromise()
.then((r) => r.data?.channels ?? null);
}
}

View file

@ -0,0 +1,18 @@
import { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { ChannelsFetcher } from "./channels-fetcher";
import { ChannelFragment } from "../../../../generated/graphql";
export const channelsRouter = router({
fetch: protectedClientProcedure.query(
async ({ ctx: { logger, apiClient } }): Promise<ChannelFragment[]> => {
const fetcher = new ChannelsFetcher(apiClient);
logger.debug("Fetching channels");
const channels = fetcher.fetchChannels().then((channels) => channels ?? []);
logger.debug("Channels fetched successfully");
return channels;
},
),
});

View file

@ -0,0 +1,17 @@
import { trpcClient } from "../../trpc/trpc-client";
import { useRouter } from "next/router";
import { useEffect } from "react";
/**
* This app requires channels to exist, so redirect to error page if channels don't exist
*/
export const useChannelsExistenceChecking = () => {
const channels = trpcClient.channels.fetch.useQuery();
const router = useRouter();
useEffect(() => {
if (channels.isSuccess && channels.data.length === 0) {
router.push("/not-ready");
}
}, [channels.data, channels.isSuccess, router]);
};

View file

@ -0,0 +1,58 @@
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
export const exampleVariantData: GoogleFeedProductVariantFragment = {
id: "UHJvZHVjdFZhcmlhbnQ6MzYx",
name: "M",
sku: "218223580",
pricing: {
price: {
gross: {
currency: "USD",
amount: 45,
},
},
},
quantityAvailable: 50,
attributes: [
{
attribute: {
id: "QXR0cmlidXRlOjM4",
},
values: [
{
value: "",
name: "M",
},
],
},
],
product: {
id: "UHJvZHVjdDoxMzc=",
name: "Blue Polygon Shirt",
slug: "blue-polygon-shirt",
description:
'{"time": 1653425319677, "blocks": [{"id": "sMEIn2NR8s", "data": {"text": "<b>Ever have those days where you feel a bit geometric?</b> Can\'t quite shape yourself up right? Show your different sides with a Saleor styles."}, "type": "paragraph"}], "version": "2.22.2"}',
seoDescription: "",
attributes: [
{
attribute: {
id: "QXR0cmlidXRlOjM2",
},
values: [
{
value: "",
name: "Cotton",
},
],
},
],
thumbnail: {
url: "https://example.eu.saleor.cloud/media/thumbnails/products/saleor-blue-polygon-tee-front_thumbnail_256.png",
},
category: {
id: "Q2F0ZWdvcnk6Mzk=",
name: "T-shirts",
googleCategoryId: "42",
},
},
};

View file

@ -0,0 +1,44 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Button, Input, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { useGetFeedApiUrl } from "../feed-url/use-get-feed-api-url";
interface FeedPreviewCardProps {
channelSlug: string;
}
export const FeedPreviewCard = ({ channelSlug, ...props }: PropsWithBox<FeedPreviewCardProps>) => {
const { appBridge } = useAppBridge();
const googleFeedUrl = useGetFeedApiUrl(channelSlug);
if (!googleFeedUrl) {
// Should never happen
return null;
}
const openUrlInNewTab = async (url: string) => {
await appBridge?.dispatch(actions.Redirect({ to: url, newContext: true }));
};
return (
<Box {...props}>
<Text variant={"heading"} as={"h2"} marginBottom={1.5}>
Test your feed
</Text>
<Input
label="Google feed URL"
value={googleFeedUrl}
onFocus={(e) => {
e.target.select();
}}
helperText="Dedicated URL for your Google Merchant Feed. Click to select and copy."
/>
<Box display={"flex"} justifyContent={"flex-end"}>
<Button variant="secondary" onClick={() => openUrlInNewTab(googleFeedUrl)} marginTop={3}>
Open feed in a new tab
</Button>
</Box>
</Box>
);
};

View file

@ -0,0 +1,98 @@
import { ImageSizeInput, imageSizeInputSchema } from "./app-config";
import { useForm } from "react-hook-form";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo } from "react";
import { Select } from "@saleor/react-hook-form-macaw";
import { zodResolver } from "@hookform/resolvers/zod";
import { trpcClient } from "../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
type Props = {
initialData: ImageSizeInput;
onSubmit(data: ImageSizeInput): Promise<void>;
};
const imageSizeOptions = [
{ value: "256", label: "256px" },
{ value: "512", label: "512px" },
{ value: "1024", label: "1024px" },
{ value: "2048", label: "2048px" },
{ value: "4096", label: "4096px" },
];
export const ImageConfigurationForm = (props: Props) => {
const { handleSubmit, control, formState } = useForm<ImageSizeInput>({
defaultValues: props.initialData,
resolver: zodResolver(imageSizeInputSchema),
});
return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit(props.onSubmit)}
>
<Select control={control} name="imageSize" label="Image size" options={imageSizeOptions} />
{!!formState.errors.imageSize?.message && (
<Text variant="caption" color={"textCriticalSubdued"}>
{formState.errors.imageSize?.message}
</Text>
)}
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button type="submit" variant="primary">
Save
</Button>
</Box>
</Box>
);
};
export const ConnectedImageConfigurationForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
const { mutate } = trpcClient.appConfiguration.setImageSize.useMutation({
onSuccess() {
notifySuccess("Success", "Updated image size");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});
const handleSubmit = useCallback(
async (data: ImageSizeInput) => {
mutate(data);
},
[mutate],
);
const formData: ImageSizeInput = useMemo(() => {
if (data?.imageSize) {
return {
imageSize: data.imageSize,
};
}
return imageSizeInputSchema.parse({});
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
}
return (
<>
{!isLoading ? (
<ImageConfigurationForm onSubmit={handleSubmit} initialData={formData} />
) : (
<Box>Loading</Box>
)}
</>
);
};

View file

@ -0,0 +1,19 @@
import { getMappedAttributes } from "../google-feed/attribute-mapping";
import { RootConfig } from "./app-config";
import { exampleVariantData } from "./example-variant-data";
interface PrepareExampleVariantData {
attributeMapping?: RootConfig["attributeMapping"];
}
export const prepareExampleVariantData = ({ attributeMapping }: PrepareExampleVariantData) => {
const attributes = getMappedAttributes({
attributeMapping: attributeMapping,
variant: exampleVariantData,
});
return {
variant: exampleVariantData,
googleAttributes: attributes,
};
};

View file

@ -0,0 +1,134 @@
import { AppConfigSchema, RootConfig } from "./app-config";
import { useForm } from "react-hook-form";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo } from "react";
import { Input, Select } from "@saleor/react-hook-form-macaw";
import { zodResolver } from "@hookform/resolvers/zod";
import { trpcClient } from "../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
import { awsRegionList } from "../file-storage/s3/aws-region-list";
type S3BucketConfiguration = Exclude<RootConfig["s3"], null>;
type Props = {
initialData: S3BucketConfiguration;
onSubmit(data: S3BucketConfiguration): Promise<void>;
onValidate(data: S3BucketConfiguration): Promise<void>;
};
export const S3ConfigurationForm = (props: Props) => {
const { handleSubmit, control, getValues } = useForm<S3BucketConfiguration>({
defaultValues: props.initialData,
resolver: zodResolver(AppConfigSchema.s3Bucket),
});
return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit((data) => {
props.onSubmit(data);
})}
>
<Input size={"small"} name={"accessKeyId"} control={control} label="Amazon access key ID" />
<Input
type={"password"}
size={"small"}
name={"secretAccessKey"}
control={control}
label="Amazon secret access key"
/>
<Input size={"small"} name={"bucketName"} control={control} label="Bucket name" />
<Select
control={control}
label="Region"
name={"region"}
options={awsRegionList.map((region) => ({ label: region, value: region }))}
/>
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button variant="secondary" onClick={() => props.onValidate(getValues())}>
Test credentials
</Button>
<Button type="submit" variant="primary">
Save bucket configuration
</Button>
</Box>
</Box>
);
};
export const ConnectedS3ConfigurationForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const { mutate } = trpcClient.appConfiguration.setS3BucketConfiguration.useMutation({
onSuccess() {
notifySuccess("Success", "Updated S3 configuration");
},
onError({ message }) {
if (message) {
notifyError("Error", message);
return;
}
notifyError("Error", "Failed to update, please refresh and try again");
},
});
const { mutate: testConfigurationMutate } =
trpcClient.appConfiguration.testS3BucketConfiguration.useMutation({
onSuccess() {
notifySuccess("Configuration is valid");
},
onError({ message }) {
notifyError("Error", message);
},
});
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
const handleSubmit = useCallback(
async (data: S3BucketConfiguration) => {
mutate(data);
},
[mutate]
);
const handleValidate = useCallback(
async (data: S3BucketConfiguration) => {
testConfigurationMutate(data);
},
[testConfigurationMutate]
);
const formData: S3BucketConfiguration = useMemo(() => {
if (data?.s3) {
return data.s3;
}
return {
accessKeyId: "",
bucketName: "",
region: "",
secretAccessKey: "",
};
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
}
return (
<S3ConfigurationForm
onSubmit={handleSubmit}
initialData={formData}
onValidate={handleValidate}
/>
);
};

View file

@ -0,0 +1,120 @@
import { TitleTemplateInput, titleTemplateInputSchema } from "./app-config";
import { useForm } from "react-hook-form";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo, useState } from "react";
import { Input } from "@saleor/react-hook-form-macaw";
import { zodResolver } from "@hookform/resolvers/zod";
import { trpcClient } from "../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
type Props = {
initialData: TitleTemplateInput;
preview: string | undefined;
onSubmit(data: TitleTemplateInput): Promise<void>;
onPreview(data: TitleTemplateInput): Promise<void>;
};
export const TitleFormattingConfigurationForm = (props: Props) => {
const { handleSubmit, control, getValues } = useForm<TitleTemplateInput>({
defaultValues: props.initialData,
resolver: zodResolver(titleTemplateInputSchema),
});
return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit((data) => {
props.onSubmit(data);
})}
>
<Input control={control} name="titleTemplate" label="Title template" />
{props.preview?.length && <Text variant="caption">{props.preview}</Text>}
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button
variant="secondary"
onClick={() => {
props.onPreview(getValues());
}}
>
Preview
</Button>
<Button type="submit" variant="primary">
Save
</Button>
</Box>
</Box>
);
};
export const ConnectedTitleFormattingForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const [preview, setPreview] = useState<string | undefined>();
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
const { mutate } = trpcClient.appConfiguration.setTitleTemplate.useMutation({
onSuccess() {
notifySuccess("Success", "Updated title template");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});
const { mutate: previewMutate } = trpcClient.appConfiguration.renderTemplate.useMutation({
onSuccess(data) {
setPreview(data.title);
},
onError() {
notifyError("Error", "Template invalid");
},
});
const handleSubmit = useCallback(
async (data: TitleTemplateInput) => {
mutate(data);
},
[mutate],
);
const handlePreview = useCallback(
async (data: TitleTemplateInput) => {
previewMutate(data);
},
[previewMutate],
);
const formData: TitleTemplateInput = useMemo(() => {
if (data?.titleTemplate) {
return {
titleTemplate: data.titleTemplate,
};
}
return titleTemplateInputSchema.parse({});
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
}
return (
<>
{!isLoading ? (
<TitleFormattingConfigurationForm
onSubmit={handleSubmit}
initialData={formData}
onPreview={handlePreview}
preview={preview}
/>
) : (
<Box>Loading</Box>
)}
</>
);
};

View file

@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CategoriesFetcher } from "./categories-fetcher";
import { Client, OperationResult } from "urql";
import { FetchCategoriesWithMappingQuery } from "../../../generated/graphql";
type FetchResult = OperationResult<FetchCategoriesWithMappingQuery, { cursor: string | undefined }>;
const generateCategoryEdgeMock = (uniqueIncrement: number) => {
return {
node: {
name: `Category ${uniqueIncrement}`,
id: `cat-${uniqueIncrement}`,
googleCategoryId: `${uniqueIncrement * 2}`,
},
};
};
const generateArr = (length: number) => new Array(length).fill(null).map((_, index) => index);
const wait = () => new Promise((res) => setTimeout(res, 100));
describe("CategoriesFetcher", () => {
const mockQueryPromise = vi.fn<any, FetchResult>();
const mockClient: Pick<Client, "query"> = {
// @ts-ignore - It's hard to mock urql mocks - but it can be improved
query() {
return {
toPromise: mockQueryPromise,
};
},
};
const instance = new CategoriesFetcher(mockClient);
beforeEach(() => {
vi.resetAllMocks();
});
it("Fetches single page of categories correctly", async () => {
mockQueryPromise.mockImplementationOnce(async () => {
const data: FetchCategoriesWithMappingQuery = {
categories: {
pageInfo: {
endCursor: undefined,
hasNextPage: false,
},
edges: [generateCategoryEdgeMock(1), generateCategoryEdgeMock(2)],
},
};
return {
error: undefined,
data: data,
} as FetchResult;
});
const result = await instance.fetchAllCategories();
expect(result).toEqual([
{
googleCategoryId: "2",
id: "cat-1",
name: "Category 1",
},
{
googleCategoryId: "4",
id: "cat-2",
name: "Category 2",
},
]);
});
it("Fetches 3 pages correctly and merges them", async () => {
mockQueryPromise.mockImplementationOnce(async () => {
await wait();
return {
error: undefined,
data: {
categories: {
edges: generateArr(100).map((index) => generateCategoryEdgeMock(index)),
pageInfo: {
hasNextPage: true,
endCursor: "cat-99",
},
},
},
} as FetchResult;
});
mockQueryPromise.mockImplementationOnce(async () => {
await wait();
return {
error: undefined,
data: {
categories: {
edges: generateArr(100)
.map((index) => index + 100)
.map((index) => generateCategoryEdgeMock(index)),
pageInfo: {
hasNextPage: true,
endCursor: "cat-199",
},
},
},
} as FetchResult;
});
mockQueryPromise.mockImplementationOnce(async () => {
await wait();
return {
error: undefined,
data: {
categories: {
edges: generateArr(100)
.map((index) => index + 200)
.map((index) => generateCategoryEdgeMock(index)),
pageInfo: {
hasNextPage: false,
endCursor: undefined,
},
},
},
} as FetchResult;
});
const result = await instance.fetchAllCategories();
expect(result).toHaveLength(300);
/**
* Some indexes assertions
*/
[0, 5, 99, 299].forEach((index) => {
expect(result[index]).toEqual({
googleCategoryId: `${index * 2}`,
id: `cat-${index}`,
name: `Category ${index}`,
});
});
});
it("Returns empty array if not categories returned from the API", async () => {
mockQueryPromise.mockImplementationOnce(async () => {
const data: FetchCategoriesWithMappingQuery = {
categories: {
pageInfo: {
endCursor: undefined,
hasNextPage: false,
},
edges: [],
},
};
return {
error: undefined,
data: data,
} as FetchResult;
});
const result = await instance.fetchAllCategories();
expect(result).toEqual([]);
});
});

View file

@ -0,0 +1,49 @@
import { Client } from "urql";
import {
CategoryWithMappingFragmentFragment,
FetchCategoriesWithMappingDocument,
} from "../../../generated/graphql";
export class CategoriesFetcher {
constructor(private apiClient: Pick<Client, "query">) {}
private async fetchRecursivePage(
accumulator: CategoryWithMappingFragmentFragment[],
cursor?: string
): Promise<CategoryWithMappingFragmentFragment[]> {
const result = await this.apiClient
.query(FetchCategoriesWithMappingDocument, {
cursor,
})
.toPromise();
if (result.error) {
throw new Error(result.error.message);
}
if (!result.data) {
// todo sentry
throw new Error("Empty categories data");
}
accumulator = [...accumulator, ...(result.data.categories?.edges.map((c) => c.node) ?? [])];
const hasNextPage = result.data.categories?.pageInfo.hasNextPage;
const endCursor = result.data.categories?.pageInfo.endCursor;
if (hasNextPage && endCursor) {
return this.fetchRecursivePage(accumulator, endCursor);
} else {
return accumulator;
}
}
/**
* Fetches all categories pages - standard page is max 100 items
*/
async fetchAllCategories(): Promise<CategoryWithMappingFragmentFragment[]> {
let categories: CategoryWithMappingFragmentFragment[] = [];
return this.fetchRecursivePage(categories, undefined);
}
}

View file

@ -0,0 +1,8 @@
import { z } from "zod";
export const SetCategoryMappingInputSchema = z.object({
categoryId: z.string().min(0),
googleCategoryId: z.string().optional(),
});
export type SetCategoryMappingInputType = z.infer<typeof SetCategoryMappingInputSchema>;

View file

@ -0,0 +1,60 @@
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { SetCategoryMappingInputSchema } from "./category-mapping-input-schema";
import { UpdateCategoryMappingDocument } from "../../../generated/graphql";
import { TRPCError } from "@trpc/server";
import { CategoriesFetcher } from "./categories-fetcher";
export const categoryMappingRouter = router({
/**
* Get all the category mappings to Google categories from its public metadata
*/
getCategoryMappings: protectedClientProcedure.query(
async ({ ctx: { logger, apiClient }, input }) => {
const categoriesFetcher = new CategoriesFetcher(apiClient);
const result = await categoriesFetcher.fetchAllCategories().catch((e) => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch the categories",
});
});
logger.debug("Returning categories");
return result;
}
),
/**
* Sets the Google category mapping for a given category in its public metadata
*/
setCategoryMapping: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(SetCategoryMappingInputSchema)
.mutation(async ({ ctx: { logger, apiClient }, input }) => {
const { error } = await apiClient
.mutation(UpdateCategoryMappingDocument, {
id: input.categoryId,
googleCategoryId: input.googleCategoryId || "",
})
.toPromise();
logger.debug(
{
input,
},
"Updated category mapping"
);
if (error) {
logger.error(`Error during the GraphqlAPI call: ${error.message}`);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't save the category",
});
}
return;
}),
});

View file

@ -0,0 +1,27 @@
import { CategoryWithMappingFragmentFragment } from "../../../generated/graphql";
import React from "react";
import { Box, ChevronRightIcon, Text } from "@saleor/macaw-ui/next";
export const constructCategoryBreadcrumbs = (category: CategoryWithMappingFragmentFragment) =>
[category.parent?.parent?.name, category.parent?.name, category.name].filter((segment) =>
Boolean(segment)
);
export const CategoryBreadcrumbs = (props: { category: CategoryWithMappingFragmentFragment }) => {
const breadcrumbs = constructCategoryBreadcrumbs(props.category);
return (
<Box display={"flex"} marginBottom={1} data-testid={"category-breadcrumb"}>
{breadcrumbs.map((category, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<Box display={"flex"} key={category}>
<Text variant={isLast ? "bodyStrong" : "body"}>{category}</Text>
{!isLast && <ChevronRightIcon />}
</Box>
);
})}
</Box>
);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,90 @@
import { Controller, useForm } from "react-hook-form";
import React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import {
SetCategoryMappingInputSchema,
SetCategoryMappingInputType,
} from "../category-mapping-input-schema";
import { CategoryWithMappingFragmentFragment } from "../../../../generated/graphql";
import { trpcClient } from "../../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, PropsWithBox } from "@saleor/macaw-ui/next";
import { GoogleProductCategories } from "../google-product-categories";
import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs";
type CategoryMappingFormProps = {
category: CategoryWithMappingFragmentFragment;
};
/**
* Use Combobox, when virtualized. For such amount of data it almost crashes app due to slow rendering
* https://github.com/saleor/macaw-ui/issues/452
*/
export const CategoryMappingForm = ({
category,
...props
}: PropsWithBox<CategoryMappingFormProps>) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { control, handleSubmit, formState } = useForm<SetCategoryMappingInputType>({
defaultValues: {
categoryId: category.id,
googleCategoryId: category.googleCategoryId || undefined,
},
resolver: zodResolver(SetCategoryMappingInputSchema),
});
const { mutate, isLoading } = trpcClient.categoryMapping.setCategoryMapping.useMutation({
onError() {
notifyError("Could not save the category mapping");
},
onSuccess() {
notifySuccess("Success");
},
});
return (
<Box
as={"form"}
{...props}
onSubmit={handleSubmit((data, event) => {
mutate(data);
})}
>
<CategoryBreadcrumbs category={category} />
<Box display={"flex"} gap={5} __width={"100%"}>
<Controller
control={control}
name="googleCategoryId"
render={({ field: { value, onChange } }) => {
return (
<select
data-testid={"google-category-select"}
style={{ width: "100%" }}
value={value}
onChange={(event) => {
onChange(event.target.value);
}}
>
<option value={undefined}>No override</option>
{GoogleProductCategories.map((choice) => (
<option key={choice.id} value={choice.id.toString()}>
{choice.name}
</option>
))}
</select>
);
}}
/>
<Button
data-testid={"google-category-submit"}
type="submit"
variant="primary"
disabled={isLoading || !formState.isDirty}
>
Save
</Button>
</Box>
</Box>
);
};

View file

@ -0,0 +1,50 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import { trpcClient } from "../../trpc/trpc-client";
import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs";
import { GoogleProductCategories } from "../google-product-categories";
import { useMemo } from "react";
const GoogleCategory = (props: { categoryId: string }) => {
const googleCategory = useMemo(() => {
return GoogleProductCategories.find((cat) => cat.id === props.categoryId);
}, [props.categoryId]);
return <Text size={"small"}>{googleCategory?.name}</Text>;
};
export const CategoryMappingPreview = () => {
const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery();
if (isLoading) {
return <Text>Loading</Text>;
}
if (categories?.length === 0) {
return <Text>No categories</Text>;
}
return (
<Box>
<Text marginBottom={5} as={"h2"} variant={"heading"}>
Mapped categories
</Text>
{categories!
.filter((c) => c.googleCategoryId)
.map((category) => {
return (
<Box
key={category.id}
marginBottom={1.5}
borderBottomStyle={"solid"}
borderColor={"neutralHighlight"}
borderWidth={1}
paddingBottom={1.5}
>
<CategoryBreadcrumbs category={category} />
<GoogleCategory categoryId={category.googleCategoryId!} />
</Box>
);
})}
</Box>
);
};

View file

@ -0,0 +1,25 @@
import { trpcClient } from "../../trpc/trpc-client";
import React from "react";
import { CategoryMappingForm } from "./category-mapping-form";
import { Box, Text } from "@saleor/macaw-ui/next";
export const CategoryMapping = () => {
const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery();
if (isLoading) {
return <Text>Loading</Text>;
}
if (categories && categories.length === 0) {
return <Text>No categories to map</Text>;
}
return (
<Box>
{categories!.map((category) => (
<CategoryMappingForm category={category} key={category.id} marginBottom={5} />
))}
</Box>
);
};

View file

@ -0,0 +1,23 @@
import React, { PropsWithChildren } from "react";
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { useGetFeedApiUrl } from "./use-get-feed-api-url";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
describe("useGetFeedApiUrl", function () {
const appBridge = new AppBridge({ saleorApiUrl: "https://example.com/graphql/" });
const HookWrapper = ({ children }: PropsWithChildren<{}>) => {
return <AppBridgeProvider appBridgeInstance={appBridge}>{children}</AppBridgeProvider>;
};
it("Constructs valid URL from window origin and channel slug", () => {
const { result } = renderHook(() => useGetFeedApiUrl("test-slug"), {
wrapper: HookWrapper,
});
expect(result.current).toEqual(
"http://localhost:3000/api/feed/https%3A%2F%2Fexample.com%2Fgraphql%2F/test-slug/google.xml"
);
});
});

View file

@ -0,0 +1,13 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
export const useGetFeedApiUrl = (channelSlug: string) => {
const { appBridgeState } = useAppBridge();
if (!appBridgeState || !window.location.origin) {
return null;
}
return `${window.location.origin}/api/feed/${encodeURIComponent(
appBridgeState.saleorApiUrl as string
)}/${channelSlug}/google.xml`;
};

View file

@ -0,0 +1,31 @@
export const awsRegionList = [
"af-south-1",
"ap-east-1",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ca-central-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"me-central-1",
"me-south-1",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-gov-east-1",
"us-gov-west-1",
"us-west-1",
"us-west-2",
] as const;

View file

@ -0,0 +1,15 @@
import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3";
interface checkBucketAccessArgs {
s3Client: S3Client;
bucketName: string;
}
// Check if client can access the bucket. Throws an error otherwise
export const checkBucketAccess = async ({ s3Client, bucketName }: checkBucketAccessArgs) => {
await s3Client.send(
new HeadBucketCommand({
Bucket: bucketName,
})
);
};

View file

@ -0,0 +1,6 @@
/*
* AWS multipart uploads require a minimum file size of 5 MB.
* https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html
*/
export const MULTI_PART_SIZE_THRESHOLD = 5 * 1024 * 1024;

View file

@ -0,0 +1,16 @@
import { S3Client } from "@aws-sdk/client-s3";
import { RootConfig } from "../../app-configuration/app-config";
export const createS3ClientFromConfiguration = ({
accessKeyId,
secretAccessKey,
region,
}: Exclude<RootConfig["s3"], null>) => {
return new S3Client({
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
},
region: region,
});
};

View file

@ -0,0 +1,17 @@
import { GetObjectAttributesCommand, S3Client } from "@aws-sdk/client-s3";
export interface GetFileDetailsArgs {
s3Client: S3Client;
fileName: string;
bucketName: string;
}
export const getFileDetails = async ({ s3Client, bucketName, fileName }: GetFileDetailsArgs) => {
return await s3Client.send(
new GetObjectAttributesCommand({
Bucket: bucketName,
Key: fileName,
ObjectAttributes: ["ObjectParts"],
})
);
};

View file

@ -0,0 +1,18 @@
import { S3Client } from "@aws-sdk/client-s3";
import { UploadMultiPart } from "./upload-multi-part";
import { UploadSinglePart } from "./upload-single-part";
import { MULTI_PART_SIZE_THRESHOLD } from "./const";
export interface UploadFileArgs {
s3Client: S3Client;
fileName: string;
buffer: Buffer;
bucketName: string;
}
export const uploadFile = async (args: UploadFileArgs) => {
if (args.buffer.length > MULTI_PART_SIZE_THRESHOLD) {
return await UploadMultiPart(args);
}
return await UploadSinglePart(args);
};

View file

@ -0,0 +1,94 @@
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { UploadFileArgs } from "./upload-file";
import { createLogger } from "@saleor/apps-shared";
import { MULTI_PART_SIZE_THRESHOLD } from "./const";
/*
* Code based on S3 docs:
* https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/javascript_s3_code_examples.html
*/
const logger = createLogger({
fn: "UploadMultiPart",
});
export const UploadMultiPart = async ({
s3Client,
fileName,
buffer,
bucketName,
}: UploadFileArgs) => {
let uploadId;
try {
const multipartUpload = await s3Client.send(
new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: fileName,
})
);
uploadId = multipartUpload.UploadId;
const uploadPromises = [];
// Multipart uploads require a minimum size of 5 MB per part.
const partSize = MULTI_PART_SIZE_THRESHOLD;
const numberOfParts = Math.ceil(buffer.length / partSize);
// Upload each part.
for (let i = 0; i < numberOfParts; i++) {
const start = i * partSize;
const end = start + partSize;
uploadPromises.push(
s3Client
.send(
new UploadPartCommand({
Bucket: bucketName,
Key: fileName,
UploadId: uploadId,
Body: buffer.subarray(start, end),
PartNumber: i + 1,
})
)
.then((d) => {
logger.debug(`Part ${i + 1}/${numberOfParts} uploaded`);
return d;
})
);
}
const uploadResults = await Promise.all(uploadPromises);
return await s3Client.send(
new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: fileName,
UploadId: uploadId,
MultipartUpload: {
Parts: uploadResults.map(({ ETag }, i) => ({
ETag,
PartNumber: i + 1,
})),
},
})
);
} catch (err) {
logger.error(err);
if (uploadId) {
const abortCommand = new AbortMultipartUploadCommand({
Bucket: bucketName,
Key: fileName,
UploadId: uploadId,
});
await s3Client.send(abortCommand);
}
}
};

View file

@ -0,0 +1,17 @@
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { UploadFileArgs } from "./upload-file";
export const UploadSinglePart = async ({
s3Client,
fileName,
buffer,
bucketName,
}: UploadFileArgs) => {
return await s3Client.send(
new PutObjectCommand({
Bucket: bucketName,
Body: buffer,
Key: fileName,
})
);
};

View file

@ -0,0 +1,28 @@
import { RootConfig } from "../../app-configuration/app-config";
interface GetDownloadUrlArgs {
s3BucketConfiguration: Exclude<RootConfig["s3"], null>;
saleorApiUrl: string;
channel: string;
}
export const getDownloadUrl = ({
s3BucketConfiguration,
saleorApiUrl,
channel,
}: GetDownloadUrlArgs) => {
return `https://${s3BucketConfiguration.bucketName}.s3.${
s3BucketConfiguration.region
}.amazonaws.com/${getFileName({ saleorApiUrl, channel })}`;
};
interface GetFileNameArgs {
saleorApiUrl: string;
channel: string;
}
export const getFileName = ({ saleorApiUrl, channel }: GetFileNameArgs) => {
const apiUrl = new URL(saleorApiUrl);
return `${apiUrl.hostname}/${channel}/google.xml`;
};

View file

@ -0,0 +1,268 @@
import { describe, expect, it } from "vitest";
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
import { attributeArrayToValueString, getMappedAttributes } from "./attribute-mapping";
const productBase: GoogleFeedProductVariantFragment["product"] = {
name: "Product",
__typename: "Product",
id: "product-id",
category: {
id: "cat-id",
__typename: "Category",
name: "Category Name",
googleCategoryId: "1",
},
description: "Product description",
seoDescription: "Seo description",
slug: "product-slug",
thumbnail: { __typename: "Image", url: "" },
attributes: [
{
attribute: {
id: "main-color",
},
values: [{ name: "Black" }],
},
{
attribute: {
id: "accent-color",
},
values: [{ name: "Red" }],
},
{
attribute: {
id: "size",
},
values: [{ name: "XL" }],
},
{
attribute: {
id: "pattern",
},
values: [{ name: "plain" }],
},
],
};
const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
__typename: "VariantPricingInfo",
price: {
__typename: "TaxedMoney",
gross: {
__typename: "Money",
amount: 1,
currency: "USD",
},
},
};
describe("attribute-mapping", () => {
describe("attributeArrayToValueString", () => {
it("Return undefined, when no attributes", () => {
expect(attributeArrayToValueString([])).toStrictEqual(undefined);
});
it("Return value, when attribute have value assigned", () => {
expect(
attributeArrayToValueString([
{
attribute: {
id: "1",
},
values: [
{
name: "Red",
},
],
},
{
attribute: {
id: "2",
},
values: [],
},
])
).toStrictEqual("Red");
});
it("Return all values, when attribute have multiple value assigned", () => {
expect(
attributeArrayToValueString([
{
attribute: {
id: "1",
},
values: [
{
name: "Red",
},
{
name: "Blue",
},
],
},
{
attribute: {
id: "2",
},
values: [
{
name: "Yellow",
},
],
},
])
).toStrictEqual("Red/Blue/Yellow");
});
});
describe("getMappedAttributes", () => {
it("Return undefined, when no mapping is passed", () => {
expect(
getMappedAttributes({
variant: {
id: "id1",
__typename: "ProductVariant",
sku: "sku1",
quantityAvailable: 1,
pricing: priceBase,
name: "Product variant",
product: productBase,
attributes: [],
},
})
).toStrictEqual(undefined);
});
it("Return empty values, when variant has no related attributes", () => {
expect(
getMappedAttributes({
variant: {
id: "id1",
__typename: "ProductVariant",
sku: "sku1",
quantityAvailable: 1,
pricing: priceBase,
name: "Product variant",
product: productBase,
attributes: [],
},
attributeMapping: {
brandAttributeIds: ["brand-id"],
colorAttributeIds: ["color-id"],
patternAttributeIds: ["pattern-id"],
materialAttributeIds: ["material-id"],
sizeAttributeIds: ["size-id"],
},
})
).toStrictEqual({
material: undefined,
color: undefined,
size: undefined,
brand: undefined,
pattern: undefined,
});
});
it("Return attribute values, when variant has attributes used by mapping", () => {
expect(
getMappedAttributes({
variant: {
id: "id1",
__typename: "ProductVariant",
sku: "sku1",
quantityAvailable: 1,
pricing: priceBase,
name: "Product variant",
product: productBase,
attributes: [
{
attribute: {
id: "should be ignored",
},
values: [
{
name: "ignored",
},
],
},
{
attribute: {
id: "brand-id",
},
values: [
{
name: "Saleor",
},
],
},
{
attribute: {
id: "size-id",
},
values: [
{
name: "XL",
},
],
},
{
attribute: {
id: "color-base-id",
},
values: [
{
name: "Red",
},
],
},
{
attribute: {
id: "color-secondary-id",
},
values: [
{
name: "Black",
},
],
},
{
attribute: {
id: "material-id",
},
values: [
{
name: "Cotton",
},
],
},
{
attribute: {
id: "pattern-id",
},
values: [
{
name: "Plain",
},
],
},
],
},
attributeMapping: {
brandAttributeIds: ["brand-id"],
colorAttributeIds: ["color-base-id", "color-secondary-id"],
materialAttributeIds: ["material-id"],
sizeAttributeIds: ["size-id"],
patternAttributeIds: ["pattern-id"],
},
})
).toStrictEqual({
material: "Cotton",
color: "Red/Black",
size: "XL",
brand: "Saleor",
pattern: "Plain",
});
});
});
});

View file

@ -0,0 +1,69 @@
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
import { RootConfig } from "../app-configuration/app-config";
interface GetMappedAttributesArgs {
variant: GoogleFeedProductVariantFragment;
attributeMapping?: RootConfig["attributeMapping"];
}
export const attributeArrayToValueString = (
attributes?: GoogleFeedProductVariantFragment["attributes"]
) => {
if (!attributes?.length) {
return;
}
return attributes
.map((a) => a.values)
.flat() // Multiple values can be assigned to the attribute
.map((v) => v.name) // get value to display
.filter((v) => !!v) // filter out empty values
.join("/"); // Format of multi value attribute recommended by Google
};
export const getMappedAttributes = ({
variant,
attributeMapping: mapping,
}: GetMappedAttributesArgs) => {
/*
* We have to take in account both product and variant attributes since we use flat
* model in the feed
*/
if (!mapping) {
return;
}
const attributes = variant.attributes.concat(variant.product.attributes);
const materialAttributes = attributes.filter((a) =>
mapping.materialAttributeIds.includes(a.attribute.id)
);
const materialValue = attributeArrayToValueString(materialAttributes);
const brandAttributes = attributes.filter((a) =>
mapping.brandAttributeIds.includes(a.attribute.id)
);
const brandValue = attributeArrayToValueString(brandAttributes);
const colorAttributes = attributes.filter((a) =>
mapping.colorAttributeIds.includes(a.attribute.id)
);
const colorValue = attributeArrayToValueString(colorAttributes);
const patternAttributes = attributes.filter((a) =>
mapping.patternAttributeIds.includes(a.attribute.id)
);
const patternValue = attributeArrayToValueString(patternAttributes);
const sizeAttributes = attributes.filter((a) =>
mapping.sizeAttributeIds.includes(a.attribute.id)
);
const sizeValue = attributeArrayToValueString(sizeAttributes);
return {
material: materialValue,
brand: brandValue,
color: colorValue,
size: sizeValue,
pattern: patternValue,
};
};

View file

@ -0,0 +1,99 @@
import { url } from "inspector";
import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
import {
FetchProductCursorsDocument,
FetchProductDataForFeedDocument,
GoogleFeedProductVariantFragment,
} from "../../../generated/graphql";
export const getCursors = async ({ client, channel }: { client: Client; channel: string }) => {
const logger = createLogger({ saleorApiUrl: url, channel, fn: "getCursors" });
logger.debug(`Fetching cursors for channel ${channel}`);
let result = await client
.query(FetchProductCursorsDocument, { channel: channel, first: 100 })
.toPromise();
const cursors: Array<string> = [];
while (result.data?.productVariants?.pageInfo.hasNextPage) {
result = await client
.query(FetchProductCursorsDocument, {
channel: channel,
first: 100,
after: result.data.productVariants.pageInfo.endCursor,
})
.toPromise();
const endCursor = result.data?.productVariants?.pageInfo.endCursor;
if (endCursor) {
cursors.push(endCursor);
}
}
return cursors;
};
const fetchVariants = async ({
client,
after,
channel,
imageSize,
}: {
client: Client;
after?: string;
channel: string;
imageSize?: number;
}): Promise<GoogleFeedProductVariantFragment[]> => {
const logger = createLogger({ saleorApiUrl: url, channel, fn: "fetchVariants" });
logger.debug(`Fetching variants for channel ${channel} with cursor ${after}`);
const result = await client
.query(FetchProductDataForFeedDocument, {
channel: channel,
first: 100,
after,
imageSize,
})
.toPromise();
if (result.error) {
logger.error(`Error during the GraphqlAPI call: ${result.error.message}`);
return [];
}
return result.data?.productVariants?.edges.map((e) => e.node) || [];
};
interface FetchProductDataArgs {
client: Client;
channel: string;
cursors?: Array<string>;
imageSize?: number;
}
export const fetchProductData = async ({
client,
channel,
cursors,
imageSize,
}: FetchProductDataArgs) => {
const logger = createLogger({ saleorApiUrl: url, channel, route: "Google Product Feed" });
const cachedCursors = cursors || (await getCursors({ client, channel }));
const pageCursors = [undefined, ...cachedCursors];
logger.debug(`Query generated ${pageCursors.length} cursors`);
const promises = pageCursors.map((cursor) =>
fetchVariants({ client, after: cursor, channel, imageSize }),
);
const results = await Promise.all(promises);
return results.flat();
};

View file

@ -0,0 +1,31 @@
import { url } from "inspector";
import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
import { ShopDetailsDocument } from "../../../generated/graphql";
interface FetchShopDataArgs {
client: Client;
channel: string;
}
export const fetchShopData = async ({ client, channel }: FetchShopDataArgs) => {
const logger = createLogger({ saleorApiUrl: url, channel, route: "Google Product Feed" });
const result = await client.query(ShopDetailsDocument, {}).toPromise();
const shopDetails = result.data?.shop;
if (result.error) {
logger.error(`Error during the GraphqlAPI call: ${result.error.message}`);
throw new Error("Error during the GraphQL API call");
}
if (!shopDetails) {
logger.error("Shop details query returned no data");
throw new Error("Shop details query returned no data");
}
return {
shopName: shopDetails?.name,
shopDescription: shopDetails?.description || undefined,
};
};

View file

@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
import { generateGoogleXmlFeed } from "./generate-google-xml-feed";
const productBase: GoogleFeedProductVariantFragment["product"] = {
name: "Product",
__typename: "Product",
id: "product-id",
category: {
id: "cat-id",
__typename: "Category",
name: "Category Name",
googleCategoryId: "1",
},
description: "Product description",
seoDescription: "Seo description",
slug: "product-slug",
thumbnail: { __typename: "Image", url: "" },
attributes: [],
};
const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
__typename: "VariantPricingInfo",
price: {
__typename: "TaxedMoney",
gross: {
__typename: "Money",
amount: 1,
currency: "USD",
},
},
priceUndiscounted: {
__typename: "TaxedMoney",
gross: {
__typename: "Money",
amount: 2,
currency: "USD",
},
},
};
describe("generateGoogleXmlFeed", () => {
it("Generates feed", () => {
const result = generateGoogleXmlFeed({
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
shopDescription: "Description",
shopName: "Example",
storefrontUrl: "https://example.com",
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
productVariants: [
{
id: "id1",
__typename: "ProductVariant",
sku: "sku1",
quantityAvailable: 1,
pricing: priceBase,
name: "Product variant",
product: productBase,
attributes: [],
},
{
id: "id2",
__typename: "ProductVariant",
sku: "sku2",
quantityAvailable: 0,
pricing: priceBase,
name: "Product variant 2",
product: productBase,
attributes: [],
},
],
});
expect(result).toMatchInlineSnapshot(`
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
<rss xmlns:g=\\"http://base.google.com/ns/1.0\\" version=\\"2.0\\">
<channel>
<title>Example</title>
<link>https://example.com</link>
<description>Description</description>
<item>
<g:id>sku1</g:id>
<g:item_group_id>product-id</g:item_group_id>
<title>Product - Product variant</title>
<g:condition>new</g:condition>
<g:availability>in_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item>
<item>
<g:id>sku2</g:id>
<g:item_group_id>product-id</g:item_group_id>
<title>Product - Product variant 2</title>
<g:condition>new</g:condition>
<g:availability>out_of_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item>
</channel>
</rss>"
`);
});
it("Generates feed with rendered urls, when provided old style URL template", () => {
const result = generateGoogleXmlFeed({
productStorefrontUrl: "https://example.com/p/{productSlug}",
shopDescription: "Description",
shopName: "Example",
storefrontUrl: "https://example.com",
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
productVariants: [
{
id: "id1",
__typename: "ProductVariant",
sku: "sku1",
quantityAvailable: 1,
pricing: priceBase,
name: "Product variant",
product: productBase,
attributes: [],
},
{
id: "id2",
__typename: "ProductVariant",
sku: "sku2",
quantityAvailable: 0,
pricing: priceBase,
name: "Product variant 2",
product: productBase,
attributes: [],
},
],
});
expect(result).toMatchInlineSnapshot(`
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
<rss xmlns:g=\\"http://base.google.com/ns/1.0\\" version=\\"2.0\\">
<channel>
<title>Example</title>
<link>https://example.com</link>
<description>Description</description>
<item>
<g:id>sku1</g:id>
<g:item_group_id>product-id</g:item_group_id>
<title>Product - Product variant</title>
<g:condition>new</g:condition>
<g:availability>in_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item>
<item>
<g:id>sku2</g:id>
<g:item_group_id>product-id</g:item_group_id>
<title>Product - Product variant 2</title>
<g:condition>new</g:condition>
<g:availability>out_of_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item>
</channel>
</rss>"
`);
});
});

View file

@ -0,0 +1,138 @@
import { XMLBuilder } from "fast-xml-parser";
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
import { productToProxy } from "./product-to-proxy";
import { shopDetailsToProxy } from "./shop-details-to-proxy";
import { RootConfig } from "../app-configuration/app-config";
import { getMappedAttributes } from "./attribute-mapping";
import { priceMapping } from "./price-mapping";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
import { EditorJsPlaintextRenderer } from "@saleor/apps-shared";
import { getRelatedMedia, getVariantMediaMap } from "./get-related-media";
interface GenerateGoogleXmlFeedArgs {
productVariants: GoogleFeedProductVariantFragment[];
storefrontUrl: string;
productStorefrontUrl: string;
titleTemplate: string;
attributeMapping?: RootConfig["attributeMapping"];
shopName: string;
shopDescription?: string;
}
export const generateGoogleXmlFeed = ({
attributeMapping,
productVariants,
storefrontUrl,
titleTemplate,
productStorefrontUrl,
shopName,
shopDescription,
}: GenerateGoogleXmlFeedArgs) => {
const items = productVariants.map((variant) => {
const attributes = getMappedAttributes({
attributeMapping: attributeMapping,
variant,
});
const pricing = priceMapping({ pricing: variant.pricing });
let title = "";
try {
title = renderHandlebarsTemplate({
data: {
variant,
googleAttributes: attributes,
},
template: titleTemplate,
});
} catch {}
let link = undefined;
const { additionalImages, thumbnailUrl } = getRelatedMedia({
productMedia: variant.product.media || [],
productVariantId: variant.id,
variantMediaMap: getVariantMediaMap({ variant }) || [],
});
try {
link = renderHandlebarsTemplate({
data: {
variant,
googleAttributes: attributes,
},
template: transformTemplateFormat({ template: productStorefrontUrl }),
});
} catch {}
return productToProxy({
link,
title: title || "",
id: variant.product.id,
slug: variant.product.slug,
variantId: variant.id,
sku: variant.sku || undefined,
description: EditorJsPlaintextRenderer({ stringData: variant.product.description }),
availability:
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
category: variant.product.category?.name || "unknown",
googleProductCategory: variant.product.category?.googleCategoryId || "",
imageUrl: thumbnailUrl,
additionalImageLinks: additionalImages,
material: attributes?.material,
color: attributes?.color,
brand: attributes?.brand,
pattern: attributes?.pattern,
size: attributes?.size,
...pricing,
});
});
const builder = new XMLBuilder({
attributeNamePrefix: "@_",
attributesGroupName: "@",
textNodeName: "#text",
ignoreAttributes: false,
format: true,
indentBy: " ",
suppressEmptyNode: false,
preserveOrder: true,
});
const channelData = shopDetailsToProxy({
title: shopName,
description: shopDescription,
storefrontUrl,
});
const data = [
{
"?xml": [
{
"#text": "",
},
],
":@": {
"@_version": "1.0",
"@_encoding": "utf-8",
},
},
{
rss: [
{
// @ts-ignore - This is "just an object" that is transformed to XML. I don't see good way to type it, other than "any"
channel: channelData.concat(items),
},
],
":@": {
"@_xmlns:g": "http://base.google.com/ns/1.0",
"@_version": "2.0",
},
},
];
return builder.build(data);
};

View file

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import { GoogleFeedSettingsFetcher } from "./get-google-feed-settings";
import { AppConfig } from "../app-configuration/app-config";
describe("GoogleFeedSettingsFetcher", () => {
it("Fetches channel-specific config with S3 config from Metadata", async () => {
const getMetadataMock = vi.fn().mockImplementation(async () => {
const appConfig = new AppConfig({
channelConfig: {
testChannel: {
storefrontUrls: {
storefrontUrl: "https://example.com",
productStorefrontUrl:
"https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
},
},
anotherChannel: {
storefrontUrls: {
storefrontUrl: "https://another.example.com",
productStorefrontUrl:
"https://another.example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
},
},
},
s3: {
accessKeyId: "accessKeyId",
bucketName: "bucketName",
region: "region",
secretAccessKey: "secretAccessKey",
},
attributeMapping: null,
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
return appConfig.serialize();
});
const instance = new GoogleFeedSettingsFetcher({
settingsManager: {
get: getMetadataMock,
},
});
const result = await instance.fetch("testChannel");
expect(result).toStrictEqual({
storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
s3BucketConfiguration: {
bucketName: "bucketName",
secretAccessKey: "secretAccessKey",
accessKeyId: "accessKeyId",
region: "region",
},
attributeMapping: null,
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
});
});

View file

@ -0,0 +1,52 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
import { GraphqlClientFactory } from "../../lib/create-graphql-client";
import { createSettingsManager } from "../../lib/metadata-manager";
import { AppConfig } from "../app-configuration/app-config";
export class GoogleFeedSettingsFetcher {
static createFromAuthData(authData: AuthData) {
return new GoogleFeedSettingsFetcher({
settingsManager: new AppConfigMetadataManager(
createSettingsManager(GraphqlClientFactory.fromAuthData(authData)),
),
});
}
settingsManager: Pick<AppConfigMetadataManager, "get">;
constructor(params: { settingsManager: Pick<AppConfigMetadataManager, "get"> }) {
this.settingsManager = params.settingsManager;
}
async fetch(channelSlug: string) {
const configString = await this.settingsManager.get();
if (!configString) {
throw new Error("App is not configured");
}
const appConfig = AppConfig.parse(configString);
const channelConfig = appConfig.getUrlsForChannel(channelSlug);
if (!channelConfig) {
throw new Error("App is not configured");
}
const storefrontUrl = channelConfig.storefrontUrl;
const productStorefrontUrl = channelConfig.productStorefrontUrl;
if (!storefrontUrl.length || !productStorefrontUrl.length) {
throw new Error("The application has not been configured");
}
return {
storefrontUrl,
productStorefrontUrl,
s3BucketConfiguration: appConfig.getS3Config(),
attributeMapping: appConfig.getAttributeMapping(),
titleTemplate: appConfig.getTitleTemplate(),
imageSize: appConfig.getImageSize(),
};
}
}

View file

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { getRelatedMedia } from "./get-related-media";
import { ProductMediaType } from "../../../generated/graphql";
describe("getRelatedMedia", () => {
it("Returns thumbnail, when set to the product", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/1.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/1.jpg",
additionalImages: [],
});
});
it("Returns undefined as thumbnail URL, when product has no media", () => {
expect(
getRelatedMedia({
productMedia: [],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: undefined,
additionalImages: [],
});
});
it("Returns thumbnail and additional images, when all images are set to the product", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/1.jpg",
},
{
id: "id-2",
type: ProductMediaType.Image,
url: "http://example.com/2.jpg",
},
{
id: "id-3",
type: ProductMediaType.Image,
url: "http://example.com/3.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/1.jpg",
additionalImages: ["http://example.com/2.jpg", "http://example.com/3.jpg"],
});
});
it("Returns filtered list of images, when some of the images are assigned to the other variants", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/1.jpg",
},
{
id: "id-other-variant",
type: ProductMediaType.Image,
url: "http://example.com/2-other-variant.jpg",
},
{
id: "id-3",
type: ProductMediaType.Image,
url: "http://example.com/3.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {
"variant-2": [
{
id: "id-other-variant",
type: ProductMediaType.Image,
url: "http://example.com/2-other-variant.jpg",
},
],
},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/1.jpg",
additionalImages: ["http://example.com/3.jpg"],
});
});
it("Returns only URLs to photos, when product has both Image and Video media types", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/image-1.jpg",
},
{
id: "id-2",
type: ProductMediaType.Video,
url: "http://example.com/video-2.mp4",
},
{
id: "id-3",
type: ProductMediaType.Image,
url: "http://example.com/image-3.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/image-1.jpg",
additionalImages: ["http://example.com/image-3.jpg"],
});
});
});

View file

@ -0,0 +1,63 @@
import { GoogleFeedProductVariantFragment, ProductMediaType } from "../../../generated/graphql";
type Media = {
id: string;
url: string;
type: ProductMediaType;
};
interface getRelatedMediaArgs {
productVariantId: string;
productMedia: Media[];
variantMediaMap: Record<string, Media[]>;
}
/*
* Filters media related to the product variant and returns:
* - thumbnailUrl: Product thumbnail (since Saleor has no dedicated field to the variant thumbnail)
* - additionalImages: Url list of photos assigned to particular variant and product photos not associated with any other variant
*/
export const getRelatedMedia = ({
productVariantId,
variantMediaMap,
productMedia,
}: getRelatedMediaArgs) => {
// Saleor always uses the first photo as thumbnail - even if it's assigned to the variant
const productThumbnailUrl = productMedia[0]?.url;
const mediaAssignedToAnyVariant = Object.values(variantMediaMap).flat() || [];
const mediaAssignedToNoVariant =
productMedia?.filter((m) => !mediaAssignedToAnyVariant.find((vm) => vm.id === m.id)) || [];
const mediaAssignedToVariant = variantMediaMap[productVariantId] || [];
const additionalImages =
[...mediaAssignedToVariant, ...mediaAssignedToNoVariant]
?.filter((media) => media.type === ProductMediaType.Image) // Videos are not supported by the field
.map((media) => media.url)
.filter((url) => url !== productThumbnailUrl) || []; // Exclude image used as thumbnail
return {
thumbnailUrl: productThumbnailUrl,
additionalImages,
};
};
interface GetVariantMediaMapArgs {
variant: GoogleFeedProductVariantFragment;
}
export const getVariantMediaMap = ({ variant }: GetVariantMediaMapArgs) => {
return (
variant.product.variants?.reduce((accumulator: Record<string, Array<Media>>, currentValue) => {
const id = currentValue?.id;
if (!id) {
return accumulator;
}
accumulator[id] = currentValue.media?.filter((m) => !!m) || [];
return accumulator;
}, {}) || {}
);
};

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { priceMapping } from "./price-mapping";
describe("priceMapping", () => {
it("Return undefined, when no pricing available", () => {
expect(
priceMapping({
pricing: undefined,
})
).toStrictEqual(undefined);
});
it("Return undefined, when amount is equal to 0", () => {
expect(
priceMapping({
pricing: {
priceUndiscounted: {
gross: {
amount: 0,
currency: "USD",
},
},
},
})
).toStrictEqual(undefined);
});
it("Return formatted base price, when there is no sale", () => {
expect(
priceMapping({
pricing: {
priceUndiscounted: {
gross: {
amount: 10.5,
currency: "USD",
},
},
},
})
).toStrictEqual({ price: "10.50 USD" });
});
it("Return formatted base and sale prices, when there is a sale", () => {
expect(
priceMapping({
pricing: {
priceUndiscounted: {
gross: {
amount: 10.5,
currency: "USD",
},
},
price: {
gross: {
amount: 5.25,
currency: "USD",
},
},
},
})
).toStrictEqual({ price: "10.50 USD", salePrice: "5.25 USD" });
});
});

View file

@ -0,0 +1,56 @@
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
/**
* Price format has to be altered from the en format to the one expected by Google
* eg. 1.00 USD, 5.00 PLN
*/
const formatCurrency = (currency: string, amount: number) => {
return (
new Intl.NumberFormat("en-EN", {
useGrouping: false,
minimumFractionDigits: 2,
style: "decimal",
currencyDisplay: "code",
currency: currency,
}).format(amount) + ` ${currency}`
);
};
interface priceMappingArgs {
pricing: GoogleFeedProductVariantFragment["pricing"];
}
/*
* Maps variant pricing to Google Feed format.
* https://support.google.com/merchants/answer/6324371
*/
export const priceMapping = ({ pricing }: priceMappingArgs) => {
const priceUndiscounted = pricing?.priceUndiscounted?.gross;
// Pricing should not be submitted empty or with 0 value
if (!priceUndiscounted?.amount) {
return;
}
// Price attribute is expected to be a base price
const formattedUndiscountedPrice = formatCurrency(
priceUndiscounted.currency,
priceUndiscounted.amount
);
const discountedPrice = pricing?.price?.gross;
// Return early if there is no sale
if (!discountedPrice || discountedPrice?.amount === priceUndiscounted.amount) {
return {
price: formattedUndiscountedPrice,
};
}
const formattedDiscountedPrice = formatCurrency(discountedPrice.currency, discountedPrice.amount);
return {
price: formattedUndiscountedPrice,
salePrice: formattedDiscountedPrice,
};
};

View file

@ -0,0 +1,167 @@
import { describe, it, expect } from "vitest";
import { productToProxy } from "./product-to-proxy";
describe("productToProxy", () => {
it("Falls back product ID, if product SKU doesn't exist", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
id: "product-id",
title: "title",
variantId: "variant-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:id": expect.arrayContaining([{ "#text": "variant-id" }]),
},
]),
);
});
it('Falls back g:condition to "new" if product condition doesn\'t exist', () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
/*
* Missing condition field:
* condition: "new",
*/
id: "product-id",
title: "title",
variantId: "variant-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:condition": expect.arrayContaining([{ "#text": "new" }]),
},
]),
);
});
it("Fills product description if exist", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
id: "product-id",
title: "title",
variantId: "variant-id",
description: "Product description",
additionalImageLinks: [],
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:description": expect.arrayContaining([{ "#text": "Product description" }]),
},
]),
);
});
it("Fills google product category if exist", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "product-id",
title: "title",
variantId: "variant-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:google_product_category": expect.arrayContaining([{ "#text": "1" }]),
},
]),
);
});
it("Adds link section, when url is provided", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "product-id",
title: "title",
variantId: "variant-id",
link: "https://example.com/p/product-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
expect.arrayContaining([
{
link: expect.arrayContaining([
{
"#text": "https://example.com/p/product-id",
},
]),
},
]),
);
});
it("Adds g:image_link if imageUrl exist in product", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "product-id",
title: "title",
variantId: "variant-id",
imageUrl: "https://image.example.com",
additionalImageLinks: [],
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:image_link": expect.arrayContaining([{ "#text": "https://image.example.com" }]),
},
]),
);
});
it("Adds g:price if product price exists", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "product-id",
title: "title",
variantId: "variant-id",
imageUrl: "https://image.example.com",
price: "50.00 USD",
additionalImageLinks: [],
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:price": expect.arrayContaining([{ "#text": "50.00 USD" }]),
},
]),
);
});
});

View file

@ -0,0 +1,179 @@
import { GoogleProxyItem, ProductEntry } from "./types";
export const productToProxy = (p: ProductEntry) => {
const item: GoogleProxyItem[] = [
{
"g:id": [
{
"#text": p.sku || p.variantId,
},
],
},
{
"g:item_group_id": [
{
"#text": p.id,
},
],
},
{
title: [
{
"#text": p.title,
},
],
},
{
"g:condition": [
{
"#text": p.condition || "new",
},
],
},
{
"g:availability": [
{
"#text": p.availability,
},
],
},
{
"g:product_type": [
{
"#text": p.category,
},
],
},
];
if (p.description?.length) {
item.push({
"g:description": [
{
"#text": p.description,
},
],
});
}
/**
* This field is optional and Google automatically match category if not has been provided
*
* https://support.google.com/merchants/answer/6324436?hl=en
*/
if (p.googleProductCategory?.length) {
item.push({
"g:google_product_category": [
{
"#text": p.googleProductCategory,
},
],
});
}
if (p.link?.length) {
item.push({
link: [
{
"#text": p.link,
},
],
});
}
if (p.imageUrl?.length) {
item.push({
"g:image_link": [
{
"#text": p.imageUrl,
},
],
});
}
for (const additional_image of p.additionalImageLinks) {
item.push({
"g:additional_image_link": [
{
"#text": additional_image,
},
],
});
}
if (p.price?.length) {
item.push({
"g:price": [
{
"#text": p.price,
},
],
});
}
if (p.salePrice?.length) {
item.push({
"g:sale_price": [
{
"#text": p.salePrice,
},
],
});
}
if (p.material) {
item.push({
"g:material": [
{
"#text": p.material,
},
],
});
}
if (p.brand) {
item.push({
"g:brand": [
{
"#text": p.brand,
},
],
});
}
if (p.color) {
item.push({
"g:color": [
{
"#text": p.color,
},
],
});
}
if (p.size) {
item.push({
"g:size": [
{
"#text": p.size,
},
],
});
}
if (p.pattern) {
item.push({
"g:pattern": [
{
"#text": p.pattern,
},
],
});
}
return {
item,
};
};

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { shopDetailsToProxy } from "./shop-details-to-proxy";
describe("shopDetailsToProxy", () => {
it("Renders title and storefront url", () => {
expect(
shopDetailsToProxy({
description: "Shop description",
storefrontUrl: "https://example.com",
title: "Shop title",
})
).toMatchInlineSnapshot(`
[
{
"title": [
{
"#text": "Shop title",
},
],
},
{
"link": [
{
"#text": "https://example.com",
},
],
},
{
"description": [
{
"#text": "Shop description",
},
],
},
]
`);
});
it("Renders without description if not provided", () => {
expect(
shopDetailsToProxy({
storefrontUrl: "https://example.com",
title: "Shop title",
})
).toMatchInlineSnapshot(`
[
{
"title": [
{
"#text": "Shop title",
},
],
},
{
"link": [
{
"#text": "https://example.com",
},
],
},
]
`);
});
});

View file

@ -0,0 +1,32 @@
import { GoogleProxyItem, ShopDetailsEntry } from "./types";
export const shopDetailsToProxy = ({ title, storefrontUrl, description }: ShopDetailsEntry) => {
const data: GoogleProxyItem[] = [
{
title: [
{
"#text": title,
},
],
},
{
link: [
{
"#text": storefrontUrl,
},
],
},
];
if (description) {
data.push({
description: [
{
"#text": description,
},
],
});
}
return data;
};

View file

@ -0,0 +1,30 @@
export type ProductEntry = {
id: string;
title: string;
sku?: string;
slug: string;
variantId: string;
description?: string;
link?: string;
imageUrl?: string;
additionalImageLinks: string[];
condition?: "new" | "refurbished" | "used";
price?: string;
salePrice?: string;
googleProductCategory?: string;
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
category: string;
material?: string;
color?: string;
size?: string;
brand?: string;
pattern?: string;
};
export type ShopDetailsEntry = {
title: string;
storefrontUrl: string;
description?: string;
};
export type GoogleProxyItem = Record<string, Array<Record<string, string>>>;

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { renderHandlebarsTemplate } from "./render-handlebars-template";
describe("renderHandlebarsTemplate", () => {
it("Returns formatted string, when valid template and data are provided", () => {
expect(
renderHandlebarsTemplate({
data: { name: "John", hobby: "fishing" },
template: "Hello, my name is {{ name }}. My hobby is {{ hobby }}.",
})
).toStrictEqual("Hello, my name is John. My hobby is fishing.");
});
it("Throws an error, when provided template is not valid", () => {
expect(() =>
renderHandlebarsTemplate({
data: { name: "John", hobby: "fishing" },
template: "Hello, my name is {{ name }}. My hobby is {{ hobby", // no closing brackets to trigger an error
})
).toThrowError("Could not render the template");
});
});

View file

@ -0,0 +1,24 @@
// TODO: PF and EAM apps use handlebars. Extract this module to shared package.
import Handlebars from "handlebars";
import { createLogger } from "@saleor/apps-shared";
const logger = createLogger({
name: "renderHandlebarsTemplate",
});
interface RenderHandlebarsTemplateArgs {
template: string;
data: Record<string, unknown>;
}
export const renderHandlebarsTemplate = ({ template, data }: RenderHandlebarsTemplateArgs) => {
try {
const compiledTemplate = Handlebars.compile(template);
return compiledTemplate(data);
} catch (error) {
logger.error(error, "Template compilation failed");
throw new Error("Could not render the template");
}
};

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { transformTemplateFormat } from "./transform-template-format";
describe("templateTransformer", () => {
it("Returns unchanged string, when no v1 tags are found", () => {
const template = "No changes, {unknownTag}";
expect(transformTemplateFormat({ template })).toBe(template);
});
it("Transforms tags to handlebars format, when template contain any", () => {
const oldTemplate = "Test: {productId} {productName} {productSlug} {variantId} {variantName}";
const handlebarsTemplate =
"Test: {{ variant.product.id }} {{ variant.product.name }} {{ variant.product.slug }} {{ variant.id }} {{ variant.name }}";
expect(transformTemplateFormat({ template: oldTemplate })).toBe(handlebarsTemplate);
});
});

View file

@ -0,0 +1,15 @@
interface transformTemplateFormatArgs {
template: string;
}
/*
* Transform simple templates to handlebars format.
* Example: `{productID}` will be transformed to `{{ variant. product.id}}`.
*/
export const transformTemplateFormat = ({ template }: transformTemplateFormatArgs) =>
template
.replace("{productId}", "{{ variant.product.id }}")
.replace("{productSlug}", "{{ variant.product.slug }}")
.replace("{productName}", "{{ variant.product.name }}")
.replace("{variantId}", "{{ variant.id }}")
.replace("{variantName}", "{{ variant.name }}");

View file

@ -0,0 +1,33 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export class CacheConfigurator {
private metadataKeyPrefix = "cursor-cache-";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
private constructKey(channel: string) {
return this.metadataKeyPrefix + channel;
}
get({ channel }: { channel: string }): Promise<string[] | undefined> {
return this.metadataManager.get(this.constructKey(channel), this.saleorApiUrl).then((data) => {
if (!data) {
return undefined;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, can't be parsed");
}
});
}
set({ channel, value }: { channel: string; value: string[] }): Promise<void> {
return this.metadataManager.set({
key: this.constructKey(channel),
value: JSON.stringify(value),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,33 @@
import { createLogger } from "@saleor/apps-shared";
import { CacheConfigurator } from "./cache-configurator";
import { createSettingsManager } from "../../lib/metadata-manager";
import { getCursors } from "../google-feed/fetch-product-data";
import { Client } from "urql";
interface UpdateCacheForConfigurationsArgs {
client: Client;
saleorApiUrl: string;
channelsSlugs: string[];
}
export const updateCacheForConfigurations = async ({
client,
channelsSlugs,
saleorApiUrl,
}: UpdateCacheForConfigurationsArgs) => {
const logger = createLogger({ saleorApiUrl: saleorApiUrl });
logger.debug("Updating the cursor cache");
const cache = new CacheConfigurator(createSettingsManager(client), saleorApiUrl);
const cacheUpdatePromises = channelsSlugs.map(async (channel) => {
const cursors = await getCursors({ client, channel });
await cache.set({ channel, value: cursors });
});
await Promise.all(cacheUpdatePromises);
logger.debug("Cursor cache updated");
};

View file

@ -0,0 +1,41 @@
import { GraphqlClientFactory } from "../../lib/create-graphql-client";
import { updateCacheForConfigurations } from "./update-cache-for-configurations";
import { AuthData } from "@saleor/app-sdk/APL";
import {
ProductVariantWebhookPayloadFragment,
ProductWebhookPayloadFragment,
} from "../../../generated/graphql";
import { NextApiResponse } from "next";
type ChannelFragment =
| Pick<ProductWebhookPayloadFragment, "channel" | "channelListings">
| Pick<ProductVariantWebhookPayloadFragment, "channel" | "channelListings">;
export const updateCacheOnWebhook = async ({
channels,
authData,
res,
}: {
authData: AuthData;
channels: ChannelFragment;
res: NextApiResponse;
}) => {
const client = GraphqlClientFactory.fromAuthData(authData);
const channelsSlugs = [
channels.channel,
...(channels.channelListings?.map((cl) => cl.channel.slug) ?? []),
].filter((c) => c) as string[];
if (channelsSlugs.length === 0) {
return res.status(200).end();
}
await updateCacheForConfigurations({
channelsSlugs,
client,
saleorApiUrl: authData.saleorApiUrl,
});
return res.status(200).end();
};

View file

@ -0,0 +1,32 @@
import { createLogger } from "@saleor/apps-shared";
import { middleware } from "./trpc-server";
export const attachLogger = middleware(async ({ ctx, next, type, path }) => {
const loggerName = `tRPC ${type} ${path.replace(/\./g, "/")}`;
const logger = createLogger({
name: loggerName,
requestType: type,
path,
saleorApiUrl: ctx.saleorApiUrl,
});
const start = Date.now();
logger.debug(`Requested received`);
const result = await next({
ctx: {
logger,
},
});
const durationMs = Date.now() - start;
if (result.ok) {
logger.debug({ durationMs }, `Response successful`);
} else {
logger.debug({ durationMs }, `Response with error`);
}
return result;
});

Some files were not shown because too many files have changed in this diff Show more