Add search app (#108)
* Add Search App to apps * Link eslint config to invoices * Changesets
5
.changeset/eight-apples-itch.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"saleor-app-invoices": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Link local eslint config to package json
|
5
.changeset/honest-timers-float.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"saleor-app-search": major
|
||||||
|
---
|
||||||
|
|
||||||
|
Include Search app to apps
|
|
@ -64,7 +64,8 @@
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "^4.1.1",
|
"vite": "^4.1.1",
|
||||||
"vitest": "^0.28.4"
|
"vitest": "^0.28.4",
|
||||||
|
"eslint-config-saleor": "workspace:*"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||||
|
|
2
apps/search/.env.example
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# Encryption key used by the EncryptedSettingsManager. Required by the production builds
|
||||||
|
SECRET_KEY=
|
3
apps/search/.eslintrc
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": ["saleor"]
|
||||||
|
}
|
20
apps/search/.graphqlrc.yml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
||||||
|
- urql-introspection
|
||||||
|
- typescript-urql:
|
||||||
|
documentVariablePrefix: "Untyped"
|
||||||
|
fragmentVariablePrefix: "Untyped"
|
||||||
|
- typed-document-node
|
||||||
|
generated/schema.graphql:
|
||||||
|
plugins:
|
||||||
|
- schema-ast
|
85
apps/search/README.md
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|

