---
title: Build a discounts UI with React Router
description: Create a UI that merchants can use to configure your discount function.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/discounts/build-ui-with-react-router?extension=javascript
  md: >-
    https://shopify.dev/docs/apps/build/discounts/build-ui-with-react-router.md?extension=javascript
api_type: functions
---

# Build a discounts UI with React Router

To build a UI that merchants can use to configure a Discount Function, you can use admin UI extensions to display a form on the discount details page, or you can add a page to a React Router app. This tutorial describes how to add a page to a React Router app, which will render the Discount Function editing UI at a path of your own specification.

## What you'll learn

In this tutorial, you'll learn how to do the following tasks:

1. **Scaffold a React Router app with the Shopify CLI**

   Build your app using the provided template with all necessary components.

2. **Understand the discount configuration UI**

   Learn how the example app supports creating and editing discounts.

3. **Review routes, UI components, and discount configuration**

   Explore the routes, forms, and Polaris components that create the merchant interface.

4. **Review server-side discount management**

   Examine how the app creates, reads, and updates discounts.

5. **Review GraphQL operations**

   Review the queries and mutations that retrieve resources and manage discounts.

6. **Set up the Discount Function**

   Scaffold the Function that applies product, order, and shipping discounts.

7. **Configure the Discount Function**

   Configure UI paths, metafields, input queries, and Function logic.

8. **Deploy your app to get the Function ID**

   Deploy your app so Shopify creates the Function ID required by the app intent links.

9. **Let Sidekick open your discount pages**

   Register `shopify/Discount` app intent links, and then deploy your app again so Sidekick can open your app's create and edit pages.

10. **Create a test discount**

    Use your app's interface to create an automatic discount for products, orders, and shipping.

11. **Test the discount**

    Test the discount in your dev store's cart and checkout flow.

