chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
import { storefrontProductSchema, collectionSchema, cartSchema, pageInfoSchema } from './schemas'
|
||||
|
||||
export const actions = {
|
||||
searchProducts: {
|
||||
title: 'Search Products',
|
||||
description: 'Search public-facing products via the Shopify Storefront API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
query: z.string().title('Search Query').describe('Search term to find products'),
|
||||
first: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(250)
|
||||
.default(50)
|
||||
.optional()
|
||||
.title('Limit')
|
||||
.describe('Number of products to return'),
|
||||
after: z.string().optional().title('After Cursor').describe('Cursor for pagination'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
products: z.array(storefrontProductSchema).title('Products').describe('List of matching products'),
|
||||
pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
getProduct: {
|
||||
title: 'Get Product',
|
||||
description: 'Get a single product from the Shopify Storefront API by handle or ID',
|
||||
input: {
|
||||
schema: z.object({
|
||||
handle: z.string().optional().title('Handle').describe('The URL-friendly handle of the product'),
|
||||
productId: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Product ID')
|
||||
.describe('The Storefront GID of the product (e.g. gid://shopify/Product/12345)'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
product: storefrontProductSchema.title('Product').describe('The product details'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
listCollections: {
|
||||
title: 'List Collections',
|
||||
description: 'List collections from the Shopify Storefront API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
first: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(250)
|
||||
.default(50)
|
||||
.optional()
|
||||
.title('Limit')
|
||||
.describe('Number of collections to return'),
|
||||
after: z.string().optional().title('After Cursor').describe('Cursor for pagination'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
collections: z.array(collectionSchema).title('Collections').describe('List of collections'),
|
||||
pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
getCollection: {
|
||||
title: 'Get Collection',
|
||||
description: 'Get a single collection with its products from the Shopify Storefront API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
handle: z.string().optional().title('Handle').describe('The URL-friendly handle of the collection'),
|
||||
collectionId: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Collection ID')
|
||||
.describe('The Storefront GID of the collection (e.g. gid://shopify/Collection/12345)'),
|
||||
productsFirst: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(250)
|
||||
.default(50)
|
||||
.optional()
|
||||
.title('Products Limit')
|
||||
.describe('Number of products to include'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
collection: collectionSchema.title('Collection').describe('The collection details'),
|
||||
products: z.array(storefrontProductSchema).title('Products').describe('Products in the collection'),
|
||||
pageInfo: pageInfoSchema.title('Page Info').describe('Pagination info for products'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
createCart: {
|
||||
title: 'Create Cart',
|
||||
description: 'Create a new cart via the Shopify Storefront API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
lines: z
|
||||
.array(
|
||||
z.object({
|
||||
merchandiseId: z.string().title('Merchandise ID').describe('The Storefront GID of the product variant'),
|
||||
quantity: z.number().min(1).title('Quantity').describe('The quantity to add'),
|
||||
})
|
||||
)
|
||||
.title('Lines')
|
||||
.describe('Cart line items to add'),
|
||||
buyerEmail: z.string().optional().title('Buyer Email').describe('Email of the buyer'),
|
||||
countryCode: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Country Code')
|
||||
.describe('ISO 3166-1 alpha-2 country code for the buyer'),
|
||||
discountCodes: z.array(z.string()).optional().title('Discount Codes').describe('Discount codes to apply'),
|
||||
note: z.string().optional().title('Note').describe('A note for the cart'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
cart: cartSchema.title('Cart').describe('The created cart'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
getCart: {
|
||||
title: 'Get Cart',
|
||||
description: 'Retrieve a cart by ID from the Shopify Storefront API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
cart: cartSchema.title('Cart').describe('The cart details'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
addCartLines: {
|
||||
title: 'Add Cart Lines',
|
||||
description: 'Add line items to an existing cart via the Shopify Storefront API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'),
|
||||
lines: z
|
||||
.array(
|
||||
z.object({
|
||||
merchandiseId: z.string().title('Merchandise ID').describe('The Storefront GID of the product variant'),
|
||||
quantity: z.number().min(1).title('Quantity').describe('The quantity to add'),
|
||||
})
|
||||
)
|
||||
.title('Lines')
|
||||
.describe('Line items to add to the cart'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
cart: cartSchema.title('Cart').describe('The updated cart'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
applyCartDiscount: {
|
||||
title: 'Apply Cart Discount',
|
||||
description: 'Apply or update discount codes on a cart via the Shopify Storefront API',
|
||||
input: {
|
||||
schema: z.object({
|
||||
cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'),
|
||||
discountCodes: z.array(z.string()).title('Discount Codes').describe('Discount codes to apply to the cart'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
cart: cartSchema.title('Cart').describe('The updated cart'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['actions']
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export { actions } from './actions'
|
||||
export { states } from './states'
|
||||
export * as schemas from './schemas'
|
||||
|
||||
export const configuration = {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
},
|
||||
schema: z.object({}),
|
||||
}
|
||||
|
||||
export const secrets = {
|
||||
SHOPIFY_CLIENT_ID: { description: 'The Client ID of the Shopify app' },
|
||||
SHOPIFY_CLIENT_SECRET: { description: 'The Client Secret of the Shopify app' },
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const pageInfoSchema = z.object({
|
||||
hasNextPage: z.boolean().title('Has Next Page').describe('Whether there are more results'),
|
||||
endCursor: z.string().optional().title('End Cursor').describe('Cursor for the next page'),
|
||||
})
|
||||
|
||||
export const moneySchema = z.object({
|
||||
amount: z.string().title('Amount').describe('Decimal money amount'),
|
||||
currencyCode: z.string().title('Currency Code').describe('ISO 4217 currency code'),
|
||||
})
|
||||
|
||||
export const storefrontVariantSchema = z.object({
|
||||
id: z.string().title('Variant ID').describe(`The Storefront GID of the product
|
||||
variant. This is the unique identifier for a variant within a product. This
|
||||
id, in the format "gid://shopify/ProductVariant/{number}", can be used for
|
||||
adding a product to a cart`),
|
||||
title: z.string().title('Title').describe('The title of the variant'),
|
||||
availableForSale: z.boolean().title('Available for Sale').describe('Whether the variant is available for purchase'),
|
||||
price: moneySchema.title('Price').describe('The price of the variant'),
|
||||
})
|
||||
|
||||
export const storefrontProductSchema = z.object({
|
||||
id: z.string().title('Product ID').describe(`The Storefront GID of the
|
||||
product. Don't offer the user to add products to the chart. Only offer the
|
||||
user to add specific variants to the cart`),
|
||||
title: z.string().title('Title').describe('The title of the product'),
|
||||
handle: z.string().title('Handle').describe('The URL-friendly handle of the product'),
|
||||
description: z.string().optional().title('Description').describe('Plain-text description of the product'),
|
||||
productType: z.string().optional().title('Product Type').describe('The product type'),
|
||||
vendor: z.string().optional().title('Vendor').describe('The vendor of the product'),
|
||||
availableForSale: z.boolean().title('Available for Sale').describe('Whether the product is available for purchase'),
|
||||
priceRange: z
|
||||
.object({
|
||||
minVariantPrice: moneySchema.title('Min Variant Price'),
|
||||
maxVariantPrice: moneySchema.title('Max Variant Price'),
|
||||
})
|
||||
.optional()
|
||||
.title('Price Range')
|
||||
.describe('The price range across all variants'),
|
||||
variants: z.array(storefrontVariantSchema).title('Variants').describe('The product variants'),
|
||||
imageUrl: z.string().optional().title('Image URL').describe('URL of the primary product image'),
|
||||
storefrontUrl: z
|
||||
.string()
|
||||
.title('Storefront URL')
|
||||
.describe("Canonical storefront URL on the shop's myshopify.com domain — always populated"),
|
||||
onlineStoreUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Online Store URL')
|
||||
.describe(
|
||||
'Published Online Store URL (may use a custom domain). Undefined if the product is not published to the Online Store sales channel'
|
||||
),
|
||||
})
|
||||
|
||||
export const collectionSchema = z.object({
|
||||
id: z.string().title('Collection ID').describe('The Storefront GID of the collection'),
|
||||
title: z.string().title('Title').describe('The title of the collection'),
|
||||
handle: z.string().title('Handle').describe(`The URL-friendly handle of the
|
||||
collection. Do not offer to add a collection to the cart. Collections are
|
||||
groups of products. Ask the user if they want to see the procuts belonging to
|
||||
a collection`),
|
||||
description: z.string().optional().title('Description').describe('Plain-text description of the collection'),
|
||||
imageUrl: z.string().optional().title('Image URL').describe('URL of the collection image'),
|
||||
})
|
||||
|
||||
export const cartLineSchema = z.object({
|
||||
lineId: z.string().title('Line ID').describe('The GID of the cart line'),
|
||||
quantity: z.number().title('Quantity').describe('The quantity of this line item'),
|
||||
merchandiseId: z.string().title('Merchandise ID').describe('The GID of the product variant'),
|
||||
title: z.string().title('Title').describe('The product title'),
|
||||
variantTitle: z.string().optional().title('Variant Title').describe('The variant title'),
|
||||
price: moneySchema.title('Price').describe('The unit price of the line item'),
|
||||
})
|
||||
|
||||
export const cartSchema = z.object({
|
||||
cartId: z.string().title('Cart ID').describe('The Storefront GID of the cart'),
|
||||
checkoutUrl: z
|
||||
.string()
|
||||
.title('Checkout URL')
|
||||
.describe(
|
||||
`Final checkout URL returned by Shopify, in the form
|
||||
"https://{shop}.myshopify.com/checkouts/cn/{token}". Use this value
|
||||
verbatim when linking the buyer to checkout - do not modify it, shorten
|
||||
it, replace it, or construct your own URL from the shop domain. When
|
||||
rendering an affordance, it must be a link (not a button) targeting
|
||||
exactly this string.`
|
||||
),
|
||||
totalQuantity: z.number().title('Total Quantity').describe('Total number of items in the cart'),
|
||||
totalAmount: moneySchema.title('Total Amount').describe('The estimated total cost'),
|
||||
subtotalAmount: moneySchema.title('Subtotal Amount').describe('The estimated subtotal before taxes and shipping'),
|
||||
lines: z.array(cartLineSchema).title('Lines').describe('The cart line items'),
|
||||
discountCodes: z
|
||||
.array(
|
||||
z.object({
|
||||
code: z.string().title('Code').describe('The discount code'),
|
||||
applicable: z.boolean().title('Applicable').describe('Whether the discount code is currently applicable'),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.title('Discount Codes')
|
||||
.describe('Applied discount codes'),
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
// Admin access token is intentionally not persisted: Shopify expiring offline tokens
|
||||
// (April 2026) make stored values useless after 60 minutes. The token is used in-memory
|
||||
// inside the OAuth wizard to provision the Storefront API token, then discarded.
|
||||
// If a future feature needs admin access, run it inside the OAuth callback or trigger
|
||||
// a re-auth wizard step.
|
||||
export const states = {
|
||||
credentials: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
shopDomain: z.string().optional().title('Shop Domain').describe('The myshopify.com domain of the store'),
|
||||
storefrontAccessToken: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Storefront Access Token')
|
||||
.describe('Storefront API access token used to authenticate Storefront GraphQL requests'),
|
||||
}),
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['states']
|
||||
@@ -0,0 +1,34 @@
|
||||
Connect your Botpress chatbot with the Shopify Storefront API to power buyer-facing shopping experiences: browse products, navigate collections, and manage carts and checkout. The integration auto-provisions a Storefront API access token during OAuth so no additional configuration is required.
|
||||
|
||||
For back-office access to products, customers, and orders — plus order webhooks — use the separate **Shopify Admin** integration.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the Shopify Storefront integration in your bot.
|
||||
2. Enter your Shopify store domain (e.g. `my-store.myshopify.com`) when prompted.
|
||||
3. Click **Authorize** to connect via OAuth. You will be redirected to Shopify to grant permissions.
|
||||
|
||||
Once authorized, the integration creates a Storefront API access token for this bot and stores it securely. No additional configuration is required.
|
||||
|
||||
## Actions
|
||||
|
||||
These actions use the Shopify Storefront API to power customer-facing shopping experiences.
|
||||
|
||||
### Product and Collection Browsing
|
||||
|
||||
- **Search Products** — Search the public product catalog by keyword with pagination support.
|
||||
- **Get Product** — Retrieve a product by its URL handle or GID, including pricing and availability.
|
||||
- **List Collections** — List all product collections with pagination.
|
||||
- **Get Collection** — Retrieve a collection by handle or GID, along with its products.
|
||||
|
||||
### Cart Management
|
||||
|
||||
- **Create Cart** — Create a new shopping cart with line items. Optionally attach a buyer email, country code, discount codes, and a note. Returns a `checkoutUrl` that you can send to the customer.
|
||||
- **Get Cart** — Retrieve the current state of a cart by its GID.
|
||||
- **Add Cart Lines** — Add additional items to an existing cart.
|
||||
- **Apply Cart Discount** — Apply or update discount codes on a cart.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Pagination uses cursor-based navigation. To retrieve the next page of results, pass the `after` cursor from the previous response's `pageInfo`.
|
||||
- Cart actions create Storefront API carts. Removing individual line items or updating quantities on existing lines is not yet supported; create a new cart instead.
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 108.44 122.88" style="enable-background:new 0 0 108.44 122.88" xml:space="preserve">
|
||||
<style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#95BF47;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#5E8E3E;} .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}</style>
|
||||
<g>
|
||||
<path class="st0" d="M94.98,23.66c-0.09-0.62-0.63-0.96-1.08-1c-0.45-0.04-9.19-0.17-9.19-0.17s-7.32-7.1-8.04-7.83 c-0.72-0.72-2.13-0.5-2.68-0.34c-0.01,0-1.37,0.43-3.68,1.14c-0.38-1.25-0.95-2.78-1.76-4.32c-2.6-4.97-6.42-7.6-11.03-7.61 c-0.01,0-0.01,0-0.02,0c-0.32,0-0.64,0.03-0.96,0.06c-0.14-0.16-0.27-0.32-0.42-0.48c-2.01-2.15-4.58-3.19-7.67-3.1 c-5.95,0.17-11.88,4.47-16.69,12.11c-3.38,5.37-5.96,12.12-6.69,17.35c-6.83,2.12-11.61,3.6-11.72,3.63 c-3.45,1.08-3.56,1.19-4.01,4.44C9.03,39.99,0,109.8,0,109.8l75.65,13.08l32.79-8.15C108.44,114.73,95.06,24.28,94.98,23.66 L94.98,23.66z M66.52,16.63c-1.74,0.54-3.72,1.15-5.87,1.82c-0.04-3.01-0.4-7.21-1.81-10.83C63.36,8.47,65.58,13.58,66.52,16.63 L66.52,16.63z M56.69,19.68c-3.96,1.23-8.29,2.57-12.63,3.91c1.22-4.67,3.54-9.33,6.38-12.38c1.06-1.14,2.54-2.4,4.29-3.12 C56.38,11.52,56.73,16.39,56.69,19.68L56.69,19.68z M48.58,3.97c1.4-0.03,2.57,0.28,3.58,0.94C50.55,5.74,49,6.94,47.54,8.5 c-3.78,4.06-6.68,10.35-7.83,16.43c-3.6,1.11-7.13,2.21-10.37,3.21C31.38,18.58,39.4,4.23,48.58,3.97L48.58,3.97z"/>
|
||||
<path class="st1" d="M93.9,22.66c-0.45-0.04-9.19-0.17-9.19-0.17s-7.32-7.1-8.04-7.83c-0.27-0.27-0.63-0.41-1.02-0.47l0,108.68 l32.78-8.15c0,0-13.38-90.44-13.46-91.06C94.9,23.04,94.35,22.7,93.9,22.66L93.9,22.66z"/>
|
||||
<path class="st2" d="M57.48,39.52l-3.81,14.25c0,0-4.25-1.93-9.28-1.62c-7.38,0.47-7.46,5.12-7.39,6.29 c0.4,6.37,17.16,7.76,18.11,22.69c0.74,11.74-6.23,19.77-16.27,20.41c-12.05,0.76-18.69-6.35-18.69-6.35l2.55-10.86 c0,0,6.68,5.04,12.02,4.7c3.49-0.22,4.74-3.06,4.61-5.07c-0.52-8.31-14.18-7.82-15.04-21.48c-0.73-11.49,6.82-23.14,23.48-24.19 C54.2,37.88,57.48,39.52,57.48,39.52L57.48,39.52z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,20 @@
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
import { actions, states, configuration, secrets } from './definitions'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'shopify-storefront',
|
||||
version: '0.1.3',
|
||||
title: 'Shopify Storefront',
|
||||
description:
|
||||
'Connect your Shopify store via the Storefront API to power buyer-facing product browsing, collections, and cart/checkout flows via OAuth 2.0.',
|
||||
icon: 'icon.svg',
|
||||
readme: 'hub.md',
|
||||
configuration,
|
||||
actions,
|
||||
states,
|
||||
secrets,
|
||||
attributes: {
|
||||
category: 'E-commerce & Payments',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@botpresshub/shopify-storefront",
|
||||
"description": "Shopify Storefront integration for Botpress",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:bplint": "bp lint",
|
||||
"check:type": "tsc --noEmit",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@shopify/cli": "~4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "eb491c945dae316d4b1a669e0d217036"
|
||||
name = "Botpress Storefront Connector"
|
||||
application_url = "https://webhook.botpress.cloud/oauth/wizard/start"
|
||||
embedded = false
|
||||
|
||||
[access_scopes]
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "unauthenticated_write_checkouts,unauthenticated_read_checkouts,unauthenticated_read_product_listings"
|
||||
optional_scopes = [ ]
|
||||
use_legacy_install_flow = false
|
||||
|
||||
[auth]
|
||||
redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ]
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-04"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
|
||||
uri = "https://controller.botpress.cloud/v1/interation/shopify-storefront"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "6a194452b03f5f02f8ad2010e2f2c5fd"
|
||||
name = "Storefront Integration Staging"
|
||||
application_url = "https://webhook.botpress.dev/oauth/wizard/start"
|
||||
embedded = false
|
||||
|
||||
[access_scopes]
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "unauthenticated_write_checkouts,unauthenticated_read_checkouts,unauthenticated_read_product_listings"
|
||||
optional_scopes = [ ]
|
||||
use_legacy_install_flow = false
|
||||
|
||||
[auth]
|
||||
redirect_urls = [ "https://webhook.botpress.dev/oauth/wizard/oauth-callback" ]
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-04"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
|
||||
uri = "https://controller.botpress.dev/v1/interation/shopify-storefront"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = "eb491c945dae316d4b1a669e0d217036"
|
||||
name = "Botpress Storefront Connector"
|
||||
application_url = "https://webhook.botpress.cloud/oauth/wizard/start"
|
||||
embedded = false
|
||||
|
||||
[access_scopes]
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "unauthenticated_write_checkouts,unauthenticated_read_checkouts,unauthenticated_read_product_listings"
|
||||
optional_scopes = [ ]
|
||||
use_legacy_install_flow = false
|
||||
|
||||
[auth]
|
||||
redirect_urls = [ "https://webhook.botpress.cloud/oauth/wizard/oauth-callback" ]
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-04"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
|
||||
uri = "https://controller.botpress.cloud/v1/interation/shopify-storefront"
|
||||
@@ -0,0 +1,35 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { CART_LINES_ADD } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { CartNode, transformCart } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CartLinesAddResponse = {
|
||||
cartLinesAdd: {
|
||||
cart: CartNode | null
|
||||
userErrors: Array<{ field: string[] | null; message: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export const addCartLines: bp.IntegrationProps['actions']['addCartLines'] = async ({ input, client, ctx }) => {
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const data = await storefront.query<CartLinesAddResponse>(CART_LINES_ADD, {
|
||||
cartId: input.cartId,
|
||||
lines: input.lines.map((line) => ({
|
||||
merchandiseId: line.merchandiseId,
|
||||
quantity: line.quantity,
|
||||
})),
|
||||
})
|
||||
|
||||
const userErrors = data.cartLinesAdd.userErrors
|
||||
if (userErrors.length) {
|
||||
throw new RuntimeError(`Failed to add cart lines: ${userErrors.map((e) => e.message).join(', ')}`)
|
||||
}
|
||||
|
||||
if (!data.cartLinesAdd.cart) {
|
||||
throw new RuntimeError('cartLinesAdd returned no cart.')
|
||||
}
|
||||
|
||||
return { cart: transformCart(data.cartLinesAdd.cart) }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { CART_DISCOUNT_CODES_UPDATE } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { CartNode, transformCart } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CartDiscountCodesUpdateResponse = {
|
||||
cartDiscountCodesUpdate: {
|
||||
cart: CartNode | null
|
||||
userErrors: Array<{ field: string[] | null; message: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export const applyCartDiscount: bp.IntegrationProps['actions']['applyCartDiscount'] = async ({
|
||||
input,
|
||||
client,
|
||||
ctx,
|
||||
}) => {
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const data = await storefront.query<CartDiscountCodesUpdateResponse>(CART_DISCOUNT_CODES_UPDATE, {
|
||||
cartId: input.cartId,
|
||||
discountCodes: input.discountCodes,
|
||||
})
|
||||
|
||||
const userErrors = data.cartDiscountCodesUpdate.userErrors
|
||||
if (userErrors.length) {
|
||||
throw new RuntimeError(`Failed to apply discount codes: ${userErrors.map((e) => e.message).join(', ')}`)
|
||||
}
|
||||
|
||||
if (!data.cartDiscountCodesUpdate.cart) {
|
||||
throw new RuntimeError('cartDiscountCodesUpdate returned no cart.')
|
||||
}
|
||||
|
||||
return { cart: transformCart(data.cartDiscountCodesUpdate.cart) }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { CART_CREATE } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { CartNode, transformCart } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CartCreateResponse = {
|
||||
cartCreate: {
|
||||
cart: CartNode | null
|
||||
userErrors: Array<{ field: string[] | null; message: string }>
|
||||
}
|
||||
}
|
||||
|
||||
export const createCart: bp.IntegrationProps['actions']['createCart'] = async ({ input, client, ctx }) => {
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const cartInput: Record<string, unknown> = {
|
||||
lines: input.lines.map((line) => ({
|
||||
merchandiseId: line.merchandiseId,
|
||||
quantity: line.quantity,
|
||||
})),
|
||||
}
|
||||
|
||||
if (input.buyerEmail || input.countryCode) {
|
||||
cartInput.buyerIdentity = {
|
||||
...(input.buyerEmail && { email: input.buyerEmail }),
|
||||
...(input.countryCode && { countryCode: input.countryCode }),
|
||||
}
|
||||
}
|
||||
|
||||
if (input.discountCodes?.length) {
|
||||
cartInput.discountCodes = input.discountCodes
|
||||
}
|
||||
|
||||
if (input.note) {
|
||||
cartInput.note = input.note
|
||||
}
|
||||
|
||||
const data = await storefront.query<CartCreateResponse>(CART_CREATE, { input: cartInput })
|
||||
|
||||
const userErrors = data.cartCreate.userErrors
|
||||
if (userErrors.length) {
|
||||
throw new RuntimeError(`Failed to create cart: ${userErrors.map((e) => e.message).join(', ')}`)
|
||||
}
|
||||
|
||||
if (!data.cartCreate.cart) {
|
||||
throw new RuntimeError('Cart creation returned no cart.')
|
||||
}
|
||||
|
||||
return { cart: transformCart(data.cartCreate.cart) }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { CART_QUERY } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { CartNode, transformCart } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CartQueryResponse = {
|
||||
cart: CartNode | null
|
||||
}
|
||||
|
||||
export const getCart: bp.IntegrationProps['actions']['getCart'] = async ({ input, client, ctx }) => {
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const data = await storefront.query<CartQueryResponse>(CART_QUERY, { id: input.cartId })
|
||||
|
||||
if (!data.cart) {
|
||||
throw new RuntimeError(`Cart not found: ${input.cartId}`)
|
||||
}
|
||||
|
||||
return { cart: transformCart(data.cart) }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { STOREFRONT_GET_COLLECTION_BY_HANDLE, STOREFRONT_GET_COLLECTION_BY_ID } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { transformCollection, transformStorefrontProduct } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CollectionResponse = {
|
||||
collection: {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
description: string | null
|
||||
image: { url: string; altText: string | null } | null
|
||||
products: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
description: string | null
|
||||
productType: string | null
|
||||
vendor: string | null
|
||||
availableForSale: boolean
|
||||
onlineStoreUrl: string | null
|
||||
priceRange?: {
|
||||
minVariantPrice: { amount: string; currencyCode: string }
|
||||
maxVariantPrice: { amount: string; currencyCode: string }
|
||||
}
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
availableForSale: boolean
|
||||
price: { amount: string; currencyCode: string }
|
||||
}
|
||||
}>
|
||||
}
|
||||
images: { edges: Array<{ node: { url: string; altText: string | null } }> }
|
||||
}
|
||||
}>
|
||||
pageInfo: {
|
||||
hasNextPage: boolean
|
||||
endCursor: string | null
|
||||
}
|
||||
}
|
||||
} | null
|
||||
}
|
||||
|
||||
export const getCollection: bp.IntegrationProps['actions']['getCollection'] = async ({ input, client, ctx }) => {
|
||||
if (!input.handle && !input.collectionId) {
|
||||
throw new RuntimeError('Either "handle" or "collectionId" must be provided.')
|
||||
}
|
||||
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const query = input.handle ? STOREFRONT_GET_COLLECTION_BY_HANDLE : STOREFRONT_GET_COLLECTION_BY_ID
|
||||
const variables = input.handle
|
||||
? { handle: input.handle, productsFirst: input.productsFirst ?? 50 }
|
||||
: { id: input.collectionId, productsFirst: input.productsFirst ?? 50 }
|
||||
|
||||
const data = await storefront.query<CollectionResponse>(query, variables)
|
||||
|
||||
if (!data.collection) {
|
||||
throw new RuntimeError(`Collection not found: ${input.handle ?? input.collectionId}`)
|
||||
}
|
||||
|
||||
return {
|
||||
collection: transformCollection(data.collection),
|
||||
products: data.collection.products.edges.map(({ node }) => transformStorefrontProduct(node, storefront.shopDomain)),
|
||||
pageInfo: {
|
||||
hasNextPage: data.collection.products.pageInfo.hasNextPage,
|
||||
endCursor: data.collection.products.pageInfo.endCursor ?? undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { STOREFRONT_GET_PRODUCT_BY_HANDLE, STOREFRONT_GET_PRODUCT_BY_ID } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { transformStorefrontProduct } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type ProductResponse = {
|
||||
product: {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
description: string | null
|
||||
productType: string | null
|
||||
vendor: string | null
|
||||
availableForSale: boolean
|
||||
onlineStoreUrl: string | null
|
||||
priceRange?: {
|
||||
minVariantPrice: { amount: string; currencyCode: string }
|
||||
maxVariantPrice: { amount: string; currencyCode: string }
|
||||
}
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
availableForSale: boolean
|
||||
price: { amount: string; currencyCode: string }
|
||||
}
|
||||
}>
|
||||
}
|
||||
images: { edges: Array<{ node: { url: string; altText: string | null } }> }
|
||||
} | null
|
||||
}
|
||||
|
||||
export const getProduct: bp.IntegrationProps['actions']['getProduct'] = async ({ input, client, ctx }) => {
|
||||
if (!input.handle && !input.productId) {
|
||||
throw new RuntimeError('Either "handle" or "productId" must be provided.')
|
||||
}
|
||||
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const query = input.handle ? STOREFRONT_GET_PRODUCT_BY_HANDLE : STOREFRONT_GET_PRODUCT_BY_ID
|
||||
const variables = input.handle ? { handle: input.handle } : { id: input.productId }
|
||||
|
||||
const data = await storefront.query<ProductResponse>(query, variables)
|
||||
|
||||
if (!data.product) {
|
||||
throw new RuntimeError(`Product not found: ${input.handle ?? input.productId}`)
|
||||
}
|
||||
|
||||
return { product: transformStorefrontProduct(data.product, storefront.shopDomain) }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { addCartLines } from './add-cart-lines'
|
||||
import { applyCartDiscount } from './apply-cart-discount'
|
||||
import { createCart } from './create-cart'
|
||||
import { getCart } from './get-cart'
|
||||
import { getCollection } from './get-collection'
|
||||
import { getProduct } from './get-product'
|
||||
import { listCollections } from './list-collections'
|
||||
import { searchProducts } from './search-products'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default {
|
||||
searchProducts,
|
||||
getProduct,
|
||||
listCollections,
|
||||
getCollection,
|
||||
createCart,
|
||||
getCart,
|
||||
addCartLines,
|
||||
applyCartDiscount,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,39 @@
|
||||
import { STOREFRONT_LIST_COLLECTIONS } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { transformCollection } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CollectionsResponse = {
|
||||
collections: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
description: string | null
|
||||
image: { url: string; altText: string | null } | null
|
||||
}
|
||||
}>
|
||||
pageInfo: {
|
||||
hasNextPage: boolean
|
||||
endCursor: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const listCollections: bp.IntegrationProps['actions']['listCollections'] = async ({ input, client, ctx }) => {
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const data = await storefront.query<CollectionsResponse>(STOREFRONT_LIST_COLLECTIONS, {
|
||||
first: input.first ?? 50,
|
||||
after: input.after,
|
||||
})
|
||||
|
||||
return {
|
||||
collections: data.collections.edges.map(({ node }) => transformCollection(node)),
|
||||
pageInfo: {
|
||||
hasNextPage: data.collections.pageInfo.hasNextPage,
|
||||
endCursor: data.collections.pageInfo.endCursor ?? undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { STOREFRONT_SEARCH_PRODUCTS } from '../client/queries/storefront'
|
||||
import { StorefrontClient } from '../client/storefront'
|
||||
import { transformStorefrontProduct } from '../transformers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type SearchProductsResponse = {
|
||||
search: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
description: string | null
|
||||
productType: string | null
|
||||
vendor: string | null
|
||||
availableForSale: boolean
|
||||
onlineStoreUrl: string | null
|
||||
priceRange?: {
|
||||
minVariantPrice: { amount: string; currencyCode: string }
|
||||
maxVariantPrice: { amount: string; currencyCode: string }
|
||||
}
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string
|
||||
title: string
|
||||
availableForSale: boolean
|
||||
price: { amount: string; currencyCode: string }
|
||||
}
|
||||
}>
|
||||
}
|
||||
images: { edges: Array<{ node: { url: string; altText: string | null } }> }
|
||||
}
|
||||
}>
|
||||
pageInfo: {
|
||||
hasNextPage: boolean
|
||||
endCursor: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const searchProducts: bp.IntegrationProps['actions']['searchProducts'] = async ({ input, client, ctx }) => {
|
||||
const storefront = await StorefrontClient.create({ client, ctx })
|
||||
|
||||
const data = await storefront.query<SearchProductsResponse>(STOREFRONT_SEARCH_PRODUCTS, {
|
||||
query: input.query,
|
||||
first: input.first ?? 50,
|
||||
after: input.after,
|
||||
})
|
||||
|
||||
return {
|
||||
products: data.search.edges.map(({ node }) => transformStorefrontProduct(node, storefront.shopDomain)),
|
||||
pageInfo: {
|
||||
hasNextPage: data.search.pageInfo.hasNextPage,
|
||||
endCursor: data.search.pageInfo.endCursor ?? undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id'
|
||||
process.env.SECRET_SHOPIFY_CLIENT_SECRET = 'test-client-secret'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('exchangeCodeForAccessToken', () => {
|
||||
it('sends expiring=1 in the JSON body', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ access_token: 'shpat_x' }), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const { exchangeCodeForAccessToken } = await import('./index')
|
||||
await exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string)
|
||||
expect(body).toMatchObject({
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
code: 'abc',
|
||||
expiring: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the access_token from the response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ access_token: 'shpat_x', expires_in: 3600 }), { status: 200 }))
|
||||
)
|
||||
|
||||
const { exchangeCodeForAccessToken } = await import('./index')
|
||||
await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).resolves.toBe('shpat_x')
|
||||
})
|
||||
|
||||
it('throws when access_token is missing from the response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ scope: 'x' }), { status: 200 })))
|
||||
|
||||
const { exchangeCodeForAccessToken } = await import('./index')
|
||||
await expect(exchangeCodeForAccessToken({ shop: 'example', code: 'abc' })).rejects.toThrow(/access_token/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { SHOPIFY_API_VERSION } from './queries/common'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type ShopifyAdminClientProps = {
|
||||
shopDomain: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
// Minimal Admin GraphQL client, used only to provision a Storefront Access Token during OAuth.
|
||||
// Runtime actions use `StorefrontClient` from `./storefront`, not this class.
|
||||
export class ShopifyAdminClient {
|
||||
public readonly shopDomain: string
|
||||
private readonly _accessToken: string
|
||||
|
||||
public constructor({ shopDomain, accessToken }: ShopifyAdminClientProps) {
|
||||
this.shopDomain = shopDomain
|
||||
this._accessToken = accessToken
|
||||
}
|
||||
|
||||
public async query<T = unknown>(graphql: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||
const url = `https://${this.shopDomain}.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shopify-Access-Token': this._accessToken,
|
||||
},
|
||||
body: JSON.stringify({ query: graphql, variables }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new RuntimeError(`Shopify API error: ${response.status} ${response.statusText} — ${body.slice(0, 500)}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }
|
||||
|
||||
if (json.errors?.length) {
|
||||
throw new RuntimeError(`Shopify GraphQL error: ${json.errors.map((e) => e.message).join(', ')}`)
|
||||
}
|
||||
|
||||
return json.data as T
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a Shopify OAuth authorization code for an expiring offline Admin access token.
|
||||
*
|
||||
* Shopify deprecated non-expiring offline tokens for new public apps as of 2026-04-01;
|
||||
* `expiring: 1` opts into the supported flow (60-min access TTL, 90-day refresh TTL).
|
||||
* This integration uses the token only inside the OAuth wizard to provision a Storefront
|
||||
* Access Token, so the access TTL is not material and no refresh logic is needed.
|
||||
*
|
||||
* See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
|
||||
*/
|
||||
export const exchangeCodeForAccessToken = async ({ shop, code }: { shop: string; code: string }): Promise<string> => {
|
||||
const response = await fetch(`https://${shop}.myshopify.com/admin/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: bp.secrets.SHOPIFY_CLIENT_ID,
|
||||
client_secret: bp.secrets.SHOPIFY_CLIENT_SECRET,
|
||||
code,
|
||||
expiring: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new RuntimeError(
|
||||
`Failed to exchange authorization code for access token: ${response.status} ${response.statusText} — ${body.slice(0, 500)}`
|
||||
)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
access_token?: string
|
||||
scope?: string
|
||||
expires_in?: number
|
||||
refresh_token?: string
|
||||
refresh_token_expires_in?: number
|
||||
}
|
||||
|
||||
if (!json.access_token) {
|
||||
throw new RuntimeError('Shopify did not return an access_token in the token exchange response')
|
||||
}
|
||||
|
||||
return json.access_token
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Admin GraphQL mutations used solely to provision a Storefront API access token after OAuth.
|
||||
// The Storefront integration does not otherwise touch the Admin API at runtime.
|
||||
|
||||
export const STOREFRONT_ACCESS_TOKENS_QUERY = `
|
||||
query storefrontAccessTokens {
|
||||
shop {
|
||||
storefrontAccessTokens(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const STOREFRONT_ACCESS_TOKEN_CREATE = `
|
||||
mutation storefrontAccessTokenCreate($input: StorefrontAccessTokenInput!) {
|
||||
storefrontAccessTokenCreate(input: $input) {
|
||||
storefrontAccessToken {
|
||||
id
|
||||
title
|
||||
accessToken
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1 @@
|
||||
export const SHOPIFY_API_VERSION = '2026-04'
|
||||
@@ -0,0 +1,209 @@
|
||||
const PRODUCT_FIELDS = `
|
||||
id
|
||||
title
|
||||
handle
|
||||
description
|
||||
productType
|
||||
vendor
|
||||
availableForSale
|
||||
onlineStoreUrl
|
||||
priceRange {
|
||||
minVariantPrice { amount currencyCode }
|
||||
maxVariantPrice { amount currencyCode }
|
||||
}
|
||||
variants(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
availableForSale
|
||||
price { amount currencyCode }
|
||||
}
|
||||
}
|
||||
}
|
||||
images(first: 1) {
|
||||
edges {
|
||||
node { url altText }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CART_FIELDS = `
|
||||
id
|
||||
checkoutUrl
|
||||
totalQuantity
|
||||
cost {
|
||||
totalAmount { amount currencyCode }
|
||||
subtotalAmount { amount currencyCode }
|
||||
}
|
||||
lines(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
quantity
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
id
|
||||
title
|
||||
price { amount currencyCode }
|
||||
product { title }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
discountCodes {
|
||||
code
|
||||
applicable
|
||||
}
|
||||
`
|
||||
|
||||
export const STOREFRONT_SEARCH_PRODUCTS = `
|
||||
query searchProducts($query: String!, $first: Int!, $after: String) {
|
||||
search(query: $query, types: PRODUCT, first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
... on Product {
|
||||
${PRODUCT_FIELDS}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const STOREFRONT_GET_PRODUCT_BY_HANDLE = `
|
||||
query getProductByHandle($handle: String!) {
|
||||
product(handle: $handle) {
|
||||
${PRODUCT_FIELDS}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const STOREFRONT_GET_PRODUCT_BY_ID = `
|
||||
query getProductById($id: ID!) {
|
||||
product(id: $id) {
|
||||
${PRODUCT_FIELDS}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const STOREFRONT_LIST_COLLECTIONS = `
|
||||
query listCollections($first: Int!, $after: String) {
|
||||
collections(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
handle
|
||||
description
|
||||
image { url altText }
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const STOREFRONT_GET_COLLECTION_BY_HANDLE = `
|
||||
query getCollectionByHandle($handle: String!, $productsFirst: Int!) {
|
||||
collection(handle: $handle) {
|
||||
id
|
||||
title
|
||||
handle
|
||||
description
|
||||
image { url altText }
|
||||
products(first: $productsFirst) {
|
||||
edges {
|
||||
node {
|
||||
${PRODUCT_FIELDS}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const STOREFRONT_GET_COLLECTION_BY_ID = `
|
||||
query getCollectionById($id: ID!, $productsFirst: Int!) {
|
||||
collection(id: $id) {
|
||||
id
|
||||
title
|
||||
handle
|
||||
description
|
||||
image { url altText }
|
||||
products(first: $productsFirst) {
|
||||
edges {
|
||||
node {
|
||||
${PRODUCT_FIELDS}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CART_CREATE = `
|
||||
mutation cartCreate($input: CartInput!) {
|
||||
cartCreate(input: $input) {
|
||||
cart {
|
||||
${CART_FIELDS}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CART_QUERY = `
|
||||
query getCart($id: ID!) {
|
||||
cart(id: $id) {
|
||||
${CART_FIELDS}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CART_LINES_ADD = `
|
||||
mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
|
||||
cartLinesAdd(cartId: $cartId, lines: $lines) {
|
||||
cart {
|
||||
${CART_FIELDS}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CART_DISCOUNT_CODES_UPDATE = `
|
||||
mutation cartDiscountCodesUpdate($cartId: ID!, $discountCodes: [String!]) {
|
||||
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
|
||||
cart {
|
||||
${CART_FIELDS}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,67 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { SHOPIFY_API_VERSION } from './queries/common'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type StorefrontClientProps = {
|
||||
shopDomain: string
|
||||
storefrontAccessToken: string
|
||||
}
|
||||
|
||||
type CreateProps = {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
}
|
||||
|
||||
export class StorefrontClient {
|
||||
public readonly shopDomain: string
|
||||
private readonly _storefrontAccessToken: string
|
||||
|
||||
public constructor({ shopDomain, storefrontAccessToken }: StorefrontClientProps) {
|
||||
this.shopDomain = shopDomain
|
||||
this._storefrontAccessToken = storefrontAccessToken
|
||||
}
|
||||
|
||||
public static async create({ client, ctx }: CreateProps): Promise<StorefrontClient> {
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
|
||||
if (!state.payload.shopDomain) {
|
||||
throw new RuntimeError('Shopify credentials not found. Please complete the OAuth flow first.')
|
||||
}
|
||||
|
||||
if (!state.payload.storefrontAccessToken) {
|
||||
throw new RuntimeError(
|
||||
'Storefront access token not found. The integration may need to be re-installed to provision Storefront API access.'
|
||||
)
|
||||
}
|
||||
|
||||
return new StorefrontClient({
|
||||
shopDomain: state.payload.shopDomain,
|
||||
storefrontAccessToken: state.payload.storefrontAccessToken,
|
||||
})
|
||||
}
|
||||
|
||||
public async query<T = unknown>(graphql: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||
const url = `https://${this.shopDomain}.myshopify.com/api/${SHOPIFY_API_VERSION}/graphql.json`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Shopify-Storefront-Access-Token': this._storefrontAccessToken,
|
||||
},
|
||||
body: JSON.stringify({ query: graphql, variables }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RuntimeError(`Shopify Storefront API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }
|
||||
|
||||
if (json.errors?.length) {
|
||||
throw new RuntimeError(`Shopify Storefront GraphQL error: ${json.errors.map((e) => e.message).join(', ')}`)
|
||||
}
|
||||
|
||||
return json.data as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const SECRET = 'test-shopify-secret'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.SECRET_SHOPIFY_CLIENT_ID = 'test-client-id'
|
||||
process.env.SECRET_SHOPIFY_CLIENT_SECRET = SECRET
|
||||
})
|
||||
|
||||
const computeHmac = (body: string) => createHmac('sha256', SECRET).update(body, 'utf8').digest('base64')
|
||||
|
||||
const buildProps = (opts: { topic?: string; hmac?: string; body?: string; path?: string }) => {
|
||||
const body = opts.body ?? '{}'
|
||||
const headers: Record<string, string> = {}
|
||||
if (opts.topic !== undefined) headers['x-shopify-topic'] = opts.topic
|
||||
if (opts.hmac !== undefined) headers['x-shopify-hmac-sha256'] = opts.hmac
|
||||
const noop = () => {}
|
||||
const forBot = () => ({ info: noop, warn: noop, error: noop, debug: noop })
|
||||
return {
|
||||
req: { path: opts.path ?? '/', headers, body },
|
||||
logger: { forBot },
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('Shopify Storefront webhook handler', () => {
|
||||
const validBody = '{"shop_id":1,"shop_domain":"x.myshopify.com"}'
|
||||
|
||||
describe('GDPR compliance topics', () => {
|
||||
const topics = ['customers/data_request', 'customers/redact', 'shop/redact']
|
||||
|
||||
for (const topic of topics) {
|
||||
it(`returns 200 on valid HMAC for ${topic}`, async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ topic, hmac: computeHmac(validBody), body: validBody }))
|
||||
expect(response).toEqual({ status: 200, body: '' })
|
||||
})
|
||||
|
||||
it(`returns 401 on invalid HMAC for ${topic}`, async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ topic, hmac: 'invalid-hmac', body: validBody }))
|
||||
expect(response).toMatchObject({ status: 401 })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('request validation', () => {
|
||||
it('returns 400 when topic header is missing', async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ hmac: computeHmac(validBody), body: validBody }))
|
||||
expect(response).toMatchObject({ status: 400 })
|
||||
})
|
||||
|
||||
it('returns 400 when hmac header is missing', async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(buildProps({ topic: 'customers/redact', body: validBody }))
|
||||
expect(response).toMatchObject({ status: 400 })
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 200 on unknown topic after HMAC passes', async () => {
|
||||
const { handler } = await import('./handler')
|
||||
const response = await handler(
|
||||
buildProps({ topic: 'products/create', hmac: computeHmac(validBody), body: validBody })
|
||||
)
|
||||
expect(response).toEqual({ status: 200, body: '' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { verifyWebhookHmac } from './oauth/hmac'
|
||||
import { oauthWizardHandler } from './oauth/wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const SHOPIFY_TOPIC_HEADER = 'x-shopify-topic'
|
||||
const SHOPIFY_HMAC_HEADER = 'x-shopify-hmac-sha256'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, logger } = props
|
||||
|
||||
if (oauthWizard.isOAuthWizardUrl(req.path)) {
|
||||
return await oauthWizardHandler(props)
|
||||
}
|
||||
|
||||
// Storefront API has no business webhooks, but Shopify requires every app to respond to the
|
||||
// three GDPR compliance topics — otherwise the app is flagged during review.
|
||||
const topic = req.headers[SHOPIFY_TOPIC_HEADER]
|
||||
const hmac = req.headers[SHOPIFY_HMAC_HEADER]
|
||||
|
||||
if (!topic || !hmac || !req.body) {
|
||||
logger.forBot().warn('Rejected Shopify webhook: missing required headers or body')
|
||||
return { status: 400, body: 'Missing Shopify webhook headers or body' }
|
||||
}
|
||||
|
||||
if (!verifyWebhookHmac(req.body, hmac, bp.secrets.SHOPIFY_CLIENT_SECRET)) {
|
||||
logger.forBot().warn('Rejected Shopify webhook with invalid HMAC signature')
|
||||
return { status: 401, body: 'Invalid HMAC signature' }
|
||||
}
|
||||
|
||||
switch (topic) {
|
||||
case 'customers/data_request':
|
||||
case 'customers/redact':
|
||||
case 'shop/redact':
|
||||
// GDPR compliance webhooks. This integration does not persist Shopify customer data —
|
||||
// Storefront API responses flow straight through to bot actions. Per-shop credentials
|
||||
// are cleared by unregister(); shop/redact (fired 48h after uninstall) is a safety-net no-op.
|
||||
// https://shopify.dev/docs/apps/build/compliance/privacy-law-compliance
|
||||
logger.forBot().info(`Received Shopify compliance webhook: ${topic}`)
|
||||
return { status: 200, body: '' }
|
||||
default:
|
||||
logger.forBot().warn(`Unhandled Shopify webhook topic: ${topic}`)
|
||||
return { status: 200, body: '' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import actions from './actions'
|
||||
import { handler } from './handler'
|
||||
import { register, unregister } from './setup'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default new bp.Integration({
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels: {},
|
||||
handler,
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createHmac } from 'crypto'
|
||||
import { verifyOAuthCallbackHmac, verifyWebhookHmac } from './hmac'
|
||||
|
||||
const SECRET = 'test-shopify-secret'
|
||||
|
||||
// Helper: compute the OAuth callback HMAC exactly as the source does
|
||||
const computeOAuthHmac = (params: Record<string, string>, secret: string): string => {
|
||||
const entries = Object.entries(params)
|
||||
.filter(([k]) => k !== 'hmac' && k !== 'signature')
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
||||
return createHmac('sha256', secret).update(message).digest('hex')
|
||||
}
|
||||
|
||||
// Helper: compute the webhook HMAC exactly as the source does
|
||||
const computeWebhookHmac = (body: string, secret: string): string =>
|
||||
createHmac('sha256', secret).update(body, 'utf8').digest('base64')
|
||||
|
||||
describe('verifyOAuthCallbackHmac', () => {
|
||||
it('accepts a valid HMAC', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com', state: 'nonce', timestamp: '1234567890' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when hmac param is missing', () => {
|
||||
const query = new URLSearchParams({ code: 'abc123', shop: 'my-store.myshopify.com' })
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false with wrong secret', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, 'wrong-secret')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when a query param is tampered', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac, shop: 'evil-store.myshopify.com' })
|
||||
|
||||
// Recompute — the hmac was computed with the original shop value
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes signature param from hash input', () => {
|
||||
const params = { code: 'abc123', shop: 'my-store.myshopify.com', signature: 'legacy-sig' }
|
||||
const hmac = computeOAuthHmac(params, SECRET) // helper already excludes signature
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles params with special characters', () => {
|
||||
const params = { code: 'abc=123&456', shop: 'my store.myshopify.com' }
|
||||
const hmac = computeOAuthHmac(params, SECRET)
|
||||
const query = new URLSearchParams({ ...params, hmac })
|
||||
|
||||
expect(verifyOAuthCallbackHmac(query, SECRET)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyWebhookHmac', () => {
|
||||
const body = '{"id":12345,"name":"#1001"}'
|
||||
|
||||
it('accepts a valid HMAC', () => {
|
||||
const hmac = computeWebhookHmac(body, SECRET)
|
||||
expect(verifyWebhookHmac(body, hmac, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false with wrong secret', () => {
|
||||
const hmac = computeWebhookHmac(body, SECRET)
|
||||
expect(verifyWebhookHmac(body, hmac, 'wrong-secret')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when body is tampered', () => {
|
||||
const hmac = computeWebhookHmac(body, SECRET)
|
||||
expect(verifyWebhookHmac('{"id":99999}', hmac, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false with hex-encoded HMAC instead of base64', () => {
|
||||
const hexHmac = createHmac('sha256', SECRET).update(body, 'utf8').digest('hex')
|
||||
expect(verifyWebhookHmac(body, hexHmac, SECRET)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles empty body', () => {
|
||||
const hmac = computeWebhookHmac('', SECRET)
|
||||
expect(verifyWebhookHmac('', hmac, SECRET)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles unicode body', () => {
|
||||
const unicodeBody = '{"name":"Ünïcödé Shöp"}'
|
||||
const hmac = computeWebhookHmac(unicodeBody, SECRET)
|
||||
expect(verifyWebhookHmac(unicodeBody, hmac, SECRET)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
/**
|
||||
* Verifies the HMAC signature on Shopify's OAuth callback query string.
|
||||
*
|
||||
* Shopify signs the callback query params with the app's client secret. The `hmac` (and legacy
|
||||
* `signature`) parameter must be removed, the remaining params sorted alphabetically by key,
|
||||
* URL-encoded, and joined as `key=value&key=value`. The HMAC-SHA256 of that string (hex) must
|
||||
* match the received `hmac` value.
|
||||
*
|
||||
* See https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant
|
||||
*/
|
||||
export const verifyOAuthCallbackHmac = (query: URLSearchParams, secret: string): boolean => {
|
||||
const receivedHmac = query.get('hmac')
|
||||
if (!receivedHmac) {
|
||||
return false
|
||||
}
|
||||
|
||||
const entries: [string, string][] = []
|
||||
for (const [key, value] of query.entries()) {
|
||||
if (key === 'hmac' || key === 'signature') {
|
||||
continue
|
||||
}
|
||||
entries.push([key, value])
|
||||
}
|
||||
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
|
||||
const message = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
||||
const computed = createHmac('sha256', secret).update(message).digest()
|
||||
const received = Buffer.from(receivedHmac, 'hex')
|
||||
|
||||
return computed.length === received.length && timingSafeEqual(computed, received)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the HMAC signature on an incoming Shopify webhook request.
|
||||
*
|
||||
* Shopify sends the HMAC-SHA256 of the raw request body (base64-encoded) in the
|
||||
* `X-Shopify-Hmac-Sha256` header. The HMAC is computed with the app's client secret.
|
||||
*
|
||||
* See https://shopify.dev/docs/apps/build/webhooks/subscribe#verify-a-webhook
|
||||
*/
|
||||
export const verifyWebhookHmac = (rawBody: string, hmacHeader: string, secret: string): boolean => {
|
||||
const computed = createHmac('sha256', secret).update(rawBody, 'utf8').digest()
|
||||
const received = Buffer.from(hmacHeader, 'base64')
|
||||
|
||||
return computed.length === received.length && timingSafeEqual(computed, received)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { normalizeShopDomain } from './wizard'
|
||||
|
||||
describe('normalizeShopDomain', () => {
|
||||
it('returns bare domain as-is', () => {
|
||||
expect(normalizeShopDomain('my-store')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips .myshopify.com suffix', () => {
|
||||
expect(normalizeShopDomain('my-store.myshopify.com')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips https:// protocol', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips http:// protocol', () => {
|
||||
expect(normalizeShopDomain('http://my-store.myshopify.com')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips trailing slash', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com/')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips path segments', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com/admin')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('strips deep path segments', () => {
|
||||
expect(normalizeShopDomain('https://my-store.myshopify.com/admin/products/123')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeShopDomain(' my-store.myshopify.com ')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('lowercases input', () => {
|
||||
expect(normalizeShopDomain('MY-STORE.MYSHOPIFY.COM')).toBe('my-store')
|
||||
})
|
||||
|
||||
it('handles full URL with mixed case and whitespace', () => {
|
||||
expect(normalizeShopDomain(' HTTPS://MY-STORE.MYSHOPIFY.COM/admin/products ')).toBe('my-store')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { exchangeCodeForAccessToken, ShopifyAdminClient } from '../client'
|
||||
import { STOREFRONT_ACCESS_TOKEN_CREATE, STOREFRONT_ACCESS_TOKENS_QUERY } from '../client/queries/admin'
|
||||
import { verifyOAuthCallbackHmac } from './hmac'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
const SHOPIFY_OAUTH_SCOPES = [
|
||||
'unauthenticated_read_product_listings',
|
||||
'unauthenticated_write_checkouts',
|
||||
'unauthenticated_read_checkouts',
|
||||
].join(',')
|
||||
|
||||
const SHOP_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/i
|
||||
const STOREFRONT_TOKEN_TITLE = 'Botpress Storefront Access'
|
||||
|
||||
export const oauthWizardHandler = async (props: bp.HandlerProps): Promise<sdk.Response> => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start', handler: _startHandler })
|
||||
.addStep({ id: 'get-shop', handler: _getShopHandler })
|
||||
.addStep({ id: 'validate-shop', handler: _validateShopHandler })
|
||||
.addStep({ id: 'authorize', handler: _authorizeHandler })
|
||||
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
|
||||
.addStep({ id: 'end', handler: _endHandler })
|
||||
.build()
|
||||
|
||||
return await wizard.handleRequest()
|
||||
}
|
||||
|
||||
const _startHandler: WizardHandler = ({ responses }) =>
|
||||
responses.displayButtons({
|
||||
pageTitle: 'Connect Shopify Storefront',
|
||||
htmlOrMarkdownPageContents:
|
||||
'This wizard will connect your Shopify storefront to Botpress. If the integration was previously connected, the existing connection will be reset.\n\nDo you want to continue?',
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Yes, continue', navigateToStep: 'get-shop', buttonType: 'primary' },
|
||||
{ action: 'close', label: 'No, cancel', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
|
||||
const _getShopHandler: WizardHandler = ({ responses }) =>
|
||||
responses.displayInput({
|
||||
pageTitle: 'Enter Shopify Store',
|
||||
htmlOrMarkdownPageContents:
|
||||
'Enter the domain of your Shopify store. It looks like `your-store.myshopify.com` — you can find it in the Shopify admin URL.',
|
||||
input: { label: 'e.g. your-store.myshopify.com', type: 'text' },
|
||||
nextStepId: 'validate-shop',
|
||||
})
|
||||
|
||||
const _validateShopHandler: WizardHandler = async ({ client, ctx, inputValue, responses }) => {
|
||||
if (!inputValue) {
|
||||
throw new sdk.RuntimeError('Shop domain cannot be empty')
|
||||
}
|
||||
|
||||
const shopDomain = normalizeShopDomain(inputValue)
|
||||
if (!SHOP_NAME_REGEX.test(shopDomain)) {
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Invalid Shop Domain',
|
||||
htmlOrMarkdownPageContents: `"${inputValue}" doesn't look like a valid Shopify store domain. Please enter a domain like \`your-store.myshopify.com\`.`,
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Try again', navigateToStep: 'get-shop', buttonType: 'primary' },
|
||||
{ action: 'close', label: 'Cancel', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
await _patchCredentialsState(client, ctx, {
|
||||
shopDomain,
|
||||
storefrontAccessToken: undefined,
|
||||
})
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Confirm Shopify Store',
|
||||
htmlOrMarkdownPageContents: `Is <strong>${shopDomain}.myshopify.com</strong> your Shopify store?`,
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Yes, connect', navigateToStep: 'authorize', buttonType: 'primary' },
|
||||
{ action: 'navigate', label: 'No, go back', navigateToStep: 'get-shop', buttonType: 'secondary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const _authorizeHandler: WizardHandler = async ({ client, ctx, responses }) => {
|
||||
const { shopDomain } = await _getCredentialsState(client, ctx)
|
||||
if (!shopDomain) {
|
||||
throw new sdk.RuntimeError('Shop domain missing from state; please restart the wizard')
|
||||
}
|
||||
|
||||
const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback').toString()
|
||||
const authorizeUrl =
|
||||
`https://${shopDomain}.myshopify.com/admin/oauth/authorize` +
|
||||
`?client_id=${encodeURIComponent(bp.secrets.SHOPIFY_CLIENT_ID)}` +
|
||||
`&scope=${encodeURIComponent(SHOPIFY_OAUTH_SCOPES)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&state=${encodeURIComponent(ctx.webhookId)}`
|
||||
|
||||
return responses.redirectToExternalUrl(authorizeUrl)
|
||||
}
|
||||
|
||||
const _oauthCallbackHandler: WizardHandler = async ({ query, client, ctx, logger, responses }) => {
|
||||
try {
|
||||
const state = query.get('state')
|
||||
if (state !== ctx.webhookId) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'OAuth state mismatch — possible CSRF attempt. Please retry the connection.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!verifyOAuthCallbackHmac(query, bp.secrets.SHOPIFY_CLIENT_SECRET)) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Shopify OAuth callback HMAC verification failed. Please retry the connection.',
|
||||
})
|
||||
}
|
||||
|
||||
const code = query.get('code')
|
||||
const shopParam = query.get('shop')
|
||||
if (!code || !shopParam) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: 'Missing `code` or `shop` parameter on Shopify OAuth callback.',
|
||||
})
|
||||
}
|
||||
|
||||
const shopDomainFromCallback = shopParam.replace(/\.myshopify\.com$/i, '').toLowerCase()
|
||||
const stored = await _getCredentialsState(client, ctx)
|
||||
if (stored.shopDomain && stored.shopDomain.toLowerCase() !== shopDomainFromCallback) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: `Shop mismatch: expected ${stored.shopDomain} but Shopify returned ${shopDomainFromCallback}.`,
|
||||
})
|
||||
}
|
||||
|
||||
const accessToken = await exchangeCodeForAccessToken({ shop: shopDomainFromCallback, code })
|
||||
|
||||
const admin = new ShopifyAdminClient({ shopDomain: shopDomainFromCallback, accessToken })
|
||||
const storefrontAccessToken = await _provisionStorefrontToken(admin)
|
||||
if (!storefrontAccessToken) {
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage:
|
||||
'Failed to provision a Storefront API access token. Ensure the Shopify app has `unauthenticated_*` scopes enabled.',
|
||||
})
|
||||
}
|
||||
|
||||
await _patchCredentialsState(client, ctx, {
|
||||
shopDomain: shopDomainFromCallback,
|
||||
storefrontAccessToken,
|
||||
})
|
||||
|
||||
await client.configureIntegration({ identifier: shopDomainFromCallback })
|
||||
|
||||
return responses.redirectToStep('end')
|
||||
} catch (e) {
|
||||
logger.forBot().error({ err: e }, 'Shopify OAuth callback failed')
|
||||
return responses.endWizard({
|
||||
success: false,
|
||||
errorMessage: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const _endHandler: WizardHandler = ({ responses }) => responses.endWizard({ success: true })
|
||||
|
||||
export const normalizeShopDomain = (raw: string): string =>
|
||||
raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/.*$/, '')
|
||||
.replace(/\.myshopify\.com$/, '')
|
||||
|
||||
type StorefrontAccessTokenNode = { id: string; title: string; accessToken: string }
|
||||
|
||||
type StorefrontAccessTokensResponse = {
|
||||
shop: {
|
||||
storefrontAccessTokens: {
|
||||
edges: Array<{ node: StorefrontAccessTokenNode }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type StorefrontAccessTokenCreateResponse = {
|
||||
storefrontAccessTokenCreate: {
|
||||
storefrontAccessToken: StorefrontAccessTokenNode | null
|
||||
userErrors: Array<{ field: string[] | null; message: string }>
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotently ensure a Storefront Access Token exists for this shop. Reuses an existing
|
||||
// Botpress-labeled token if present, otherwise creates a new one via the Admin API.
|
||||
const _provisionStorefrontToken = async (admin: ShopifyAdminClient): Promise<string | undefined> => {
|
||||
const existing = await admin.query<StorefrontAccessTokensResponse>(STOREFRONT_ACCESS_TOKENS_QUERY)
|
||||
const found = existing.shop.storefrontAccessTokens.edges.find((e) => e.node.title === STOREFRONT_TOKEN_TITLE)
|
||||
if (found) {
|
||||
return found.node.accessToken
|
||||
}
|
||||
|
||||
const result = await admin.query<StorefrontAccessTokenCreateResponse>(STOREFRONT_ACCESS_TOKEN_CREATE, {
|
||||
input: { title: STOREFRONT_TOKEN_TITLE },
|
||||
})
|
||||
|
||||
if (result.storefrontAccessTokenCreate.userErrors.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return result.storefrontAccessTokenCreate.storefrontAccessToken?.accessToken
|
||||
}
|
||||
|
||||
type CredentialsPatch = {
|
||||
shopDomain?: string
|
||||
storefrontAccessToken?: string
|
||||
}
|
||||
|
||||
// `client.patchState` has known issues — merge manually via getState/setState
|
||||
const _patchCredentialsState = async (client: bp.Client, ctx: bp.Context, patch: CredentialsPatch) => {
|
||||
const current = await _getCredentialsState(client, ctx)
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'credentials',
|
||||
id: ctx.integrationId,
|
||||
payload: { ...current, ...patch },
|
||||
})
|
||||
}
|
||||
|
||||
const _getCredentialsState = async (client: bp.Client, ctx: bp.Context): Promise<CredentialsPatch> => {
|
||||
try {
|
||||
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
|
||||
return (state?.payload as CredentialsPatch | undefined) ?? {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async ({ logger }) => {
|
||||
// Storefront credentials (shop domain + storefront access token) are persisted by the OAuth
|
||||
// wizard (`src/oauth/wizard.ts`). No webhooks to subscribe — register is a no-op.
|
||||
logger.forBot().info('Shopify Storefront integration registered.')
|
||||
}
|
||||
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async ({ logger }) => {
|
||||
// No external resources to clean up; Storefront credentials are cleared with the integration state.
|
||||
logger.forBot().info('Shopify Storefront integration unregistered.')
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
transformStorefrontVariant,
|
||||
transformStorefrontProduct,
|
||||
transformCollection,
|
||||
transformCartLine,
|
||||
transformCart,
|
||||
} from './transformers'
|
||||
|
||||
describe('transformStorefrontVariant', () => {
|
||||
it('maps price as {amount, currencyCode}', () => {
|
||||
const result = transformStorefrontVariant({
|
||||
id: 'sv1',
|
||||
title: 'Small',
|
||||
availableForSale: true,
|
||||
price: { amount: '19.99', currencyCode: 'CAD' },
|
||||
})
|
||||
expect(result).toEqual({
|
||||
id: 'sv1',
|
||||
title: 'Small',
|
||||
availableForSale: true,
|
||||
price: { amount: '19.99', currencyCode: 'CAD' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformStorefrontProduct', () => {
|
||||
const baseProduct = {
|
||||
id: 'sp1',
|
||||
title: 'Widget',
|
||||
handle: 'widget',
|
||||
description: 'A widget',
|
||||
productType: 'Gadget',
|
||||
vendor: 'Acme',
|
||||
availableForSale: true,
|
||||
onlineStoreUrl: 'https://shop.myshopify.com/products/widget',
|
||||
priceRange: {
|
||||
minVariantPrice: { amount: '10.00', currencyCode: 'USD' },
|
||||
maxVariantPrice: { amount: '20.00', currencyCode: 'USD' },
|
||||
},
|
||||
variants: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: 'sv1',
|
||||
title: 'Default',
|
||||
availableForSale: true,
|
||||
price: { amount: '15.00', currencyCode: 'USD' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
images: { edges: [{ node: { url: 'https://cdn.shopify.com/image.jpg', altText: 'Widget' } }] },
|
||||
}
|
||||
|
||||
it('extracts imageUrl from first image edge', () => {
|
||||
expect(transformStorefrontProduct(baseProduct, 'shop').imageUrl).toBe('https://cdn.shopify.com/image.jpg')
|
||||
})
|
||||
|
||||
it('returns undefined imageUrl when images are empty', () => {
|
||||
expect(transformStorefrontProduct({ ...baseProduct, images: { edges: [] } }, 'shop').imageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps priceRange when present', () => {
|
||||
const result = transformStorefrontProduct(baseProduct, 'shop')
|
||||
expect(result.priceRange).toEqual({
|
||||
minVariantPrice: { amount: '10.00', currencyCode: 'USD' },
|
||||
maxVariantPrice: { amount: '20.00', currencyCode: 'USD' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined priceRange when absent', () => {
|
||||
const { priceRange: _, ...noRange } = baseProduct
|
||||
expect(transformStorefrontProduct(noRange, 'shop').priceRange).toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts null optional fields to undefined', () => {
|
||||
const result = transformStorefrontProduct(
|
||||
{ ...baseProduct, description: null, productType: null, vendor: null },
|
||||
'shop'
|
||||
)
|
||||
expect(result.description).toBeUndefined()
|
||||
expect(result.productType).toBeUndefined()
|
||||
expect(result.vendor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses onlineStoreUrl as storefrontUrl when present', () => {
|
||||
const result = transformStorefrontProduct(baseProduct, 'shop')
|
||||
expect(result.storefrontUrl).toBe('https://shop.myshopify.com/products/widget')
|
||||
})
|
||||
|
||||
it('falls back to constructed URL when onlineStoreUrl is null', () => {
|
||||
const result = transformStorefrontProduct({ ...baseProduct, onlineStoreUrl: null }, 'my-shop')
|
||||
expect(result.storefrontUrl).toBe('https://my-shop.myshopify.com/products/widget')
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformCollection', () => {
|
||||
it('maps all fields', () => {
|
||||
const result = transformCollection({
|
||||
id: 'col1',
|
||||
title: 'Summer',
|
||||
handle: 'summer',
|
||||
description: 'Summer collection',
|
||||
image: { url: 'https://cdn.shopify.com/col.jpg', altText: 'Summer' },
|
||||
})
|
||||
expect(result).toEqual({
|
||||
id: 'col1',
|
||||
title: 'Summer',
|
||||
handle: 'summer',
|
||||
description: 'Summer collection',
|
||||
imageUrl: 'https://cdn.shopify.com/col.jpg',
|
||||
})
|
||||
})
|
||||
|
||||
it('converts null image to undefined imageUrl', () => {
|
||||
expect(
|
||||
transformCollection({ id: 'col1', title: 'Summer', handle: 'summer', description: null, image: null }).imageUrl
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformCartLine', () => {
|
||||
it('maps merchandise fields', () => {
|
||||
const result = transformCartLine({
|
||||
id: 'line1',
|
||||
quantity: 2,
|
||||
merchandise: {
|
||||
id: 'merch1',
|
||||
title: 'Small',
|
||||
price: { amount: '25.00', currencyCode: 'USD' },
|
||||
product: { title: 'Widget' },
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
lineId: 'line1',
|
||||
quantity: 2,
|
||||
merchandiseId: 'merch1',
|
||||
title: 'Widget',
|
||||
variantTitle: 'Small',
|
||||
price: { amount: '25.00', currencyCode: 'USD' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformCart', () => {
|
||||
const baseCart = {
|
||||
id: 'cart1',
|
||||
checkoutUrl: 'https://shop.myshopify.com/cart/checkout',
|
||||
totalQuantity: 3,
|
||||
cost: {
|
||||
totalAmount: { amount: '75.00', currencyCode: 'USD' },
|
||||
subtotalAmount: { amount: '70.00', currencyCode: 'USD' },
|
||||
},
|
||||
lines: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: 'line1',
|
||||
quantity: 3,
|
||||
merchandise: {
|
||||
id: 'merch1',
|
||||
title: 'Default',
|
||||
price: { amount: '25.00', currencyCode: 'USD' },
|
||||
product: { title: 'Widget' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
discountCodes: [{ code: 'SAVE10', applicable: true }],
|
||||
}
|
||||
|
||||
it('maps cart structure', () => {
|
||||
const result = transformCart(baseCart)
|
||||
expect(result.cartId).toBe('cart1')
|
||||
expect(result.totalQuantity).toBe(3)
|
||||
expect(result.totalAmount).toEqual({ amount: '75.00', currencyCode: 'USD' })
|
||||
expect(result.subtotalAmount).toEqual({ amount: '70.00', currencyCode: 'USD' })
|
||||
})
|
||||
|
||||
it('maps cart lines', () => {
|
||||
expect(transformCart(baseCart).lines).toHaveLength(1)
|
||||
expect(transformCart(baseCart).lines[0]!.title).toBe('Widget')
|
||||
})
|
||||
|
||||
it('passes through discountCodes', () => {
|
||||
expect(transformCart(baseCart).discountCodes).toEqual([{ code: 'SAVE10', applicable: true }])
|
||||
})
|
||||
|
||||
it('handles undefined discountCodes', () => {
|
||||
const { discountCodes: _, ...noDiscount } = baseCart
|
||||
expect(transformCart(noDiscount).discountCodes).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
type MoneyV2 = {
|
||||
amount: string
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
type StorefrontVariantNode = {
|
||||
id: string
|
||||
title: string
|
||||
availableForSale: boolean
|
||||
price: MoneyV2
|
||||
}
|
||||
|
||||
type StorefrontProductNode = {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
description: string | null
|
||||
productType: string | null
|
||||
vendor: string | null
|
||||
availableForSale: boolean
|
||||
onlineStoreUrl: string | null
|
||||
priceRange?: {
|
||||
minVariantPrice: MoneyV2
|
||||
maxVariantPrice: MoneyV2
|
||||
}
|
||||
variants: { edges: Array<{ node: StorefrontVariantNode }> }
|
||||
images: { edges: Array<{ node: { url: string; altText: string | null } }> }
|
||||
}
|
||||
|
||||
type CollectionNode = {
|
||||
id: string
|
||||
title: string
|
||||
handle: string
|
||||
description: string | null
|
||||
image: { url: string; altText: string | null } | null
|
||||
}
|
||||
|
||||
type CartLineNode = {
|
||||
id: string
|
||||
quantity: number
|
||||
merchandise: {
|
||||
id: string
|
||||
title: string
|
||||
price: MoneyV2
|
||||
product: { title: string }
|
||||
}
|
||||
}
|
||||
|
||||
export type CartNode = {
|
||||
id: string
|
||||
checkoutUrl: string
|
||||
totalQuantity: number
|
||||
cost: {
|
||||
totalAmount: MoneyV2
|
||||
subtotalAmount: MoneyV2
|
||||
}
|
||||
lines: { edges: Array<{ node: CartLineNode }> }
|
||||
discountCodes?: Array<{ code: string; applicable: boolean }>
|
||||
}
|
||||
|
||||
export const transformStorefrontVariant = (node: StorefrontVariantNode) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
availableForSale: node.availableForSale,
|
||||
price: { amount: node.price.amount, currencyCode: node.price.currencyCode },
|
||||
})
|
||||
|
||||
export const transformStorefrontProduct = (node: StorefrontProductNode, shopDomain: string) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
handle: node.handle,
|
||||
description: node.description ?? undefined,
|
||||
productType: node.productType ?? undefined,
|
||||
vendor: node.vendor ?? undefined,
|
||||
availableForSale: node.availableForSale,
|
||||
priceRange: node.priceRange
|
||||
? {
|
||||
minVariantPrice: {
|
||||
amount: node.priceRange.minVariantPrice.amount,
|
||||
currencyCode: node.priceRange.minVariantPrice.currencyCode,
|
||||
},
|
||||
maxVariantPrice: {
|
||||
amount: node.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: node.priceRange.maxVariantPrice.currencyCode,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
variants: node.variants.edges.map(({ node: v }) => transformStorefrontVariant(v)),
|
||||
imageUrl: node.images.edges[0]?.node.url ?? undefined,
|
||||
storefrontUrl: node.onlineStoreUrl ?? `https://${shopDomain}.myshopify.com/products/${node.handle}`,
|
||||
onlineStoreUrl: node.onlineStoreUrl ?? undefined,
|
||||
})
|
||||
|
||||
export const transformCollection = (node: CollectionNode) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
handle: node.handle,
|
||||
description: node.description ?? undefined,
|
||||
imageUrl: node.image?.url ?? undefined,
|
||||
})
|
||||
|
||||
export const transformCartLine = (node: CartLineNode) => ({
|
||||
lineId: node.id,
|
||||
quantity: node.quantity,
|
||||
merchandiseId: node.merchandise.id,
|
||||
title: node.merchandise.product.title,
|
||||
variantTitle: node.merchandise.title ?? undefined,
|
||||
price: { amount: node.merchandise.price.amount, currencyCode: node.merchandise.price.currencyCode },
|
||||
})
|
||||
|
||||
export const transformCart = (cart: CartNode) => ({
|
||||
cartId: cart.id,
|
||||
checkoutUrl: cart.checkoutUrl,
|
||||
totalQuantity: cart.totalQuantity,
|
||||
totalAmount: { amount: cart.cost.totalAmount.amount, currencyCode: cart.cost.totalAmount.currencyCode },
|
||||
subtotalAmount: { amount: cart.cost.subtotalAmount.amount, currencyCode: cart.cost.subtotalAmount.currencyCode },
|
||||
lines: cart.lines.edges.map(({ node }) => transformCartLine(node)),
|
||||
discountCodes: cart.discountCodes,
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts", "*.json"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user