|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h1>Saleor App Search</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
Saleor App providing integration with the Algolia (and more search engines in the future).
|
||||||
|
|
||||||
|
### What's included?
|
||||||
|
|
||||||
|
- Synchronize changes of your products catalog with Algolia
|
||||||
|
- Search results preview
|
||||||
|
- Each channel is a separate index
|
||||||
|
|
||||||
|
### How to install
|
||||||
|
|
||||||
|
- start local dev server or deploy the application
|
||||||
|
- install the application in your Saleor Instance using manifest URL
|
||||||
|
- [configure the application](./docs/application-setup.md)
|
||||||
|
|
||||||
|
### Learn more about Apps
|
||||||
|
|
||||||
|
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
|
||||||
|
|
||||||
|
[Configuring apps in dashboard](https://docs.saleor.io/docs/3.x/dashboard/apps)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
1. Install the dependencies by running:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the local server with:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Expose local environment using tunnel:
|
||||||
|
|
||||||
|
- Use Saleor CLI `saleor app 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
|
||||||
|
- `vercel`: used by deployments from the Marketplace. It's single-tenant only and only supported by Vercel deployments done with Saleor CLI. Requires `SALEOR_REGISTER_APP_URL` and `SALEOR_DEPLOYMENT_TOKEN` environment variables to be set (handled automatically by the Saleor CLI)
|
||||||
|
|
||||||
|
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)
|
40
apps/search/docs/application-setup.md
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# App Configuration
|
||||||
|
|
||||||
|
## Required configuration options
|
||||||
|
|
||||||
|
Setting up those values is required by the application to work properly"
|
||||||
|
|
||||||
|
- Application ID: ID of the Algolia application
|
||||||
|
- Admin key: key required to update documents in the Algolia API
|
||||||
|
- Search key: public key used by search preview
|
||||||
|
|
||||||
|
All can be found in your Algolia dashboard. Choose existing application or create new one. Then choose `Overview` from the menu on the left hand side.
|
||||||
|
Below the welcome message you'll find link to the section with keys:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This view contains all the keys we'll need:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Now you can set it in the Search App configuration view and run reindexing operation.
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- Index prefix: App will create multiple indices. For testing purposes you might want to have separate indices with names starting with `staging`.
|
||||||
|
|
||||||
|
# Algolia configuration
|
||||||
|
|
||||||
|
## Grouping
|
||||||
|
|
||||||
|
To provide the most precise search results on variant level, Search app uploads each variant as separate document. Most likely you would like to group results by product. To achieve that, navigate to index settings:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
At the menu on the left choose `Deduplication and Grouping`. Change value of distinct to `true` and set attribute as `productId`. If attribute is not there, make sure you already reindexed your products
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
After saving the changes variants will be displayed as single product:
|
||||||
|
|
||||||
|

|
BIN
apps/search/docs/images/index_configuration.png
Normal file
After Width: | Height: | Size: 191 KiB |
BIN
apps/search/docs/images/index_configured.png
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
apps/search/docs/images/index_initial.png
Normal file
After Width: | Height: | Size: 224 KiB |
BIN
apps/search/docs/images/keys.png
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
apps/search/docs/images/overview.png
Normal file
After Width: | Height: | Size: 86 KiB |
77137
apps/search/generated/graphql.ts
Normal file
24206
apps/search/generated/schema.graphql
Normal file
0
apps/search/graphql/fragments/.gitkeep
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
fragment ProductAttributesData on SelectedAttribute {
|
||||||
|
attribute {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
values {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
42
apps/search/graphql/fragments/ProductData.graphql
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
fragment ProductData on Product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
slug
|
||||||
|
category {
|
||||||
|
name
|
||||||
|
parent {
|
||||||
|
name
|
||||||
|
parent {
|
||||||
|
name
|
||||||
|
parent {
|
||||||
|
name
|
||||||
|
parent {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thumbnail {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
attributes {
|
||||||
|
...ProductAttributesData
|
||||||
|
}
|
||||||
|
channelListings {
|
||||||
|
id
|
||||||
|
visibleInListings
|
||||||
|
channel {
|
||||||
|
slug
|
||||||
|
currencyCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collections {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
metadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
32
apps/search/graphql/fragments/ProductVariantData.graphql
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
fragment ProductVariantData on ProductVariant {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
sku
|
||||||
|
pricing {
|
||||||
|
price {
|
||||||
|
gross {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channelListings {
|
||||||
|
id
|
||||||
|
channel {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
price {
|
||||||
|
currency
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes {
|
||||||
|
...ProductAttributesData
|
||||||
|
}
|
||||||
|
|
||||||
|
product {
|
||||||
|
...ProductData
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
fragment ProductVariantWebhookPayload on ProductVariant {
|
||||||
|
...ProductVariantData
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
fragment ProductWebhookPayload on Product {
|
||||||
|
id
|
||||||
|
variants {
|
||||||
|
...ProductVariantData
|
||||||
|
}
|
||||||
|
}
|
0
apps/search/graphql/mutations/.gitkeep
Normal file
10
apps/search/graphql/mutations/UpdateAppMetadata.graphql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||||
|
updatePrivateMetadata(id: $id, input: $input) {
|
||||||
|
item {
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
0
apps/search/graphql/queries/.gitkeep
Normal file
5
apps/search/graphql/queries/Channels.graphql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
query Channels {
|
||||||
|
channels {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
9
apps/search/graphql/queries/FetchAppDetails.graphql
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
query FetchAppDetails {
|
||||||
|
app {
|
||||||
|
id
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/search/graphql/queries/ProductsData.graphql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
query ProductsDataForImport($first: Int, $channel: String, $after: String) {
|
||||||
|
products(first: $first, channel: $channel, after: $after) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...ProductWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24120
apps/search/graphql/schema.graphql
Normal file
0
apps/search/graphql/subscriptions/.gitkeep
Normal file
41
apps/search/graphql/subscriptions/productEdited.graphql
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
subscription ProductEdited {
|
||||||
|
event {
|
||||||
|
__typename
|
||||||
|
... on ProductUpdated {
|
||||||
|
__typename
|
||||||
|
product {
|
||||||
|
...ProductWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ProductCreated {
|
||||||
|
__typename
|
||||||
|
product {
|
||||||
|
...ProductWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ProductDeleted {
|
||||||
|
__typename
|
||||||
|
product {
|
||||||
|
...ProductWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ProductVariantCreated {
|
||||||
|
__typename
|
||||||
|
productVariant {
|
||||||
|
...ProductVariantWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ProductVariantUpdated {
|
||||||
|
__typename
|
||||||
|
productVariant {
|
||||||
|
...ProductVariantWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ProductVariantDeleted {
|
||||||
|
__typename
|
||||||
|
productVariant {
|
||||||
|
...ProductVariantWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
apps/search/next-env.d.ts
vendored
Normal 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.
|
49
apps/search/next.config.js
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
// This file sets a custom webpack configuration to use your Next.js app
|
||||||
|
// with Sentry.
|
||||||
|
// https://nextjs.org/docs/api-reference/next.config.js/introduction
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
const { withSentryConfig } = require("@sentry/nextjs");
|
||||||
|
|
||||||
|
const isSentryPropertiesInEnvironment =
|
||||||
|
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
|
||||||
|
|
||||||
|
const moduleExports = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
sentry: {
|
||||||
|
// Use `hidden-source-map` rather than `source-map` as the Webpack `devtool`
|
||||||
|
// for client-side builds. (This will be the default starting in
|
||||||
|
// `@sentry/nextjs` version 8.0.0.) See
|
||||||
|
// https://webpack.js.org/configuration/devtool/ and
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#use-hidden-source-map
|
||||||
|
// for more information.
|
||||||
|
hideSourceMaps: true,
|
||||||
|
disableServerWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||||
|
disableClientWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentryWebpackPluginOptions = {
|
||||||
|
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||||
|
// the following options are set automatically, and overriding them is not
|
||||||
|
// recommended:
|
||||||
|
// release, url, org, project, authToken, configFile, stripPrefix,
|
||||||
|
// urlPrefix, include, ignore
|
||||||
|
|
||||||
|
silent: true, // Suppresses all logs
|
||||||
|
// For all available options, see:
|
||||||
|
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make sure adding Sentry options is the last code to run before exporting, to
|
||||||
|
// ensure that your source maps include changes from all other Webpack plugins
|
||||||
|
module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions);
|
64
apps/search/package.json
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"name": "saleor-app-search",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||||
|
"build": "pnpm generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||||
|
"generate": "graphql-codegen"
|
||||||
|
},
|
||||||
|
"saleor": {
|
||||||
|
"schemaVersion": "3.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@material-ui/core": "^4.12.4",
|
||||||
|
"@material-ui/icons": "^4.11.3",
|
||||||
|
"@material-ui/lab": "4.0.0-alpha.61",
|
||||||
|
"@saleor/app-sdk": "0.27.1",
|
||||||
|
"@saleor/macaw-ui": "0.7.2",
|
||||||
|
"@sentry/nextjs": "^7.31.1",
|
||||||
|
"@types/debug": "^4.1.7",
|
||||||
|
"@urql/exchange-auth": "^1.0.0",
|
||||||
|
"algoliasearch": "4.14.2",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"graphql": "^16.6.0",
|
||||||
|
"graphql-tag": "^2.12.6",
|
||||||
|
"instantsearch.css": "^7.4.5",
|
||||||
|
"next": "13.1.2",
|
||||||
|
"next-urql": "4.0.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
|
"react-hook-form": "^7.39.1",
|
||||||
|
"react-instantsearch-hooks-web": "^6.38.0",
|
||||||
|
"react-query": "^3.39.2",
|
||||||
|
"urql": "^3.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@graphql-codegen/cli": "2.13.11",
|
||||||
|
"@graphql-codegen/introspection": "2.2.1",
|
||||||
|
"@graphql-codegen/typed-document-node": "^2.3.6",
|
||||||
|
"@graphql-codegen/typescript": "2.8.1",
|
||||||
|
"@graphql-codegen/typescript-operations": "2.5.6",
|
||||||
|
"@graphql-codegen/typescript-urql": "^3.7.3",
|
||||||
|
"@graphql-codegen/urql-introspection": "2.2.1",
|
||||||
|
"@graphql-typed-document-node/core": "^3.1.1",
|
||||||
|
"@types/node": "^18.11.9",
|
||||||
|
"@types/react": "^18.0.25",
|
||||||
|
"@types/react-dom": "^18.0.8",
|
||||||
|
"eslint": "8.27.0",
|
||||||
|
"eslint-config-next": "13.0.2",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"typescript": "4.8.4",
|
||||||
|
"eslint-config-saleor": "workspace:*"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||||
|
"*.{js,ts,tsx,css,md,json}": "prettier --write"
|
||||||
|
}
|
||||||
|
}
|
6343
apps/search/pnpm-lock.yaml
Normal file
51
apps/search/saleor-app.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { APL, FileAPL, UpstashAPL, SaleorCloudAPL } from "@saleor/app-sdk/APL";
|
||||||
|
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* By default auth data are stored in the `.auth-data.json` (FileAPL).
|
||||||
|
* For multi-tenant applications and deployments please use UpstashAPL.
|
||||||
|
*
|
||||||
|
* To read more about storing auth data, read the
|
||||||
|
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
|
||||||
|
*/
|
||||||
|
const aplType = process.env.APL ?? "file";
|
||||||
|
let apl: APL;
|
||||||
|
|
||||||
|
switch (aplType) {
|
||||||
|
case "upstash":
|
||||||
|
apl = new UpstashAPL();
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "file":
|
||||||
|
apl = new FileAPL();
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "saleor-cloud": {
|
||||||
|
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
|
||||||
|
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
apl = new SaleorCloudAPL({
|
||||||
|
resourceUrl: process.env.REST_APL_ENDPOINT as string,
|
||||||
|
token: process.env.REST_APL_TOKEN as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error("Invalid APL config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.SECRET_KEY && process.env.NODE_ENV === "production") {
|
||||||
|
throw new Error(
|
||||||
|
"For production deployment SECRET_KEY is mandatory to use EncryptedSettingsManager.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use placeholder value for the development
|
||||||
|
export const settingsManagerSecretKey = process.env.SECRET_KEY || "CHANGE_ME";
|
||||||
|
|
||||||
|
export const saleorApp = new SaleorApp({
|
||||||
|
apl,
|
||||||
|
});
|
17
apps/search/sentry.client.config.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file configures the initialization of Sentry on the browser.
|
||||||
|
// The config you add here will be used whenever a page is visited.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
17
apps/search/sentry.edge.config.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file configures the initialization of Sentry on the server.
|
||||||
|
// The config you add here will be used whenever middleware or an Edge route handles a request.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
17
apps/search/sentry.server.config.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
167
apps/search/src/components/AlgoliaConfigurationCard.tsx
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||||
|
import { Card, CardActions, CardHeader, TextField } from "@material-ui/core";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { useQuery, useQueryClient, useMutation } from "react-query";
|
||||||
|
import { AlgoliaConfigurationFields } from "../lib/algolia/types";
|
||||||
|
import { fetchConfiguration } from "../lib/configuration";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
form: {
|
||||||
|
margin: theme.spacing(2),
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
},
|
||||||
|
fieldContainer: {
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const AlgoliaConfigurationCard = () => {
|
||||||
|
const { appBridge, appBridgeState } = useAppBridge();
|
||||||
|
const { handleSubmit, setValue, control } = useForm<AlgoliaConfigurationFields>({
|
||||||
|
defaultValues: { appId: "", indexNamePrefix: "", searchKey: "", secretKey: "" },
|
||||||
|
});
|
||||||
|
const classes = useStyles();
|
||||||
|
const { token, saleorApiUrl } = appBridgeState || {};
|
||||||
|
|
||||||
|
const reactQueryClient = useQueryClient();
|
||||||
|
const { isLoading: isQueryLoading } = useQuery({
|
||||||
|
queryKey: ["configuration"],
|
||||||
|
onSuccess(data) {
|
||||||
|
setValue("secretKey", data?.secretKey || "");
|
||||||
|
setValue("searchKey", data?.searchKey || "");
|
||||||
|
setValue("appId", data?.appId || "");
|
||||||
|
setValue("indexNamePrefix", data?.indexNamePrefix || "");
|
||||||
|
},
|
||||||
|
queryFn: async () => fetchConfiguration(saleorApiUrl!, token!),
|
||||||
|
enabled: !!token && !!saleorApiUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate, isLoading: isMutationLoading } = useMutation(
|
||||||
|
async (conf: AlgoliaConfigurationFields) => {
|
||||||
|
const resp = await fetch("/api/configuration", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-api-url": saleorApiUrl!,
|
||||||
|
"authorization-bearer": token!,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(conf),
|
||||||
|
});
|
||||||
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
|
const data = (await resp.json()) as { data?: AlgoliaConfigurationFields };
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
throw new Error(`Server responded with status code ${resp.status}`);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
reactQueryClient.refetchQueries({
|
||||||
|
queryKey: ["configuration"],
|
||||||
|
});
|
||||||
|
appBridge?.dispatch({
|
||||||
|
type: "notification",
|
||||||
|
payload: {
|
||||||
|
status: "success",
|
||||||
|
title: "Configuration saved!",
|
||||||
|
actionId: "message-from-app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: async (data: Error) => {
|
||||||
|
appBridge?.dispatch({
|
||||||
|
type: "notification",
|
||||||
|
payload: {
|
||||||
|
status: "error",
|
||||||
|
title: "Could not save the configuration",
|
||||||
|
text: data.message,
|
||||||
|
actionId: "message-from-app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onFormSubmit = handleSubmit(async (conf) => mutate(conf));
|
||||||
|
|
||||||
|
const isFormDisabled = isMutationLoading || isQueryLoading || !token || !saleorApiUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<form onSubmit={onFormSubmit}>
|
||||||
|
<CardHeader title="Configure Algolia settings"></CardHeader>
|
||||||
|
|
||||||
|
<div className={classes.form}>
|
||||||
|
<Controller
|
||||||
|
name="appId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
className={classes.fieldContainer}
|
||||||
|
disabled={isFormDisabled}
|
||||||
|
label="Application ID"
|
||||||
|
helperText="Usually 10 characters, e.g. XYZAAABB00"
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="searchKey"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
className={classes.fieldContainer}
|
||||||
|
disabled={isFormDisabled}
|
||||||
|
label="Search-Only API Key"
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div key="secret" className={classes.fieldContainer}>
|
||||||
|
<Controller
|
||||||
|
name="secretKey"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
helperText="In Algolia dashboard it's a masked field"
|
||||||
|
disabled={isFormDisabled}
|
||||||
|
label="Admin API Key"
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="indexNamePrefix"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
className={classes.fieldContainer}
|
||||||
|
disabled={isFormDisabled}
|
||||||
|
label="Index name prefix"
|
||||||
|
helperText='Optional prefix, you can add "test" or "staging" to test the app'
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<CardActions style={{ padding: "30px 0 0 0" }}>
|
||||||
|
<Button disabled={isFormDisabled} type="submit" variant="primary" fullWidth>
|
||||||
|
{isFormDisabled ? "Loading..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export default for Next.dynamic
|
||||||
|
*/
|
||||||
|
export default AlgoliaConfigurationCard;
|
41
apps/search/src/components/AppColumnsLayout.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { makeStyles } from "@saleor/macaw-ui";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root3equal: {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "280px auto 280px",
|
||||||
|
alignItems: "start",
|
||||||
|
gap: 32,
|
||||||
|
maxWidth: 1180,
|
||||||
|
margin: "0 auto",
|
||||||
|
},
|
||||||
|
oneTo2: {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "460px 1fr",
|
||||||
|
alignItems: "start",
|
||||||
|
gap: 32,
|
||||||
|
maxWidth: 1180,
|
||||||
|
margin: "0 auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = PropsWithChildren<{
|
||||||
|
variant?: "3-equal" | "1:2";
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO Refactor, make generic across the apps
|
||||||
|
*/
|
||||||
|
export const AppColumnsLayout = ({ children, variant = "3-equal" }: Props) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
|
switch (variant) {
|
||||||
|
case "1:2":
|
||||||
|
return <div className={styles.oneTo2}>{children}</div>;
|
||||||
|
case "3-equal":
|
||||||
|
return <div className={styles.root3equal}>{children}</div>;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid variant");
|
||||||
|
}
|
||||||
|
};
|
6
apps/search/src/components/AppIcon.svg
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="23" viewBox="0 0 24 23" fill="none">
|
||||||
|
<path d="M23.5 2.66667C23.5 1.47005 22.53 0.5 21.3333 0.5H2.66667C1.47005 0.5 0.5 1.47005 0.5 2.66667V17.3333C0.5 18.53 1.47005 19.5 2.66667 19.5H8C8.46024 19.5 8.83333 19.1269 8.83333 18.6667C8.83333 18.2064 8.46024 17.8333 8 17.8333H2.66667C2.39053 17.8333 2.16667 17.6095 2.16667 17.3333V2.66667C2.16667 2.39052 2.39053 2.16667 2.66667 2.16667H21.3333C21.6095 2.16667 21.8333 2.39052 21.8333 2.66667L21.8333 10.5208C21.8333 10.9811 22.2064 11.3542 22.6667 11.3542C23.1269 11.3542 23.5 10.9811 23.5 10.5208L23.5 2.66667Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.83333 15.3333C9.83333 12.2958 12.2958 9.83333 15.3333 9.83333C18.3709 9.83333 20.8333 12.2958 20.8333 15.3333C20.8333 16.5515 20.4373 17.6771 19.767 18.5885L22.5893 21.4107C22.9147 21.7362 22.9147 22.2638 22.5893 22.5893C22.2638 22.9147 21.7362 22.9147 21.4107 22.5893L18.5885 19.767C17.6771 20.4373 16.5515 20.8333 15.3333 20.8333C12.2958 20.8333 9.83333 18.3709 9.83333 15.3333ZM15.3333 11.5C13.2162 11.5 11.5 13.2162 11.5 15.3333C11.5 17.4504 13.2162 19.1667 15.3333 19.1667C17.4504 19.1667 19.1667 17.4504 19.1667 15.3333C19.1667 13.2162 17.4504 11.5 15.3333 11.5Z" fill="white"/>
|
||||||
|
<path d="M6.66667 5.33333C6.66667 6.06971 6.06971 6.66667 5.33333 6.66667C4.59695 6.66667 4 6.06971 4 5.33333C4 4.59695 4.59695 4 5.33333 4C6.06971 4 6.66667 4.59695 6.66667 5.33333Z" fill="white"/>
|
||||||
|
<path d="M9.33333 6.66667C10.0697 6.66667 10.6667 6.06971 10.6667 5.33333C10.6667 4.59695 10.0697 4 9.33333 4C8.59695 4 8 4.59695 8 5.33333C8 6.06971 8.59695 6.66667 9.33333 6.66667Z" fill="white"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
23
apps/search/src/components/AppIcon.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { makeStyles } from "@saleor/macaw-ui";
|
||||||
|
import Image from "next/image";
|
||||||
|
import appIcon from "./AppIcon.svg";
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
appIconContainer: {
|
||||||
|
background: `rgb(199, 58, 63)`,
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: "50%",
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AppIcon = () => {
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.appIconContainer}>
|
||||||
|
<Image width={30} height={30} alt="icon" src={appIcon} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
59
apps/search/src/components/ConfigurationView.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import {Card, CardContent, CardHeader} from "@material-ui/core";
|
||||||
|
import { ImportProductsToAlgolia } from "./ImportProductsToAlgolia";
|
||||||
|
import AlgoliaConfigurationCard from "./AlgoliaConfigurationCard";
|
||||||
|
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import Instructions from "./Instructions";
|
||||||
|
|
||||||
|
import { AppColumnsLayout } from "./AppColumnsLayout";
|
||||||
|
import { SearchAppMainBar } from "./SearchAppMainBar";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
buttonsGrid: { display: "flex", gap: 10 },
|
||||||
|
topBar: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
indexActions: {
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
wrapper: {
|
||||||
|
border: `1px solid ${theme.palette.grey.A100}`,
|
||||||
|
minHeight: "calc(100vh - 100px)",
|
||||||
|
paddingBottom: 50,
|
||||||
|
},
|
||||||
|
tabs: { marginLeft: 32, marginBottom: 32 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ConfigurationView = () => {
|
||||||
|
const styles = useStyles();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleClick = (val: string) => router.push("/" + val);
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<SearchAppMainBar />
|
||||||
|
<PageTabs className={styles.tabs} value="" onChange={handleClick}>
|
||||||
|
<PageTab label={"Configuration"} value="" />
|
||||||
|
<PageTab label={"Preview"} value="search" />
|
||||||
|
</PageTabs>
|
||||||
|
<AppColumnsLayout>
|
||||||
|
<div />
|
||||||
|
<div>
|
||||||
|
<AlgoliaConfigurationCard />
|
||||||
|
<Card className={styles.indexActions}>
|
||||||
|
<CardHeader title="Indexing" />
|
||||||
|
<CardContent>
|
||||||
|
<ImportProductsToAlgolia />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Instructions />
|
||||||
|
</AppColumnsLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export default for Next.dynamic
|
||||||
|
*/
|
||||||
|
export default ConfigurationView;
|
30
apps/search/src/components/Hits.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { ImageIcon } from "@saleor/macaw-ui";
|
||||||
|
import { Highlight, useHits } from "react-instantsearch-hooks-web";
|
||||||
|
import styles from "../styles/search.module.css";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
function Hit(props: { hit: any }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
{props.hit?.thumbnail ? (
|
||||||
|
<Image height={60} width={60} src={props.hit.thumbnail} alt={props.hit.slug} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.fallbackThumbnail}>
|
||||||
|
<ImageIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Highlight attribute="name" hit={props.hit} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hits() {
|
||||||
|
const { hits } = useHits();
|
||||||
|
return (
|
||||||
|
<div className={styles.hitsWrapper}>
|
||||||
|
{hits.map((hit) => (
|
||||||
|
<Hit key={hit.objectID} hit={hit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
111
apps/search/src/components/ImportProductsToAlgolia.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { Button } from "@saleor/macaw-ui";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
|
||||||
|
import { useConfiguration } from "../lib/configuration";
|
||||||
|
import { Products, useQueryAllProducts } from "./useQueryAllProducts";
|
||||||
|
import { WarningOutlined, WarningRounded } from "@material-ui/icons";
|
||||||
|
import { Typography } from "@material-ui/core";
|
||||||
|
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
export const ImportProductsToAlgolia = () => {
|
||||||
|
const [started, setStarted] = useState(false);
|
||||||
|
const [currentProductIndex, setCurrentProductIndex] = useState(0);
|
||||||
|
const [isAlgoliaImporting, setIsAlgoliaImporting] = useState(false);
|
||||||
|
|
||||||
|
const products = useQueryAllProducts(!started);
|
||||||
|
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
const algoliaConfiguration = useConfiguration(
|
||||||
|
appBridgeState?.saleorApiUrl,
|
||||||
|
appBridgeState?.token,
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchProvider = useMemo(() => {
|
||||||
|
if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new AlgoliaSearchProvider({
|
||||||
|
appId: algoliaConfiguration.data.appId,
|
||||||
|
apiKey: algoliaConfiguration.data.secretKey,
|
||||||
|
indexNamePrefix: algoliaConfiguration.data.indexNamePrefix,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
algoliaConfiguration?.data?.appId,
|
||||||
|
algoliaConfiguration?.data?.indexNamePrefix,
|
||||||
|
algoliaConfiguration?.data?.secretKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const importProducts = useCallback(() => {
|
||||||
|
setStarted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchProvider || isAlgoliaImporting || products.length <= currentProductIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
setIsAlgoliaImporting(true);
|
||||||
|
const productsBatchStartIndex = currentProductIndex;
|
||||||
|
const productsBatchEndIndex = Math.min(currentProductIndex + BATCH_SIZE, products.length);
|
||||||
|
const productsBatch = products.slice(productsBatchStartIndex, productsBatchEndIndex);
|
||||||
|
|
||||||
|
await searchProvider.updatedBatchProducts(productsBatch);
|
||||||
|
|
||||||
|
setIsAlgoliaImporting(false);
|
||||||
|
setCurrentProductIndex(productsBatchEndIndex);
|
||||||
|
})();
|
||||||
|
}, [searchProvider, currentProductIndex, isAlgoliaImporting, products]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 32,
|
||||||
|
cursor: started ? "wait" : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchProvider ? (
|
||||||
|
<Button disabled={started || !searchProvider} onClick={importProducts} fullWidth>
|
||||||
|
Start importing products and variants to Algolia
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Typography align="center">
|
||||||
|
<WarningRounded />
|
||||||
|
</Typography>
|
||||||
|
<Typography>Ensure Algolia is configured</Typography>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{started && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "20px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{countVariants(products, currentProductIndex)} /{" "}
|
||||||
|
{countVariants(products, products.length)}
|
||||||
|
<progress
|
||||||
|
value={currentProductIndex}
|
||||||
|
max={products.length}
|
||||||
|
style={{
|
||||||
|
height: "30px",
|
||||||
|
width: "500px",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const countVariants = (products: Products, index: number) =>
|
||||||
|
products.slice(0, index).reduce((acc, p) => acc + (p.variants?.length ?? 0), 0);
|
74
apps/search/src/components/Instructions.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { Card, CardContent, CardHeader, List, ListItem, Typography, Link } from "@material-ui/core";
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
|
||||||
|
function Instructions() {
|
||||||
|
const { appBridge } = useAppBridge();
|
||||||
|
|
||||||
|
const algoliaDashboardUrl = "https://www.algolia.com/apps/dashboard";
|
||||||
|
const algoliaApiKeysUrl = "https://www.algolia.com/account/api-keys/all";
|
||||||
|
|
||||||
|
const openExternalUrl = (to: string) => {
|
||||||
|
appBridge?.dispatch({
|
||||||
|
type: "redirect",
|
||||||
|
payload: {
|
||||||
|
newContext: true,
|
||||||
|
actionId: "redirect_from_search_app",
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Instructions" />
|
||||||
|
<CardContent>
|
||||||
|
<Typography paragraph>
|
||||||
|
How to configure
|
||||||
|
<List style={{ marginBottom: 20 }}>
|
||||||
|
<ListItem>
|
||||||
|
<Link
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openExternalUrl(algoliaDashboardUrl);
|
||||||
|
}}
|
||||||
|
href={algoliaDashboardUrl}
|
||||||
|
>
|
||||||
|
1. Create a new Algolia application
|
||||||
|
</Link>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<Typography>
|
||||||
|
2. Navigate to{" "}
|
||||||
|
<Link
|
||||||
|
href={algoliaApiKeysUrl}
|
||||||
|
onClick={() => {
|
||||||
|
openExternalUrl(algoliaApiKeysUrl);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
application keys
|
||||||
|
</Link>{" "}
|
||||||
|
section and copy values to the form below
|
||||||
|
</Typography>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>3. Save configuration</ListItem>
|
||||||
|
</List>
|
||||||
|
Useful links
|
||||||
|
<List>
|
||||||
|
<ListItem>
|
||||||
|
<Link
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openExternalUrl("https://github.com/saleor/saleor-app-search");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Visit repository & detailed configuration guide
|
||||||
|
</Link>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Instructions;
|
52
apps/search/src/components/MainBar.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { makeStyles } from "@saleor/macaw-ui";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { Paper, PaperProps } from "@material-ui/core";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
root: {
|
||||||
|
height: 96,
|
||||||
|
padding: "0 32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
leftColumn: {
|
||||||
|
marginRight: "auto",
|
||||||
|
},
|
||||||
|
rightColumn: {},
|
||||||
|
iconColumn: {
|
||||||
|
marginRight: 24,
|
||||||
|
},
|
||||||
|
appName: { fontSize: 24, margin: 0 },
|
||||||
|
appAuthor: {
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontWeight: 500,
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
rightColumnContent?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
} & PaperProps;
|
||||||
|
|
||||||
|
export const MainBar = ({ name, author, rightColumnContent, className, icon }: Props) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} className={clsx(styles.root, className)}>
|
||||||
|
{icon && <div className={styles.iconColumn}>{icon}</div>}
|
||||||
|
<div className={styles.leftColumn}>
|
||||||
|
<h1 className={styles.appName}>{name}</h1>
|
||||||
|
<h1 className={styles.appAuthor}>{author}</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rightColumn}>{rightColumnContent}</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
60
apps/search/src/components/SearchAppMainBar.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { GitHub, OfflineBoltOutlined } from "@material-ui/icons";
|
||||||
|
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||||
|
import { MainBar } from "./MainBar";
|
||||||
|
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { AppIcon } from "./AppIcon";
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
buttonsGrid: { display: "flex", gap: 10 },
|
||||||
|
topBar: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
indexActions: {
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SearchAppMainBar = () => {
|
||||||
|
const { appBridge } = useAppBridge();
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
|
const openInNewTab = (url: string) => {
|
||||||
|
appBridge?.dispatch(
|
||||||
|
actions.Redirect({
|
||||||
|
to: url,
|
||||||
|
newContext: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainBar
|
||||||
|
icon={<AppIcon />}
|
||||||
|
className={styles.topBar}
|
||||||
|
name="Saleor Search"
|
||||||
|
author="By Saleor Commerce"
|
||||||
|
rightColumnContent={
|
||||||
|
<div className={styles.buttonsGrid}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
startIcon={<GitHub />}
|
||||||
|
onClick={() => {
|
||||||
|
openInNewTab("https://github.com/saleor/saleor-app-search");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Repository
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<OfflineBoltOutlined />}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
openInNewTab("https://github.com/saleor/apps/discussions");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request a feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
32
apps/search/src/components/SearchBox.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { InputAdornment, TextField } from "@material-ui/core";
|
||||||
|
import { SearchIcon } from "@saleor/macaw-ui";
|
||||||
|
import { ChangeEvent } from "react";
|
||||||
|
import { useSearchBox } from "react-instantsearch-hooks-web";
|
||||||
|
import styles from "../styles/search.module.css";
|
||||||
|
|
||||||
|
export function SearchBox() {
|
||||||
|
const { query, refine } = useSearchBox();
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
refine(e.target.value);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={styles.textFieldContainer}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
value={query}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={"Search products..."}
|
||||||
|
inputProps={{ style: { padding: "16px" } }}
|
||||||
|
className={styles.textField}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
77
apps/search/src/components/useQueryAllProducts.tsx
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ChannelsDocument,
|
||||||
|
ProductsDataForImportDocument,
|
||||||
|
ProductsDataForImportQuery,
|
||||||
|
} from "../../generated/graphql";
|
||||||
|
import { nextClient } from "../lib/graphql";
|
||||||
|
|
||||||
|
const PER_PAGE = 100;
|
||||||
|
|
||||||
|
export type Products = NonNullable<
|
||||||
|
ProductsDataForImportQuery["products"]
|
||||||
|
>["edges"][number]["node"][];
|
||||||
|
|
||||||
|
export const useQueryAllProducts = (paused: boolean) => {
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
|
||||||
|
|
||||||
|
const [products, setProducts] = useState<Products>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appBridgeState?.token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = appBridgeState.token;
|
||||||
|
const client = nextClient(saleorApiUrl, () => Promise.resolve({ token }));
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChannels = () => client.query(ChannelsDocument, {}).toPromise();
|
||||||
|
|
||||||
|
const getProducts = async (channelSlug: string, cursor: string): Promise<void> => {
|
||||||
|
const response = await client
|
||||||
|
.query(ProductsDataForImportDocument, {
|
||||||
|
after: cursor,
|
||||||
|
first: PER_PAGE,
|
||||||
|
channel: channelSlug!,
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? [];
|
||||||
|
|
||||||
|
if (newProducts.length > 0) {
|
||||||
|
setProducts((ps) => [...ps, ...newProducts]);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
response?.data?.products?.pageInfo.hasNextPage &&
|
||||||
|
response?.data?.products?.pageInfo.endCursor
|
||||||
|
) {
|
||||||
|
// get next page of products
|
||||||
|
return getProducts(channelSlug, response.data.products?.pageInfo.endCursor);
|
||||||
|
} else {
|
||||||
|
// do nothing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const channels = await getChannels();
|
||||||
|
// get all products for each channel
|
||||||
|
await channels.data?.channels?.reduce(async (acc, channel) => {
|
||||||
|
await acc;
|
||||||
|
await getProducts(channel.slug, "");
|
||||||
|
}, Promise.resolve());
|
||||||
|
})();
|
||||||
|
}, [appBridgeState?.token, saleorApiUrl, paused]);
|
||||||
|
|
||||||
|
return products;
|
||||||
|
};
|
194
apps/search/src/lib/algolia/algoliaSearchProvider.ts
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
import Algoliasearch, { SearchClient } from "algoliasearch";
|
||||||
|
import {
|
||||||
|
ProductVariantWebhookPayloadFragment,
|
||||||
|
ProductWebhookPayloadFragment,
|
||||||
|
} from "../../../generated/graphql";
|
||||||
|
import { isNotNil } from "../isNotNil";
|
||||||
|
import { SearchProvider } from "../searchProvider";
|
||||||
|
import { createDebug } from "../debug";
|
||||||
|
import {
|
||||||
|
AlgoliaObject,
|
||||||
|
channelListingToAlgoliaIndexId,
|
||||||
|
productAndVariantToAlgolia,
|
||||||
|
} from "./algoliaUtils";
|
||||||
|
|
||||||
|
export interface AlgoliaSearchProviderOptions {
|
||||||
|
appId: string;
|
||||||
|
apiKey: string;
|
||||||
|
indexNamePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debug = createDebug("AlgoliaSearchProvider");
|
||||||
|
|
||||||
|
export class AlgoliaSearchProvider implements SearchProvider {
|
||||||
|
#algolia: SearchClient;
|
||||||
|
#indexNamePrefix?: string | undefined;
|
||||||
|
|
||||||
|
constructor({ appId, apiKey, indexNamePrefix }: AlgoliaSearchProviderOptions) {
|
||||||
|
this.#algolia = Algoliasearch(appId, apiKey);
|
||||||
|
this.#indexNamePrefix = indexNamePrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveGroupedByIndex(groupedByIndex: GroupedByIndex) {
|
||||||
|
debug("saveGroupedByIndex called");
|
||||||
|
return Promise.all(
|
||||||
|
Object.entries(groupedByIndex).map(([indexName, objects]) => {
|
||||||
|
const index = this.#algolia.initIndex(indexName);
|
||||||
|
return index.saveObjects(objects);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteGroupedByIndex(groupedByIndex: GroupedByIndex) {
|
||||||
|
debug("deleteGroupedByIndex called");
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
Object.entries(groupedByIndex).map(([indexName, objects]) => {
|
||||||
|
const index = this.#algolia.initIndex(indexName);
|
||||||
|
return index.deleteObjects(objects.map((o) => o.objectID));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatedBatchProducts(productsBatch: ProductWebhookPayloadFragment[]) {
|
||||||
|
debug(`updatedBatchProducts called`);
|
||||||
|
|
||||||
|
const groupedByIndex = groupProductsByIndexName(productsBatch, {
|
||||||
|
visibleInListings: true,
|
||||||
|
indexNamePrefix: this.#indexNamePrefix,
|
||||||
|
});
|
||||||
|
await this.saveGroupedByIndex(groupedByIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProduct(product: ProductWebhookPayloadFragment) {
|
||||||
|
debug(`createProduct called`);
|
||||||
|
await this.updateProduct(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProduct(product: ProductWebhookPayloadFragment) {
|
||||||
|
debug(`updateProduct called`);
|
||||||
|
|
||||||
|
if (!product.variants) {
|
||||||
|
debug("Product has no variants - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(product.variants.map((variant) => this.updateProductVariant(variant)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProduct(product: ProductWebhookPayloadFragment) {
|
||||||
|
debug(`deleteProduct`);
|
||||||
|
if (!product.variants) {
|
||||||
|
debug("Product has no variants - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(product.variants.map((variant) => this.deleteProductVariant(variant)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProductVariant(productVariant: ProductVariantWebhookPayloadFragment) {
|
||||||
|
debug(`createProductVariant called`);
|
||||||
|
return this.updateProductVariant(productVariant);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProductVariant(productVariant: ProductVariantWebhookPayloadFragment) {
|
||||||
|
debug(`updateProductVariant called`);
|
||||||
|
|
||||||
|
if (!productVariant.product.channelListings) {
|
||||||
|
debug("Product has no channelListings - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedByIndexToSave = groupVariantByIndexName(productVariant, {
|
||||||
|
visibleInListings: true,
|
||||||
|
indexNamePrefix: this.#indexNamePrefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupedByIndexToSave && !!Object.keys(groupedByIndexToSave).length) {
|
||||||
|
await this.saveGroupedByIndex(groupedByIndexToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedByIndexToDelete = groupVariantByIndexName(productVariant, {
|
||||||
|
visibleInListings: false,
|
||||||
|
indexNamePrefix: this.#indexNamePrefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupedByIndexToDelete && !!Object.keys(groupedByIndexToDelete).length) {
|
||||||
|
await this.deleteGroupedByIndex(groupedByIndexToDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProductVariant(productVariant: ProductVariantWebhookPayloadFragment) {
|
||||||
|
debug(`deleteProductVariant called`);
|
||||||
|
|
||||||
|
if (!productVariant.product.channelListings) {
|
||||||
|
debug("No channel listing data - return");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedByIndexToDelete = groupVariantByIndexName(productVariant, {
|
||||||
|
visibleInListings: null,
|
||||||
|
indexNamePrefix: this.#indexNamePrefix,
|
||||||
|
});
|
||||||
|
if (groupedByIndexToDelete) {
|
||||||
|
await this.deleteGroupedByIndex(groupedByIndexToDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupedByIndex = Record<string, AlgoliaObject[]>;
|
||||||
|
|
||||||
|
const groupVariantByIndexName = (
|
||||||
|
productVariant: ProductVariantWebhookPayloadFragment,
|
||||||
|
{
|
||||||
|
visibleInListings,
|
||||||
|
indexNamePrefix,
|
||||||
|
}: { visibleInListings: true | false | null; indexNamePrefix: string | undefined },
|
||||||
|
) => {
|
||||||
|
if (!productVariant.product.channelListings) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectsToSaveByIndexName = productVariant.product.channelListings
|
||||||
|
.filter((channelListing) =>
|
||||||
|
// don't filter if `visibleInListings` is null
|
||||||
|
visibleInListings === null ? true : channelListing.visibleInListings === visibleInListings,
|
||||||
|
)
|
||||||
|
.map((channelListing) => {
|
||||||
|
const object = productAndVariantToAlgolia({
|
||||||
|
variant: productVariant,
|
||||||
|
channel: channelListing.channel.slug,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
object,
|
||||||
|
indexName: channelListingToAlgoliaIndexId(channelListing, indexNamePrefix),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.reduce((acc, { object, indexName }) => {
|
||||||
|
acc[indexName] = acc[indexName] ?? [];
|
||||||
|
acc[indexName].push(object);
|
||||||
|
return acc;
|
||||||
|
}, {} as GroupedByIndex);
|
||||||
|
|
||||||
|
return objectsToSaveByIndexName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupProductsByIndexName = (
|
||||||
|
productsBatch: ProductWebhookPayloadFragment[],
|
||||||
|
{
|
||||||
|
visibleInListings,
|
||||||
|
indexNamePrefix,
|
||||||
|
}: { visibleInListings: true | false | null; indexNamePrefix: string | undefined },
|
||||||
|
) => {
|
||||||
|
debug(`groupProductsByIndexName called`);
|
||||||
|
const batchesAndIndices = productsBatch
|
||||||
|
.flatMap((p) => p.variants)
|
||||||
|
.filter(isNotNil)
|
||||||
|
.map((p) => groupVariantByIndexName(p, { visibleInListings, indexNamePrefix }))
|
||||||
|
.filter(isNotNil)
|
||||||
|
.flatMap((x) => Object.entries(x));
|
||||||
|
const groupedByIndex = batchesAndIndices.reduce((acc, [indexName, objects]) => {
|
||||||
|
acc[indexName] = acc[indexName] ?? [];
|
||||||
|
acc[indexName].push(...objects);
|
||||||
|
return acc;
|
||||||
|
}, {} as GroupedByIndex);
|
||||||
|
return groupedByIndex;
|
||||||
|
};
|
101
apps/search/src/lib/algolia/algoliaUtils.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import { ProductVariantWebhookPayloadFragment } from "../../../generated/graphql";
|
||||||
|
import { isNotNil } from "../isNotNil";
|
||||||
|
|
||||||
|
type PartialChannelListing = {
|
||||||
|
channel: {
|
||||||
|
slug: string;
|
||||||
|
currencyCode: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function channelListingToAlgoliaIndexId(
|
||||||
|
channelListing: PartialChannelListing,
|
||||||
|
indexNamePrefix: string | undefined,
|
||||||
|
) {
|
||||||
|
const nameSegments = [
|
||||||
|
indexNamePrefix,
|
||||||
|
channelListing.channel.slug,
|
||||||
|
channelListing.channel.currencyCode,
|
||||||
|
"products",
|
||||||
|
];
|
||||||
|
return nameSegments.filter(isNotNil).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces category tree in the format expected by hierarchical Algolia widgets, for example:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "lvl0": "Root Category",
|
||||||
|
* "lvl1": "Root Category > Subcategory"
|
||||||
|
* "lvl2": "Root Category > Subcategory > Sub-subcategory"
|
||||||
|
* }
|
||||||
|
* https://www.algolia.com/doc/guides/managing-results/refine-results/faceting/#hierarchical-facets
|
||||||
|
*/
|
||||||
|
export function categoryHierarchicalFacets({ product }: ProductVariantWebhookPayloadFragment) {
|
||||||
|
const categoryParents = [
|
||||||
|
product.category?.parent?.parent?.parent?.parent?.name,
|
||||||
|
product.category?.parent?.parent?.parent?.name,
|
||||||
|
product.category?.parent?.parent?.name,
|
||||||
|
product.category?.parent?.name,
|
||||||
|
product.category?.name,
|
||||||
|
].filter((category) => category?.length);
|
||||||
|
|
||||||
|
const categoryLvlMapping: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < categoryParents.length; i += 1) {
|
||||||
|
categoryLvlMapping[`lvl${i}`] = categoryParents.slice(0, i + 1).join(" > ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryLvlMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMetadata({ product }: ProductVariantWebhookPayloadFragment) {
|
||||||
|
return Object.fromEntries(product.metadata?.map(({ key, value }) => [key, value]) || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlgoliaObject = ReturnType<typeof productAndVariantToAlgolia>;
|
||||||
|
|
||||||
|
export function productAndVariantToAlgolia({
|
||||||
|
variant,
|
||||||
|
channel,
|
||||||
|
}: {
|
||||||
|
variant: ProductVariantWebhookPayloadFragment;
|
||||||
|
channel: string;
|
||||||
|
}) {
|
||||||
|
const product = variant.product;
|
||||||
|
const attributes = {
|
||||||
|
...product.attributes.reduce((acc, attr, idx) => {
|
||||||
|
return { ...acc, [attr.attribute.name ?? ""]: attr.values[idx]?.name ?? "" };
|
||||||
|
}, {}),
|
||||||
|
...variant.attributes.reduce((acc, attr, idx) => {
|
||||||
|
return { ...acc, [attr.attribute.name ?? ""]: attr.values[idx]?.name ?? "" };
|
||||||
|
}, {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const listing = variant.channelListings?.find((l) => l.channel.slug === channel);
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
objectID: productAndVariantToObjectID(variant),
|
||||||
|
productId: product.id,
|
||||||
|
variantId: variant.id,
|
||||||
|
name: `${product.name} - ${variant.name}`,
|
||||||
|
productName: product.name,
|
||||||
|
variantName: variant.name,
|
||||||
|
attributes,
|
||||||
|
description: product.description,
|
||||||
|
slug: product.slug,
|
||||||
|
thumbnail: product.thumbnail?.url,
|
||||||
|
grossPrice: listing?.price?.amount,
|
||||||
|
categories: categoryHierarchicalFacets(variant),
|
||||||
|
collections: product.collections?.map((collection) => collection.name) || [],
|
||||||
|
metadata: formatMetadata(variant),
|
||||||
|
};
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productAndVariantToObjectID({
|
||||||
|
product,
|
||||||
|
...variant
|
||||||
|
}: ProductVariantWebhookPayloadFragment) {
|
||||||
|
return `${product.id}_${variant.id}`;
|
||||||
|
}
|
6
apps/search/src/lib/algolia/types.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export type AlgoliaConfigurationFields = {
|
||||||
|
appId?: string;
|
||||||
|
secretKey?: string;
|
||||||
|
searchKey?: string;
|
||||||
|
indexNamePrefix?: string;
|
||||||
|
};
|
20
apps/search/src/lib/configuration.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { AlgoliaConfigurationFields } from "./algolia/types";
|
||||||
|
|
||||||
|
export const fetchConfiguration = async (saleorApiUrl: string, token: string) => {
|
||||||
|
const res = await fetch("/api/configuration", {
|
||||||
|
headers: {
|
||||||
|
"authorization-bearer": token,
|
||||||
|
"saleor-api-url": saleorApiUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { data?: AlgoliaConfigurationFields };
|
||||||
|
return data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfiguration = (saleorApiUrl?: string | undefined, token?: string | undefined) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["configuration"],
|
||||||
|
queryFn: () => fetchConfiguration(saleorApiUrl!, token!),
|
||||||
|
enabled: !!token && !!saleorApiUrl,
|
||||||
|
});
|
3
apps/search/src/lib/debug.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import debugPkg from "debug";
|
||||||
|
|
||||||
|
export const createDebug = (namespace: string) => debugPkg.debug(`app-search:${namespace}`);
|
62
apps/search/src/lib/graphql.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { initUrqlClient } from "next-urql";
|
||||||
|
import { AuthConfig, authExchange } from "@urql/exchange-auth";
|
||||||
|
import {
|
||||||
|
cacheExchange,
|
||||||
|
createClient as urqlCreateClient,
|
||||||
|
dedupExchange,
|
||||||
|
fetchExchange,
|
||||||
|
ssrExchange,
|
||||||
|
} from "urql";
|
||||||
|
|
||||||
|
interface IAuthState {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExchanges = (getAuth: AuthConfig<IAuthState>["getAuth"]) => [
|
||||||
|
dedupExchange,
|
||||||
|
cacheExchange,
|
||||||
|
authExchange<IAuthState>({
|
||||||
|
addAuthToOperation: ({ authState, operation }) => {
|
||||||
|
if (!authState || !authState?.token) {
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions =
|
||||||
|
typeof operation.context.fetchOptions === "function"
|
||||||
|
? operation.context.fetchOptions()
|
||||||
|
: operation.context.fetchOptions || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...operation,
|
||||||
|
context: {
|
||||||
|
...operation.context,
|
||||||
|
fetchOptions: {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: {
|
||||||
|
...fetchOptions.headers,
|
||||||
|
"Authorization-Bearer": authState.token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getAuth,
|
||||||
|
}),
|
||||||
|
fetchExchange,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const nextClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) => {
|
||||||
|
return initUrqlClient(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
exchanges: getExchanges(getAuth),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||||
|
urqlCreateClient({
|
||||||
|
url,
|
||||||
|
exchanges: getExchanges(getAuth),
|
||||||
|
});
|
7
apps/search/src/lib/is-in-iframe.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export function isInIframe() {
|
||||||
|
try {
|
||||||
|
return window.self !== window.top;
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
1
apps/search/src/lib/isNotNil.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const isNotNil = <T>(x: T | null | undefined): x is T => x !== undefined && x !== null;
|
81
apps/search/src/lib/metadata.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { Client } from "urql";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FetchAppDetailsDocument,
|
||||||
|
FetchAppDetailsQuery,
|
||||||
|
UpdateAppMetadataDocument,
|
||||||
|
} from "../../generated/graphql";
|
||||||
|
import { settingsManagerSecretKey } from "../../saleor-app";
|
||||||
|
|
||||||
|
// Function is using urql graphql client to fetch all available metadata.
|
||||||
|
// Before returning query result, we are transforming response to list of objects with key and value fields
|
||||||
|
// which can be used by the manager.
|
||||||
|
// Result of this query is cached by the manager.
|
||||||
|
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||||
|
const { error, data } = await client
|
||||||
|
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.debug("Error during fetching the metadata: ", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutate function takes urql client and metadata entries, and construct mutation to the API.
|
||||||
|
// Before data are send, additional query for required App ID is made.
|
||||||
|
// The manager will use updated entries returned by this mutation to update it's cache.
|
||||||
|
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) {
|
||||||
|
console.debug("Could not fetch the app id: ", 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) {
|
||||||
|
console.debug("Missing app id");
|
||||||
|
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) {
|
||||||
|
console.debug("Mutation error: ", 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: settingsManagerSecretKey,
|
||||||
|
fetchMetadata: () => fetchAllMetadata(client),
|
||||||
|
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
|
||||||
|
});
|
||||||
|
};
|
8
apps/search/src/lib/no-ssr-wrapper.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import React, { PropsWithChildren } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
|
||||||
|
|
||||||
|
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
15
apps/search/src/lib/searchProvider.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import {
|
||||||
|
ProductWebhookPayloadFragment,
|
||||||
|
Product,
|
||||||
|
ProductVariantWebhookPayloadFragment,
|
||||||
|
ProductVariant,
|
||||||
|
} from "../../generated/graphql";
|
||||||
|
|
||||||
|
export interface SearchProvider {
|
||||||
|
createProduct(product: ProductWebhookPayloadFragment): Promise<void>;
|
||||||
|
updateProduct(product: ProductWebhookPayloadFragment): Promise<void>;
|
||||||
|
deleteProduct(productId: ProductWebhookPayloadFragment): Promise<void>;
|
||||||
|
createProductVariant(productVariant: ProductVariantWebhookPayloadFragment): Promise<void>;
|
||||||
|
updateProductVariant(productVariant: ProductVariantWebhookPayloadFragment): Promise<void>;
|
||||||
|
deleteProductVariant(productId: ProductVariantWebhookPayloadFragment): Promise<void>;
|
||||||
|
}
|
28
apps/search/src/lib/theme-synchronizer.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { useTheme } from "@saleor/macaw-ui";
|
||||||
|
import { memo, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
||||||
|
* Macaw must be informed about this change from AppBridge.
|
||||||
|
*
|
||||||
|
* If you are not using Macaw, you can remove this.
|
||||||
|
*/
|
||||||
|
function _ThemeSynchronizer() {
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
const { setTheme, themeType } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setTheme || !appBridgeState?.theme) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeType !== appBridgeState?.theme) {
|
||||||
|
setTheme(appBridgeState.theme);
|
||||||
|
}
|
||||||
|
}, [appBridgeState?.theme, setTheme, themeType]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
69
apps/search/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import "../styles/globals.css";
|
||||||
|
|
||||||
|
import { Theme } from "@material-ui/core/styles";
|
||||||
|
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { ThemeProvider as MacawUIThemeProvider } from "@saleor/macaw-ui";
|
||||||
|
import React, { PropsWithChildren, useEffect } from "react";
|
||||||
|
import { AppProps } from "next/app";
|
||||||
|
import GraphQLProvider from "../providers/GraphQLProvider";
|
||||||
|
import { QueryClient, QueryClientProvider } from "react-query";
|
||||||
|
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||||
|
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
|
||||||
|
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
|
||||||
|
|
||||||
|
const themeOverrides: Partial<Theme> = {
|
||||||
|
/**
|
||||||
|
* You can override MacawUI theme here
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure instance is a singleton.
|
||||||
|
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||||
|
*/
|
||||||
|
const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* That's a hack required by Macaw-UI incompatibility with React@18
|
||||||
|
*/
|
||||||
|
const ThemeProvider = MacawUIThemeProvider as React.FC<
|
||||||
|
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean }>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function NextApp({ Component, pageProps }: AppProps) {
|
||||||
|
/**
|
||||||
|
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const jssStyles = document.querySelector("#jss-server-side");
|
||||||
|
if (jssStyles) {
|
||||||
|
jssStyles?.parentElement?.removeChild(jssStyles);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoSSRWrapper>
|
||||||
|
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||||
|
<GraphQLProvider>
|
||||||
|
<ThemeProvider overrides={themeOverrides} ssr>
|
||||||
|
<ThemeSynchronizer />
|
||||||
|
<RoutePropagator />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</GraphQLProvider>
|
||||||
|
</AppBridgeProvider>
|
||||||
|
</NoSSRWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NextApp;
|
39
apps/search/src/pages/_error.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
|
||||||
|
*
|
||||||
|
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
|
||||||
|
* penultimate line in `CustomErrorComponent`.
|
||||||
|
*
|
||||||
|
* This page is loaded by Nextjs:
|
||||||
|
* - on the server, when data-fetching methods throw or reject
|
||||||
|
* - on the client, when `getInitialProps` throws or rejects
|
||||||
|
* - on the client, when a React lifecycle method throws or rejects, and it's
|
||||||
|
* caught by the built-in Nextjs error boundary
|
||||||
|
*
|
||||||
|
* See:
|
||||||
|
* - https://nextjs.org/docs/basic-features/data-fetching/overview
|
||||||
|
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
|
||||||
|
* - https://reactjs.org/docs/error-boundaries.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import NextErrorComponent from 'next/error';
|
||||||
|
|
||||||
|
const CustomErrorComponent = props => {
|
||||||
|
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
|
||||||
|
// compensate for https://github.com/vercel/next.js/issues/8592
|
||||||
|
// Sentry.captureUnderscoreErrorException(props);
|
||||||
|
|
||||||
|
return <NextErrorComponent statusCode={props.statusCode} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomErrorComponent.getInitialProps = async contextData => {
|
||||||
|
// In case this is running in a serverless function, await this in order to give Sentry
|
||||||
|
// time to send the error before the lambda exits
|
||||||
|
await Sentry.captureUnderscoreErrorException(contextData);
|
||||||
|
|
||||||
|
// This will contain the status code of the response
|
||||||
|
return NextErrorComponent.getInitialProps(contextData);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomErrorComponent;
|
72
apps/search/src/pages/api/configuration.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
|
||||||
|
import { createClient } from "../../lib/graphql";
|
||||||
|
import { createSettingsManager } from "../../lib/metadata";
|
||||||
|
import { saleorApp } from "../../../saleor-app";
|
||||||
|
import { AlgoliaConfigurationFields } from "../../lib/algolia/types";
|
||||||
|
import { createDebug } from "../../lib/debug";
|
||||||
|
|
||||||
|
import { createProtectedHandler, ProtectedHandlerContext } from "@saleor/app-sdk/handlers/next";
|
||||||
|
|
||||||
|
const debug = createDebug("/api/configuration");
|
||||||
|
|
||||||
|
export interface SettingsApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: AlgoliaConfigurationFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponse = async (
|
||||||
|
res: NextApiResponse<SettingsApiResponse>,
|
||||||
|
statusCode: number,
|
||||||
|
settings: SettingsManager,
|
||||||
|
domain: string,
|
||||||
|
) => {
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: statusCode === 200,
|
||||||
|
data: {
|
||||||
|
secretKey: (await settings.get("secretKey", domain)) || "",
|
||||||
|
searchKey: (await settings.get("searchKey", domain)) || "",
|
||||||
|
appId: (await settings.get("appId", domain)) || "",
|
||||||
|
indexNamePrefix: (await settings.get("indexNamePrefix", domain)) || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handler = async (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse,
|
||||||
|
ctx: ProtectedHandlerContext,
|
||||||
|
) => {
|
||||||
|
debug("Configuration handler received request");
|
||||||
|
|
||||||
|
const {
|
||||||
|
authData: { domain, token, saleorApiUrl },
|
||||||
|
} = ctx;
|
||||||
|
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token: token }));
|
||||||
|
|
||||||
|
const settings = createSettingsManager(client);
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
debug("Returning configuration");
|
||||||
|
await sendResponse(res, 200, settings, domain);
|
||||||
|
return;
|
||||||
|
} else if (req.method === "POST") {
|
||||||
|
debug("Updating the configuration");
|
||||||
|
const { appId, searchKey, secretKey, indexNamePrefix } = JSON.parse(
|
||||||
|
req.body,
|
||||||
|
) as AlgoliaConfigurationFields;
|
||||||
|
await settings.set([
|
||||||
|
{ key: "secretKey", value: secretKey || "", domain },
|
||||||
|
{ key: "searchKey", value: searchKey || "", domain },
|
||||||
|
{ key: "appId", value: appId || "", domain },
|
||||||
|
{ key: "indexNamePrefix", value: indexNamePrefix || "", domain },
|
||||||
|
]);
|
||||||
|
await sendResponse(res, 200, settings, domain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug("Method not supported");
|
||||||
|
res.status(405).end();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS", "MANAGE_PRODUCTS"]);
|
51
apps/search/src/pages/api/manifest.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { AppManifest } from "@saleor/app-sdk/types";
|
||||||
|
|
||||||
|
import packageJson from "../../../package.json";
|
||||||
|
import { webhookProductCreated } from "./webhooks/saleor/product_created";
|
||||||
|
import { webhookProductDeleted } from "./webhooks/saleor/product_deleted";
|
||||||
|
import { webhookProductUpdated } from "./webhooks/saleor/product_updated";
|
||||||
|
import { webhookProductVariantCreated } from "./webhooks/saleor/product_variant_created";
|
||||||
|
import { webhookProductVariantDeleted } from "./webhooks/saleor/product_variant_deleted";
|
||||||
|
import { webhookProductVariantUpdated } from "./webhooks/saleor/product_variant_updated";
|
||||||
|
|
||||||
|
export default createManifestHandler({
|
||||||
|
async manifestFactory(context) {
|
||||||
|
const manifest: AppManifest = {
|
||||||
|
name: packageJson.name,
|
||||||
|
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
|
||||||
|
appUrl: context.appBaseUrl,
|
||||||
|
permissions: [
|
||||||
|
/**
|
||||||
|
* Set permissions for app if needed
|
||||||
|
* https://docs.saleor.io/docs/3.x/developer/permissions
|
||||||
|
*/
|
||||||
|
"MANAGE_PRODUCTS",
|
||||||
|
"MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES",
|
||||||
|
],
|
||||||
|
id: packageJson.name,
|
||||||
|
version: packageJson.version,
|
||||||
|
webhooks: [
|
||||||
|
/**
|
||||||
|
* Configure webhooks here. They will be created in Saleor during installation
|
||||||
|
* Read more
|
||||||
|
* https://docs.saleor.io/docs/3.x/developer/api-reference/objects/webhook
|
||||||
|
*/
|
||||||
|
webhookProductCreated.getWebhookManifest(context.appBaseUrl),
|
||||||
|
webhookProductDeleted.getWebhookManifest(context.appBaseUrl),
|
||||||
|
webhookProductUpdated.getWebhookManifest(context.appBaseUrl),
|
||||||
|
webhookProductVariantCreated.getWebhookManifest(context.appBaseUrl),
|
||||||
|
webhookProductVariantDeleted.getWebhookManifest(context.appBaseUrl),
|
||||||
|
webhookProductVariantUpdated.getWebhookManifest(context.appBaseUrl),
|
||||||
|
],
|
||||||
|
extensions: [
|
||||||
|
/**
|
||||||
|
* Optionally, extend Dashboard with custom UIs
|
||||||
|
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
|
||||||
|
*/
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
},
|
||||||
|
});
|
4
apps/search/src/pages/api/register.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { saleorApp } from "../../../saleor-app";
|
||||||
|
|
||||||
|
export default createAppRegisterHandler(saleorApp);
|
102
apps/search/src/pages/api/webhooks/saleor/_index.ts
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { ProductEditedSubscription } from "../../../../../generated/graphql";
|
||||||
|
import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider";
|
||||||
|
import { createDebug } from "../../../../lib/debug";
|
||||||
|
import { createClient } from "../../../../lib/graphql";
|
||||||
|
import { createSettingsManager } from "../../../../lib/metadata";
|
||||||
|
import { AlgoliaConfigurationFields } from "../../../../lib/algolia/types";
|
||||||
|
|
||||||
|
const debug = createDebug("Webhooks handler");
|
||||||
|
|
||||||
|
export const handler: NextWebhookApiHandler<ProductEditedSubscription["event"]> = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
|
const { event, authData } = context;
|
||||||
|
debug(
|
||||||
|
`New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = createClient(authData.saleorApiUrl, async () =>
|
||||||
|
Promise.resolve({ token: authData.token }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = createSettingsManager(client);
|
||||||
|
|
||||||
|
const algoliaConfiguration: AlgoliaConfigurationFields = {
|
||||||
|
secretKey: (await settings.get("secretKey", authData.domain)) || "",
|
||||||
|
appId: (await settings.get("appId", authData.domain)) || "",
|
||||||
|
indexNamePrefix: (await settings.get("indexNamePrefix", authData.domain)) || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!algoliaConfiguration?.appId) {
|
||||||
|
debug("Missing AppID configuration - returning error response");
|
||||||
|
return res.status(500).json({
|
||||||
|
message: `Missing 'appId'`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!algoliaConfiguration.secretKey) {
|
||||||
|
debug("Missing SecretKey configuration - returning error response");
|
||||||
|
return res.status(500).json({
|
||||||
|
message: `Missing 'secretKey'`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchProvider = new AlgoliaSearchProvider({
|
||||||
|
appId: algoliaConfiguration.appId,
|
||||||
|
apiKey: algoliaConfiguration.secretKey,
|
||||||
|
indexNamePrefix: algoliaConfiguration.indexNamePrefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (context.payload?.__typename) {
|
||||||
|
case "ProductCreated": {
|
||||||
|
const { product } = context.payload;
|
||||||
|
if (product) {
|
||||||
|
await searchProvider.createProduct(product);
|
||||||
|
}
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "ProductUpdated": {
|
||||||
|
const { product } = context.payload;
|
||||||
|
if (product) {
|
||||||
|
await searchProvider.updateProduct(product);
|
||||||
|
}
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "ProductDeleted": {
|
||||||
|
const { product } = context.payload;
|
||||||
|
if (product) {
|
||||||
|
await searchProvider.deleteProduct(product);
|
||||||
|
}
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "ProductVariantCreated": {
|
||||||
|
const { productVariant } = context.payload;
|
||||||
|
if (productVariant) {
|
||||||
|
await searchProvider.createProductVariant(productVariant);
|
||||||
|
}
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "ProductVariantUpdated": {
|
||||||
|
const { productVariant } = context.payload;
|
||||||
|
if (productVariant) {
|
||||||
|
await searchProvider.updateProductVariant(productVariant);
|
||||||
|
}
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "ProductVariantDeleted": {
|
||||||
|
const { productVariant } = context.payload;
|
||||||
|
if (productVariant) {
|
||||||
|
await searchProvider.deleteProductVariant(productVariant);
|
||||||
|
}
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
19
apps/search/src/pages/api/webhooks/saleor/product_created.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { ProductEditedDocument, ProductEditedSubscription } from "../../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../../saleor-app";
|
||||||
|
import { handler } from "./_index";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhookProductCreated = new SaleorAsyncWebhook<ProductEditedSubscription["event"]>({
|
||||||
|
webhookPath: "api/webhooks/saleor/product_created",
|
||||||
|
asyncEvent: "PRODUCT_CREATED",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
subscriptionQueryAst: ProductEditedDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default webhookProductCreated.createHandler(handler);
|
19
apps/search/src/pages/api/webhooks/saleor/product_deleted.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { ProductEditedDocument, ProductEditedSubscription } from "../../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../../saleor-app";
|
||||||
|
import { handler } from "./_index";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhookProductDeleted = new SaleorAsyncWebhook<ProductEditedSubscription["event"]>({
|
||||||
|
webhookPath: "api/webhooks/saleor/product_deleted",
|
||||||
|
asyncEvent: "PRODUCT_DELETED",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
subscriptionQueryAst: ProductEditedDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default webhookProductDeleted.createHandler(handler);
|
19
apps/search/src/pages/api/webhooks/saleor/product_updated.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { ProductEditedDocument, ProductEditedSubscription } from "../../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../../saleor-app";
|
||||||
|
import { handler } from "./_index";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhookProductUpdated = new SaleorAsyncWebhook<ProductEditedSubscription["event"]>({
|
||||||
|
webhookPath: "api/webhooks/saleor/product_updated",
|
||||||
|
asyncEvent: "PRODUCT_UPDATED",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
subscriptionQueryAst: ProductEditedDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default webhookProductUpdated.createHandler(handler);
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { ProductEditedDocument, ProductEditedSubscription } from "../../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../../saleor-app";
|
||||||
|
import { handler } from "./_index";
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhookProductVariantCreated = new SaleorAsyncWebhook<
|
||||||
|
ProductEditedSubscription["event"]
|
||||||
|
>({
|
||||||
|
webhookPath: "api/webhooks/saleor/product_variant_created",
|
||||||
|
asyncEvent: "PRODUCT_VARIANT_CREATED",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
subscriptionQueryAst: ProductEditedDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default webhookProductVariantCreated.createHandler(handler);
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { ProductEditedDocument, ProductEditedSubscription } from "../../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../../saleor-app";
|
||||||
|
import { handler } from "./_index";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhookProductVariantDeleted = new SaleorAsyncWebhook<
|
||||||
|
ProductEditedSubscription["event"]
|
||||||
|
>({
|
||||||
|
webhookPath: "api/webhooks/saleor/product_variant_deleted",
|
||||||
|
asyncEvent: "PRODUCT_VARIANT_DELETED",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
subscriptionQueryAst: ProductEditedDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default webhookProductVariantDeleted.createHandler(handler);
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { ProductEditedDocument, ProductEditedSubscription } from "../../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../../saleor-app";
|
||||||
|
import { handler } from "./_index";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhookProductVariantUpdated = new SaleorAsyncWebhook<
|
||||||
|
ProductEditedSubscription["event"]
|
||||||
|
>({
|
||||||
|
webhookPath: "api/webhooks/saleor/product_variant_updated",
|
||||||
|
asyncEvent: "PRODUCT_VARIANT_UPDATED",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
subscriptionQueryAst: ProductEditedDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default webhookProductVariantUpdated.createHandler(handler);
|
21
apps/search/src/pages/index.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import ConfigurationView from "../components/ConfigurationView";
|
||||||
|
import { isInIframe } from "../lib/is-in-iframe";
|
||||||
|
import { LinearProgress } from "@material-ui/core";
|
||||||
|
|
||||||
|
const IndexPage = () => {
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
|
||||||
|
if (isInIframe() && !appBridgeState?.token) {
|
||||||
|
return <LinearProgress />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ConfigurationView />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withAuthorization({
|
||||||
|
notIframe: <div>App can be used in Saleor Dashboard only</div>,
|
||||||
|
unmounted: null,
|
||||||
|
noDashboardToken: <div>Error authorizing with Saleor Dashboard</div>,
|
||||||
|
dashboardTokenInvalid: <div>Error authorizing with Saleor Dashboard</div>,
|
||||||
|
})(IndexPage);
|
132
apps/search/src/pages/search/index.tsx
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import algoliasearch from "algoliasearch";
|
||||||
|
import {
|
||||||
|
InstantSearch,
|
||||||
|
Pagination,
|
||||||
|
Breadcrumb,
|
||||||
|
HierarchicalMenu,
|
||||||
|
} from "react-instantsearch-hooks-web";
|
||||||
|
import { Card, CardContent, CardHeader, Typography } from "@material-ui/core";
|
||||||
|
import { RangeInput } from "react-instantsearch-hooks-web";
|
||||||
|
import "instantsearch.css/themes/reset.css";
|
||||||
|
import "instantsearch.css/themes/satellite.css";
|
||||||
|
import styles from "../../styles/search.module.css";
|
||||||
|
|
||||||
|
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
|
||||||
|
import { SearchBox } from "../../components/SearchBox";
|
||||||
|
import { Hits } from "../../components/Hits";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { useConfiguration } from "../../lib/configuration";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Select, MenuItem } from "@material-ui/core";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { SearchAppMainBar } from "../../components/SearchAppMainBar";
|
||||||
|
import { AppColumnsLayout } from "../../components/AppColumnsLayout";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
wrapper: {
|
||||||
|
border: `1px solid ${theme.palette.grey.A100}`,
|
||||||
|
minHeight: "calc(100vh - 100px)",
|
||||||
|
paddingBottom: 50,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Search() {
|
||||||
|
const classes = useStyles();
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
const [indexName, setIndexName] = useState<string>();
|
||||||
|
const algoliaConfiguration = useConfiguration(
|
||||||
|
appBridgeState?.saleorApiUrl,
|
||||||
|
appBridgeState?.token,
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchClient = useMemo(() => {
|
||||||
|
if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return algoliasearch(algoliaConfiguration.data.appId, algoliaConfiguration.data.secretKey);
|
||||||
|
}, [algoliaConfiguration?.data?.appId, algoliaConfiguration?.data?.secretKey]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const handleClick = (val: string) => router.push("/" + val);
|
||||||
|
|
||||||
|
const indicesQuery = useQuery({
|
||||||
|
queryKey: ["indices"],
|
||||||
|
queryFn: () => searchClient?.listIndices(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// auto select the first fetched index to display its contents
|
||||||
|
if (data?.items?.length) {
|
||||||
|
setIndexName(data.items[0].name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!searchClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableIndices = indicesQuery.data?.items.map((index) => index.name) || [];
|
||||||
|
|
||||||
|
const displayInterface = !!searchClient && indexName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
<SearchAppMainBar />
|
||||||
|
<PageTabs className={styles.tabs} value="search" onChange={handleClick}>
|
||||||
|
<PageTab label={"Configuration"} value="" />
|
||||||
|
<PageTab label={"Preview"} value="search" />
|
||||||
|
</PageTabs>
|
||||||
|
|
||||||
|
{displayInterface ? (
|
||||||
|
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
||||||
|
<AppColumnsLayout variant="1:2">
|
||||||
|
<div className={styles.filterGrid}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={"Index"} />
|
||||||
|
<CardContent>
|
||||||
|
<Select
|
||||||
|
labelId="index-select-label"
|
||||||
|
id="index-select"
|
||||||
|
value={indexName}
|
||||||
|
onChange={(event) => setIndexName(event.target.value as string)}
|
||||||
|
>
|
||||||
|
{availableIndices.map((name) => (
|
||||||
|
<MenuItem key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={"Categories"} />
|
||||||
|
<CardContent>
|
||||||
|
<HierarchicalMenu
|
||||||
|
attributes={["categories.lvl0", "categories.lvl1", "categories.lvl1"]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={"Filters"} />
|
||||||
|
<CardContent>
|
||||||
|
<RangeInput attribute="grossPrice" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className={styles.contentWrapper}>
|
||||||
|
<Breadcrumb attributes={["categories.lvl0", "categories.lvl1", "categories.lvl2"]} />
|
||||||
|
<SearchBox />
|
||||||
|
<Hits />
|
||||||
|
<Pagination />
|
||||||
|
</div>
|
||||||
|
</AppColumnsLayout>
|
||||||
|
</InstantSearch>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="To preview indexes, ensure app is configured" />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Search;
|
22
apps/search/src/providers/GraphQLProvider.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { Provider } from "urql";
|
||||||
|
|
||||||
|
import { createClient } from "../lib/graphql";
|
||||||
|
|
||||||
|
function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
|
||||||
|
|
||||||
|
if (!appBridgeState?.saleorApiUrl) {
|
||||||
|
return <div {...props}></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(saleorApiUrl, async () =>
|
||||||
|
Promise.resolve({ token: appBridgeState?.token! }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Provider value={client} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GraphQLProvider;
|
BIN
apps/search/src/public/favicon.ico
Normal file
After Width: | Height: | Size: 25 KiB |
4
apps/search/src/public/vercel.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
17
apps/search/src/styles/globals.css
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
body {
|
||||||
|
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||||
|
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
|
color: #111;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #f6f8fa;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0.1em 0.2em;
|
||||||
|
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||||
|
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||||
|
}
|
50
apps/search/src/styles/search.module.css
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
.wrapper {
|
||||||
|
display: grid;
|
||||||
|
height: 60px;
|
||||||
|
width: 100%;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-template-columns: 60px auto;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitsWrapper {
|
||||||
|
display: grid;
|
||||||
|
gap: 15px;
|
||||||
|
|
||||||
|
}
|
||||||
|
.textField {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.textFieldContainer {
|
||||||
|
display: flex;
|
||||||
|
place-content: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4fr;
|
||||||
|
grid-gap: 2rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallbackThumbnail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
margin-left: 32px;
|
||||||
|
}
|
20
apps/search/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
1329
pnpm-lock.yaml
11
turbo.json
|
@ -21,6 +21,17 @@
|
||||||
"ALLOWED_DOMAIN_PATTERN"
|
"ALLOWED_DOMAIN_PATTERN"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"build#saleor-app-search": {
|
||||||
|
"env": [
|
||||||
|
"APL",
|
||||||
|
"APP_DEBUG",
|
||||||
|
"NODE_ENV",
|
||||||
|
"SECRET_KEY",
|
||||||
|
"ALLOWED_DOMAIN_PATTERN",
|
||||||
|
"REST_APL_ENDPOINT",
|
||||||
|
"REST_APL_TOKEN"
|
||||||
|
]
|
||||||
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
|