![The UI for configuring the discount](https://shopify.dev/assets/assets/apps/discounts/discount-function-order-product-shipping-wFP7iSsc.png)

**Display this discount in the admin and Sidekick:**

By default, the discount type picker doesn't display a discount that you configure in a React Router app. When a discount creation intent is invoked without a `type`, the discount won't appear as an option. To have it appear there, and to let Sidekick open its create and edit pages, register `shopify/Discount` app intent links. See [Create and edit discounts with `shopify/Discount` intents](https://shopify.dev/docs/apps/build/sidekick/build-app-actions#create-and-edit-discounts-with-shopify-discount-intents). (A [discount UI extension](https://shopify.dev/docs/apps/build/discounts/build-ui-extension) is displayed automatically, with no app intent link required.) This tutorial walks through registering those app intent links in [Let Sidekick open your discount pages](#let-sidekick-open-your-discount-pages).

## Requirements

[Create a Partner account](https://www.shopify.com/partners)

[Create a dev store](https://shopify.dev/docs/apps/build/stores/development-stores#create-a-dev-store)

[Install Node.js](https://nodejs.org/en/download)

Install Node.js 22 or higher.

## Project

[View on GitHub](https://github.com/Shopify/discounts-reference-app/blob/main/examples/react-router-app)

## Scaffold the React Router app

To scaffold a complete React Router app, run this command to set up your development environment with a fully functional React Router app.

## Terminal

```terminal
shopify app init --template https://github.com/Shopify/discounts-reference-app/examples/react-router-app
```

This command sets up your development environment with a fully functional React Router app. The next steps walk you through the key components.

## Understand the discount configuration UI

The app structure includes these key directories.

* 📁 `components/` Reusable UI components
* 📁 `graphql/` GraphQL queries and mutations for functions, collections, and discounts
* 📁 `hooks/` Custom React hooks, including `useDiscountForm` for discount management
* 📁 `routes/` React Router routes handling app navigation, authentication, and discount management
* 📁 `types/` TypeScript definitions including form types, admin types, and generated types
* 📁 `utils/` Utility functions including navigation helpers

**Info:**

The app integrates [@shopify/polaris](https://www.npmjs.com/package/@shopify/polaris) components to match the Shopify admin interface.

## Review routes, UI components, and discount configuration

The route files manage discount creation and editing.

### Ensure the correct access scopes are set

Review the `shopify.app.toml` file to ensure the correct [access scopes](https://shopify.dev/docs/api/usage/access-scopes#authenticated-access-scopes) are set. Your app needs `write_discounts` and `read_products` scopes.

## shopify.app.toml

```toml
# This file stores configurations for your Shopify app.


scopes = "write_discounts,read_products"


[sidekick]
extensions_summary = "Create and edit discounts powered by Shopify Functions"


[webhooks]
api_version = "2024-10"


  # Handled by: /app/routes/webhooks.app.uninstalled.tsx
  [[webhooks.subscriptions]]
  uri = "/webhooks/app/uninstalled"
  topics = ["app/uninstalled"]


  # Handled by: /app/routes/webhooks.app.scopes_update.tsx
  [[webhooks.subscriptions]]
  topics = [ "app/scopes_update" ]
  uri = "/webhooks/app/scopes_update"


  # Webhooks can have filters
  # Only receive webhooks for product updates with a product price >= 10.00
  # See: https://shopify.dev/docs/apps/build/webhooks/customize/filters
  # [[webhooks.subscriptions]]
  # topics = ["products/update"]
  # uri = "/webhooks/products/update"
  # filter = "variants.price:>=10.00"


  # Mandatory compliance topic for public apps only
  # See: https://shopify.dev/docs/apps/build/privacy-law-compliance
  # [[webhooks.subscriptions]]
  # uri = "/webhooks/customers/data_request"
  # compliance_topics = ["customers/data_request"]


  # [[webhooks.subscriptions]]
  # uri = "/webhooks/customers/redact"
  # compliance_topics = ["customers/redact"]


  # [[webhooks.subscriptions]]
  # uri = "/webhooks/shop/redact"
  # compliance_topics = ["shop/redact"]
```

### Explore the create discount route

Review the discount creation logic in the create route file.

The [`action`](https://reactrouter.com/start/framework/actions) function processes form submissions through the [`discountCodeAppCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/discountCodeAppCreate) mutation.

### Examine the edit discount route

To understand discount editing, review the edit route file.

### Review the `DiscountForm` component

The DiscountForm component is responsible for rendering the form that allows merchants to configure the discount:

This form includes fields required by the discount creation mutation:

* `title`
* `method`
* `code`
* `combinesWith`
* `discountClasses`
* `usageLimit`
* `appliesOncePerCustomer`
* `startsAt`
* `endsAt`
* `metafield`

### Understand the `discountClasses` configuration

The form includes a section for selecting discount classes. This section allows merchants to apply discounts to `PRODUCT`, `ORDER`, and `SHIPPING` classes.

## app/components/DiscountForm/DiscountForm.tsx

```tsx
import {returnToDiscounts} from "app/utils/navigation";
import {useCallback, useMemo, useState} from "react";
import {Form} from "react-router";


import {useDiscountForm} from "../../hooks/useDiscountForm";
import {DiscountClass} from "../../types/admin.types.d";
import {DiscountMethod} from "../../types/types";
import {CollectionPicker} from "../CollectionPicker/CollectionPicker";
import {DatePickerField} from "../DatePickerField/DatePickerField";


interface SubmitError {
  message: string;
  field: string[];
}


interface DiscountFormProps {
  initialData?: {
    title: string;
    method: DiscountMethod;
    code: string;
    combinesWith: {
      orderDiscounts: boolean;
      productDiscounts: boolean;
      shippingDiscounts: boolean;
    };
    discountClasses: DiscountClass[];
    usageLimit: number | null;
    appliesOncePerCustomer: boolean;
    startsAt: string | Date;
    endsAt: string | Date | null;
    configuration: {
      cartLinePercentage: string;
      orderPercentage: string;
      deliveryPercentage: string;
      metafieldId?: string;
      collectionIds?: string[];
    };
  };
  collections: {id: string; title: string}[];
  isEditing?: boolean;
  submitErrors?: SubmitError[];
  isLoading?: boolean;
  success?: boolean;
}


const methodOptions = [
  {label: "Discount code", value: DiscountMethod.Code},
  {label: "Automatic discount", value: DiscountMethod.Automatic},
];


export function DiscountForm({
  initialData,
  collections: initialCollections,
  isEditing = false,
  submitErrors = [],
  success = false,
}: DiscountFormProps) {
  const {formState, setField, setConfigField, setCombinesWith, submit} =
    useDiscountForm({
      initialData,
    });


  const [collections, setCollections] =
    useState<DiscountFormProps["collections"]>(initialCollections);


  const today = useMemo(() => {
    const date = new Date();
    date.setHours(0, 0, 0, 0);
    return date;
  }, []);


  const errorBanner = useMemo(
    () =>
      submitErrors.length > 0 ? (
        <s-banner tone="critical">
          <p>There were some issues with your form submission:</p>
          <ul>
            {submitErrors.map(({message, field}, index) => (
              <li key={index}>
                {field.join(".")} {message}
              </li>
            ))}
          </ul>
        </s-banner>
      ) : null,
    [submitErrors],
  );


  const successBanner = useMemo(
    () =>
      success ? (
        <s-banner tone="success">
          <p>Discount saved successfully</p>
        </s-banner>
      ) : null,
    [success],
  );


  const handleCollectionSelect = useCallback(
    async (selectedCollections: {id: string; title: string}[]) => {
      setConfigField(
        "collectionIds",
        selectedCollections.map(collection => collection.id),
      );
      setCollections(selectedCollections);
    },
    [setConfigField],
  );


  const handleDiscountClassChange = useCallback(
    (discountClassValue: DiscountClass, checked: boolean) => {
      setField(
        "discountClasses",
        checked
          ? [...formState.discountClasses, discountClassValue]
          : formState.discountClasses.filter(
              discountClass => discountClass !== discountClassValue,
            ),
      );
    },
    [formState.discountClasses, setField],
  );


  const handleEndDateCheckboxChange = useCallback(
    (checked: boolean) => {
      if (!checked) {
        setField("endDate", null);
      } else if (!formState.endDate) {
        const tomorrow = new Date(formState.startDate || today);
        tomorrow.setDate(tomorrow.getDate() + 1);
        setField("endDate", tomorrow);
      }
    },
    [formState.startDate, formState.endDate, today, setField],
  );


  const handleDateChange = useCallback(
    (date: Date | [Date, Date]) => {
      if (Array.isArray(date)) {
        // Range mode
        setField("startDate", date[0]);
        setField("endDate", date[1]);
      } else {
        // Single mode
        setField("startDate", date);
        if (formState.endDate) {
          setField("endDate", null);
        }
      }
    },
    [setField, formState.endDate],
  );


  const getDatePickerValue = useCallback(():
    | Date
    | string
    | null
    | [Date | string | null, Date | string | null] => {
    if (formState.endDate) {
      return [formState.startDate, formState.endDate];
    }
    return formState.startDate;
  }, [formState.startDate, formState.endDate]);


  const handleReset = useCallback(() => {
    returnToDiscounts();
  }, []);


  return (
    <Form
      method="post"
      id="discount-form"
      data-save-bar
      onSubmit={submit}
      onReset={handleReset}
    >
      <input
        type="hidden"
        name="discount"
        value={JSON.stringify({
          title: formState.title,
          method: formState.method,
          code: formState.code,
          combinesWith: formState.combinesWith,
          discountClasses: formState.discountClasses,
          usageLimit:
            formState.usageLimit === ""
              ? null
              : parseInt(formState.usageLimit, 10),
          appliesOncePerCustomer: formState.appliesOncePerCustomer,
          startsAt: formState.startDate,
          endsAt: formState.endDate,
          configuration: {
            ...(formState.configuration.metafieldId
              ? {metafieldId: formState.configuration.metafieldId}
              : {}),
            cartLinePercentage: parseFloat(
              formState.configuration.cartLinePercentage,
            ),
            orderPercentage: parseFloat(
              formState.configuration.orderPercentage,
            ),
            deliveryPercentage: parseFloat(
              formState.configuration.deliveryPercentage,
            ),
            collectionIds: formState.configuration.collectionIds || [],
          },
        })}
      />
      <s-stack gap="base">
        {errorBanner}
        {successBanner}


        <s-stack gap="base">
          {/* Method section */}
          <s-section heading={isEditing ? "Edit discount" : "Create discount"}>
            <s-select
              label="Discount type"
              value={formState.method}
              onChange={(e: any) =>
                setField("method", e.target.value as DiscountMethod)
              }
              disabled={isEditing}
            >
              {methodOptions.map(option => (
                <s-option key={option.value} value={option.value}>
                  {option.label}
                </s-option>
              ))}
            </s-select>


            {formState.method === DiscountMethod.Automatic ? (
              <s-text-field
                label="Discount title"
                autocomplete="off"
                value={formState.title}
                onChange={(e: any) => setField("title", e.target.value)}
              />
            ) : (
              <s-text-field
                label="Discount code"
                autocomplete="off"
                value={formState.code}
                onChange={(e: any) => setField("code", e.target.value)}
              />
            )}
          </s-section>


          {/* Discount classes section */}


          <s-section heading="Discount Classes">
            <s-text>Select which types of discounts to apply</s-text>


            <s-stack gap="base">
              <s-checkbox
                label="Product discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Product,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Product,
                    e.target.checked,
                  )
                }
              />
              <s-checkbox
                label="Order discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Order,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Order,
                    e.target.checked,
                  )
                }
              />
              <s-checkbox
                label="Shipping discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Shipping,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Shipping,
                    e.target.checked,
                  )
                }
              />
            </s-stack>
          </s-section>


          <s-section heading="Discount Configuration">
            <s-stack gap="base">
              {formState.discountClasses?.includes(DiscountClass.Product) ? (
                <>
                  <s-number-field
                    label="Product discount percentage"
                    autocomplete="on"
                    min={0}
                    max={100}
                    suffix="%"
                    value={formState.configuration.cartLinePercentage}
                    onChange={(e: any) =>
                      setConfigField("cartLinePercentage", e.target.value)
                    }
                  />
                  <CollectionPicker
                    onSelect={handleCollectionSelect}
                    selectedCollectionIds={
                      formState.configuration.collectionIds || []
                    }
                    collections={
                      formState.configuration.collections || collections
                    }
                    buttonText="Select collections for discount"
                  />
                </>
              ) : null}


              {formState.discountClasses?.includes(DiscountClass.Order) ? (
                <s-number-field
                  label="Order discount percentage"
                  autocomplete="on"
                  min={0}
                  max={100}
                  suffix="%"
                  value={formState.configuration.orderPercentage}
                  onChange={(e: any) =>
                    setConfigField("orderPercentage", e.target.value)
                  }
                />
              ) : null}


              {formState.discountClasses?.includes(DiscountClass.Shipping) ? (
                <s-number-field
                  label="Shipping discount percentage"
                  autocomplete="on"
                  min={0}
                  max={100}
                  suffix="%"
                  value={formState.configuration.deliveryPercentage}
                  onChange={(e: any) =>
                    setConfigField("deliveryPercentage", e.target.value)
                  }
                />
              ) : null}
            </s-stack>
          </s-section>


          {/* Usage limits section */}
          {formState.method === DiscountMethod.Code ? (
            <s-section heading="Usage limits">
              <s-number-field
                label="Usage limit"
                autocomplete="on"
                min={0}
                placeholder="No limit"
                value={formState.usageLimit}
                onChange={(e: any) => setField("usageLimit", e.target.value)}
              />
              <s-checkbox
                label="Limit to one use per customer"
                checked={formState.appliesOncePerCustomer}
                onChange={(e: any) =>
                  setField("appliesOncePerCustomer", e.target.checked)
                }
              />
            </s-section>
          ) : null}


          {/* Combination section */}
          <s-section heading="Combination">
            <s-text>
              Select which discounts can be combined with this discount
            </s-text>


            <s-checkbox
              label="Order discounts"
              checked={formState.combinesWith.orderDiscounts}
              onChange={(e: any) =>
                setCombinesWith("orderDiscounts", e.target.checked)
              }
            />


            <s-checkbox
              label="Product discounts"
              checked={formState.combinesWith.productDiscounts}
              onChange={(e: any) =>
                setCombinesWith("productDiscounts", e.target.checked)
              }
            />


            <s-checkbox
              label="Shipping discounts"
              checked={formState.combinesWith.shippingDiscounts}
              onChange={(e: any) =>
                setCombinesWith("shippingDiscounts", e.target.checked)
              }
            />
          </s-section>


          {/* Active dates section */}
          <s-section heading="Active dates">
            <s-stack gap="base">
              <DatePickerField
                label={formState.endDate ? "Date range" : "Start date"}
                type={formState.endDate ? "range" : "single"}
                value={getDatePickerValue()}
                onChange={handleDateChange}
                minDate={today}
              />


              <s-checkbox
                label="Set end date"
                checked={!!formState.endDate}
                onChange={(e: any) =>
                  handleEndDateCheckboxChange(e.target.checked)
                }
              />
            </s-stack>
          </s-section>
        </s-stack>
      </s-stack>
    </Form>
  );
}
```

### Review the discount percentage configuration

Examine how merchants set discount percentages.

This section of the form allows merchants to set the discount percentage for each class, using a number input.

## app/components/DiscountForm/DiscountForm.tsx

```tsx
import {returnToDiscounts} from "app/utils/navigation";
import {useCallback, useMemo, useState} from "react";
import {Form} from "react-router";


import {useDiscountForm} from "../../hooks/useDiscountForm";
import {DiscountClass} from "../../types/admin.types.d";
import {DiscountMethod} from "../../types/types";
import {CollectionPicker} from "../CollectionPicker/CollectionPicker";
import {DatePickerField} from "../DatePickerField/DatePickerField";


interface SubmitError {
  message: string;
  field: string[];
}


interface DiscountFormProps {
  initialData?: {
    title: string;
    method: DiscountMethod;
    code: string;
    combinesWith: {
      orderDiscounts: boolean;
      productDiscounts: boolean;
      shippingDiscounts: boolean;
    };
    discountClasses: DiscountClass[];
    usageLimit: number | null;
    appliesOncePerCustomer: boolean;
    startsAt: string | Date;
    endsAt: string | Date | null;
    configuration: {
      cartLinePercentage: string;
      orderPercentage: string;
      deliveryPercentage: string;
      metafieldId?: string;
      collectionIds?: string[];
    };
  };
  collections: {id: string; title: string}[];
  isEditing?: boolean;
  submitErrors?: SubmitError[];
  isLoading?: boolean;
  success?: boolean;
}


const methodOptions = [
  {label: "Discount code", value: DiscountMethod.Code},
  {label: "Automatic discount", value: DiscountMethod.Automatic},
];


export function DiscountForm({
  initialData,
  collections: initialCollections,
  isEditing = false,
  submitErrors = [],
  success = false,
}: DiscountFormProps) {
  const {formState, setField, setConfigField, setCombinesWith, submit} =
    useDiscountForm({
      initialData,
    });


  const [collections, setCollections] =
    useState<DiscountFormProps["collections"]>(initialCollections);


  const today = useMemo(() => {
    const date = new Date();
    date.setHours(0, 0, 0, 0);
    return date;
  }, []);


  const errorBanner = useMemo(
    () =>
      submitErrors.length > 0 ? (
        <s-banner tone="critical">
          <p>There were some issues with your form submission:</p>
          <ul>
            {submitErrors.map(({message, field}, index) => (
              <li key={index}>
                {field.join(".")} {message}
              </li>
            ))}
          </ul>
        </s-banner>
      ) : null,
    [submitErrors],
  );


  const successBanner = useMemo(
    () =>
      success ? (
        <s-banner tone="success">
          <p>Discount saved successfully</p>
        </s-banner>
      ) : null,
    [success],
  );


  const handleCollectionSelect = useCallback(
    async (selectedCollections: {id: string; title: string}[]) => {
      setConfigField(
        "collectionIds",
        selectedCollections.map(collection => collection.id),
      );
      setCollections(selectedCollections);
    },
    [setConfigField],
  );


  const handleDiscountClassChange = useCallback(
    (discountClassValue: DiscountClass, checked: boolean) => {
      setField(
        "discountClasses",
        checked
          ? [...formState.discountClasses, discountClassValue]
          : formState.discountClasses.filter(
              discountClass => discountClass !== discountClassValue,
            ),
      );
    },
    [formState.discountClasses, setField],
  );


  const handleEndDateCheckboxChange = useCallback(
    (checked: boolean) => {
      if (!checked) {
        setField("endDate", null);
      } else if (!formState.endDate) {
        const tomorrow = new Date(formState.startDate || today);
        tomorrow.setDate(tomorrow.getDate() + 1);
        setField("endDate", tomorrow);
      }
    },
    [formState.startDate, formState.endDate, today, setField],
  );


  const handleDateChange = useCallback(
    (date: Date | [Date, Date]) => {
      if (Array.isArray(date)) {
        // Range mode
        setField("startDate", date[0]);
        setField("endDate", date[1]);
      } else {
        // Single mode
        setField("startDate", date);
        if (formState.endDate) {
          setField("endDate", null);
        }
      }
    },
    [setField, formState.endDate],
  );


  const getDatePickerValue = useCallback(():
    | Date
    | string
    | null
    | [Date | string | null, Date | string | null] => {
    if (formState.endDate) {
      return [formState.startDate, formState.endDate];
    }
    return formState.startDate;
  }, [formState.startDate, formState.endDate]);


  const handleReset = useCallback(() => {
    returnToDiscounts();
  }, []);


  return (
    <Form
      method="post"
      id="discount-form"
      data-save-bar
      onSubmit={submit}
      onReset={handleReset}
    >
      <input
        type="hidden"
        name="discount"
        value={JSON.stringify({
          title: formState.title,
          method: formState.method,
          code: formState.code,
          combinesWith: formState.combinesWith,
          discountClasses: formState.discountClasses,
          usageLimit:
            formState.usageLimit === ""
              ? null
              : parseInt(formState.usageLimit, 10),
          appliesOncePerCustomer: formState.appliesOncePerCustomer,
          startsAt: formState.startDate,
          endsAt: formState.endDate,
          configuration: {
            ...(formState.configuration.metafieldId
              ? {metafieldId: formState.configuration.metafieldId}
              : {}),
            cartLinePercentage: parseFloat(
              formState.configuration.cartLinePercentage,
            ),
            orderPercentage: parseFloat(
              formState.configuration.orderPercentage,
            ),
            deliveryPercentage: parseFloat(
              formState.configuration.deliveryPercentage,
            ),
            collectionIds: formState.configuration.collectionIds || [],
          },
        })}
      />
      <s-stack gap="base">
        {errorBanner}
        {successBanner}


        <s-stack gap="base">
          {/* Method section */}
          <s-section heading={isEditing ? "Edit discount" : "Create discount"}>
            <s-select
              label="Discount type"
              value={formState.method}
              onChange={(e: any) =>
                setField("method", e.target.value as DiscountMethod)
              }
              disabled={isEditing}
            >
              {methodOptions.map(option => (
                <s-option key={option.value} value={option.value}>
                  {option.label}
                </s-option>
              ))}
            </s-select>


            {formState.method === DiscountMethod.Automatic ? (
              <s-text-field
                label="Discount title"
                autocomplete="off"
                value={formState.title}
                onChange={(e: any) => setField("title", e.target.value)}
              />
            ) : (
              <s-text-field
                label="Discount code"
                autocomplete="off"
                value={formState.code}
                onChange={(e: any) => setField("code", e.target.value)}
              />
            )}
          </s-section>


          {/* Discount classes section */}


          <s-section heading="Discount Classes">
            <s-text>Select which types of discounts to apply</s-text>


            <s-stack gap="base">
              <s-checkbox
                label="Product discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Product,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Product,
                    e.target.checked,
                  )
                }
              />
              <s-checkbox
                label="Order discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Order,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Order,
                    e.target.checked,
                  )
                }
              />
              <s-checkbox
                label="Shipping discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Shipping,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Shipping,
                    e.target.checked,
                  )
                }
              />
            </s-stack>
          </s-section>


          <s-section heading="Discount Configuration">
            <s-stack gap="base">
              {formState.discountClasses?.includes(DiscountClass.Product) ? (
                <>
                  <s-number-field
                    label="Product discount percentage"
                    autocomplete="on"
                    min={0}
                    max={100}
                    suffix="%"
                    value={formState.configuration.cartLinePercentage}
                    onChange={(e: any) =>
                      setConfigField("cartLinePercentage", e.target.value)
                    }
                  />
                  <CollectionPicker
                    onSelect={handleCollectionSelect}
                    selectedCollectionIds={
                      formState.configuration.collectionIds || []
                    }
                    collections={
                      formState.configuration.collections || collections
                    }
                    buttonText="Select collections for discount"
                  />
                </>
              ) : null}


              {formState.discountClasses?.includes(DiscountClass.Order) ? (
                <s-number-field
                  label="Order discount percentage"
                  autocomplete="on"
                  min={0}
                  max={100}
                  suffix="%"
                  value={formState.configuration.orderPercentage}
                  onChange={(e: any) =>
                    setConfigField("orderPercentage", e.target.value)
                  }
                />
              ) : null}


              {formState.discountClasses?.includes(DiscountClass.Shipping) ? (
                <s-number-field
                  label="Shipping discount percentage"
                  autocomplete="on"
                  min={0}
                  max={100}
                  suffix="%"
                  value={formState.configuration.deliveryPercentage}
                  onChange={(e: any) =>
                    setConfigField("deliveryPercentage", e.target.value)
                  }
                />
              ) : null}
            </s-stack>
          </s-section>


          {/* Usage limits section */}
          {formState.method === DiscountMethod.Code ? (
            <s-section heading="Usage limits">
              <s-number-field
                label="Usage limit"
                autocomplete="on"
                min={0}
                placeholder="No limit"
                value={formState.usageLimit}
                onChange={(e: any) => setField("usageLimit", e.target.value)}
              />
              <s-checkbox
                label="Limit to one use per customer"
                checked={formState.appliesOncePerCustomer}
                onChange={(e: any) =>
                  setField("appliesOncePerCustomer", e.target.checked)
                }
              />
            </s-section>
          ) : null}


          {/* Combination section */}
          <s-section heading="Combination">
            <s-text>
              Select which discounts can be combined with this discount
            </s-text>


            <s-checkbox
              label="Order discounts"
              checked={formState.combinesWith.orderDiscounts}
              onChange={(e: any) =>
                setCombinesWith("orderDiscounts", e.target.checked)
              }
            />


            <s-checkbox
              label="Product discounts"
              checked={formState.combinesWith.productDiscounts}
              onChange={(e: any) =>
                setCombinesWith("productDiscounts", e.target.checked)
              }
            />


            <s-checkbox
              label="Shipping discounts"
              checked={formState.combinesWith.shippingDiscounts}
              onChange={(e: any) =>
                setCombinesWith("shippingDiscounts", e.target.checked)
              }
            />
          </s-section>


          {/* Active dates section */}
          <s-section heading="Active dates">
            <s-stack gap="base">
              <DatePickerField
                label={formState.endDate ? "Date range" : "Start date"}
                type={formState.endDate ? "range" : "single"}
                value={getDatePickerValue()}
                onChange={handleDateChange}
                minDate={today}
              />


              <s-checkbox
                label="Set end date"
                checked={!!formState.endDate}
                onChange={(e: any) =>
                  handleEndDateCheckboxChange(e.target.checked)
                }
              />
            </s-stack>
          </s-section>
        </s-stack>
      </s-stack>
    </Form>
  );
}
```

### Explore the `CollectionPicker` component

Review how collection selection works.

This section uses the AppBridge `ResourcePicker` component to allow merchants to select collections, to make sure that discounts are applied to the expected products.

**Tip:**

The discount configuration uses metafields for storage. Learn more about [using metafields with input queries](https://shopify.dev/docs/apps/build/functions/input-queries/metafields-for-input-queries).

## Review server-side discount management

To learn how the React Router app handles server-side operations, examine the files in the app/models directory. These files query and mutate resources using Shopify's GraphQL Admin API.

### Explore functions server file

This file queries the Functions associated with your app. We use this query to populate the `functionId` field used in the `discountAutomaticAppCreate` and `discountCodeAppCreate` mutations.

### Review collections server file

This file handles collection data stored in discount metafields. We use this query to populate the list of collections displayed in the section where merchants can select collections to target with the discount. When editing a discount, we use this query to populate the list of collections displayed below the resource picker.

### Examine discounts server file

This file manages discount creation, updates, and retrieval. We use this file to create, read, and update discounts.

## Review Graph​QL operations

In this step, you'll examine the app's GraphQL queries and mutations. These operations communicate with the Shopify Admin GraphQL API to create, read, and update discounts, retrieve collections, and retrieve functions.

### Review discount graphql file

These queries and mutations handle, retrieving discounts, creating code and automatic discounts, and updating code and automatic discounts.

### Examine collections graphql file

This file contains queries for collection data which is used to populate the list of collections displayed in the section where merchants can select collections. When editing a discount, we use this query to populate the list of collections displayed below the resource picker.

### Review functions graphql file

This query returns functions for your app when the app is installed on a merchant's store. This example uses this query to populate the app's home page, which allows you to navigate to the create discount page and it also populates the `functionId` field used in the `discountAutomaticAppCreate` and `discountCodeAppCreate` mutations.

## Set up the Discount Function

Now, create a Discount Function. This function will be used to apply discounts to products, orders, and shipping, and merchants can configure these discounts using your React Router app's UI.

Run this command to scaffold your Discount Function:

## Terminal

```terminal
shopify app generate extension --template discount --name discount-function-js
```

## Configure the Discount Function

In this step, you'll configure the Discount Function to apply discounts to products, orders, and shipping based on the discount configuration that is stored on the discount instance and its metafield.

**Caution:**

Your Function should only return operations for `discountClasses` that the discount applies to. For example, if the discount is configured to apply to `PRODUCT` and `ORDER`, but not `SHIPPING`, your Function should only return operations for `PRODUCT` and `ORDER`.

### Define the UI paths and input variables

1. Update the UI paths in `shopify.extension.toml`. This property tells the Shopify admin where to find the UI that allows merchants to configure discounts associated with your Discount Function.
2. Register a metafield variable that your Function will use as a dynamic input. Refer to [variables in input queries](https://shopify.dev/docs/apps/build/functions/input-queries/use-variables-input-queries) for more information. In this example, the `collectionIds` property of the metafield object is used as the input variable for the Function.

## extensions/shopify.extension.toml

```toml
api_version = "2025-04"


[[extensions]]
name = "t:name"
description = "t:description"
handle = "discount-function-rs"
type = "function"


  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/generate_cart_run.graphql"
  export = "generate-cart-run"


  [[extensions.targeting]]
  target = "cart.delivery-options.discounts.generate.run"
  input_query = "src/generate_delivery_run.graphql"
  export = "generate-delivery-run"


  [extensions.build]
  command = ""
  path = "dist/function.wasm"


[extensions.input.variables]
namespace = "$app:example-discounts--ui-extension"
key = "function-configuration"


[extensions.ui]
handle = "ui-multiclass-metafield-js"


[extensions.ui.paths]
create = "/app/discount/:functionId/new"
details = "/app/discount/:functionId/:id"


# Register app intent links so Sidekick can create and edit discounts backed by
# this app's Function. Each admin.app.intent.link target maps a shopify/Discount
# action (create or edit) to a page in your app.
[[extensions]]
name = "Create discount"
description = "Create a metafield-configured discount powered by this app's Shopify Function."
handle = "discount-app-intent-link-create"
type = "admin_link"


  [[extensions.targeting]]
  target = "admin.app.intent.link"
  # Hardcode your deployed Function's ID (see "Set your Function ID" below).
  url = "/app/discount/YOUR_FUNCTION_ID/new"
  instructions = "./instructions.md"


  [[extensions.targeting.intents]]
  type = "shopify/Discount"
  action = "create"
  schema = "./create-intent-schema.json"


[[extensions]]
name = "Edit discount"
description = "Edit a metafield-configured discount powered by this app's Shopify Function."
handle = "discount-app-intent-link-edit"
type = "admin_link"


  [[extensions.targeting]]
  target = "admin.app.intent.link"
  # {id} receives the discount ID; the Function ID stays hardcoded.
  url = "/app/discount/YOUR_FUNCTION_ID/{id}"
  instructions = "./instructions.md"


  [[extensions.targeting.intents]]
  type = "shopify/Discount"
  action = "edit"
  schema = "./edit-intent-schema.json"
```

### Query the data needed for your Function cart run target

The `cart_lines_discounts_generate_run.graphql` file drives your function logic by querying essential cart data which is used as the input for your Function. This file queries:

* Cart properties to use with the `inAnyCollection` field for determining which collections your Function will target, `$collectionIds` are passed to the query as a variable.
* The `discountClasses` property to identify which discount classes (PRODUCT, ORDER, SHIPPING) your Function will return discounts for.
* Metafield data to retrieve collection IDs and discount percentage values. The metafield is queried by its key and namespace.

**Tip:**

The [`inAnyCollection`](https://shopify.dev/docs/api/functions/reference/discount/graphql/common-objects/product) field is used to determine whether a product belongs to one of the specified collections. This field is `true` when a product variant is associated with the specified set of collections, and `false` otherwise. Note that if the collection set is empty, it returns `false`.

## extensions/discount-function/src/cart\_lines\_discounts\_generate\_run.graphql

```graphql
query CartInput($collectionIds: [ID!]) {
  cart {
    lines {
      id
      cost {
        subtotalAmount {
          amount
        }
      }
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            inAnyCollection(ids: $collectionIds)
          }
        }
      }
    }
  }
  discount {
    discountClasses
    metafield(
      namespace: "$app"
      key: "function-configuration"
    ) {
      value
    }
  }
}
```

### Query the data needed for your Function delivery run target

The `cart_delivery_options_discounts_generate_run.graphql` file drives your function logic by querying essential delivery data which is used as the input for your Function. This file queries:

* Cart `deliveryGroups` to retrieve the delivery options available to the customer.
* The `discountClasses` property to determine whether the SHIPPING discount class is set.
* Metafield data to retrieve the discount percentage for delivery options. The metafield is queried by its key and namespace.

**Note:**

Checkouts and orders can include multiple delivery methods, such as shipping and pickup in the same order. When your app uses delivery or fulfillment data, iterate over all delivery groups or fulfillment orders to determine the delivery method for each one. Don't assume one method for the order. For more information, refer to [split carts in checkout](https://shopify.dev/docs/apps/build/orders-fulfillment/split-carts).

## extensions/discount-function/src/cart\_delivery\_options\_discounts\_generate\_run.graphql

```graphql
query DeliveryInput {
  cart {
    deliveryGroups {
      id
    }
  }
  discount {
    discountClasses
    metafield(
      namespace: "$app"
      key: "function-configuration"
    ) {
      value
    }
  }
}
```

### Create your cart run Function logic

Using the input data from the `cart_lines_discounts_generate_run.graphql` file, you can create your Function's logic.

In this example, you retrieve the metafield object which contains the cart line and order discount percentages, the collection IDs for which the discount applies and the discountClasses that your discount will apply to. You can then use this data to create your Function's logic.

First, you parse the metafield, then you can conditionally add [ProductDiscountsAddOperation](https://shopify.dev/docs/api/functions/reference/discount/graphql/common-objects/productdiscountsaddoperation) and [OrderDiscountsAddOperation](https://shopify.dev/docs/api/functions/reference/discount/graphql/common-objects/orderdiscountsaddoperation) operations to the return value based on whether the cart line's product is part of a collection that your discount targets, and whether the discountClasses for the discount are set to `PRODUCT` or `ORDER`.

## extensions/discount-function/src/cart\_lines\_discounts\_generate\_run.js

```javascript
import {
  OrderDiscountSelectionStrategy,
  ProductDiscountSelectionStrategy,
  DiscountClass,
} from "../generated/api";


export function cartLinesDiscountsGenerateRun(input) {
  if (!input.cart.lines.length) {
    throw new Error("No cart lines found");
  }


  const {cartLinePercentage, orderPercentage, collectionIds} = parseMetafield(
    input.discount.metafield,
  );


  const hasOrderDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Order,
  );
  const hasProductDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Product,
  );


  if (!hasOrderDiscountClass && !hasProductDiscountClass) {
    return {operations: []};
  }


  const operations = [];
  // Add product discounts first if available and allowed
  if (hasProductDiscountClass && cartLinePercentage > 0) {
    const cartLineTargets = input.cart.lines.reduce((targets, line) => {
      if (
        "product" in line.merchandise &&
        (line.merchandise.product.inAnyCollection || collectionIds.length === 0)
      ) {
        targets.push({
          cartLine: {
            id: line.id,
          },
        });
      }
      return targets;
    }, []);


    if (cartLineTargets.length > 0) {
      operations.push({
        productDiscountsAdd: {
          candidates: [
            {
              message: `${cartLinePercentage}% OFF PRODUCT`,
              targets: cartLineTargets,
              value: {
                percentage: {
                  value: cartLinePercentage,
                },
              },
            },
          ],
          selectionStrategy: ProductDiscountSelectionStrategy.First,
        },
      });
    }
  }


  // Then add order discounts if available and allowed
  if (hasOrderDiscountClass && orderPercentage > 0) {
    operations.push({
      orderDiscountsAdd: {
        candidates: [
          {
            message: `${orderPercentage}% OFF ORDER`,
            targets: [
              {
                orderSubtotal: {
                  excludedCartLineIds: [],
                },
              },
            ],
            value: {
              percentage: {
                value: orderPercentage,
              },
            },
          },
        ],
        selectionStrategy: OrderDiscountSelectionStrategy.First,
      },
    });
  }


  return {operations};
}


function parseMetafield(metafield) {
  try {
    const value = JSON.parse(metafield.value);
    return {
      cartLinePercentage: value.cartLinePercentage || 0,
      orderPercentage: value.orderPercentage || 0,
      collectionIds: value.collectionIds || [],
    };
  } catch (error) {
    console.error("Error parsing metafield", error);
    return {
      cartLinePercentage: 0,
      orderPercentage: 0,
      collectionIds: [],
    };
  }
}
```

### Create your delivery run Function logic

Using the input data from the `cart_delivery_options_discounts_generate_run.graphql` file, you can create your Function's logic.

In this example, you retrieve the metafield object which contains the delivery discount percentage. You can then use this data to create your Function's logic.

First, you parse the metafield, then you can conditionally add [DeliveryDiscountsAddOperation](https://shopify.dev/docs/api/functions/reference/discount/graphql/common-objects/deliverydiscountsaddoperation) operations to the return value based on whether the discountClasses for the discount are set to `SHIPPING`.

## extensions/discount-function/src/cart\_delivery\_options\_discounts\_generate\_run.js

```javascript
import {
  DeliveryDiscountSelectionStrategy,
  DiscountClass,
} from "../generated/api";


export function cartDeliveryOptionsDiscountsGenerateRun(input) {
  const firstDeliveryGroup = input.cart.deliveryGroups[0];
  if (!firstDeliveryGroup) {
    throw new Error("No delivery groups found");
  }


  const {deliveryPercentage} = parseMetafield(input.discount.metafield);
  const hasShippingDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Shipping,
  );
  if (!hasShippingDiscountClass) {
    return {operations: []};
  }


  const operations = [];
  if (hasShippingDiscountClass && deliveryPercentage > 0) {
    operations.push({
      deliveryDiscountsAdd: {
        candidates: [
          {
            message: `${deliveryPercentage}% OFF DELIVERY`,
            targets: [
              {
                deliveryGroup: {
                  id: firstDeliveryGroup.id,
                },
              },
            ],
            value: {
              percentage: {
                value: deliveryPercentage,
              },
            },
          },
        ],
        selectionStrategy: DeliveryDiscountSelectionStrategy.All,
      },
    });
  }
  return {operations};
}


function parseMetafield(metafield) {
  try {
    const value = JSON.parse(metafield.value);
    return {deliveryPercentage: value.deliveryPercentage || 0};
  } catch (error) {
    console.error("Error parsing metafield", error);
    return {deliveryPercentage: 0};
  }
}
```

## Deploy your app to get the Function ID

Deploy your app so Shopify creates the Function ID that you'll use to register the app intent links in the next step.

When you're ready to release your changes to users, you can create and release an [app version](https://shopify.dev/docs/apps/launch/deployment/app-versions). An app version is a snapshot of your app configuration and all extensions.

1. Navigate to your app directory.

2. Run the following command.

   Optionally, you can provide a name or message for the version using the `--version` and `--message` flags.

   ## Terminal

   ```terminal
   shopify app deploy
   ```

Releasing an app version replaces the current active version that's served to stores that have your app installed. It might take several minutes for app users to be upgraded to the new version.

**Tip:**

If you want to create a version, but avoid releasing it to users, then run the `deploy` command with a `--no-release` flag. You can release the unreleased app version using Shopify CLI's [`release`](https://shopify.dev/docs/api/shopify-cli/app/app-release) command, or through the Dev Dashboard.

## Let Sidekick open your discount pages

After the initial deployment, register `shopify/Discount` app intent links so Sidekick can open the create and edit pages for discounts backed by your Function. After you add the intent-link configuration, you'll deploy your app again so the links take effect.

You declare the app intent links in the same `shopify.extension.toml` that configures your Discount Function (the file with `[extensions.ui.paths]`), by adding an `admin.app.intent.link` target for each action. For more on `shopify/Discount` intents, see [Create and edit discounts with `shopify/Discount` intents](https://shopify.dev/docs/apps/build/sidekick/build-app-actions#create-and-edit-discounts-with-shopify-discount-intents).

### Add the create and edit app intent links

In your Discount Function's `shopify.extension.toml`, add an `admin.app.intent.link` target for each action, with `type = "shopify/Discount"`. Point each `url` at the pages you built: the `create` URL hardcodes your deployed Function ID, and the `edit` URL keeps the Function ID hardcoded while the discount's ID fills the `{id}` segment.

## extensions/shopify.extension.toml

```toml
api_version = "2025-04"


[[extensions]]
name = "t:name"
description = "t:description"
handle = "discount-function-rs"
type = "function"


  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/generate_cart_run.graphql"
  export = "generate-cart-run"


  [[extensions.targeting]]
  target = "cart.delivery-options.discounts.generate.run"
  input_query = "src/generate_delivery_run.graphql"
  export = "generate-delivery-run"


  [extensions.build]
  command = ""
  path = "dist/function.wasm"


[extensions.input.variables]
namespace = "$app:example-discounts--ui-extension"
key = "function-configuration"


[extensions.ui]
handle = "ui-multiclass-metafield-js"


[extensions.ui.paths]
create = "/app/discount/:functionId/new"
details = "/app/discount/:functionId/:id"


# Register app intent links so Sidekick can create and edit discounts backed by
# this app's Function. Each admin.app.intent.link target maps a shopify/Discount
# action (create or edit) to a page in your app.
[[extensions]]
name = "Create discount"
description = "Create a metafield-configured discount powered by this app's Shopify Function."
handle = "discount-app-intent-link-create"
type = "admin_link"


  [[extensions.targeting]]
  target = "admin.app.intent.link"
  # Hardcode your deployed Function's ID (see "Set your Function ID" below).
  url = "/app/discount/YOUR_FUNCTION_ID/new"
  instructions = "./instructions.md"


  [[extensions.targeting.intents]]
  type = "shopify/Discount"
  action = "create"
  schema = "./create-intent-schema.json"


[[extensions]]
name = "Edit discount"
description = "Edit a metafield-configured discount powered by this app's Shopify Function."
handle = "discount-app-intent-link-edit"
type = "admin_link"


  [[extensions.targeting]]
  target = "admin.app.intent.link"
  # {id} receives the discount ID; the Function ID stays hardcoded.
  url = "/app/discount/YOUR_FUNCTION_ID/{id}"
  instructions = "./instructions.md"


  [[extensions.targeting.intents]]
  type = "shopify/Discount"
  action = "edit"
  schema = "./edit-intent-schema.json"
```

### Define the create intent schema

The create intent schema references the `shopify/Discount` baseline schema and sets `matchValue` on `functionId`, so Shopify routes a `create:shopify/Discount` invocation carrying a matching Function ID to your app.

### Define the edit intent schema

The edit intent schema adds a top-level `value` that receives the GID of the discount being edited and maps it into the `{id}` URL segment with `mapTo: "param"`.

### Resolve the intent after save or cancel

When the merchant saves a discount from an intent, return the discount GID declared by the intent's `outputSchema` by calling `shopify.intents.response.ok({id})`. When the merchant cancels, call `shopify.intents.response.closed()` instead. These responses return control to Sidekick or whichever surface invoked the intent.

The create and edit actions return the discount GID from the Admin API mutation. After a successful action, each route passes that GID to a shared helper:

The helper checks `shopify.intents.request.value` to determine whether the page was opened through an intent. For an intent, it resolves the workflow. For a direct visit, it preserves the existing navigation back to the Discounts page.

## app/routes/app.discount.$functionId.new\.tsx

```tsx
import {useEffect, useRef} from "react";
import {
  type ActionFunctionArgs,
  useActionData,
  useLoaderData,
  useNavigation,
} from "react-router";


import {DiscountForm} from "../components/DiscountForm/DiscountForm";
import {
  createCodeDiscount,
  createAutomaticDiscount,
} from "../models/discounts.server";
import {DiscountMethod} from "../types/types";
import {completeDiscountWorkflow, returnToDiscounts} from "../utils/navigation";


export const loader = async () => {
  // Initially load with empty collections since none are selected yet
  return {collections: []};
};


export const action = async ({params, request}: ActionFunctionArgs) => {
  const {functionId} = params;
  const formData = await request.formData();
  const discountData = formData.get("discount");
  if (!discountData || typeof discountData !== "string")
    throw new Error("No discount data provided");


  const {
    title,
    method,
    code,
    combinesWith,
    usageLimit,
    appliesOncePerCustomer,
    startsAt,
    endsAt,
    discountClasses,
    configuration,
  } = JSON.parse(discountData);


  const baseDiscount = {
    functionId,
    title,
    combinesWith,
    discountClasses,
    startsAt: new Date(startsAt),
    endsAt: endsAt && new Date(endsAt),
  };


  let result;


  if (method === DiscountMethod.Code) {
    result = await createCodeDiscount(
      request,
      baseDiscount,
      code,
      usageLimit,
      appliesOncePerCustomer,
      {
        cartLinePercentage: parseFloat(configuration.cartLinePercentage),
        orderPercentage: parseFloat(configuration.orderPercentage),
        deliveryPercentage: parseFloat(configuration.deliveryPercentage),
        collectionIds: configuration.collectionIds || [],
      },
    );
  } else {
    result = await createAutomaticDiscount(request, baseDiscount, {
      cartLinePercentage: parseFloat(configuration.cartLinePercentage),
      orderPercentage: parseFloat(configuration.orderPercentage),
      deliveryPercentage: parseFloat(configuration.deliveryPercentage),
      collectionIds: configuration.collectionIds || [],
    });
  }


  if (result.errors?.length > 0) {
    return {errors: result.errors};
  }
  if (!result.discountId) {
    return {
      errors: [{message: "Unable to save discount.", field: []}],
    };
  }
  return {success: true, discountId: result.discountId};
};


interface ActionData {
  errors?: {
    code?: string;
    message: string;
    field: string[];
  }[];
  success?: boolean;
  discountId?: string;
}


interface LoaderData {
  collections: {id: string; title: string}[];
}


export default function VolumeNew() {
  const actionData = useActionData<ActionData>();
  const {collections} = useLoaderData<LoaderData>();
  const navigation = useNavigation();
  const isLoading = navigation.state === "submitting";
  const submitErrors = actionData?.errors || [];
  const completedDiscountId = useRef<string | null>(null);


  useEffect(() => {
    const discountId = actionData?.discountId;
    if (!discountId || completedDiscountId.current === discountId) return;


    completedDiscountId.current = discountId;
    void completeDiscountWorkflow(discountId);
  }, [actionData?.discountId]);


  const initialData = {
    title: "",
    method: DiscountMethod.Code,
    code: "",
    discountClasses: [],
    combinesWith: {
      orderDiscounts: false,
      productDiscounts: false,
      shippingDiscounts: false,
    },
    usageLimit: null,
    appliesOncePerCustomer: false,
    startsAt: new Date(),
    endsAt: null,
    configuration: {
      cartLinePercentage: "0",
      orderPercentage: "0",
      deliveryPercentage: "0",
      collectionIds: [],
    },
  };


  return (
    <s-page heading="Create product, order, and shipping discount">
      <button
        slot="breadcrumb-actions"
        type="button"
        variant="breadcrumb"
        onClick={returnToDiscounts}
      >
        Discounts
      </button>
      <DiscountForm
        initialData={initialData}
        collections={collections}
        isLoading={isLoading}
        submitErrors={submitErrors}
        success={actionData?.success}
      />
    </s-page>
  );
}
```

### Add instructions for Sidekick

The `instructions.md` file tells Sidekick when to open your app and what to pass when it invokes the intent.

### Add an extensions summary

In your app's `shopify.app.toml`, add an `extensions_summary` under `[sidekick]`. The summary helps Sidekick route merchant requests to your app and is required for apps with Sidekick-eligible extensions. Omitting it causes a validation error when you deploy.

## shopify.app.toml

```toml
[sidekick]
extensions_summary = "Create and edit discounts powered by Shopify Functions"
```

Learn how to [write an effective extensions summary](https://shopify.dev/docs/apps/build/sidekick#add-an-extensions-summary-to-your-app).

### Set your Function ID

Replace every `YOUR_FUNCTION_ID` placeholder, in the `url` and `matchValue` fields and in `instructions.md`, with the ID of the deployed Function that backs your discount. You can query it with the [`shopifyFunctions`](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyFunctions) Admin API query. The `functions.server.ts` file you reviewed earlier already fetches it. Use the returned `node.id` (a UUID such as `01234567-89ab-cdef-0123-456789abcdef`). Then deploy your app again so the updated intent links, instructions, and extensions summary take effect.

**Info:**

If your app backs more than one Function, register a separate `shopify/Discount` intent for each, within the [app intent limits](https://shopify.dev/docs/apps/build/sidekick#limits).

## Create a test discount

1. In your Shopify admin, navigate to **Discounts**.

2. To prevent conflicting discounts from activating, deactivate any existing discounts.

3. Click **Create discount**.

4. Under your app name, select your discount function.

5. Configure the discount with these values:

   * **Method**: **Automatic**
   * **Title**: **Product, Order, Shipping Discount**
   * **DiscountClasses**: Select **Product**, **Order**, and **Shipping**
   * **Product discount percentage**: **20**
   * **Order discount percentage**: **10**
   * **Shipping discount percentage**: **5**
   * **Collection IDs**: Select your test collections

6. Click **Save**

## Test the discount

1. Open **Discounts** in your Shopify admin
2. Locate your new cart line, order, and shipping discount

   ![A list of all active discounts for the store.](https://shopify.dev/assets/assets/apps/discounts/functions-discount-list-multi-class-bMqqn_8b.png)
3. Now, go to your dev store and add products to your cart.

Your cart page displays:

* Product line discounts
* Order subtotal discount

Your checkout page displays:

* Product line discounts
* Order subtotal discount
* Shipping rate discounts (after entering shipping address)

  ![A checkout summary that lists discounts for all three classes](https://shopify.dev/assets/assets/apps/discounts/multi-class-DqNqJQCF.png)

### Review the execution of the Function

### Review the Function execution

1. In the terminal where `shopify app dev` is running, review your Function executions.

   When [testing Functions on development stores](https://shopify.dev/docs/apps/build/functions/test-debug-functions#test-your-function-on-a-development-store), the `dev` output shows Function executions, debug logs you've added, and a link to a local file containing full execution details.

2. In a new terminal window, use the Shopify CLI command [`app function replay`](https://shopify.dev/docs/api/shopify-cli/app/app-function-replay) to [replay a Function execution locally](https://shopify.dev/docs/apps/build/functions/test-debug-functions#execute-the-function-locally-using-shopify-cli). This lets you debug your Function without triggering it again on Shopify.

   ## Terminal

   ```terminal
   shopify app function replay
   ```

3. Select the Function execution from the top of the list. Press `q` to quit when you are finished debugging.

## shopify.app.toml

```toml
# This file stores configurations for your Shopify app.


scopes = "write_discounts,read_products"


[sidekick]
extensions_summary = "Create and edit discounts powered by Shopify Functions"


[webhooks]
api_version = "2024-10"


  # Handled by: /app/routes/webhooks.app.uninstalled.tsx
  [[webhooks.subscriptions]]
  uri = "/webhooks/app/uninstalled"
  topics = ["app/uninstalled"]


  # Handled by: /app/routes/webhooks.app.scopes_update.tsx
  [[webhooks.subscriptions]]
  topics = [ "app/scopes_update" ]
  uri = "/webhooks/app/scopes_update"


  # Webhooks can have filters
  # Only receive webhooks for product updates with a product price >= 10.00
  # See: https://shopify.dev/docs/apps/build/webhooks/customize/filters
  # [[webhooks.subscriptions]]
  # topics = ["products/update"]
  # uri = "/webhooks/products/update"
  # filter = "variants.price:>=10.00"


  # Mandatory compliance topic for public apps only
  # See: https://shopify.dev/docs/apps/build/privacy-law-compliance
  # [[webhooks.subscriptions]]
  # uri = "/webhooks/customers/data_request"
  # compliance_topics = ["customers/data_request"]


  # [[webhooks.subscriptions]]
  # uri = "/webhooks/customers/redact"
  # compliance_topics = ["customers/redact"]


  # [[webhooks.subscriptions]]
  # uri = "/webhooks/shop/redact"
  # compliance_topics = ["shop/redact"]
```

## app/routes/app.discount.$functionId.new\.tsx

```tsx
import {useEffect, useRef} from "react";
import {
  type ActionFunctionArgs,
  useActionData,
  useLoaderData,
  useNavigation,
} from "react-router";


import {DiscountForm} from "../components/DiscountForm/DiscountForm";
import {
  createCodeDiscount,
  createAutomaticDiscount,
} from "../models/discounts.server";
import {DiscountMethod} from "../types/types";
import {completeDiscountWorkflow, returnToDiscounts} from "../utils/navigation";


export const loader = async () => {
  // Initially load with empty collections since none are selected yet
  return {collections: []};
};


export const action = async ({params, request}: ActionFunctionArgs) => {
  const {functionId} = params;
  const formData = await request.formData();
  const discountData = formData.get("discount");
  if (!discountData || typeof discountData !== "string")
    throw new Error("No discount data provided");


  const {
    title,
    method,
    code,
    combinesWith,
    usageLimit,
    appliesOncePerCustomer,
    startsAt,
    endsAt,
    discountClasses,
    configuration,
  } = JSON.parse(discountData);


  const baseDiscount = {
    functionId,
    title,
    combinesWith,
    discountClasses,
    startsAt: new Date(startsAt),
    endsAt: endsAt && new Date(endsAt),
  };


  let result;


  if (method === DiscountMethod.Code) {
    result = await createCodeDiscount(
      request,
      baseDiscount,
      code,
      usageLimit,
      appliesOncePerCustomer,
      {
        cartLinePercentage: parseFloat(configuration.cartLinePercentage),
        orderPercentage: parseFloat(configuration.orderPercentage),
        deliveryPercentage: parseFloat(configuration.deliveryPercentage),
        collectionIds: configuration.collectionIds || [],
      },
    );
  } else {
    result = await createAutomaticDiscount(request, baseDiscount, {
      cartLinePercentage: parseFloat(configuration.cartLinePercentage),
      orderPercentage: parseFloat(configuration.orderPercentage),
      deliveryPercentage: parseFloat(configuration.deliveryPercentage),
      collectionIds: configuration.collectionIds || [],
    });
  }


  if (result.errors?.length > 0) {
    return {errors: result.errors};
  }
  if (!result.discountId) {
    return {
      errors: [{message: "Unable to save discount.", field: []}],
    };
  }
  return {success: true, discountId: result.discountId};
};


interface ActionData {
  errors?: {
    code?: string;
    message: string;
    field: string[];
  }[];
  success?: boolean;
  discountId?: string;
}


interface LoaderData {
  collections: {id: string; title: string}[];
}


export default function VolumeNew() {
  const actionData = useActionData<ActionData>();
  const {collections} = useLoaderData<LoaderData>();
  const navigation = useNavigation();
  const isLoading = navigation.state === "submitting";
  const submitErrors = actionData?.errors || [];
  const completedDiscountId = useRef<string | null>(null);


  useEffect(() => {
    const discountId = actionData?.discountId;
    if (!discountId || completedDiscountId.current === discountId) return;


    completedDiscountId.current = discountId;
    void completeDiscountWorkflow(discountId);
  }, [actionData?.discountId]);


  const initialData = {
    title: "",
    method: DiscountMethod.Code,
    code: "",
    discountClasses: [],
    combinesWith: {
      orderDiscounts: false,
      productDiscounts: false,
      shippingDiscounts: false,
    },
    usageLimit: null,
    appliesOncePerCustomer: false,
    startsAt: new Date(),
    endsAt: null,
    configuration: {
      cartLinePercentage: "0",
      orderPercentage: "0",
      deliveryPercentage: "0",
      collectionIds: [],
    },
  };


  return (
    <s-page heading="Create product, order, and shipping discount">
      <button
        slot="breadcrumb-actions"
        type="button"
        variant="breadcrumb"
        onClick={returnToDiscounts}
      >
        Discounts
      </button>
      <DiscountForm
        initialData={initialData}
        collections={collections}
        isLoading={isLoading}
        submitErrors={submitErrors}
        success={actionData?.success}
      />
    </s-page>
  );
}
```

## app/routes/app.discount.$functionId.$id.tsx

```tsx
import {Collection, DiscountClass} from "app/types/admin.types";
import {useEffect, useRef} from "react";
import {
  type ActionFunctionArgs,
  type LoaderFunctionArgs,
  useActionData,
  useLoaderData,
  useNavigation,
} from "react-router";


import {DiscountForm} from "../components/DiscountForm/DiscountForm";
import {NotFoundPage} from "../components/NotFoundPage";
import {getCollectionsByIds} from "../models/collections.server";
import {
  getDiscount,
  updateAutomaticDiscount,
  updateCodeDiscount,
} from "../models/discounts.server";
import {DiscountMethod} from "../types/types";
import {completeDiscountWorkflow, returnToDiscounts} from "../utils/navigation";


interface ActionData {
  errors?: {
    code?: string;
    message: string;
    field?: string[];
  }[];
  success?: boolean;
  discountId?: string;
}


interface LoaderData {
  discount: {
    title: string;
    method: DiscountMethod;
    code: string;
    combinesWith: {
      orderDiscounts: boolean;
      productDiscounts: boolean;
      shippingDiscounts: boolean;
    };
    discountClasses: DiscountClass[];
    usageLimit: number | null;
    appliesOncePerCustomer: boolean;
    startsAt: string;
    endsAt: string | null;
    configuration: {
      cartLinePercentage: number;
      orderPercentage: number;
      deliveryPercentage: number;
      metafieldId: string;
      collectionIds: string[];
    };
  } | null;
  collections: Collection[];
}


export const action = async ({params, request}: ActionFunctionArgs) => {
  const {id, functionId} = params;
  if (!id) throw new Error("No discount ID provided");


  const formData = await request.formData();
  const discountData = formData.get("discount");
  if (!discountData || typeof discountData !== "string") {
    throw new Error("No discount data provided");
  }


  const {
    title,
    method,
    code,
    combinesWith,
    discountClasses,
    usageLimit,
    appliesOncePerCustomer,
    startsAt,
    endsAt,
    configuration,
  } = JSON.parse(discountData);


  const baseDiscount = {
    functionId,
    title,
    combinesWith,
    discountClasses,
    startsAt: new Date(startsAt),
    endsAt: endsAt && new Date(endsAt),
  };


  // Parse configuration values
  const parsedConfiguration = {
    metafieldId: configuration.metafieldId,
    cartLinePercentage: parseFloat(configuration.cartLinePercentage),
    orderPercentage: parseFloat(configuration.orderPercentage),
    deliveryPercentage: parseFloat(configuration.deliveryPercentage),
    collectionIds: configuration.collectionIds || [],
  };


  let result;


  if (method === DiscountMethod.Code) {
    result = await updateCodeDiscount(
      request,
      id,
      baseDiscount,
      code,
      usageLimit,
      appliesOncePerCustomer,
      parsedConfiguration,
    );
  } else {
    result = await updateAutomaticDiscount(
      request,
      id,
      baseDiscount,
      parsedConfiguration,
    );
  }
  if (result.errors?.length > 0) {
    return {errors: result.errors};
  }
  if (!result.discountId) {
    return {
      errors: [{message: "Unable to save discount.", field: []}],
    };
  }
  return {success: true, discountId: result.discountId};
};


export const loader = async ({params, request}: LoaderFunctionArgs) => {
  const {id} = params;
  if (!id) throw new Error("No discount ID provided");


  const {discount} = await getDiscount(request, id);


  // Fetch collections if they exist in the configuration
  const collections = discount?.configuration?.collectionIds
    ? await getCollectionsByIds(
        request,
        discount.configuration.collectionIds.map((id: string) =>
          id.startsWith("gid://") ? id : `gid://shopify/Collection/${id}`,
        ),
      )
    : [];


  return {discount, collections};
};


export default function VolumeEdit() {
  const actionData = useActionData<ActionData>();
  const {discount: rawDiscount, collections} = useLoaderData<LoaderData>();
  const navigation = useNavigation();
  const isLoading = navigation.state === "submitting";
  const submitErrors =
    actionData?.errors?.map(error => ({
      ...error,
      field: error.field || [],
    })) || [];
  const completedDiscountId = useRef<string | null>(null);


  useEffect(() => {
    const discountId = actionData?.discountId;
    if (!discountId || completedDiscountId.current === discountId) return;


    completedDiscountId.current = discountId;
    void completeDiscountWorkflow(discountId);
  }, [actionData?.discountId]);


  if (!rawDiscount) {
    return <NotFoundPage />;
  }


  // Transform the discount data to match expected types
  const initialData = {
    ...rawDiscount,
    method: rawDiscount.method,
    discountClasses: rawDiscount.discountClasses,
    combinesWith: {
      orderDiscounts: rawDiscount.combinesWith.orderDiscounts,
      productDiscounts: rawDiscount.combinesWith.productDiscounts,
      shippingDiscounts: rawDiscount.combinesWith.shippingDiscounts,
    },
    usageLimit: rawDiscount.usageLimit,
    appliesOncePerCustomer: rawDiscount.appliesOncePerCustomer,
    startsAt: rawDiscount.startsAt,
    endsAt: rawDiscount.endsAt,
    configuration: {
      ...rawDiscount.configuration,
      cartLinePercentage: String(rawDiscount.configuration.cartLinePercentage),
      orderPercentage: String(rawDiscount.configuration.orderPercentage),
      deliveryPercentage: String(rawDiscount.configuration.deliveryPercentage),
      metafieldId: rawDiscount.configuration.metafieldId,
      collectionIds: rawDiscount.configuration.collectionIds || [],
    },
  };


  return (
    <s-page heading={`Edit ${rawDiscount.title}`}>
      <button
        slot="breadcrumb-actions"
        type="button"
        variant="breadcrumb"
        onClick={returnToDiscounts}
      >
        Discounts
      </button>
      <DiscountForm
        initialData={initialData}
        collections={collections}
        isEditing={true}
        isLoading={isLoading}
        submitErrors={submitErrors}
        success={actionData?.success}
      />
    </s-page>
  );
}
```

## app/components/DiscountForm/DiscountForm.tsx

```tsx
import {returnToDiscounts} from "app/utils/navigation";
import {useCallback, useMemo, useState} from "react";
import {Form} from "react-router";


import {useDiscountForm} from "../../hooks/useDiscountForm";
import {DiscountClass} from "../../types/admin.types.d";
import {DiscountMethod} from "../../types/types";
import {CollectionPicker} from "../CollectionPicker/CollectionPicker";
import {DatePickerField} from "../DatePickerField/DatePickerField";


interface SubmitError {
  message: string;
  field: string[];
}


interface DiscountFormProps {
  initialData?: {
    title: string;
    method: DiscountMethod;
    code: string;
    combinesWith: {
      orderDiscounts: boolean;
      productDiscounts: boolean;
      shippingDiscounts: boolean;
    };
    discountClasses: DiscountClass[];
    usageLimit: number | null;
    appliesOncePerCustomer: boolean;
    startsAt: string | Date;
    endsAt: string | Date | null;
    configuration: {
      cartLinePercentage: string;
      orderPercentage: string;
      deliveryPercentage: string;
      metafieldId?: string;
      collectionIds?: string[];
    };
  };
  collections: {id: string; title: string}[];
  isEditing?: boolean;
  submitErrors?: SubmitError[];
  isLoading?: boolean;
  success?: boolean;
}


const methodOptions = [
  {label: "Discount code", value: DiscountMethod.Code},
  {label: "Automatic discount", value: DiscountMethod.Automatic},
];


export function DiscountForm({
  initialData,
  collections: initialCollections,
  isEditing = false,
  submitErrors = [],
  success = false,
}: DiscountFormProps) {
  const {formState, setField, setConfigField, setCombinesWith, submit} =
    useDiscountForm({
      initialData,
    });


  const [collections, setCollections] =
    useState<DiscountFormProps["collections"]>(initialCollections);


  const today = useMemo(() => {
    const date = new Date();
    date.setHours(0, 0, 0, 0);
    return date;
  }, []);


  const errorBanner = useMemo(
    () =>
      submitErrors.length > 0 ? (
        <s-banner tone="critical">
          <p>There were some issues with your form submission:</p>
          <ul>
            {submitErrors.map(({message, field}, index) => (
              <li key={index}>
                {field.join(".")} {message}
              </li>
            ))}
          </ul>
        </s-banner>
      ) : null,
    [submitErrors],
  );


  const successBanner = useMemo(
    () =>
      success ? (
        <s-banner tone="success">
          <p>Discount saved successfully</p>
        </s-banner>
      ) : null,
    [success],
  );


  const handleCollectionSelect = useCallback(
    async (selectedCollections: {id: string; title: string}[]) => {
      setConfigField(
        "collectionIds",
        selectedCollections.map(collection => collection.id),
      );
      setCollections(selectedCollections);
    },
    [setConfigField],
  );


  const handleDiscountClassChange = useCallback(
    (discountClassValue: DiscountClass, checked: boolean) => {
      setField(
        "discountClasses",
        checked
          ? [...formState.discountClasses, discountClassValue]
          : formState.discountClasses.filter(
              discountClass => discountClass !== discountClassValue,
            ),
      );
    },
    [formState.discountClasses, setField],
  );


  const handleEndDateCheckboxChange = useCallback(
    (checked: boolean) => {
      if (!checked) {
        setField("endDate", null);
      } else if (!formState.endDate) {
        const tomorrow = new Date(formState.startDate || today);
        tomorrow.setDate(tomorrow.getDate() + 1);
        setField("endDate", tomorrow);
      }
    },
    [formState.startDate, formState.endDate, today, setField],
  );


  const handleDateChange = useCallback(
    (date: Date | [Date, Date]) => {
      if (Array.isArray(date)) {
        // Range mode
        setField("startDate", date[0]);
        setField("endDate", date[1]);
      } else {
        // Single mode
        setField("startDate", date);
        if (formState.endDate) {
          setField("endDate", null);
        }
      }
    },
    [setField, formState.endDate],
  );


  const getDatePickerValue = useCallback(():
    | Date
    | string
    | null
    | [Date | string | null, Date | string | null] => {
    if (formState.endDate) {
      return [formState.startDate, formState.endDate];
    }
    return formState.startDate;
  }, [formState.startDate, formState.endDate]);


  const handleReset = useCallback(() => {
    returnToDiscounts();
  }, []);


  return (
    <Form
      method="post"
      id="discount-form"
      data-save-bar
      onSubmit={submit}
      onReset={handleReset}
    >
      <input
        type="hidden"
        name="discount"
        value={JSON.stringify({
          title: formState.title,
          method: formState.method,
          code: formState.code,
          combinesWith: formState.combinesWith,
          discountClasses: formState.discountClasses,
          usageLimit:
            formState.usageLimit === ""
              ? null
              : parseInt(formState.usageLimit, 10),
          appliesOncePerCustomer: formState.appliesOncePerCustomer,
          startsAt: formState.startDate,
          endsAt: formState.endDate,
          configuration: {
            ...(formState.configuration.metafieldId
              ? {metafieldId: formState.configuration.metafieldId}
              : {}),
            cartLinePercentage: parseFloat(
              formState.configuration.cartLinePercentage,
            ),
            orderPercentage: parseFloat(
              formState.configuration.orderPercentage,
            ),
            deliveryPercentage: parseFloat(
              formState.configuration.deliveryPercentage,
            ),
            collectionIds: formState.configuration.collectionIds || [],
          },
        })}
      />
      <s-stack gap="base">
        {errorBanner}
        {successBanner}


        <s-stack gap="base">
          {/* Method section */}
          <s-section heading={isEditing ? "Edit discount" : "Create discount"}>
            <s-select
              label="Discount type"
              value={formState.method}
              onChange={(e: any) =>
                setField("method", e.target.value as DiscountMethod)
              }
              disabled={isEditing}
            >
              {methodOptions.map(option => (
                <s-option key={option.value} value={option.value}>
                  {option.label}
                </s-option>
              ))}
            </s-select>


            {formState.method === DiscountMethod.Automatic ? (
              <s-text-field
                label="Discount title"
                autocomplete="off"
                value={formState.title}
                onChange={(e: any) => setField("title", e.target.value)}
              />
            ) : (
              <s-text-field
                label="Discount code"
                autocomplete="off"
                value={formState.code}
                onChange={(e: any) => setField("code", e.target.value)}
              />
            )}
          </s-section>


          {/* Discount classes section */}


          <s-section heading="Discount Classes">
            <s-text>Select which types of discounts to apply</s-text>


            <s-stack gap="base">
              <s-checkbox
                label="Product discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Product,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Product,
                    e.target.checked,
                  )
                }
              />
              <s-checkbox
                label="Order discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Order,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Order,
                    e.target.checked,
                  )
                }
              />
              <s-checkbox
                label="Shipping discount"
                checked={formState.discountClasses.includes(
                  DiscountClass.Shipping,
                )}
                onChange={(e: any) =>
                  handleDiscountClassChange(
                    DiscountClass.Shipping,
                    e.target.checked,
                  )
                }
              />
            </s-stack>
          </s-section>


          <s-section heading="Discount Configuration">
            <s-stack gap="base">
              {formState.discountClasses?.includes(DiscountClass.Product) ? (
                <>
                  <s-number-field
                    label="Product discount percentage"
                    autocomplete="on"
                    min={0}
                    max={100}
                    suffix="%"
                    value={formState.configuration.cartLinePercentage}
                    onChange={(e: any) =>
                      setConfigField("cartLinePercentage", e.target.value)
                    }
                  />
                  <CollectionPicker
                    onSelect={handleCollectionSelect}
                    selectedCollectionIds={
                      formState.configuration.collectionIds || []
                    }
                    collections={
                      formState.configuration.collections || collections
                    }
                    buttonText="Select collections for discount"
                  />
                </>
              ) : null}


              {formState.discountClasses?.includes(DiscountClass.Order) ? (
                <s-number-field
                  label="Order discount percentage"
                  autocomplete="on"
                  min={0}
                  max={100}
                  suffix="%"
                  value={formState.configuration.orderPercentage}
                  onChange={(e: any) =>
                    setConfigField("orderPercentage", e.target.value)
                  }
                />
              ) : null}


              {formState.discountClasses?.includes(DiscountClass.Shipping) ? (
                <s-number-field
                  label="Shipping discount percentage"
                  autocomplete="on"
                  min={0}
                  max={100}
                  suffix="%"
                  value={formState.configuration.deliveryPercentage}
                  onChange={(e: any) =>
                    setConfigField("deliveryPercentage", e.target.value)
                  }
                />
              ) : null}
            </s-stack>
          </s-section>


          {/* Usage limits section */}
          {formState.method === DiscountMethod.Code ? (
            <s-section heading="Usage limits">
              <s-number-field
                label="Usage limit"
                autocomplete="on"
                min={0}
                placeholder="No limit"
                value={formState.usageLimit}
                onChange={(e: any) => setField("usageLimit", e.target.value)}
              />
              <s-checkbox
                label="Limit to one use per customer"
                checked={formState.appliesOncePerCustomer}
                onChange={(e: any) =>
                  setField("appliesOncePerCustomer", e.target.checked)
                }
              />
            </s-section>
          ) : null}


          {/* Combination section */}
          <s-section heading="Combination">
            <s-text>
              Select which discounts can be combined with this discount
            </s-text>


            <s-checkbox
              label="Order discounts"
              checked={formState.combinesWith.orderDiscounts}
              onChange={(e: any) =>
                setCombinesWith("orderDiscounts", e.target.checked)
              }
            />


            <s-checkbox
              label="Product discounts"
              checked={formState.combinesWith.productDiscounts}
              onChange={(e: any) =>
                setCombinesWith("productDiscounts", e.target.checked)
              }
            />


            <s-checkbox
              label="Shipping discounts"
              checked={formState.combinesWith.shippingDiscounts}
              onChange={(e: any) =>
                setCombinesWith("shippingDiscounts", e.target.checked)
              }
            />
          </s-section>


          {/* Active dates section */}
          <s-section heading="Active dates">
            <s-stack gap="base">
              <DatePickerField
                label={formState.endDate ? "Date range" : "Start date"}
                type={formState.endDate ? "range" : "single"}
                value={getDatePickerValue()}
                onChange={handleDateChange}
                minDate={today}
              />


              <s-checkbox
                label="Set end date"
                checked={!!formState.endDate}
                onChange={(e: any) =>
                  handleEndDateCheckboxChange(e.target.checked)
                }
              />
            </s-stack>
          </s-section>
        </s-stack>
      </s-stack>
    </Form>
  );
}
```

## app/components/CollectionPicker/CollectionPicker.tsx

```tsx
import {useCallback} from "react";


interface Collection {
  id: string;
  title: string;
}


interface ResourcePickerResponse {
  id: string;
  title: string;
}


interface CollectionPickerProps {
  onSelect: (selectedCollections: {id: string; title: string}[]) => void;
  selectedCollectionIds: string[];
  collections: Collection[];
  buttonText?: string;
}


export function CollectionPicker({
  onSelect,
  selectedCollectionIds = [],
  collections = [],
  buttonText = "Select collections",
}: CollectionPickerProps) {
  const handleSelect = useCallback(async () => {
    const selected = await window.shopify.resourcePicker({
      type: "collection",
      action: "select",
      multiple: true,
      selectionIds: selectedCollectionIds.map(id => ({
        id: id,
        type: "collection",
      })),
    });


    if (selected) {
      const selectedCollections = selected.map(
        (collection: ResourcePickerResponse) => ({
          id: collection.id,
          title: collection.title,
        }),
      );
      onSelect(selectedCollections);
    }
  }, [selectedCollectionIds, onSelect]);


  const handleRemove = useCallback(
    (collectionId: string) => {
      onSelect(
        collections.filter(collection => collection.id !== collectionId),
      );
    },
    [onSelect, collections],
  );


  const selectedCollectionsText = collections?.length
    ? ` (${collections.length} selected)`
    : "";


  return (
    <s-stack direction="block" gap="base">
      <s-button type="button" onClick={handleSelect}>
        {buttonText}
        {selectedCollectionsText}
      </s-button>
      {collections?.length > 0 ? (
        <s-stack direction="block" gap="small">
          {collections.map(collection => (
            <s-stack direction="block" gap="small" key={collection.id}>
              <s-stack direction="inline" justifyContent="space-between">
                <s-link
                  href={`shopify://admin/collections/${collection.id.split("/").pop()}`}
                  target="_self"
                >
                  {collection.title}
                </s-link>
                <s-button
                  variant="tertiary"
                  type="button"
                  onClick={() => handleRemove(collection.id)}
                  aria-label={`Remove ${collection.title}`}
                >
                  <svg viewBox="0 0 20 20" width="20" height="20">
                    <path
                      d="M11.5 8.25a.75.75 0 0 1 .75.75v4.25a.75.75 0 0 1-1.5 0v-4.25a.75.75 0 0 1 .75-.75Z"
                      fill="currentColor"
                    />
                    <path
                      d="M9.25 9a.75.75 0 0 0-1.5 0v4.25a.75.75 0 0 0 1.5 0v-4.25Z"
                      fill="currentColor"
                    />
                    <path
                      fillRule="evenodd"
                      d="M7.25 5.25a2.75 2.75 0 0 1 5.5 0h3a.75.75 0 0 1 0 1.5h-.75v5.45c0 1.68 0 2.52-.327 3.162a3 3 0 0 1-1.311 1.311c-.642.327-1.482.327-3.162.327h-.4c-1.68 0-2.52 0-3.162-.327a3 3 0 0 1-1.311-1.311c-.327-.642-.327-1.482-.327-3.162v-5.45h-.75a.75.75 0 0 1 0-1.5h3Zm1.5 0a1.25 1.25 0 1 1 2.5 0h-2.5Zm-2.25 1.5h7v5.45c0 .865-.001 1.423-.036 1.848-.033.408-.09.559-.128.633a1.5 1.5 0 0 1-.655.655c-.074.038-.225.095-.633.128-.425.035-.983.036-1.848.036h-.4c-.865 0-1.423-.001-1.848-.036-.408-.033-.559-.09-.633-.128a1.5 1.5 0 0 1-.656-.655c-.037-.074-.094-.225-.127-.633-.035-.425-.036-.983-.036-1.848v-5.45Z"
                      fill="currentColor"
                    />
                  </svg>
                </s-button>
              </s-stack>
              <s-divider />
            </s-stack>
          ))}
        </s-stack>
      ) : null}
    </s-stack>
  );
}
```

## app/models/functions.server.ts

```typescript
import {GET_FUNCTIONS} from "../graphql/functions";
import {authenticate} from "../shopify.server";


export interface ShopifyFunction {
  id: string;
  title: string;
  functionType: string;
}


export async function getFunctions(request: Request) {
  const {admin} = await authenticate.admin(request);
  const response = await admin.graphql(GET_FUNCTIONS);
  const json = await response.json();
  return json.data.shopifyFunctions.nodes as ShopifyFunction[];
}
```

## app/models/collections.server.ts

```typescript
import {GET_COLLECTIONS} from "../graphql/collections";
import {authenticate} from "../shopify.server";


interface Collection {
  id: string;
  title: string;
}


export async function getCollectionsByIds(
  request: Request,
  collectionIds: string[],
) {
  const {admin} = await authenticate.admin(request);


  const response = await admin.graphql(GET_COLLECTIONS, {
    variables: {
      ids: collectionIds.map((id: string) =>
        id.includes("gid://") ? id : `gid://shopify/Collection/${id}`,
      ),
    },
  });


  const {data} = await response.json();
  return data.nodes.filter(Boolean) as Collection[];
}
```

## app/models/discounts.server.ts

```typescript
import {
  CREATE_CODE_DISCOUNT,
  CREATE_AUTOMATIC_DISCOUNT,
  UPDATE_CODE_DISCOUNT,
  UPDATE_AUTOMATIC_DISCOUNT,
  GET_DISCOUNT,
} from "../graphql/discounts";
import {authenticate} from "../shopify.server";
import type {DiscountClass} from "../types/admin.types";
import {DiscountMethod} from "../types/types";


interface BaseDiscount {
  functionId?: string;
  title: string;
  discountClasses: DiscountClass[];
  combinesWith: {
    orderDiscounts: boolean;
    productDiscounts: boolean;
    shippingDiscounts: boolean;
  };
  startsAt: Date;
  endsAt: Date | null;
}


interface DiscountConfiguration {
  cartLinePercentage: number;
  orderPercentage: number;
  deliveryPercentage: number;
  collectionIds?: string[];
}


interface UserError {
  code?: string;
  message: string;
  field?: string[];
}


export async function createCodeDiscount(
  request: Request,
  baseDiscount: BaseDiscount,
  code: string,
  usageLimit: number | null,
  appliesOncePerCustomer: boolean,
  configuration: DiscountConfiguration,
) {
  const {admin} = await authenticate.admin(request);
  const response = await admin.graphql(CREATE_CODE_DISCOUNT, {
    variables: {
      discount: {
        ...baseDiscount,
        title: code,
        code,
        usageLimit,
        appliesOncePerCustomer,
        metafields: [
          {
            namespace: "$app:example-discounts--ui-extension",
            key: "function-configuration",
            type: "json",
            value: JSON.stringify({
              cartLinePercentage: configuration.cartLinePercentage,
              orderPercentage: configuration.orderPercentage,
              deliveryPercentage: configuration.deliveryPercentage,
              collectionIds: configuration.collectionIds || [],
            }),
          },
        ],
      },
    },
  });


  const responseJson = await response.json();


  return {
    errors: responseJson.data.discountCreate?.userErrors as UserError[],
    discountId: responseJson.data.discountCreate?.codeAppDiscount?.discountId,
  };
}


export async function createAutomaticDiscount(
  request: Request,
  baseDiscount: BaseDiscount,
  configuration: DiscountConfiguration,
) {
  const {admin} = await authenticate.admin(request);
  const response = await admin.graphql(CREATE_AUTOMATIC_DISCOUNT, {
    variables: {
      discount: {
        ...baseDiscount,
        metafields: [
          {
            namespace: "$app:example-discounts--ui-extension",
            key: "function-configuration",
            type: "json",
            value: JSON.stringify({
              cartLinePercentage: configuration.cartLinePercentage,
              orderPercentage: configuration.orderPercentage,
              deliveryPercentage: configuration.deliveryPercentage,
              collectionIds: configuration.collectionIds || [],
            }),
          },
        ],
      },
    },
  });


  const responseJson = await response.json();


  return {
    errors: responseJson.data.discountCreate?.userErrors as UserError[],
    discountId:
      responseJson.data.discountCreate?.automaticAppDiscount?.discountId,
  };
}


export async function updateCodeDiscount(
  request: Request,
  id: string,
  baseDiscount: BaseDiscount,
  code: string,
  usageLimit: number | null,
  appliesOncePerCustomer: boolean,
  configuration: {
    metafieldId: string;
    cartLinePercentage: number;
    orderPercentage: number;
    deliveryPercentage: number;
    collectionIds?: string[];
  },
) {
  const {admin} = await authenticate.admin(request);
  const discountId = id.includes("gid://")
    ? id
    : `gid://shopify/DiscountCodeNode/${id}`;


  const response = await admin.graphql(UPDATE_CODE_DISCOUNT, {
    variables: {
      id: discountId,
      discount: {
        ...baseDiscount,
        title: code,
        code,
        usageLimit,
        appliesOncePerCustomer,
        metafields: [
          {
            id: configuration.metafieldId,
            value: JSON.stringify({
              cartLinePercentage: configuration.cartLinePercentage,
              orderPercentage: configuration.orderPercentage,
              deliveryPercentage: configuration.deliveryPercentage,
              collectionIds:
                configuration.collectionIds?.map(id =>
                  id.includes("gid://") ? id : `gid://shopify/Collection/${id}`,
                ) || [],
            }),
          },
        ],
      },
    },
  });


  const responseJson = await response.json();
  return {
    errors: responseJson.data.discountUpdate?.userErrors as UserError[],
    discountId: responseJson.data.discountUpdate?.codeAppDiscount?.discountId,
  };
}


export async function updateAutomaticDiscount(
  request: Request,
  id: string,
  baseDiscount: BaseDiscount,
  configuration: {
    metafieldId: string;
    cartLinePercentage: number;
    orderPercentage: number;
    deliveryPercentage: number;
    collectionIds?: string[];
  },
) {
  const {admin} = await authenticate.admin(request);
  const discountId = id.includes("gid://")
    ? id
    : `gid://shopify/DiscountAutomaticApp/${id}`;


  const response = await admin.graphql(UPDATE_AUTOMATIC_DISCOUNT, {
    variables: {
      id: discountId,
      discount: {
        ...baseDiscount,
        metafields: [
          {
            id: configuration.metafieldId,
            value: JSON.stringify({
              cartLinePercentage: configuration.cartLinePercentage,
              orderPercentage: configuration.orderPercentage,
              deliveryPercentage: configuration.deliveryPercentage,
              collectionIds:
                configuration.collectionIds?.map(id =>
                  id.includes("gid://") ? id : `gid://shopify/Collection/${id}`,
                ) || [],
            }),
          },
        ],
      },
    },
  });


  const responseJson = await response.json();
  return {
    errors: responseJson.data.discountUpdate?.userErrors as UserError[],
    discountId:
      responseJson.data.discountUpdate?.automaticAppDiscount?.discountId,
  };
}


export async function getDiscount(request: Request, id: string) {
  const {admin} = await authenticate.admin(request);
  const response = await admin.graphql(GET_DISCOUNT, {
    variables: {
      id: `gid://shopify/DiscountNode/${id}`,
    },
  });


  const responseJson = await response.json();
  if (
    !responseJson.data.discountNode ||
    !responseJson.data.discountNode.discount
  ) {
    return {discount: null};
  }


  const method =
    responseJson.data.discountNode.discount.__typename === "DiscountCodeApp"
      ? DiscountMethod.Code
      : DiscountMethod.Automatic;


  const {
    title,
    codes,
    combinesWith,
    usageLimit,
    appliesOncePerCustomer,
    startsAt,
    endsAt,
    discountClasses,
  } = responseJson.data.discountNode.discount;
  const configuration = JSON.parse(
    responseJson.data.discountNode.configurationField.value,
  );


  return {
    discount: {
      title,
      method,
      code: codes?.nodes[0]?.code ?? "",
      combinesWith,
      discountClasses,
      usageLimit: usageLimit ?? null,
      appliesOncePerCustomer: appliesOncePerCustomer ?? false,
      startsAt,
      endsAt,
      configuration: {
        ...configuration,
        metafieldId: responseJson.data.discountNode.configurationField.id,
      },
    },
  };
}
```

## app/graphql/discounts.ts

```typescript
// Queries
export const GET_DISCOUNT = `
  query GetDiscount($id: ID!) {
    discountNode(id: $id) {
      id
      configurationField: metafield(
        namespace: "$app:example-discounts--ui-extension"
        key: "function-configuration"
      ) {
        id
        value
      }
      discount {
        __typename
        ... on DiscountAutomaticApp {
          title
          discountClasses
          combinesWith {
            orderDiscounts
            productDiscounts
            shippingDiscounts
          }
          startsAt
          endsAt
        }
        ... on DiscountCodeApp {
          title
          discountClasses
          combinesWith {
            orderDiscounts
            productDiscounts
            shippingDiscounts
          }
          startsAt
          endsAt
          usageLimit
          appliesOncePerCustomer
          codes(first: 1) {
            nodes {
              code
            }
          }
        }
      }
    }
  }
`;


// Mutations
export const UPDATE_CODE_DISCOUNT = `
  mutation UpdateCodeDiscount($id: ID!, $discount: DiscountCodeAppInput!) {
    discountUpdate: discountCodeAppUpdate(id: $id, codeAppDiscount: $discount) {
      codeAppDiscount {
        discountId
      }
      userErrors {
        code
        message
        field
      }
    }
  }
`;


export const UPDATE_AUTOMATIC_DISCOUNT = `
  mutation UpdateAutomaticDiscount(
    $id: ID!
    $discount: DiscountAutomaticAppInput!
  ) {
    discountUpdate: discountAutomaticAppUpdate(
      id: $id
      automaticAppDiscount: $discount
    ) {
      automaticAppDiscount {
        discountId
      }
      userErrors {
        code
        message
        field
      }
    }
  }
`;


export const CREATE_CODE_DISCOUNT = `
  mutation CreateCodeDiscount($discount: DiscountCodeAppInput!) {
    discountCreate: discountCodeAppCreate(codeAppDiscount: $discount) {
      codeAppDiscount {
        discountId
      }
      userErrors {
        code
        message
        field
      }
    }
  }
`;


export const CREATE_AUTOMATIC_DISCOUNT = `
  mutation CreateAutomaticDiscount($discount: DiscountAutomaticAppInput!) {
    discountCreate: discountAutomaticAppCreate(
      automaticAppDiscount: $discount
    ) {
      automaticAppDiscount {
        discountId
      }
      userErrors {
        code
        message
        field
      }
    }
  }
`;
```

## app/graphql/collections.ts

```typescript
export const GET_COLLECTIONS = `
  query GetCollections($ids: [ID!]!) {
    nodes(ids: $ids) {
      ... on Collection {
        id
        title
      }
    }
  }
`;
```

## app/graphql/functions.ts

```typescript
export const GET_FUNCTIONS = `
  query GetFunctions {
    shopifyFunctions(first: 10) {
      nodes {
        id
        title
      }
    }
  }
`;
```

## extensions/shopify.extension.toml

```toml
api_version = "2025-04"


[[extensions]]
name = "t:name"
description = "t:description"
handle = "discount-function-rs"
type = "function"


  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/generate_cart_run.graphql"
  export = "generate-cart-run"


  [[extensions.targeting]]
  target = "cart.delivery-options.discounts.generate.run"
  input_query = "src/generate_delivery_run.graphql"
  export = "generate-delivery-run"


  [extensions.build]
  command = ""
  path = "dist/function.wasm"


[extensions.input.variables]
namespace = "$app:example-discounts--ui-extension"
key = "function-configuration"


[extensions.ui]
handle = "ui-multiclass-metafield-js"


[extensions.ui.paths]
create = "/app/discount/:functionId/new"
details = "/app/discount/:functionId/:id"


# Register app intent links so Sidekick can create and edit discounts backed by
# this app's Function. Each admin.app.intent.link target maps a shopify/Discount
# action (create or edit) to a page in your app.
[[extensions]]
name = "Create discount"
description = "Create a metafield-configured discount powered by this app's Shopify Function."
handle = "discount-app-intent-link-create"
type = "admin_link"


  [[extensions.targeting]]
  target = "admin.app.intent.link"
  # Hardcode your deployed Function's ID (see "Set your Function ID" below).
  url = "/app/discount/YOUR_FUNCTION_ID/new"
  instructions = "./instructions.md"


  [[extensions.targeting.intents]]
  type = "shopify/Discount"
  action = "create"
  schema = "./create-intent-schema.json"


[[extensions]]
name = "Edit discount"
description = "Edit a metafield-configured discount powered by this app's Shopify Function."
handle = "discount-app-intent-link-edit"
type = "admin_link"


  [[extensions.targeting]]
  target = "admin.app.intent.link"
  # {id} receives the discount ID; the Function ID stays hardcoded.
  url = "/app/discount/YOUR_FUNCTION_ID/{id}"
  instructions = "./instructions.md"


  [[extensions.targeting.intents]]
  type = "shopify/Discount"
  action = "edit"
  schema = "./edit-intent-schema.json"
```

## extensions/discount-function/src/cart\_lines\_discounts\_generate\_run.graphql

```graphql
query CartInput($collectionIds: [ID!]) {
  cart {
    lines {
      id
      cost {
        subtotalAmount {
          amount
        }
      }
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            inAnyCollection(ids: $collectionIds)
          }
        }
      }
    }
  }
  discount {
    discountClasses
    metafield(
      namespace: "$app"
      key: "function-configuration"
    ) {
      value
    }
  }
}
```

## extensions/discount-function/src/cart\_delivery\_options\_discounts\_generate\_run.graphql

```graphql
query DeliveryInput {
  cart {
    deliveryGroups {
      id
    }
  }
  discount {
    discountClasses
    metafield(
      namespace: "$app"
      key: "function-configuration"
    ) {
      value
    }
  }
}
```

## extensions/discount-function/src/cart\_lines\_discounts\_generate\_run.js

```javascript
import {
  OrderDiscountSelectionStrategy,
  ProductDiscountSelectionStrategy,
  DiscountClass,
} from "../generated/api";


export function cartLinesDiscountsGenerateRun(input) {
  if (!input.cart.lines.length) {
    throw new Error("No cart lines found");
  }


  const {cartLinePercentage, orderPercentage, collectionIds} = parseMetafield(
    input.discount.metafield,
  );


  const hasOrderDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Order,
  );
  const hasProductDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Product,
  );


  if (!hasOrderDiscountClass && !hasProductDiscountClass) {
    return {operations: []};
  }


  const operations = [];
  // Add product discounts first if available and allowed
  if (hasProductDiscountClass && cartLinePercentage > 0) {
    const cartLineTargets = input.cart.lines.reduce((targets, line) => {
      if (
        "product" in line.merchandise &&
        (line.merchandise.product.inAnyCollection || collectionIds.length === 0)
      ) {
        targets.push({
          cartLine: {
            id: line.id,
          },
        });
      }
      return targets;
    }, []);


    if (cartLineTargets.length > 0) {
      operations.push({
        productDiscountsAdd: {
          candidates: [
            {
              message: `${cartLinePercentage}% OFF PRODUCT`,
              targets: cartLineTargets,
              value: {
                percentage: {
                  value: cartLinePercentage,
                },
              },
            },
          ],
          selectionStrategy: ProductDiscountSelectionStrategy.First,
        },
      });
    }
  }


  // Then add order discounts if available and allowed
  if (hasOrderDiscountClass && orderPercentage > 0) {
    operations.push({
      orderDiscountsAdd: {
        candidates: [
          {
            message: `${orderPercentage}% OFF ORDER`,
            targets: [
              {
                orderSubtotal: {
                  excludedCartLineIds: [],
                },
              },
            ],
            value: {
              percentage: {
                value: orderPercentage,
              },
            },
          },
        ],
        selectionStrategy: OrderDiscountSelectionStrategy.First,
      },
    });
  }


  return {operations};
}


function parseMetafield(metafield) {
  try {
    const value = JSON.parse(metafield.value);
    return {
      cartLinePercentage: value.cartLinePercentage || 0,
      orderPercentage: value.orderPercentage || 0,
      collectionIds: value.collectionIds || [],
    };
  } catch (error) {
    console.error("Error parsing metafield", error);
    return {
      cartLinePercentage: 0,
      orderPercentage: 0,
      collectionIds: [],
    };
  }
}
```

## extensions/discount-function/src/cart\_delivery\_options\_discounts\_generate\_run.js

```javascript
import {
  DeliveryDiscountSelectionStrategy,
  DiscountClass,
} from "../generated/api";


export function cartDeliveryOptionsDiscountsGenerateRun(input) {
  const firstDeliveryGroup = input.cart.deliveryGroups[0];
  if (!firstDeliveryGroup) {
    throw new Error("No delivery groups found");
  }


  const {deliveryPercentage} = parseMetafield(input.discount.metafield);
  const hasShippingDiscountClass = input.discount.discountClasses.includes(
    DiscountClass.Shipping,
  );
  if (!hasShippingDiscountClass) {
    return {operations: []};
  }


  const operations = [];
  if (hasShippingDiscountClass && deliveryPercentage > 0) {
    operations.push({
      deliveryDiscountsAdd: {
        candidates: [
          {
            message: `${deliveryPercentage}% OFF DELIVERY`,
            targets: [
              {
                deliveryGroup: {
                  id: firstDeliveryGroup.id,
                },
              },
            ],
            value: {
              percentage: {
                value: deliveryPercentage,
              },
            },
          },
        ],
        selectionStrategy: DeliveryDiscountSelectionStrategy.All,
      },
    });
  }
  return {operations};
}


function parseMetafield(metafield) {
  try {
    const value = JSON.parse(metafield.value);
    return {deliveryPercentage: value.deliveryPercentage || 0};
  } catch (error) {
    console.error("Error parsing metafield", error);
    return {deliveryPercentage: 0};
  }
}
```

## extensions/create-intent-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
  "inputSchema": {
    "type": "object",
    "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount.json",
    "additionalProperties": true,
    "properties": {
      "functionId": {
        "matchValue": "YOUR_FUNCTION_ID"
      }
    }
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount/gid.json"
      }
    }
  }
}
```

## extensions/edit-intent-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
  "value": {
    "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount/gid.json",
    "mapTo": "param",
    "fieldName": "id"
  },
  "inputSchema": {
    "type": "object",
    "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount.json",
    "additionalProperties": true,
    "properties": {
      "functionId": {
        "matchValue": "YOUR_FUNCTION_ID"
      }
    }
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount/gid.json"
      }
    }
  }
}
```

## app/utils/navigation.ts

```typescript
export async function completeDiscountWorkflow(discountId: string) {
  if (shopify.intents.request.value) {
    await getIntentResponse().ok({id: discountId});
    return;
  }


  openDiscountsPage();
}


export async function returnToDiscounts() {
  if (shopify.intents.request.value) {
    await getIntentResponse().closed();
    return;
  }


  openDiscountsPage();
}


function getIntentResponse() {
  const response = shopify.intents.response;
  if (!response) throw new Error("Intent response API unavailable");
  return response;
}


function openDiscountsPage() {
  window.open("shopify://admin/discounts", "_top");
}
```

## extensions/instructions.md

```markdown
Use this app when the merchant wants to create or edit a metafield-configured discount powered by this app's Shopify Function.


When invoking `create:shopify/Discount`, pass `functionId: "YOUR_FUNCTION_ID"` so Shopify resolves directly to this app's create flow.


When invoking `edit:shopify/Discount`, pass the discount's GID (default to the canonical `gid://shopify/DiscountNode/{id}`) and `functionId: "YOUR_FUNCTION_ID"` so Shopify resolves the edit directly to this app.
```

## Tutorial complete!

You've successfully created a Discount Function and React Router app that allows merchants to set the discounts applied by that Function. Now, you can use this Function to apply discounts that target cart lines, order subtotals, and shipping rates.

***

### Next Steps

[Add network access to your discount Function\
\
Learn how to add network access to your discount Function to query an external system for discount code validation.](https://shopify.dev/docs/apps/build/discounts/network-access)[Review the UX guidelines\
\
Review the UX guidelines to learn how to implement discounts in user interfaces.](https://shopify.dev/docs/apps/build/discounts/ux-for-discounts)[Learn more about Shopify Functions\
\
Learn more about how Shopify Functions work and the benefits of using Shopify Functions.](https://shopify.dev/docs/apps/build/functions)[Consult the Shopify Functions API references\
\
Consult the API references for Shopify Functions](https://shopify.dev/docs/api/functions)[Learn more about deploying app versions\
\
Learn more about deploying app versions to Shopify](https://shopify.dev/docs/apps/launch/deployment/deploy-app-versions)[Let Sidekick open your discount pages\
\
Register a \`shopify/Discount\` app intent so Sidekick and the admin can open your app's discount create and edit surfaces.](https://shopify.dev/docs/apps/build/sidekick/build-app-actions#create-and-edit-discounts-with-shopify-discount-intents)
