---
title: Query campaign sales
description: >-
  Configure an app for Shopify Analytics, store each campaign as a metaobject
  entry, and then use ShopifyQL to query the campaign's sales, the sales from
  each content creator's discount codes, and the sales from QR code scans. Part
  one of three.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-sales?extension=react
  md: >-
    https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-sales.md?extension=react
api_name: shopifyql
---

# Query campaign sales

In this tutorial, you'll extend the [QR code app](https://shopify.dev/docs/apps/build/build) into a campaign dashboard using Shopify Analytics. The dashboard reports a marketing campaign's results and charts the store's analytics. You'll register each QR code as a marketing activity and track visits, conversion rate, and sales for each scan.

Use the [AI toolkit](https://shopify.dev/docs/apps/build/ai-toolkit) to build and customize the app. The [sample repository](https://github.com/Shopify/example-analytics--campaign-dashboard--react) contains the finished app from all three tutorials, and some files that you copy in this part contain code for parts two and three.

## What you'll learn

You'll learn how to:

* Store campaigns and content creators as [metaobjects](https://shopify.dev/docs/apps/build/metaobjects).
* Assign QR codes to a campaign.
* Query the store's sales with [ShopifyQL](https://shopify.dev/docs/api/shopifyql) and compare them to a previous period.
* Chart sales in your app with [analytics web components](https://shopify.dev/docs/api/shopifyql/latest/web-components).
* Track each QR code as a [marketing activity](https://shopify.dev/docs/api/admin-graphql/latest/objects/MarketingActivity) and report the sales and visits from its scans.

## Requirements

[Build the QR code app](https://shopify.dev/docs/apps/build/build)

You've completed [Build a Shopify app using React Router](https://shopify.dev/docs/apps/build/build). If your app uses TypeScript, then add types to the JavaScript files that you copy.

[Update Shopify CLI](https://shopify.dev/docs/api/shopify-cli)

You're using the latest version of [Shopify CLI](https://shopify.dev/docs/api/shopify-cli#upgrade-shopify-cli).

[Request protected customer data access](https://shopify.dev/docs/apps/launch/protected-customer-data)

You have Level 2 access to protected customer data in the [Partner Dashboard](https://partners.shopify.com/organizations).

## Project

[View on GitHub](https://github.com/Shopify/example-analytics--campaign-dashboard--react)

## Update your access scopes and API version

You'll start by adding the required [access scopes](https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes). Your app reads the store's sales with ShopifyQL, stores campaigns as metaobject entries, and creates metafield definitions on products, customers, and orders. In part two, your app writes values to the metafields.

Add the following access scopes, set the API version, and turn on direct API access.

### Add the access scopes

Add these eight scopes to the `[access_scopes]` section of `shopify.app.toml`:

| Scope | Authorizes |
| - | - |
| `read_reports` | [`shopifyqlQuery`](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyqlQuery). |
| `read_analytics_annotations` | The store's [analytics annotations](https://shopify.dev/docs/api/admin-graphql/latest/objects/AnalyticsAnnotation). |
| `write_products`, `write_customers`, `write_orders` | [`metafieldDefinitionCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldDefinitionCreate) and [`metafieldsSet`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldsSet) on products, customers, and orders. |
| `write_metaobject_definitions`, `write_metaobjects` | The Creator and Campaign [metaobject definitions](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectDefinitionCreate), their entries, and the QR code entries. |
| `write_marketing_events` | [`marketingActivityUpsertExternal`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/marketingActivityUpsertExternal) for each QR code. |

***

**Note:**

`shopify app dev` syncs `shopify.app.toml` to a preview on a development store. For a published app, Shopify syncs the configuration from a released [app version](https://shopify.dev/docs/apps/launch/deployment/app-versions).

[Access scopes](https://shopify.dev/docs/api/usage/access-scopes) [App versions](https://shopify.dev/docs/apps/launch/deployment/app-versions) [metafields​Set](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldsSet)

## /shopify.app.toml

```toml
... (6 unchanged lines)
[access_scopes]
scopes = "read_reports,read_analytics_annotations,write_products,write_customers,write_orders,write_metaobject_definitions,write_metaobjects,write_marketing_events"
... (72 unchanged lines)
```

### Set the API version

Move the QR code app from the January 2026 API version to October 2026. Upgrade the Shopify React Router app package to version 3 and the Prisma session storage package to version 11. Then change both API version settings in `app/shopify.server.js` to October 2026. In the same file, delete the `restResources` import and option. Version 3 of the package doesn't accept REST resources.

Replace `.graphqlrc.js` with the [version from the sample repository](https://github.com/Shopify/example-analytics--campaign-dashboard--react/blob/part-1/.graphqlrc.js). The original file imports the package root, and version 3 doesn't have a root entry point.

In `shopify.app.toml`, set the webhooks API version to `2026-10`.

***

[API versioning](https://shopify.dev/docs/api/usage/versioning)

## /app/shopify.server.js

```javascript
... (12 unchanged lines)
  apiVersion: ApiVersion.October26,
... (11 unchanged lines)
export const apiVersion = ApiVersion.October26;
... (6 unchanged lines)
```

### Turn on direct API access

Your app's metric cards query the GraphQL Admin API from the browser. Turn on [direct API access](https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration#admin) in `shopify.app.toml`:

```toml
[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true
```

***

[Direct API access](https://shopify.dev/docs/api/app-home#direct-api-access)

## /shopify.app.toml

```toml
... (9 unchanged lines)
# The metric cards query from the browser, which needs direct access.
# `online` runs those queries as the staff member who opened the app, and
# the browser holds that person's permissions and no more.
[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true
... (65 unchanged lines)
```

### Install the app and confirm the scopes

Use Shopify CLI to run the app on your development store:

## Terminal

```shell
shopify app dev
```

Press `p` to open the preview URL, and then click **Install app**. The install prompt lists the scopes that the app requests.

***

[shopify app dev](https://shopify.dev/docs/api/shopify-cli/app/app-dev) [Access scopes](https://shopify.dev/docs/api/usage/access-scopes)

##### The prompt lists fewer scopes than you set

Stop `shopify app dev`, confirm that all eight scopes are on one comma-separated line under `[access_scopes]`, and then run the command again.

If you cloned the sample repository, then `shopify app config link` overwrites the `scopes` line. Add the scopes again after you link the app.

## Create and store a campaign

Your app stores each campaign as a metaobject entry. Every sales query on the campaign's page uses the campaign's start and end dates as the query's date range.

### Add the campaign files

Copy these files into your app directory:

* `app/routes/app.campaigns._index.jsx`: The campaign list and the **New campaign** form.
* `app/routes/app.campaigns.$campaignId.jsx`: The campaign page with the charts and reports.
* `app/campaigns.server.js`: The Creator and Campaign definitions and entries.
* `app/campaign-queries.server.js`: The GraphQL queries and mutations.
* `app/dimensions.server.js`: Your app's metafield definitions and their analytics settings.
* `app/campaigns.js`: The date range for each query, the filters, and the dimension names.
* `app/annotations.js`: The `ANNOTATE` clause and the link that opens a query in the Shopify admin.
* `app/audiences.js`: The customer segments and the metafield that stores the segment.
* `app/channels.js`: The email, SMS, and push channels.
* `app/panels/sales.jsx`: The store totals, metric card, daily sales chart, and top products table.
* `app/panels/dimensions.jsx`: The promoted product, segment, order attribution, creator revenue, and QR code panels.
* `app/panels/shared.jsx`: The panel heading, the percentage text for a panel's share of store sales, and the chips that show each panel's Shopify features, such as ShopifyQL.
* `app/campaign-state.server.js`: The app's database records for each campaign.
* `app/shopifyql.server.js`: The ShopifyQL request, with a retry for throttled queries.

### Define the campaign and creator metaobjects

Your app creates two [merchant-owned](https://shopify.dev/docs/apps/build/metaobjects#merchant-owned-metaobjects) metaobject definitions. In API version 2026-10, ShopifyQL groups sales only by a merchant-owned type.

Each campaign is a `campaign_dashboard_campaign` entry. Each creator is a `campaign_dashboard_creator` entry with a name and a list of discount codes. A campaign's creators field is a [list of references](https://shopify.dev/docs/apps/build/metafields/list-of-data-types) to Creator entries.

***

[Metaobjects](https://shopify.dev/docs/apps/build/metaobjects) [Manage metaobject definitions](https://shopify.dev/docs/apps/build/metaobjects/manage-metaobject-definitions)

## /app/campaigns.server.js

```javascript
... (46 unchanged lines)
// Every query, target, and annotation on the dashboard derives from these fields.
// Each campaign is an entry the merchant can open in the admin. Descriptions show there.
// The IDs the app tracks for each campaign live in its database. See campaign-state.server.js.
const choices = (values) => [{name: 'choices', value: JSON.stringify(values)}];
const atLeastZero = [{name: 'min', value: '0'}];


export const CAMPAIGN_FIELDS = [
  {
    field: 'title',
    key: 'title',
    name: 'Title',
    type: 'single_line_text_field',
    required: true,
    description: 'Campaign name. Appears in each annotation title.',
  },
  {
    field: 'id',
    key: 'campaign_id',
    name: 'Campaign ID',
    type: 'number_integer',
    required: true,
    validations: [{name: 'min', value: String(FIRST_CAMPAIGN_ID)}],
    description: 'Numeric ID that links App Events to this campaign.',
  },
  {
    field: 'startDate',
    key: 'start_date',
    name: 'Start date',
    type: 'date',
    required: true,
    description: 'First day of the campaign.',
  },
  {
    field: 'endDate',
    key: 'end_date',
    name: 'End date',
    type: 'date',
    required: true,
    description: 'Last day of the campaign.',
  },
  {
    field: 'channels',
    key: 'channels',
    name: 'Channels',
    type: 'list.single_line_text_field',
    validations: choices(Object.keys(CHANNELS)),
    description: 'Channels the campaign sends on.',
  },
  {
    field: 'products',
    key: 'products',
    name: 'Promoted products',
    type: 'list.product_reference',
    validations: [{name: 'list.max', value: '5'}],
    description: 'Each one gets a reference back to this campaign at activation.',
  },
  {
    field: 'audience',
    key: 'audience',
    name: 'Customer segment',
    type: 'single_line_text_field',
    validations: choices(AUDIENCE_TIERS.map((tier) => tier.value)),
    description: 'Tier value written onto each targeted customer at activation.',
  },
  // ensureDefinition adds a second validation. It names the Creator definition's ID.
  {
    field: 'creators',
    key: 'creators',
    name: 'Creators',
    type: 'list.metaobject_reference',
    validations: [{name: 'list.max', value: '50'}],
    description: 'Creators who share a discount code for this campaign.',
  },
  {
    field: 'revenueTarget',
    key: 'revenue_target',
    name: 'Revenue target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Store-wide sales target. Empty means no target.',
  },
  {
    field: 'productTarget',
    key: 'product_target',
    name: 'Product target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Sales target filtered to the promoted products.',
  },
  {
    field: 'audienceTarget',
    key: 'audience_target',
    name: 'Customer segment target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Sales target filtered to the targeted tier.',
  },
  {
    field: 'qrTarget',
    key: 'qr_target',
    name: 'QR code target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Sales target for each assigned QR code.',
  },
  {
    field: 'status',
    key: 'status',
    name: 'Status',
    type: 'single_line_text_field',
    required: true,
    validations: choices(['planned', 'running', 'closed', 'canceled']),
    description: 'Planned, running, closed, or canceled.',
  },
];
... (680 unchanged lines)
```

### Create the definitions

Your app creates both definitions at runtime with [`metaobjectDefinitionCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectDefinitionCreate). Definitions in `shopify.app.toml` are app-owned. In API version 2026-10, ShopifyQL can't group sales by an app-owned definition.

Start with the Creator definition. The Campaign `creators` field validates against the Creator definition's ID. A merchant-owned definition's ID doesn't change when a user reinstalls your app. Shopify assigns an app-owned definition a new ID, which breaks the validation.

**Note:**

You can't change the validation after you create the field.

***

[metaobject​Definition​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectDefinitionCreate) [metaobject​Definition​Update](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectDefinitionUpdate)

## /app/campaigns.server.js

```javascript
... (181 unchanged lines)
const fieldInput = ({field, ...definition}, creatorDefinitionId) =>
  definition.type === 'list.metaobject_reference'
    ? {
        ...definition,
        validations: [
          ...(definition.validations ?? []),
          {name: 'metaobject_definition_id', value: creatorDefinitionId},
        ],
      }
    : definition;


const missingFields = (stored, fields) => {
  const keys = new Set(stored.fieldDefinitions.map((field) => field.key));
  return fields.filter((definition) => !keys.has(definition.key));
};


// A field added after the first install reaches existing stores on the next load.
async function addMissingFields(admin, stored, fields, read) {
  const missing = missingFields(stored, fields);
  if (!missing.length) return {definitionId: stored.id, userErrors: []};


  const body = await (
    await admin.graphql(OBJECT_FIELDS_ADD, {
      variables: {
        id: stored.id,
        definition: {
          fieldDefinitions: missing.map((definition) => ({create: definition})),
        },
      },
    })
  ).json();
  const userErrors = [
    ...transportErrors(body),
    ...(body.data?.metaobjectDefinitionUpdate?.userErrors ?? []),
  ];
  if (!userErrors.length) return {definitionId: stored.id, userErrors};
  // Two loads race to add the same field. The loser finds it already there.
  const again = (await read()).data?.definition;
  if (again && !missingFields(again, fields).length) return {definitionId: stored.id, userErrors: []};
  return {definitionId: null, userErrors};
}


async function ensureType(admin, {fieldDefinitions, ...definition}) {
  const read = async () =>
    (
      await admin.graphql(OBJECT_DEFINITION_QUERY, {
        variables: {type: definition.type},
      })
    ).json();


  const body = await read();
  const failed = transportErrors(body);
  if (failed.length) return {definitionId: null, userErrors: failed};


  const stored = body.data?.definition;
  if (stored) return addMissingFields(admin, stored, fieldDefinitions, read);


  const created = await (
    await admin.graphql(OBJECT_DEFINITION_CREATE, {
      variables: {definition: {...definition, fieldDefinitions}},
    })
  ).json();


  const result = created.data?.metaobjectDefinitionCreate;
  if (result?.metaobjectDefinition) {
    return {definitionId: result.metaobjectDefinition.id, userErrors: []};
  }
  // Two first loads race to create it. The loser finds the winner's definition.
  const again = await read();
  if (again.data?.definition) return {definitionId: again.data.definition.id, userErrors: []};
  return {
    definitionId: null,
    userErrors: [...transportErrors(created), ...(result?.userErrors ?? [])],
  };
}


// The Creator type comes first. The Campaign's `creators` field validates against its ID.
export async function ensureDefinition(admin) {
  const creator = await ensureType(admin, {
    type: CREATOR_TYPE,
    name: 'Creator',
    description: 'Creators who share discount codes for campaigns in the campaign dashboard app.',
    displayNameKey: 'name',
    fieldDefinitions: CREATOR_FIELDS.map((definition) => fieldInput(definition)),
  });
  if (!creator.definitionId) return creator;


  return ensureType(admin, {
    type: CAMPAIGN_TYPE,
    name: 'Campaign',
    description: 'Marketing campaigns planned in the campaign dashboard app.',
    displayNameKey: 'title',
    fieldDefinitions: CAMPAIGN_FIELDS.map((definition) =>
      fieldInput(definition, creator.definitionId),
    ),
  });
}
... (563 unchanged lines)
```

### Store the app's records in its database

App users can edit campaign entries in the Shopify admin. Keep the records that your app reads in your app's database:

* `ShopState`: The highest campaign ID that your app has assigned in the store.
* `CampaignState`: The annotation and target IDs that your app created for each campaign, and the number of App Events that your app sent for the campaign.
* `CampaignLock`: One row for each campaign write in progress.

Add the three models to `prisma/schema.prisma`, and copy the `prisma/migrations/20261007120000_add_campaign_state` and `prisma/migrations/20261007130000_add_stamp_operation` folders from the sample repository.

## /prisma/schema.prisma

```prisma
... (32 unchanged lines)
// The highest campaign ID this shop has handed out. App Events keeps a deleted
// campaign's events, and the ID stays spent.
model ShopState {
  shop           String @id
  lastCampaignId Int
}


// What the app tracks for each campaign entry. The merchant can edit the entry, and these
// IDs are the app's only handle on the annotations and targets it created.
// SQLite has no JSON column. `markers` and `targets` hold JSON objects as text.
model CampaignState {
  shop           String
  campaignId     Int
  campaignGid    String
  markers        String   @default("{}")
  targets        String   @default("{}")
  sentEvents     Int?
  // The bulk operation stamping a closed campaign's orders, until it finishes clean.
  stampOperation String?
  updatedAt      DateTime @updatedAt


  @@id([shop, campaignId])
}


// One row per campaign write in progress. The primary key lets one request in.
model CampaignLock {
  shop       String
  campaignId Int
  owner      String
  expiresAt  DateTime


  @@id([shop, campaignId])
}
```

### Apply the migrations

Stop `shopify app dev`, and then run the command again. The `dev` command in `shopify.web.toml` applies the migrations when the app starts:

## Terminal

```shell
shopify app dev
```

***

[shopify app dev](https://shopify.dev/docs/api/shopify-cli/app/app-dev)

### Read and write the campaign entries

A throttled request makes `admin.graphql` throw, and the action stops without undoing the earlier writes. Wrap `admin` with `withReturnedErrors` in each loader and action of the campaign routes to return the errors instead:

## JavaScript

```js
const {admin: client, session} = await authenticate.admin(request);
const admin = withReturnedErrors(client);
```

Pass `session.shop` to `readCampaigns`, which merges each entry with the entry's `CampaignState` row.

**Warning:**

A failed read returns an empty `campaigns` list and puts the errors in `failed`. The empty list looks the same as the list for a store with no campaigns. Check `failed` before you add the sample campaigns or assign a new campaign ID.

***

[metaobjects](https://shopify.dev/docs/api/admin-graphql/latest/queries/metaobjects) [metaobject​Update](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectUpdate)

## /app/campaign-queries.server.js

```javascript
... (49 unchanged lines)
const CREATOR_NODE = `
  id
  handle
  fields {
    key
    jsonValue
  }
`;


// `jsonValue` returns each field typed. Reference lists also resolve what they point at.
const CAMPAIGN_NODE = `
  id
  handle
  fields {
    key
    jsonValue
  }
  products: field(key: "products") {
    references(first: 5) {
      nodes {
        ... on Product {
          id
          title
        }
      }
    }
  }
  creators: field(key: "creators") {
    references(first: 50) {
      nodes {
        ... on Metaobject {
          ${CREATOR_NODE}
        }
      }
    }
  }
`;


// Returns this app's ID. `createdByApp` matches on it to find this app's annotations.
export const CAMPAIGNS_QUERY = `
  query CampaignList($type: String!, $after: String) {
    currentAppInstallation {
      app {
        id
        title
      }
    }
    metaobjects(type: $type, first: 250, after: $after) {
      nodes {
        ${CAMPAIGN_NODE}
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;
... (890 unchanged lines)
```

### Define the customer segments and channels

A campaign targets one customer segment. Your app writes the segment onto each targeted customer as a metafield. In part two, you make the metafield queryable in ShopifyQL.

`app/channels.js` lists the email, SMS, and push channels.

## /app/audiences.js

```javascript
// A scalar, because that's what a ShopifyQL comparison holds.
export const AUDIENCE_TIERS = [
  {
    value: 'vip',
    label: 'VIP',
    // Ranked from the sales schema, because CustomerSortKeys has no spend key.
    description: 'The 10 customers who spent the most in the last year.',
    size: 10,
  },
  {
    value: 'regulars',
    label: 'Regulars',
    // The 25 below the VIPs. `skip` becomes the ranking query's OFFSET.
    description: 'The next 25 customers by spend, below the VIPs.',
    size: 25,
    skip: 10,
  },
  {
    value: 'everyone',
    label: 'Everyone',
    description: 'No tier filter. The segment target matches the store-wide one.',
    size: 0,
  },
];


export const TIER_METAFIELD = {key: 'tier'};


export const tierLabel = (value) =>
  AUDIENCE_TIERS.find((tier) => tier.value === value)?.label ?? value;


// `everyone` measures the whole store. Nothing to filter on, and no customer gets a tier.
export const isTargetedTier = (value) => Boolean(value) && value !== 'everyone';
```

### Build the creation form

Add a multi-select channel picker to the **New campaign** form with [`s-choice-list`](https://shopify.dev/docs/api/app-home/web-components/forms/choice-list) and three `s-choice` elements, one for each channel.

***

[Choice list](https://shopify.dev/docs/api/app-home/web-components/forms/choice-list)

## /app/routes/app.campaigns.\_index.jsx

```jsx
... (265 unchanged lines)
            <s-section heading="Campaign details">
              <s-box paddingBlockStart="base" />
              {problems.length > 0 && (
                <s-banner tone="critical" heading="Campaign not created">
                  <s-unordered-list>
                    {problems.map((problem) => (
                      <s-list-item key={problem}>{problem}</s-list-item>
                    ))}
                  </s-unordered-list>
                </s-banner>
              )}


              <s-stack direction="block" gap="base">
                <s-text-field
                  label="Campaign name"
                  name="title"
                  placeholder="Summer launch"
                  details="Names the campaign in reports."
                />
                <s-stack direction="inline" gap="base">
                  <s-date-field label="Start date" name="startDate" />
                  <s-date-field label="End date" name="endDate" />
                </s-stack>


                <s-select
                  label="Customer segment"
                  name="audience"
                  value={audience}
                  onChange={(event) => setAudience(event.currentTarget.value)}
                  details="The customer segment that the campaign targets."
                >
                  {AUDIENCE_TIERS.map((tier) => (
                    <s-option key={tier.value} value={tier.value}>
                      {tier.label}
                    </s-option>
                  ))}
                </s-select>


                {/* The codes exist in Discounts. This records who hands each code out. */}
                <s-text-field
                  label="Creator codes"
                  name="creatorCodes"
                  placeholder="AVERY10:Avery, JUNO15:Juno"
                  details="Enter CODE:Creator pairs. Separate the pairs with commas. A creator can have more than one code."
                />
              </s-stack>
            </s-section>
... (261 unchanged lines)
```

### Validate the form and write the entry

Write the creators with [`metaobjectUpsert`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectUpsert). Write the `planned` campaign entry with [`metaobjectCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectCreate), and then redirect to the campaign page.

The action also validates the four target amounts. The form in this part doesn't have target fields. A blank amount means no target.

## /app/routes/app.campaigns.\_index.jsx

```jsx
... (40 unchanged lines)
export const action = async ({request}) => {
  const {admin: client, session, redirect} = await authenticate.admin(request);
  const admin = withReturnedErrors(client);
  const form = await request.formData();


  // Setup for every campaign, sharing this route's action with the create form.
  if (String(form.get('intent')) === 'enable-dimension') {
    const which = String(form.get('which'));
    const {failed, appId} = await readCampaigns(admin, session.shop);
    if (failed.length) return {problems: failed.map((error) => error.message)};
    const dimensions = await readDimensions(admin, appId);
    const definition = Object.hasOwn(dimensions, which) ? dimensions[which] : null;
    if (!definition) return {problems: [`No dimension named ${which}.`]};
    const errors = await enableDimension(admin, definition);
    return errors.length
      ? {problems: errors.map((error) => error.message)}
      : {ok: true};
  }


  const products = parseProducts(form.get('products'));
  // Sanitize on the way in. A code that can't be queried shouldn't reach the record.
  const creatorPairs = parseCreatorCodes(form.get('creatorCodes'));
  const fields = {
    title: String(form.get('title') || '').trim(),
    startDate: String(form.get('startDate') || ''),
    endDate: String(form.get('endDate') || ''),
    // A choice list with `multiple` submits one value for each selection.
    channels: form.getAll('channels').map(String),
    // JSON, because a form can't submit an array of objects.
    products: products ?? [],
    audience: String(form.get('audience') || 'everyone'),
    // Blank means no target. Activation skips it.
    revenueTarget: String(form.get('revenueTarget') || '').trim(),
    productTarget: String(form.get('productTarget') || '').trim(),
    audienceTarget: String(form.get('audienceTarget') || '').trim(),
    qrTarget: String(form.get('qrTarget') || '').trim(),
  };


  // The form hides a target activation can't create. Drop one that arrives anyway.
  if (!isTargetedTier(fields.audience)) fields.audienceTarget = '';
  if (fields.products.length === 0) fields.productTarget = '';


  const problems = [];
  if (!fields.title) problems.push('Enter a campaign name.');
  if (!fields.startDate || !fields.endDate) {
    problems.push('Enter a start date and an end date.');
  } else if (
    !isCalendarDate(fields.startDate) ||
    !isCalendarDate(fields.endDate)
  ) {
    problems.push('Enter valid dates, such as 2026-09-10.');
  } else if (fields.endDate < fields.startDate) {
    problems.push("End date can't be before the start date.");
  }
  if (fields.channels.length === 0) {
    problems.push('Select at least one channel.');
  } else if (fields.channels.some((key) => !CHANNEL_KEYS.includes(key))) {
    problems.push('Select channels from the list.');
  }
  if (products === null) {
    problems.push("Couldn't read the selected products. Select them again.");
  }
  // The tier becomes a ShopifyQL comparison and a target filter, both built as strings.
  // Only a value this app defines can reach them.
  if (!AUDIENCE_TIERS.some((tier) => tier.value === fields.audience)) {
    problems.push('Select a customer segment.');
  }
  // Expected values have to be above zero, and the mutation rejects a zero outright.
  // A blank amount is no target. A filled one has to be one activation can create.
  const amounts = [
    [fields.revenueTarget, 'Store sales target'],
    [fields.productTarget, 'Promoted product target'],
    [fields.audienceTarget, 'Customer segment target'],
    [fields.qrTarget, 'QR code target'],
  ];
  for (const [amount, name] of amounts) {
    if (amount && !isTargetAmount(amount)) {
      problems.push(`${name} must be an amount above 0 with at most two decimals. Leave it blank for no target.`);
    }
  }
  if (problems.length > 0) return {problems};


  // Each name becomes a shared Creator entry, and the campaign references it.
  const creators = await upsertCreators(admin, creatorPairs);
  if (creators.userErrors.length > 0) {
    return {problems: creators.userErrors.map((error) => error.message)};
  }


  const {campaign, userErrors} = await createCampaign(admin, session.shop, {
    ...fields,
    creators: creators.creators,
  });
  if (userErrors.length > 0) {
    return {problems: userErrors.map((error) => error.message)};
  }


  return redirect(`/app/campaigns/${campaign.id}`);
};
... (435 unchanged lines)
```

### Pick the promoted products

Call `shopify.resourcePicker` to open the Shopify admin's product search. Limit the selection to five products, and pass the current selection as `selectionIds`.

***

[Resource picker API](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/resource-picker-api)

## /app/routes/app.campaigns.\_index.jsx

```jsx
... (208 unchanged lines)
  // The app never builds a product list.
  async function pickProducts() {
    const selection = await shopify.resourcePicker({
      type: 'product',
      // Reopening with the current selection keeps the checkboxes in step.
      selectionIds: products.map((product) => ({id: product.id})),
      multiple: 5,
    });


    // Canceling returns undefined. Don't clear the selection.
    if (!selection) return;


    // Keep both. A target filters on the GID and the annotations use the title.
    setProducts(
      selection.map((product) => ({id: product.id, title: product.title})),
    );
  }
... (348 unchanged lines)
```

### Move the QR code list to `/app/qrcodes`

Move the QR code list from `app/routes/app._index.jsx` to `app/routes/app.qrcodes._index.jsx`, and add a **Campaign** column. Each **Campaign** cell links to the QR code's campaign.

### Point the app at the campaign list

Replace `app/routes/app._index.jsx` with a loader that redirects `/app` to the campaign list. Then link the campaign and QR code lists in the app's navigation, and delete `app/routes/app.additional.jsx` if it exists.

## /app/routes/app.jsx

```jsx
... (20 unchanged lines)
      <s-app-nav>
        <s-link href="/app/campaigns">Campaigns</s-link>
        <s-link href="/app/qrcodes">QR codes</s-link>
      </s-app-nav>
... (13 unchanged lines)
```

### Add two sample campaigns

Add two `planned` sample campaigns. The **Summer launch** campaign's dates cover today. The campaign lists two creators. Avery owns the `AVERY10` and `AVERYSUMMER` codes, and Juno owns `JUNO15`.

## /app/campaigns.server.js

```javascript
... (698 unchanged lines)
// Both start planned. One starts mid-flight with targets, the other weeks out without.
function seedRecords() {
  const day = (date) => date.toISOString().slice(0, 10);
  const shift = (days) => {
    const date = new Date();
    date.setDate(date.getDate() + days);
    return day(date);
  };


  return [
    {
      id: FIRST_CAMPAIGN_ID,
      title: 'Summer launch',
      // Dated 20 days in with a week left. Activate it and the nine sends land over the
      // first two weeks, leaving room for the sales they moved.
      startDate: shift(-20),
      endDate: shift(7),
      channels: ['email', 'sms', 'push'],
      audience: 'vip',
      // Two belong to one creator, the pairing no built-in dimension holds.
      creatorPairs: [
        {code: 'AVERY10', creator: 'Avery'},
        {code: 'AVERYSUMMER', creator: 'Avery'},
        {code: 'JUNO15', creator: 'Juno'},
      ],
      revenueTarget: '10000.00',
      productTarget: '4000.00',
      audienceTarget: '5000.00',
      status: 'planned',
    },
    // Starting in the future, with no targets. Every panel shows the setup it still needs.
    {
      id: FIRST_CAMPAIGN_ID + 1,
      title: 'Back to school',
      startDate: shift(14),
      endDate: shift(35),
      channels: ['email', 'sms'],
      audience: 'regulars',
      status: 'planned',
    },
  ];
}


export async function ensureCampaign(admin, shop) {
  const {userErrors: missing} = await ensureDefinition(admin);
  const state = await readCampaigns(admin, shop);
  if (missing.length) return {...state, userErrors: missing};
  // Seeding on a failed read would write over campaigns already stored.
  if (state.failed.length) return {...state, userErrors: state.failed};
  if (state.campaigns.length > 0) return {...state, userErrors: []};
  // Seed once per shop. Spent IDs mean the seeds went out already, and the merchant
  // deleted them.
  try {
    if (await hasSpentIds(shop)) return {...state, userErrors: []};
  } catch (error) {
    console.error('Could not read campaign IDs', error);
    return {...state, userErrors: [{message: "Couldn't read the app's campaign records. Try again."}]};
  }


  // Upsert on fixed handles. Two first loads racing each other write the same entries.
  const seeded = await Promise.all(
    seedRecords().map(async ({creatorPairs, ...seed}) => {
      if (creatorPairs) {
        const {creators, userErrors} = await upsertCreators(admin, creatorPairs);
        if (userErrors.length) return {campaign: null, userErrors};
        seed.creators = creators;
      }
      return entryResult(
        await (
          await admin.graphql(CAMPAIGN_UPSERT, {
            variables: {
              handle: {type: CAMPAIGN_TYPE, handle: campaignHandle(seed.id)},
              metaobject: {fields: campaignFields(seed)},
            },
          })
        ).json(),
        'metaobjectUpsert',
      );
    }),
  );
  const userErrors = seeded.flatMap((result) => result.userErrors);
  if (userErrors.length === 0) {
    try {
      await spendThrough(shop, FIRST_CAMPAIGN_ID + 1);
    } catch (error) {
      console.error('Could not save campaign IDs', error);
      userErrors.push({message: "Couldn't save the app's campaign records. Try again."});
    }
  }
  const campaigns = seeded
    .map((result) => result.campaign && withState(result.campaign, shop, null))
    .filter(Boolean);
  return {...state, campaigns, userErrors};
}
... (49 unchanged lines)
```

### Subscribe to the compliance webhooks

Apps on the Shopify App Store must use three [compliance webhooks](https://shopify.dev/docs/apps/build/compliance/privacy-law-compliance).

In `shopify.app.toml`, subscribe to three topics under `compliance_topics`:

* `customers/data_request`.
* `customers/redact`.
* `shop/redact`.

Then add a route for each topic:

* `app/routes/webhooks.customers.data_request.jsx` logs the customer's segment. A production app sends the customer's segment to the store owner.
* `app/routes/webhooks.customers.redact.jsx` deletes the customer's segment metafield.
* `app/routes/webhooks.shop.redact.jsx` deletes the store's sessions and campaign records. Shopify sends `shop/redact` 48 hours after a store owner uninstalls your app.

***

[Privacy law compliance](https://shopify.dev/docs/apps/build/compliance/privacy-law-compliance)

## /shopify.app.toml

```toml
... (29 unchanged lines)
  # `topics` rejects these three at release. They go under `compliance_topics`.
  [[webhooks.subscriptions]]
  compliance_topics = ["customers/data_request"]
  uri = "/webhooks/customers/data_request"


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


  [[webhooks.subscriptions]]
  compliance_topics = ["shop/redact"]
  uri = "/webhooks/shop/redact"
... (39 unchanged lines)
```

## Query the store's sales

The campaign page shows app users the store's sales during the campaign. Each query reads the [`sales`](https://shopify.dev/docs/api/shopifyql/latest/schemas/sales_revenue/sales) schema for the campaign's dates.

### Run the query

Send each statement to the `shopifyqlQuery` query through the app's `queryShopifyQL` helper. Run the statements in parallel.

The helper retries a throttled query once when the store's ShopifyQL limit refills within 10 seconds. Otherwise, the helper returns an error with the time that the limit refills.

Wrap the optional panel queries in `runOptional`. A failed optional query returns an empty table. The page still renders the other panels.

When a campaign started before today and ends after today, set `UNTIL` to `today`.

***

[shopifyql​Query](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyqlQuery) [sales](https://shopify.dev/docs/api/shopifyql/latest/schemas/sales_revenue/sales)

## /app/campaign-queries.server.js

```javascript
... (149 unchanged lines)
// TIMESERIES buckets by the shop's timezone. Each COMPARE TO period adds its own columns.
export const salesQuery = (campaign) => `
  query CampaignSales {
    shop {
      id
      currencyCode
      ianaTimezone
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders, new_customers, total_sales_first_time TIMESERIES day ${campaignWindow(campaign)} COMPARE TO previous_period, previous_year WITH TOTALS, PERCENT_CHANGE ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;
... (825 unchanged lines)
```

### Check the response

A failed query and a store with $0 in sales both show zero. Check `errors` and `parseErrors` before you read `tableData`.

***

[WITH TOTALS](https://shopify.dev/docs/api/shopifyql/latest/syntax/with) [PERCENT\_CHANGE](https://shopify.dev/docs/api/shopifyql/latest/syntax/with#generated-result-columns) [COMPARE TO](https://shopify.dev/docs/api/shopifyql/latest/syntax/compare-to)

## /app/routes/app.campaigns.$campaignId.jsx

```jsx
... (101 unchanged lines)
  // Two failure paths. A missing scope nulls the field and lands in `errors`. A string
  // that doesn't parse resolves the field and reports on `parseErrors`.
  const sales = salesBody.data?.shopifyqlQuery;
  const fatal = !sales
    ? (salesBody.errors?.[0]?.message ??
      'The sales query returned no data. Check the read_reports scope and Level 2 protected customer data access.')
    : sales.parseErrors?.length
      ? sales.parseErrors.join(' ')
      : null;
  if (fatal) return {campaign, locale, fatal};
... (288 unchanged lines)
```

### Format each data type

ShopifyQL returns money as decimal strings such as `"16161.6"`. Pass each string to `Intl.NumberFormat` with the store's currency, and parse it to a number only when you calculate with it. Append `T00:00:00` to each `YYYY-MM-DD` date, or browsers west of UTC show the previous day.

***

[Localize your app](https://shopify.dev/docs/apps/build/localize-your-app) [TIMESERIES](https://shopify.dev/docs/api/shopifyql/latest/syntax/timeseries)

## /app/routes/app.campaigns.$campaignId.jsx

```jsx
... (202 unchanged lines)
  // Money comes back as a string for precision. Format it here.
  const currency = (value) =>
    new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: currencyCode,
      maximumFractionDigits: 0,
    }).format(value ?? 0);


  // A bare YYYY-MM-DD is UTC midnight, a day early west of it. Append a time.
  const asLocalDay = (day) => new Date(`${day}T00:00:00`);


  const shortDate = (day) =>
    new Intl.DateTimeFormat(locale, {month: 'short', day: 'numeric'}).format(
      asLocalDay(day),
    );


  const percent = (value) =>
    value === null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(1)}%`;
... (179 unchanged lines)
```

### Chart the daily rows with a metric card

Pass `salesCardQuery` to [`s-shopifyql-metric-card`](https://shopify.dev/docs/api/shopifyql/latest/web-components/metric-card), which runs the query and draws daily sales as bars and orders as a line.

Add the [analytics script](https://shopify.dev/docs/api/shopifyql/latest/web-components) to the app layout. Render the card after `window.shopify.shopifyQL` loads, or the card stays in an error state.

The card fills its container's height. Wrap the card in an `s-box` with a fixed `blockSize`.

List each day's orders and sales below the chart with [`s-table`](https://shopify.dev/docs/api/app-home/web-components/layout-and-structure/table).

***

[Analytics web components](https://shopify.dev/docs/api/shopifyql/latest/web-components) [Metric card](https://shopify.dev/docs/api/shopifyql/latest/web-components/metric-card) [ANNOTATE](https://shopify.dev/docs/api/shopifyql/latest/syntax/annotate)

## /app/routes/app.jsx

```jsx
... (17 unchanged lines)
      {/* AppProvider leaves out the analytics UI script. It registers s-metrics-bar
          and s-shopifyql-metric-card. */}
      <script src="https://cdn.shopify.com/shopifycloud/analytics-ui.js" />
... (17 unchanged lines)
```

### Rank the top products

`GROUP BY TOP 5` returns the five products with the most sales in the campaign's window and folds every other product into one remainder row:

## ShopifyQL

```shopifyql
FROM sales
  SHOW total_sales
  GROUP BY TOP 5 product_title_at_time_of_sale
  SINCE 2026-07-22 UNTIL 2026-08-04
  ORDER BY total_sales DESC
```

Find the remainder row with `topNRemainderColumnNames` in the `rowMetadata` of [`tableData`](https://shopify.dev/docs/api/admin-graphql/latest/objects/ShopifyqlTableData), and render the remainder row last as **Other products**.

***

[GROUP BY TOP](https://shopify.dev/docs/api/shopifyql/latest/syntax/group-by#top) [Shopifyql​Table​Data](https://shopify.dev/docs/api/admin-graphql/latest/objects/ShopifyqlTableData)

## /app/campaign-queries.server.js

```javascript
... (173 unchanged lines)
// TOP 5 keeps the five products with the most total_sales, the metric the query
// aggregates. Every other product folds into one `Other` row, and rowMetadata marks it.
export const topProductsQuery = (campaign) => `
  query CampaignTopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales GROUP BY TOP 5 product_title_at_time_of_sale ${campaignWindow(campaign)} ORDER BY total_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
        rowMetadata {
          topNRemainderColumnNames
        }
      }
      parseErrors
    }
  }
`;
... (802 unchanged lines)
```

### Group several discount codes under one content creator

Orders record the discount code, and Creator entries map each code to a creator. Group the campaign's sales by `discount_code`, and then add up the rows for each creator's codes:

## ShopifyQL

```shopifyql
FROM sales
  SHOW discount_code, total_sales, orders
  WHERE discount_code IN ('AVERY10', 'AVERYSUMMER', 'JUNO15')
  GROUP BY discount_code
  SINCE 2026-07-22 UNTIL 2026-08-04
  ORDER BY total_sales DESC
```

***

* Sanitize the codes before they reach the query. Each code sits inside a quoted ShopifyQL literal, and an apostrophe closes the literal early.

***

[sales](https://shopify.dev/docs/api/shopifyql/latest/schemas/sales_revenue/sales#dimensions)

## /app/campaigns.js

```javascript
... (315 unchanged lines)
export const sanitizeCode = (code) =>
  String(code).toUpperCase().replace(/[^A-Z0-9_-]/g, '');


export function parseCreatorCodes(input) {
  return String(input ?? '')
    .split(',')
    .map((entry) => {
      const [code, creator] = entry.split(':');
      return {code: sanitizeCode(code), creator: (creator ?? code ?? '').trim()};
    })
    .filter((pair) => pair.code && pair.creator);
}


// Each referenced creator entry lists its codes. The panels work code by code.
export const creatorCodes = (campaign) =>
  (campaign.creators ?? []).flatMap((creator) =>
    creator.codes.map((code) => ({code, creator: creator.name})),
  );


// The codes as ShopifyQL sees them. Sanitized on the way in, leaving quoting as
// the only job here.
export const codeFilter = (campaign) => {
  const codes = creatorCodes(campaign).map((pair) => `'${pair.code}'`);
  return codes.length ? `discount_code IN (${codes.join(', ')})` : null;
};
```

## Connect the QR codes to Shopify attribution

Shopify credits an order to a [marketing activity](https://shopify.dev/docs/api/admin-graphql/latest/objects/MarketingActivity) when the visit's landing URL contains the activity's UTM parameters. Register an activity for each QR code, and add the activity's UTM parameters to the scan's destination.

### Update the QR code model

Replace `app/models/QRCode.server.js` with the version on the sample repository's `part-1` branch. In your app configuration file, add a `campaign` text field and a `live_on` date field to the QR code definition. Shopify CLI syncs the change while the dev server runs.

The campaign field stores the campaign entry's GID as text. A reference field in the app configuration file can point only to an app-owned or standard definition.

The model registers each QR code as a marketing activity with [`marketingActivityUpsertExternal`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/marketingActivityUpsertExternal). The code's handle is the activity's `remoteId` and `utm_campaign` value.

**Note:**

Leave out `hierarchyLevel`. You can't change `hierarchyLevel` after you set a value.

***

[marketing​Activity​Upsert​External](https://shopify.dev/docs/api/admin-graphql/latest/mutations/marketingActivityUpsertExternal)

### Register and end the activity from the form action

Replace `app/routes/app.qrcodes.$id.jsx` with the version on the sample repository's `part-1` branch. Saving a QR code creates or updates the code's marketing activity. Deleting a QR code ends the activity.

Your app deletes a QR code only when the code doesn't belong to a campaign or belongs to a `planned` campaign. The campaign page for a `running` or `closed` campaign still reports the code's scans and sales.

##### Form saves return a 400 through a dev tunnel

React Router returns `400 Bad Request` for an action POST whose `Origin` host differs from the request URL's host. A dev tunnel can make the hosts differ. Create `react-router.config.js` in the project root and allow the tunnel and admin hosts:

```javascript
export default {
  allowedActionOrigins: ["*.trycloudflare.com", "admin.shopify.com"],
};
```

### Add the UTM parameters to each scan

Replace `app/routes/qrcodes.$id.scan.jsx` with the version on the sample repository's `part-1` branch. Your app now redirects each scan to a URL with the code's UTM parameters. Shopify reads the parameters to attribute the visit.

## /app/models/QRCode.server.js

```javascript
... (154 unchanged lines)
// Both destinations land on the storefront, where Shopify attribution reads
// the UTM parameters. The cart permalink keeps them through checkout.
export function getDestinationUrl(qrCode, shop) {
  let url;
  if (qrCode.destination === "product") {
    url = new URL(`/products/${qrCode.productHandle}`, `https://${shop}`);
    url.searchParams.set("variant", qrCode.productVariantLegacyId);
  } else {
    invariant(qrCode.productVariantLegacyId, "Unrecognised product variant ID");
    url = new URL(`/cart/${qrCode.productVariantLegacyId}:1`, `https://${shop}`);
  }


  const utm = getQRCodeUTM(qrCode.handle);
  url.searchParams.set("utm_campaign", utm.campaign);
  url.searchParams.set("utm_medium", utm.medium);
  url.searchParams.set("utm_source", utm.source);
  return url.href;
}
... (202 unchanged lines)
```

## Track a QR code's sessions in the Shopify admin

Assign each QR code's activity to a marketing campaign in the Shopify admin. The marketing campaign's report then shows the sessions that follow a scan.

### Assign the activity to a marketing campaign

1. Save a QR code in the app, and then scan the code or open the code's destination URL.
2. From the Shopify admin, open **Growth** > **Campaigns**.
3. Create a marketing campaign, and then assign the QR code's activity to the marketing campaign.

### Open the campaign report after a scan

1. Scan the QR code.
2. From the Shopify admin, open **Growth** > **Campaigns**, and then open the marketing campaign that you assigned the activity to.

The campaign report shows the QR code's sessions.

##### The Sessions metric shows 0 after a scan

Shopify Analytics fills in the channel and UTM breakdowns after a delay. Reload the report later.

## Report the sales from QR code scans

The **QR code sales** panel shows app users the sales from each QR code. Each query filters on the codes' UTM parameters.

### Assign each QR code to a campaign

The QR code form's **Campaign** select lists your app's `planned` campaigns, which are separate from the marketing campaigns under **Growth** > **Campaigns**.

An app user can move a code only between `planned` campaigns. `heldByTargets` also blocks a move when a failed activation left targets on a `planned` campaign.

You build activation in [part two](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-targets-and-annotations). Every move in this part passes the check.

## /app/routes/app.qrcodes.$id.jsx

```jsx
... (32 unchanged lines)
// Codes join and leave a campaign while it's planned.
const assignable = (campaign) => campaign.status === "planned";


async function campaignChoices(admin, shop, qrCodeId) {
  const { campaigns, failed } = await readCampaigns(withReturnedErrors(admin), shop);
  const current = qrCodeId ? campaignOf(campaigns, qrCodeId) : null;
  const held = current ? heldByTargets(current) : null;
  return {
    failed,
    current,
    held,
    locked: Boolean(current && (!assignable(current) || held)),
    campaigns: campaigns
      .filter((campaign) => assignable(campaign) || campaign === current)
      .map((campaign) => ({
        gid: campaign.gid,
        title: campaign.title,
        startDate: campaign.startDate,
        endDate: campaign.endDate,
      })),
  };
}


// Blank goes live with the campaign.
function liveOnError(liveOn, choice) {
  if (!liveOn) return null;
  if (!choice) return "Select a campaign to set a live date.";
  return !isCalendarDate(liveOn) || liveOn < choice.startDate || liveOn > choice.endDate
    ? `Select a day from ${shortDate(choice.startDate)} to ${shortDate(choice.endDate)}.`
    : null;
}


const CAMPAIGNS_FAILED = "Couldn't load campaigns. Reload to try again.";


async function qrCodeIdOf(admin, handle) {
  const response = await admin.graphql(
    `query QRCodeId($handle: MetaobjectHandleInput!) {
      metaobjectByHandle(handle: $handle) { id }
    }`,
    { variables: { handle: { type: "$app:qrcode", handle } } },
  );
  const { data } = await response.json();
  return data?.metaobjectByHandle?.id ?? null;
}
... (568 unchanged lines)
```

### Query each QR code's sales

The [`campaign_sales`](https://shopify.dev/docs/api/shopifyql/latest/schemas/marketing/campaign_sales) schema reports the orders that Shopify attributes to marketing. Filter on `utm_medium` and the handles of the campaign's QR codes, and group by `utm_campaign` for one row for each code:

## ShopifyQL

```shopifyql
FROM campaign_sales
  SHOW utm_campaign, campaign_last_non_direct_click_total_sales,
    campaign_last_non_direct_click_order_count
  WHERE utm_medium = 'qr'
    AND utm_campaign IN ('counter-card', 'window-poster')
  GROUP BY utm_campaign
  SINCE 2026-07-22 UNTIL 2026-08-04
  ORDER BY campaign_last_non_direct_click_total_sales DESC
```

***

* Allow only lowercase letters, numbers, hyphens, and underscores in each handle. The handle sits inside a quoted ShopifyQL literal.
* The metrics follow the [last non-direct click](https://help.shopify.com/en/manual/reports-and-analytics/shopify-reports/report-types/default-reports/marketing-reports/marketing-attribution) attribution model.

***

[campaign\_sales](https://shopify.dev/docs/api/shopifyql/latest/schemas/marketing/campaign_sales)

## /app/campaign-queries.server.js

```javascript
... (835 unchanged lines)
// The QR app stamps every scan with utm_medium=qr and the code's handle as
// utm_campaign. The store's attribution model does the rest.
export const qrSalesQuery = (campaign, filter) => `
  query CampaignQRSales {
    shopifyqlQuery(
      query: "FROM campaign_sales SHOW utm_campaign, campaign_last_non_direct_click_total_sales, campaign_last_non_direct_click_order_count WHERE utm_medium = 'qr' AND ${filter} GROUP BY utm_campaign ${campaignWindow(campaign)} ORDER BY campaign_last_non_direct_click_total_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;
... (143 unchanged lines)
```

### Query each QR code's sessions

The [`campaign_sessions`](https://shopify.dev/docs/api/shopifyql/latest/schemas/marketing/campaign_sessions) schema counts the visits that each scan started. Query each code's sessions, add-to-cart rate, and conversion rate:

## ShopifyQL

```shopifyql
FROM campaign_sessions
  SHOW campaign_sessions, campaign_added_to_cart_rate, campaign_conversion_rate
  WHERE utm_campaign IN ('counter-card', 'window-poster')
  GROUP BY utm_campaign
  SINCE 2026-07-22 UNTIL 2026-08-04
```

Multiply each rate by 100 to show the rate as a percentage.

***

[campaign\_sessions](https://shopify.dev/docs/api/shopifyql/latest/schemas/marketing/campaign_sessions)

## /app/campaign-queries.server.js

```javascript
... (855 unchanged lines)
// Sessions count every visit a scan started, including visits with no order.
export const qrFunnelQuery = (campaign, filter) => `
  query CampaignQRFunnel {
    shopifyqlQuery(
      query: "FROM campaign_sessions SHOW campaign_sessions, campaign_added_to_cart_rate, campaign_conversion_rate WHERE ${filter} GROUP BY utm_campaign ${campaignWindow(campaign)}"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;
... (124 unchanged lines)
```

### Chart the scans and list the codes

Merge the sales rows and the sessions rows by `utm_campaign` into one row for each code.

***

[Metric card](https://shopify.dev/docs/api/shopifyql/latest/web-components/metric-card) [VISUALIZE](https://shopify.dev/docs/api/shopifyql/latest/syntax/visualize)

## /app/panels/dimensions.jsx

```jsx
... (107 unchanged lines)
// Funnel rates are fractions of each code's sessions. A code with scans and no
// order still gets a row.
export function qrShape({qrSales, qrFunnel, totalSales, cell, rate}) {
  const funnel = new Map(
    (qrFunnel?.tableData.rows ?? []).map((row) => [
      String(cell(row, 'utm_campaign') ?? ''),
      {
        sessions: Number(cell(row, 'campaign_sessions') ?? 0),
        cartRate: Number(cell(row, 'campaign_added_to_cart_rate') ?? 0),
        conversionRate: Number(cell(row, 'campaign_conversion_rate') ?? 0),
      },
    ]),
  );
  const sold = (qrSales.tableData.rows ?? [])
    .map((row) => ({
      code: String(cell(row, 'utm_campaign') ?? ''),
      sales: Number(cell(row, 'campaign_last_non_direct_click_total_sales') ?? 0),
      orders: Number(cell(row, 'campaign_last_non_direct_click_order_count') ?? 0),
    }))
    .filter((row) => row.sales > 0);
  const scannedOnly = [...funnel.entries()]
    .filter(([code, visits]) => visits.sessions > 0 && !sold.some((row) => row.code === code))
    .map(([code]) => ({code, sales: 0, orders: 0}));
  const qrRows = [...sold, ...scannedOnly].map((row) => ({
    ...row,
    ...(funnel.get(row.code) ?? {sessions: null, cartRate: null, conversionRate: null}),
  }));
  const qrTotal = qrRows.reduce((running, row) => running + row.sales, 0);
  return {qrRows, qrTotal, qrShare: rate(qrTotal, Number(totalSales))};
}
... (305 unchanged lines)
```

### Run the app and open a campaign

If `shopify app dev` isn't running, then start the app on your development store:

## Terminal

```shell
shopify app dev
```

Open the **Summer launch** sample campaign. The sales panel compares the store's totals with the prior period and last year, and charts each day's sales. **Top products** lists the best sellers.

**Creator revenue** lists Avery's and Juno's codes. The sample data doesn't create discount codes in Shopify for Avery and Juno. Each code's sales stay at $0 until an order uses the code.

**QR code sales** appears after you assign a QR code to the campaign and a scan of the QR code starts a session.

The promoted product, segment, and order attribution panels stay hidden until you finish [part two](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-targets-and-annotations).

***

* Test orders don't reach the `sales` schema. On a development store, create orders with the [`orderCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderCreate) mutation and a `processedAt` date inside the campaign's dates.

***

[shopify app dev](https://shopify.dev/docs/api/shopify-cli/app/app-dev)

##### QR code sales stay at $0 on a development store

A development store accepts only test payments, and test orders don't reach the `sales` and `campaign_sales` schemas. The **QR code sales** panel lists each scanned code's sessions, and each code's sales stay at $0.

##### The sales panel returns an access error

Your app is missing the `read_reports` scope or Level 2 [protected customer data](https://shopify.dev/docs/apps/launch/protected-customer-data) access. Add `read_reports` to `shopify.app.toml` or request Level 2 access from the [Partner Dashboard](https://partners.shopify.com/organizations), and then run the app again.

## /shopify.app.toml

```toml
# `shopify app config link` fills in client_id and the app URLs.
# Learn more: https://shopify.dev/docs/apps/tools/cli/configuration
client_id = ""


embedded = true


[access_scopes]
scopes = "read_reports,read_analytics_annotations,write_products,write_customers,write_orders,write_metaobject_definitions,write_metaobjects,write_marketing_events"


# The metric cards query from the browser, which needs direct access.
# `online` runs those queries as the staff member who opened the app, and
# the browser holds that person's permissions and no more.
[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true


[webhooks]
api_version = "2026-10"


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


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


  # `topics` rejects these three at release. They go under `compliance_topics`.
  [[webhooks.subscriptions]]
  compliance_topics = ["customers/data_request"]
  uri = "/webhooks/customers/data_request"


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


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


[metaobjects.app.qrcode]
name = "QR Code"
description = "QR codes that link to products"


  [metaobjects.app.qrcode.access]
  admin = "merchant_read_write"


[metaobjects.app.qrcode.fields.title]
name = "Title"
type = "single_line_text_field"
required = true


[metaobjects.app.qrcode.fields.product]
name = "Product"
type = "product_reference"


[metaobjects.app.qrcode.fields.product_variant]
name = "Product Variant"
type = "variant_reference"


[metaobjects.app.qrcode.fields.destination]
name = "Destination"
type = "single_line_text_field"


[metaobjects.app.qrcode.fields.scans]
name = "Scans"
type = "number_integer"


# Holds the ID of the code's campaign entry.
[metaobjects.app.qrcode.fields.campaign]
name = "Campaign"
type = "single_line_text_field"
capabilities.admin_filterable = true


# Blank goes live with its campaign.
[metaobjects.app.qrcode.fields.live_on]
name = "Live on"
type = "date"
```

## /app/shopify.server.js

```javascript
import '@shopify/shopify-app-react-router/adapters/node';
import {
  ApiVersion,
  AppDistribution,
  shopifyApp,
} from '@shopify/shopify-app-react-router/server';
import {PrismaSessionStorage} from '@shopify/shopify-app-session-storage-prisma';
import prisma from './db.server';


const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET || '',
  apiVersion: ApiVersion.October26,
  scopes: process.env.SCOPES?.split(','),
  appUrl: process.env.SHOPIFY_APP_URL || '',
  authPathPrefix: '/auth',
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  future: {
    expiringOfflineAccessTokens: true,
  },
});


export default shopify;
export const apiVersion = ApiVersion.October26;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;
```

## /app/campaigns.server.js

```javascript
import {AUDIENCE_TIERS, TIER_METAFIELD} from './audiences';
import {
  CAMPAIGN_CREATE,
  CAMPAIGN_DELETE,
  CAMPAIGN_TYPE,
  CAMPAIGN_UPDATE,
  CAMPAIGN_UPSERT,
  CAMPAIGNS_QUERY,
  CREATOR_BY_HANDLE,
  CREATOR_TYPE,
  CREATOR_UPSERT,
  CUSTOMER_TIER,
  METAFIELDS_DELETE,
  OBJECT_DEFINITION_CREATE,
  OBJECT_DEFINITION_QUERY,
  OBJECT_FIELDS_ADD,
  QR_CODE_CAMPAIGNS,
} from './campaign-queries.server';
import {
  allocateCampaignId,
  emptyState,
  hasSpentIds,
  mergeMarkers,
  readStates,
  releaseLock,
  saveState,
  spendThrough,
  takeLock,
} from './campaign-state.server';
import {CHANNELS} from './channels';
import {DIMENSION_NAMESPACE, sanitizeCode, statusLabel} from './campaigns';


// IDs stay spent. No endpoint deletes an event, and a reused ID inherits its funnel.
export const FIRST_CAMPAIGN_ID = 4210;


// The handle is the entry's lookup key. The integer is what App Events filters on.
export const campaignHandle = (id) => `campaign-${id}`;


// The new ID comes from the app's database, above this floor. The highest stored ID alone
// forgets a deleted campaign. The floor covers entries the database has no record of.
export const campaignIdFloor = (campaigns) =>
  campaigns.reduce(
    (highest, campaign) => Math.max(highest, Number(campaign.id) || 0),
    FIRST_CAMPAIGN_ID - 1,
  );


// Every query, target, and annotation on the dashboard derives from these fields.
// Each campaign is an entry the merchant can open in the admin. Descriptions show there.
// The IDs the app tracks for each campaign live in its database. See campaign-state.server.js.
const choices = (values) => [{name: 'choices', value: JSON.stringify(values)}];
const atLeastZero = [{name: 'min', value: '0'}];


export const CAMPAIGN_FIELDS = [
  {
    field: 'title',
    key: 'title',
    name: 'Title',
    type: 'single_line_text_field',
    required: true,
    description: 'Campaign name. Appears in each annotation title.',
  },
  {
    field: 'id',
    key: 'campaign_id',
    name: 'Campaign ID',
    type: 'number_integer',
    required: true,
    validations: [{name: 'min', value: String(FIRST_CAMPAIGN_ID)}],
    description: 'Numeric ID that links App Events to this campaign.',
  },
  {
    field: 'startDate',
    key: 'start_date',
    name: 'Start date',
    type: 'date',
    required: true,
    description: 'First day of the campaign.',
  },
  {
    field: 'endDate',
    key: 'end_date',
    name: 'End date',
    type: 'date',
    required: true,
    description: 'Last day of the campaign.',
  },
  {
    field: 'channels',
    key: 'channels',
    name: 'Channels',
    type: 'list.single_line_text_field',
    validations: choices(Object.keys(CHANNELS)),
    description: 'Channels the campaign sends on.',
  },
  {
    field: 'products',
    key: 'products',
    name: 'Promoted products',
    type: 'list.product_reference',
    validations: [{name: 'list.max', value: '5'}],
    description: 'Each one gets a reference back to this campaign at activation.',
  },
  {
    field: 'audience',
    key: 'audience',
    name: 'Customer segment',
    type: 'single_line_text_field',
    validations: choices(AUDIENCE_TIERS.map((tier) => tier.value)),
    description: 'Tier value written onto each targeted customer at activation.',
  },
  // ensureDefinition adds a second validation. It names the Creator definition's ID.
  {
    field: 'creators',
    key: 'creators',
    name: 'Creators',
    type: 'list.metaobject_reference',
    validations: [{name: 'list.max', value: '50'}],
    description: 'Creators who share a discount code for this campaign.',
  },
  {
    field: 'revenueTarget',
    key: 'revenue_target',
    name: 'Revenue target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Store-wide sales target. Empty means no target.',
  },
  {
    field: 'productTarget',
    key: 'product_target',
    name: 'Product target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Sales target filtered to the promoted products.',
  },
  {
    field: 'audienceTarget',
    key: 'audience_target',
    name: 'Customer segment target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Sales target filtered to the targeted tier.',
  },
  {
    field: 'qrTarget',
    key: 'qr_target',
    name: 'QR code target',
    type: 'number_decimal',
    validations: atLeastZero,
    description: 'Sales target for each assigned QR code.',
  },
  {
    field: 'status',
    key: 'status',
    name: 'Status',
    type: 'single_line_text_field',
    required: true,
    validations: choices(['planned', 'running', 'closed', 'canceled']),
    description: 'Planned, running, closed, or canceled.',
  },
];


// One entry per creator. Every campaign that names the creator references it.
export const CREATOR_FIELDS = [
  {
    field: 'name',
    key: 'name',
    name: 'Name',
    type: 'single_line_text_field',
    required: true,
    description: 'The name the creator posts under.',
  },
  {
    field: 'codes',
    key: 'discount_codes',
    name: 'Discount codes',
    type: 'list.single_line_text_field',
    description: 'Discount codes the creator shares.',
  },
];


const fieldInput = ({field, ...definition}, creatorDefinitionId) =>
  definition.type === 'list.metaobject_reference'
    ? {
        ...definition,
        validations: [
          ...(definition.validations ?? []),
          {name: 'metaobject_definition_id', value: creatorDefinitionId},
        ],
      }
    : definition;


const missingFields = (stored, fields) => {
  const keys = new Set(stored.fieldDefinitions.map((field) => field.key));
  return fields.filter((definition) => !keys.has(definition.key));
};


// A field added after the first install reaches existing stores on the next load.
async function addMissingFields(admin, stored, fields, read) {
  const missing = missingFields(stored, fields);
  if (!missing.length) return {definitionId: stored.id, userErrors: []};


  const body = await (
    await admin.graphql(OBJECT_FIELDS_ADD, {
      variables: {
        id: stored.id,
        definition: {
          fieldDefinitions: missing.map((definition) => ({create: definition})),
        },
      },
    })
  ).json();
  const userErrors = [
    ...transportErrors(body),
    ...(body.data?.metaobjectDefinitionUpdate?.userErrors ?? []),
  ];
  if (!userErrors.length) return {definitionId: stored.id, userErrors};
  // Two loads race to add the same field. The loser finds it already there.
  const again = (await read()).data?.definition;
  if (again && !missingFields(again, fields).length) return {definitionId: stored.id, userErrors: []};
  return {definitionId: null, userErrors};
}


async function ensureType(admin, {fieldDefinitions, ...definition}) {
  const read = async () =>
    (
      await admin.graphql(OBJECT_DEFINITION_QUERY, {
        variables: {type: definition.type},
      })
    ).json();


  const body = await read();
  const failed = transportErrors(body);
  if (failed.length) return {definitionId: null, userErrors: failed};


  const stored = body.data?.definition;
  if (stored) return addMissingFields(admin, stored, fieldDefinitions, read);


  const created = await (
    await admin.graphql(OBJECT_DEFINITION_CREATE, {
      variables: {definition: {...definition, fieldDefinitions}},
    })
  ).json();


  const result = created.data?.metaobjectDefinitionCreate;
  if (result?.metaobjectDefinition) {
    return {definitionId: result.metaobjectDefinition.id, userErrors: []};
  }
  // Two first loads race to create it. The loser finds the winner's definition.
  const again = await read();
  if (again.data?.definition) return {definitionId: again.data.definition.id, userErrors: []};
  return {
    definitionId: null,
    userErrors: [...transportErrors(created), ...(result?.userErrors ?? [])],
  };
}


// The Creator type comes first. The Campaign's `creators` field validates against its ID.
export async function ensureDefinition(admin) {
  const creator = await ensureType(admin, {
    type: CREATOR_TYPE,
    name: 'Creator',
    description: 'Creators who share discount codes for campaigns in the campaign dashboard app.',
    displayNameKey: 'name',
    fieldDefinitions: CREATOR_FIELDS.map((definition) => fieldInput(definition)),
  });
  if (!creator.definitionId) return creator;


  return ensureType(admin, {
    type: CAMPAIGN_TYPE,
    name: 'Campaign',
    description: 'Marketing campaigns planned in the campaign dashboard app.',
    displayNameKey: 'title',
    fieldDefinitions: CAMPAIGN_FIELDS.map((definition) =>
      fieldInput(definition, creator.definitionId),
    ),
  });
}


// `admin.graphql` throws on top-level `errors` and on any HTTP error status. A throw halfway
// through a write skips the cleanup after it. This client returns those errors in the body.
export function withReturnedErrors(admin) {
  return {
    ...admin,
    graphql: async (query, options) => {
      try {
        return await admin.graphql(query, options);
      } catch (error) {
        if (error instanceof Response) {
          // A redirect or a 401 sends the merchant to reauthorize. Let both through.
          if ((error.status >= 300 && error.status < 400) || error.status === 401) throw error;
          const message = `Shopify returned HTTP ${error.status}. Try again.`;
          // The library drops a 429's body. The code still marks it for a retry.
          const extensions = error.status === 429 ? {code: 'THROTTLED'} : undefined;
          return new Response(JSON.stringify({data: null, errors: [{message, extensions}]}));
        }
        const errors = error.body?.errors?.graphQLErrors ?? [{message: error.message}];
        // `extensions` carries the ShopifyQL budget, including when it refills.
        return new Response(
          JSON.stringify({data: error.body?.data ?? null, errors, extensions: error.body?.extensions}),
        );
      }
    },
  };
}


// A throttled or rejected request answers with `errors` and a null `data`. There are no
// nested `userErrors` to find, and an unchecked call reads as a clean one.
export const transportErrors = (body) =>
  (body.errors ?? []).map((error) => ({message: error.message}));


const isRecord = (value) =>
  Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const listOf = (value) => (Array.isArray(value) ? value : []);


// The merchant can edit any field in the admin. Read defensively and keep the app up.
export function campaignFromNode(node) {
  const values = Object.fromEntries(
    (node.fields ?? []).map((field) => [field.key, field.jsonValue]),
  );
  const titles = new Map(
    (node.products?.references?.nodes ?? [])
      .filter((product) => product?.id)
      .map((product) => [product.id, product.title]),
  );


  return {
    gid: node.id,
    handle: node.handle,
    id: Number(values.campaign_id),
    title: values.title ?? '',
    startDate: values.start_date ?? '',
    endDate: values.end_date ?? '',
    channels: listOf(values.channels),
    // The reference list drops a deleted product. The title map drops it too.
    products: listOf(values.products)
      .filter((id) => titles.has(id))
      .map((id) => ({id, title: titles.get(id) ?? ''})),
    // Each code names its campaign. readCampaigns fills this in from the codes.
    qrCodes: [],
    audience: values.audience ?? 'everyone',
    // An entry from before the `creators` field has no references and no creators.
    creators: (node.creators?.references?.nodes ?? [])
      .filter((creator) => creator?.id)
      .map(creatorFromNode),
    revenueTarget: values.revenue_target ?? '',
    productTarget: values.product_target ?? '',
    audienceTarget: values.audience_target ?? '',
    qrTarget: values.qr_target ?? '',
    status: values.status ?? 'planned',
  };
}


// The entry's fields plus the app's own record of it. The shop keys every later write.
// A row written for a different entry with this campaign ID doesn't apply.
export const withState = (campaign, shop, state) => ({
  ...campaign,
  shop,
  ...(state?.gid === campaign.gid ? state : emptyState()),
  gid: campaign.gid,
});


// Entries are editable in the admin. Codes reach ShopifyQL filters.
export function creatorFromNode(node) {
  const values = Object.fromEntries(
    (node.fields ?? []).map((field) => [field.key, field.jsonValue]),
  );
  return {
    id: node.id,
    handle: node.handle,
    name: String(values.name ?? ''),
    codes: listOf(values.discount_codes).map(sanitizeCode).filter(Boolean),
  };
}


// A code whose `campaign` names no stored campaign belongs to none.
async function withQRCodes(admin, campaigns) {
  const byGid = new Map(campaigns.map((campaign) => [campaign.gid, campaign]));
  let after = null;
  for (let page = 0; page < 20; page += 1) {
    const body = await (
      await admin.graphql(QR_CODE_CAMPAIGNS, {variables: {after}})
    ).json();
    const failed = transportErrors(body);
    if (failed.length) return failed;
    const connection = body.data?.metaobjects;
    for (const node of connection?.nodes ?? []) {
      const campaign = node?.handle && byGid.get(node.campaign?.value);
      if (!campaign) continue;
      campaign.qrCodes.push({
        id: node.id,
        handle: node.handle,
        title: node.title?.value ?? '',
        liveOn: node.liveOn?.value ?? '',
      });
    }
    if (!connection?.pageInfo?.hasNextPage) return [];
    after = connection.pageInfo.endCursor;
  }
  return [{message: 'This store has more than 5,000 QR codes. The campaign dashboard reads up to 5,000.'}];
}


// Returns this app's ID alongside the records. The namespace derives from it.
export async function readCampaigns(admin, shop) {
  const campaigns = [];
  let installation = null;
  let failed = [];
  let after = null;


  for (let page = 0; page < 20; page += 1) {
    const body = await (
      await admin.graphql(CAMPAIGNS_QUERY, {variables: {type: CAMPAIGN_TYPE, after}})
    ).json();
    failed = transportErrors(body);
    if (failed.length) break;
    installation ??= body.data?.currentAppInstallation ?? null;
    const connection = body.data?.metaobjects;
    campaigns.push(...(connection?.nodes ?? []).map(campaignFromNode));
    if (!connection?.pageInfo?.hasNextPage) break;
    after = connection.pageInfo.endCursor;
    if (page === 19) failed = [{message: 'This store has more than 5,000 campaigns. The campaign dashboard reads up to 5,000.'}];
  }


  if (!failed.length && campaigns.length) failed = await withQRCodes(admin, campaigns);


  let states = new Map();
  if (!failed.length && campaigns.length) {
    try {
      states = await readStates(shop, campaigns.map((campaign) => campaign.id));
    } catch (error) {
      console.error('Could not read campaign state', error);
      failed = [{message: "Couldn't read the app's campaign records. Try again."}];
    }
  }


  return {
    // An empty list from a failed read is indistinguishable from a store with no
    // campaigns. Seeding and ID allocation refuse on a failed read.
    failed,
    appId: installation?.app?.id ?? null,
    // The value `app_name` holds in App Events. Every app writes it.
    appName: installation?.app?.title ?? null,
    campaigns: failed.length
      ? []
      : campaigns
          .map((campaign) => withState(campaign, shop, states.get(campaign.id)))
          .sort((first, second) => first.id - second.id),
  };
}


export const findCampaign = (campaigns, campaignId) =>
  campaigns.find((campaign) => String(campaign.id) === String(campaignId)) ??
  null;


// Every field type takes a string. Lists take JSON, and an empty string clears.
function fieldValue(definition, value) {
  if (value === null || value === undefined || value === '') return '';
  // Products and creators travel as records. The field stores their IDs.
  if (definition.type.startsWith('list.')) {
    return JSON.stringify(value.map((item) => (isRecord(item) ? item.id : item)));
  }
  return String(value);
}


// Only the keys in `change`. The entry keeps every field the write leaves out.
export const campaignFields = (change) =>
  CAMPAIGN_FIELDS.filter((definition) => definition.field in change).map(
    (definition) => ({
      key: definition.key,
      value: fieldValue(definition, change[definition.field]),
    }),
  );


const entryResult = (body, name) => {
  const failed = transportErrors(body);
  if (failed.length) return {campaign: null, userErrors: failed};
  const result = body.data?.[name];
  return {
    campaign: result?.metaobject ? campaignFromNode(result.metaobject) : null,
    userErrors: result?.userErrors ?? [],
  };
};


const STATE_ERROR = {message: "Couldn't save the campaign's records. Try again."};


// `markers` and `targets` go to the app's database, before the entry. Each holds IDs of
// what's already on the store, and a failed entry write still leaves them accurate.
export async function updateCampaign(admin, campaign, change) {
  const {markers, targets, ...fields} = change;
  if (markers || targets) {
    try {
      await saveState(campaign.shop, campaign.id, campaign.gid, {markers, targets});
    } catch (error) {
      console.error('Could not save campaign state', error);
      return {campaign: null, userErrors: [STATE_ERROR]};
    }
  }
  if (!Object.keys(fields).length) return {campaign, userErrors: []};
  const body = await (
    await admin.graphql(CAMPAIGN_UPDATE, {
      variables: {id: campaign.gid, metaobject: {fields: campaignFields(fields)}},
    })
  ).json();
  return entryResult(body, 'metaobjectUpdate');
}


// Annotation IDs per mark, to find and rewrite an annotation after the campaign runs.
// The merge reads the stored IDs. Each ID lands as its mark saves.
export async function rememberMarkers(campaign, markers) {
  try {
    await mergeMarkers(campaign.shop, campaign.id, campaign.gid, markers);
    return {userErrors: []};
  } catch (error) {
    console.error('Could not save campaign state', error);
    return {userErrors: [STATE_ERROR]};
  }
}


const slug = (name) =>
  name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');


// Upsert on a handle from the name. A creator named again keeps the codes an earlier
// campaign gave them.
export async function upsertCreators(admin, pairs) {
  const byName = new Map();
  for (const {code, creator} of pairs) {
    byName.set(creator, [...(byName.get(creator) ?? []), code]);
  }
  const creators = [];
  for (const [name, codes] of byName) {
    const handle = {type: CREATOR_TYPE, handle: `creator-${slug(name) || slug(codes[0])}`};
    const read = await (
      await admin.graphql(CREATOR_BY_HANDLE, {variables: {handle}})
    ).json();
    const failed = transportErrors(read);
    if (failed.length) return {creators: [], userErrors: failed};
    const kept = read.data?.metaobjectByHandle
      ? creatorFromNode(read.data.metaobjectByHandle).codes
      : [];


    const body = await (
      await admin.graphql(CREATOR_UPSERT, {
        variables: {
          handle,
          metaobject: {
            fields: [
              {key: 'name', value: name},
              {key: 'discount_codes', value: JSON.stringify([...new Set([...kept, ...codes])])},
            ],
          },
        },
      })
    ).json();
    const result = body.data?.metaobjectUpsert;
    const userErrors = [...transportErrors(body), ...(result?.userErrors ?? [])];
    if (userErrors.length || !result?.metaobject) return {creators: [], userErrors};
    creators.push(creatorFromNode(result.metaobject));
  }
  return {creators, userErrors: []};
}


// Deletes a canceled campaign, whose annotations and targets cancel removed, or an entry
// that a create made under a suffixed handle.
// Shopify drops product references to the entry asynchronously. Creators stay for other campaigns.
export async function deleteCampaign(admin, campaign) {
  const body = await (
    await admin.graphql(CAMPAIGN_DELETE, {variables: {id: campaign.gid}})
  ).json();
  const failed = transportErrors(body);
  if (failed.length) return {userErrors: failed};
  return {userErrors: body.data?.metaobjectDelete?.userErrors ?? []};
}


// The tier metafield is the only customer data this app writes. A data request reads it,
// and a redaction deletes it.
export async function customerTier(admin, customerId) {
  const body = await (
    await admin.graphql(CUSTOMER_TIER, {variables: {id: customerId}})
  ).json();
  return {
    tier: body.data?.customer?.tier?.value ?? null,
    userErrors: transportErrors(body),
  };
}


export async function forgetCustomer(admin, customerId) {
  const body = await (
    await admin.graphql(METAFIELDS_DELETE, {
      variables: {
        metafields: [{ownerId: customerId, namespace: DIMENSION_NAMESPACE, key: TIER_METAFIELD.key}],
      },
    })
  ).json();
  return [...transportErrors(body), ...(body.data?.metafieldsDelete?.userErrors ?? [])];
}


// A failed activation leaves the campaign planned with its targets saved. Its codes wait
// for a cancel, which deletes the targets.
export const heldByTargets = (campaign) =>
  Object.keys(campaign.targets ?? {}).length > 0
    ? `${campaign.title} already has targets from an activation. Cancel the campaign, then change its QR codes.`
    : null;


// A code moves only between planned campaigns. The locks keep both campaigns planned
// until the caller saves the code's `campaign` field and releases them.
export async function lockQRCodeMove(admin, shop, qrCodeId, campaignGid) {
  const before = await readCampaigns(admin, shop);
  if (before.failed.length) return {problems: before.failed};
  const touched = before.campaigns.filter(
    (campaign) =>
      campaign.gid === campaignGid ||
      campaign.qrCodes.some((qrCode) => qrCode.id === qrCodeId),
  );
  const lock = await lockCampaigns(shop, touched.map((campaign) => campaign.id));
  if (lock.problems) return lock;


  // The form loaded before the lock. A campaign activated since then keeps its codes.
  const {campaigns, failed} = await readCampaigns(admin, shop);
  const now = campaignOf(campaigns, qrCodeId);
  const drifted = now && !touched.some((t) => t.gid === now.gid);
  const touchedNow = campaigns.filter((campaign) => touched.some((t) => t.gid === campaign.gid));
  const started = touchedNow.find((campaign) => campaign.status !== 'planned');
  const held = touchedNow.map(heldByTargets).find(Boolean);
  const problems = failed.length
    ? failed
    : drifted
      ? [
          {
            code: 'STALE',
            message: 'This QR code changed while you were working. Reload the page and try again.',
          },
        ]
      : started
        ? [
            {
              code: 'NOT_PLANNED',
              message: `${started.title} is ${statusLabel(started.status).toLowerCase()}. QR codes move only between planned campaigns.`,
            },
          ]
        : held
          ? [{code: 'HAS_TARGETS', message: held}]
          : [];
  if (problems.length) {
    await lock.release();
    return {problems};
  }
  return lock;
}


export const campaignOf = (campaigns, qrCodeId) =>
  campaigns.find((campaign) =>
    campaign.qrCodes.some((qrCode) => qrCode.id === qrCodeId),
  ) ?? null;


// Two requests can read one campaign as planned and both activate it. A row in the app's
// database holds the campaign until the first request finishes.
const HELD = 'Another change to this campaign is in progress. Reload in a moment.';


export async function lockCampaign(shop, campaignId) {
  const id = Number.parseInt(campaignId, 10) || 0;
  let owner;
  try {
    owner = await takeLock(shop, id);
  } catch (error) {
    console.error('Could not take the campaign lock', error);
    return {problems: [{message: "Couldn't lock the campaign. Try again."}]};
  }
  if (!owner) return {problems: [{message: HELD}]};


  // A failed release logs and returns. The write it guarded has already answered.
  const release = async () => {
    try {
      await releaseLock(shop, id, owner);
    } catch (error) {
      console.error('Could not release the campaign lock', error);
    }
  };
  return {release};
}


// Takes each campaign's lock in turn. One held lock releases the rest and refuses.
export async function lockCampaigns(shop, campaignIds) {
  const locks = [];
  for (const id of [...new Set(campaignIds)]) {
    const lock = await lockCampaign(shop, id);
    if (lock.problems) {
      for (const taken of locks) await taken.release();
      return {problems: lock.problems};
    }
    locks.push(lock);
  }
  return {
    release: async () => {
      for (const lock of locks) await lock.release();
    },
  };
}


// Both start planned. One starts mid-flight with targets, the other weeks out without.
function seedRecords() {
  const day = (date) => date.toISOString().slice(0, 10);
  const shift = (days) => {
    const date = new Date();
    date.setDate(date.getDate() + days);
    return day(date);
  };


  return [
    {
      id: FIRST_CAMPAIGN_ID,
      title: 'Summer launch',
      // Dated 20 days in with a week left. Activate it and the nine sends land over the
      // first two weeks, leaving room for the sales they moved.
      startDate: shift(-20),
      endDate: shift(7),
      channels: ['email', 'sms', 'push'],
      audience: 'vip',
      // Two belong to one creator, the pairing no built-in dimension holds.
      creatorPairs: [
        {code: 'AVERY10', creator: 'Avery'},
        {code: 'AVERYSUMMER', creator: 'Avery'},
        {code: 'JUNO15', creator: 'Juno'},
      ],
      revenueTarget: '10000.00',
      productTarget: '4000.00',
      audienceTarget: '5000.00',
      status: 'planned',
    },
    // Starting in the future, with no targets. Every panel shows the setup it still needs.
    {
      id: FIRST_CAMPAIGN_ID + 1,
      title: 'Back to school',
      startDate: shift(14),
      endDate: shift(35),
      channels: ['email', 'sms'],
      audience: 'regulars',
      status: 'planned',
    },
  ];
}


export async function ensureCampaign(admin, shop) {
  const {userErrors: missing} = await ensureDefinition(admin);
  const state = await readCampaigns(admin, shop);
  if (missing.length) return {...state, userErrors: missing};
  // Seeding on a failed read would write over campaigns already stored.
  if (state.failed.length) return {...state, userErrors: state.failed};
  if (state.campaigns.length > 0) return {...state, userErrors: []};
  // Seed once per shop. Spent IDs mean the seeds went out already, and the merchant
  // deleted them.
  try {
    if (await hasSpentIds(shop)) return {...state, userErrors: []};
  } catch (error) {
    console.error('Could not read campaign IDs', error);
    return {...state, userErrors: [{message: "Couldn't read the app's campaign records. Try again."}]};
  }


  // Upsert on fixed handles. Two first loads racing each other write the same entries.
  const seeded = await Promise.all(
    seedRecords().map(async ({creatorPairs, ...seed}) => {
      if (creatorPairs) {
        const {creators, userErrors} = await upsertCreators(admin, creatorPairs);
        if (userErrors.length) return {campaign: null, userErrors};
        seed.creators = creators;
      }
      return entryResult(
        await (
          await admin.graphql(CAMPAIGN_UPSERT, {
            variables: {
              handle: {type: CAMPAIGN_TYPE, handle: campaignHandle(seed.id)},
              metaobject: {fields: campaignFields(seed)},
            },
          })
        ).json(),
        'metaobjectUpsert',
      );
    }),
  );
  const userErrors = seeded.flatMap((result) => result.userErrors);
  if (userErrors.length === 0) {
    try {
      await spendThrough(shop, FIRST_CAMPAIGN_ID + 1);
    } catch (error) {
      console.error('Could not save campaign IDs', error);
      userErrors.push({message: "Couldn't save the app's campaign records. Try again."});
    }
  }
  const campaigns = seeded
    .map((result) => result.campaign && withState(result.campaign, shop, null))
    .filter(Boolean);
  return {...state, campaigns, userErrors};
}


// Every App Events filter keys on the ID. It has to be an integer, and it has to be new.
export async function createCampaign(admin, shop, fields) {
  const {campaigns, failed} = await readCampaigns(admin, shop);
  // A failed read hands out an ID that already has App Events data behind it.
  if (failed.length) return {campaign: null, userErrors: failed};


  let id = campaignIdFloor(campaigns);
  for (let attempt = 0; attempt < 5; attempt += 1) {
    // Spend the ID before using it. A failed create then skips the ID.
    try {
      id = await allocateCampaignId(shop, id);
    } catch (error) {
      console.error('Could not allocate a campaign ID', error);
      return {campaign: null, userErrors: [{message: "Couldn't assign a campaign ID. Try again."}]};
    }


    const handle = campaignHandle(id);
    const body = await (
      await admin.graphql(CAMPAIGN_CREATE, {
        variables: {
          metaobject: {
            type: CAMPAIGN_TYPE,
            handle,
            fields: campaignFields({
              audience: 'everyone',
              ...fields,
              id,
              status: 'planned',
            }),
          },
        },
      })
    ).json();
    const result = entryResult(body, 'metaobjectCreate');
    if (!result.campaign) return result;
    // Shopify suffixes a taken handle without an error. An entry the database doesn't
    // know about holds this ID.
    if (result.campaign.handle === handle) {
      return {...result, campaign: withState(result.campaign, shop, null)};
    }
    const cleanup = await deleteCampaign(admin, result.campaign);
    if (cleanup.userErrors.length) return {campaign: null, userErrors: cleanup.userErrors};
  }
  return {
    campaign: null,
    userErrors: [{message: "Couldn't assign a campaign ID. Try again."}],
  };
}
```

## /app/campaign-queries.server.js

```javascript
import {TIER_METAFIELD} from './audiences';
import {DIMENSION_NAMESPACE, campaignWindow, recentWindow} from './campaigns';
import {FUNNEL_METRICS, SEQUENCE_METRICS} from './channels';
import {queryShopifyQL} from './shopifyql.server';


export const SHOP_QUERY = `
  query CampaignShop {
    shop {
      id
      ianaTimezone
      currencyCode
    }
  }
`;


// Analytics joins only non-reserved types, and only once the merchant turns it on for
// the type. The prefix keeps it clear of a type the merchant made.
export const CAMPAIGN_TYPE = 'campaign_dashboard_campaign';


// A Campaign's `creators` field validates against this type's definition ID. A reserved
// `$app:` type gets a new ID on reinstall, and the validation can never change.
export const CREATOR_TYPE = 'campaign_dashboard_creator';


// Each code's `campaign` field holds its campaign's entry ID. Each code's handle is its
// utm_campaign, and its title names it on the chart.
export const QR_CODE_CAMPAIGNS = `
  query CampaignQRCodes($after: String) {
    metaobjects(type: "$app:qrcode", first: 250, after: $after) {
      nodes {
        id
        handle
        title: field(key: "title") {
          value
        }
        liveOn: field(key: "live_on") {
          value
        }
        campaign: field(key: "campaign") {
          value
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;


const CREATOR_NODE = `
  id
  handle
  fields {
    key
    jsonValue
  }
`;


// `jsonValue` returns each field typed. Reference lists also resolve what they point at.
const CAMPAIGN_NODE = `
  id
  handle
  fields {
    key
    jsonValue
  }
  products: field(key: "products") {
    references(first: 5) {
      nodes {
        ... on Product {
          id
          title
        }
      }
    }
  }
  creators: field(key: "creators") {
    references(first: 50) {
      nodes {
        ... on Metaobject {
          ${CREATOR_NODE}
        }
      }
    }
  }
`;


// Returns this app's ID. `createdByApp` matches on it to find this app's annotations.
export const CAMPAIGNS_QUERY = `
  query CampaignList($type: String!, $after: String) {
    currentAppInstallation {
      app {
        id
        title
      }
    }
    metaobjects(type: $type, first: 250, after: $after) {
      nodes {
        ${CAMPAIGN_NODE}
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;


export const CUSTOMER_TIER = `
  query CustomerTier($id: ID!) {
    customer(id: $id) {
      tier: metafield(namespace: "${DIMENSION_NAMESPACE}", key: "${TIER_METAFIELD.key}") {
        value
      }
    }
  }
`;


export const METAFIELDS_DELETE = `
  mutation DeleteCustomerTier($metafields: [MetafieldIdentifierInput!]!) {
    metafieldsDelete(metafields: $metafields) {
      deletedMetafields {
        key
      }
      userErrors {
        field
        message
      }
    }
  }
`;


// One mutation for product stamps and audience tags.
export const METAFIELDS_SET = `
  mutation SetCampaignMetafields($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields {
        id
        namespace
        key
      }
      userErrors {
        field
        message
      }
    }
  }
`;


// TIMESERIES buckets by the shop's timezone. Each COMPARE TO period adds its own columns.
export const salesQuery = (campaign) => `
  query CampaignSales {
    shop {
      id
      currencyCode
      ianaTimezone
    }
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders, new_customers, total_sales_first_time TIMESERIES day ${campaignWindow(campaign)} COMPARE TO previous_period, previous_year WITH TOTALS, PERCENT_CHANGE ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


// TOP 5 keeps the five products with the most total_sales, the metric the query
// aggregates. Every other product folds into one `Other` row, and rowMetadata marks it.
export const topProductsQuery = (campaign) => `
  query CampaignTopProducts {
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales GROUP BY TOP 5 product_title_at_time_of_sale ${campaignWindow(campaign)} ORDER BY total_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
        rowMetadata {
          topNRemainderColumnNames
        }
      }
      parseErrors
    }
  }
`;


// Built per panel. Two skipped panels would otherwise share a rows array.
export const emptyTable = () => ({
  tableData: {columns: [], rows: []},
  parseErrors: [],
  unavailable: null,
});


// An unreachable schema fails the whole request. A parse error nulls tableData.
// Either way the panel gets its reason in `unavailable`.
export async function runOptional(admin, query) {
  try {
    const body = await queryShopifyQL(admin, query);
    if (body.errors?.length) {
      return {...emptyTable(), unavailable: body.errors[0].message};
    }
    const result = body.data?.shopifyqlQuery;
    return {
      ...emptyTable(),
      tableData: result?.tableData ?? emptyTable().tableData,
      parseErrors: result?.parseErrors ?? [],
      unavailable: result?.parseErrors?.length
        ? result.parseErrors.join(' ')
        : null,
    };
  } catch (error) {
    return {...emptyTable(), unavailable: error.message};
  }
}


// Targets and annotations are newer than the sales query. On an older API version the
// whole request fails. An empty body leaves the other panels rendering.
export async function runIfSupported(admin, query) {
  try {
    return await (await admin.graphql(query)).json();
  } catch {
    return {data: null};
  }
}


export const TARGET_CREATE = `
  mutation CreateCampaignTarget($input: AnalyticsTargetCreateInput!) {
    analyticsTargetCreate(input: $input) {
      analyticsTarget {
        id
        name
        metric
        expectedValue
        filters
        shopifyqlQuery
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


// Only the fields you pass change. Raising a target leaves the metric and filters alone.
export const TARGET_UPDATE = `
  mutation RaiseCampaignTarget($id: ID!, $input: AnalyticsTargetUpdateInput!) {
    analyticsTargetUpdate(id: $id, input: $input) {
      analyticsTarget {
        id
        name
        expectedValue
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


export const TARGETS_DELETE = `
  mutation DeleteCampaignTargets($ids: [ID!]!) {
    analyticsTargetsDelete(ids: $ids) {
      deletedIds
      userErrors {
        field
        message
      }
    }
  }
`;


// By ID, because a QR code's target starts on the day the code goes live.
const CAMPAIGN_TARGETS = `
  query CampaignTargets($ids: [ID!]!) {
    targets: nodes(ids: $ids) {
      ... on AnalyticsTarget {
        id
        name
        metric
        expectedValue
        currencyCode
        startDate
        endDate
        filters
        shopifyqlQuery
      }
    }
  }
`;


// `nodes` takes at most 250 IDs per read, and a campaign records a target and a mark
// for each QR code. `key` is the query's alias for `nodes`.
const NODES_LIMIT = 250;


function batches(ids) {
  const chunks = [];
  for (let start = 0; start < ids.length; start += NODES_LIMIT) {
    chunks.push(ids.slice(start, start + NODES_LIMIT));
  }
  return chunks;
}


export async function readNodes(admin, query, key, ids) {
  const bodies = await Promise.all(
    batches(ids).map(async (chunk) => (await admin.graphql(query, {variables: {ids: chunk}})).json()),
  );
  const errors = bodies.flatMap((body) => body.errors ?? []);
  return {
    data: {[key]: bodies.flatMap((body) => body.data?.[key] ?? [])},
    ...(errors.length ? {errors} : {}),
  };
}


export async function readTargets(admin, campaign) {
  const ids = Object.values(campaign.targets ?? {}).filter(
    (id) => typeof id === 'string' && id.startsWith('gid://shopify/AnalyticsTarget/'),
  );
  return readNodes(admin, CAMPAIGN_TARGETS, 'targets', ids);
}


// `analyticsTargetsDelete` takes the same 250-ID limit.
export async function deleteTargets(admin, ids) {
  const bodies = [];
  for (const chunk of batches(ids)) {
    bodies.push(await (await admin.graphql(TARGETS_DELETE, {variables: {ids: chunk}})).json());
  }
  const errors = bodies.flatMap((body) => body.errors ?? []);
  const results = bodies.map((body) => body.data?.analyticsTargetsDelete ?? {});
  return {
    data: {
      analyticsTargetsDelete: {
        deletedIds: results.flatMap((result) => result.deletedIds ?? []),
        userErrors: results.flatMap((result) => result.userErrors ?? []),
      },
    },
    ...(errors.length ? {errors} : {}),
  };
}


// A deleted target comes back as null.
export const targetNodes = (body) => (body.data?.targets ?? []).filter(Boolean);




// Every campaign's recorded IDs. A paged `analyticsTargets` list drops targets past the
// first page once QR codes add their own.
export async function readAllTargets(admin, campaigns) {
  const ids = [...new Set(campaigns.flatMap((campaign) => Object.values(campaign.targets ?? {})))];
  try {
    return await readTargets(admin, {targets: ids});
  } catch (error) {
    // The overview shows this read's errors. A bare null would read as no targets.
    return {data: null, errors: [{message: `Couldn't read targets: ${error.message}`}]};
  }
}


export function progressQuery(targets) {
  const fields = targets
    .map(
      (target, index) =>
        `t${index}: shopifyqlQuery(query: ${JSON.stringify(target.shopifyqlQuery)}) {
          tableData { rows }
          parseErrors
        }`,
    )
    .join('\n');
  return `query TargetProgress {\n${fields}\n}`;
}


// The generated query selects one metric. Its single row holds one value.
export function progressValue(result) {
  if (!result || result.parseErrors?.length) return null;
  const row = result.tableData?.rows?.[0];
  if (!row) return 0;
  return Number(Object.values(row)[0] ?? 0);
}


// Re-measuring one target before writing a permanent annotation for it.
export const measureQuery = (generated) => `
  query MeasureTarget {
    shopifyqlQuery(query: ${JSON.stringify(generated)}) {
      tableData { rows }
      parseErrors
    }
  }
`;


export const ANNOTATION_CREATE = `
  mutation CreateCampaignAnnotation($input: AnalyticsAnnotationCreateInput!) {
    analyticsAnnotationCreate(input: $input) {
      analyticsAnnotation {
        id
        type
        title
        startedAt
        endedAt
      }
      userErrors {
        field
        message
      }
    }
  }
`;


// A bundle and a plain product take different annotation types.
export const BUNDLE_QUERY = `
  query PromotedProductKinds($ids: [ID!]!) {
    nodes(ids: $ids) {
      ... on Product {
        id
        title
        hasVariantsThatRequiresComponents
      }
    }
  }
`;


export const ANNOTATION_UPDATE = `
  mutation CloseCampaignAnnotation($id: ID!, $input: AnalyticsAnnotationUpdateInput!) {
    analyticsAnnotationUpdate(id: $id, input: $input) {
      analyticsAnnotation {
        id
        title
        description
        startedAt
        endedAt
        updatedAt
      }
      userErrors {
        field
        message
      }
    }
  }
`;


// No bulk variant, and the removed ID comes back as `deletedId`.
export const ANNOTATION_DELETE = `
  mutation DeleteCampaignAnnotation($id: ID!) {
    analyticsAnnotationDelete(id: $id) {
      deletedId
      userErrors {
        field
        message
      }
    }
  }
`;


// A deleted annotation comes back as null. `createdByApp` guards against an ID that
// someone edited into the record.
export const RECORDED_ANNOTATIONS = `
  query RecordedAnnotations($ids: [ID!]!) {
    annotations: nodes(ids: $ids) {
      ... on AnalyticsAnnotation {
        id
        createdByApp {
          id
        }
      }
    }
  }
`;


// A window holding more annotations than the page size continues from `endCursor`.
export const annotationsQuery = (campaign, after = null) => `
  query CampaignAnnotations {
    currentAppInstallation {
      app {
        id
      }
    }
    shop {
      analyticsAnnotations(
        first: 50
        ${after ? `after: "${after}"` : ''}
        sortKey: STARTED_AT
        query: "started_at:>=${campaign.startDate} started_at:<=${campaign.endDate}"
      ) {
        nodes {
          id
          type
          title
          description
          startedAt
          endedAt
          source
          createdByApp {
            id
            title
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  }
`;


// Campaign IDs repeat across apps. Matching `app_name` keeps another app's sends out.
// Doubling an apostrophe keeps the ShopifyQL string valid. JSON escaping guards GraphQL.
const appNameValue = (appName) =>
  JSON.stringify(appName.replaceAll("'", "''")).slice(1, -1);


export const appEventsQuery = (campaign, appName) => `
  query CampaignFunnel {
    shopifyqlQuery(
      query: "FROM app_events SHOW ${FUNNEL_METRICS.join(', ')} WHERE campaign_id = ${campaign.id} AND app_name = '${appNameValue(appName)}' TIMESERIES day ${recentWindow(campaign)} ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


// SINCE -30d UNTIL today spans 31 days, one past the limit.
export const sliceQuery = () => `
  query CampaignComparison {
    shopifyqlQuery(
      query: "FROM app_events SHOW emails_sent, emails_opened, sms_messages_sent, push_notifications_sent GROUP BY campaign_id, app_name SINCE -29d UNTIL today ORDER BY emails_sent DESC"
    ) {
      tableData {
        columns {
          name
        }
        rows
      }
      parseErrors
    }
  }
`;


export const sequenceQuery = (campaign, appName) => `
  query CampaignSequence {
    shopifyqlQuery(
      query: "FROM app_events SHOW ${SEQUENCE_METRICS.join(', ')} WHERE campaign_id = ${campaign.id} AND app_name = '${appNameValue(appName)}' GROUP BY template_id SINCE -29d UNTIL today ORDER BY template_id ASC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


export const bounceQuery = (campaign, appName) => `
  query CampaignBounces {
    shopifyqlQuery(
      query: "FROM app_events SHOW emails_bounced WHERE campaign_id = ${campaign.id} AND app_name = '${appNameValue(appName)}' GROUP BY recipient_status SINCE -29d UNTIL today ORDER BY emails_bounced DESC"
    ) {
      tableData {
        columns {
          name
        }
        rows
      }
      parseErrors
    }
  }
`;




export const DEFINITION_QUERY = `
  query AppMetafieldDefinitions($namespace: String!) {
    products: metafieldDefinitions(
      first: 1
      ownerType: PRODUCT
      namespace: $namespace
      key: "campaign"
    ) {
      nodes {
        id
        key
        namespace
        ownerType
        capabilities {
          analyticsQueryable {
            eligible
            enabled
          }
        }
      }
    }
    orders: metafieldDefinitions(
      first: 1
      ownerType: ORDER
      namespace: $namespace
      key: "campaign_touch"
    ) {
      nodes {
        id
        key
        namespace
        ownerType
        capabilities {
          analyticsQueryable {
            eligible
            enabled
          }
        }
      }
    }
    customers: metafieldDefinitions(
      first: 1
      ownerType: CUSTOMER
      namespace: $namespace
      key: "tier"
    ) {
      nodes {
        id
        key
        namespace
        ownerType
        capabilities {
          analyticsQueryable {
            eligible
            enabled
          }
        }
      }
    }
  }
`;


export const DEFINITION_CREATE = `
  mutation CreateAnalyticsDimension($definition: MetafieldDefinitionInput!) {
    metafieldDefinitionCreate(definition: $definition) {
      createdDefinition {
        id
        key
        namespace
        ownerType
        capabilities {
          analyticsQueryable {
            eligible
            enabled
          }
        }
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


// No `id` field. It addresses the definition by key and owner type, neither changeable.
export const DEFINITION_UPDATE = `
  mutation EnableAnalyticsDimension($definition: MetafieldDefinitionUpdateInput!) {
    metafieldDefinitionUpdate(definition: $definition) {
      updatedDefinition {
        id
        key
        capabilities {
          analyticsQueryable {
            enabled
          }
        }
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


// ShopifyQL needs the double-hyphen namespace quoted, and those quotes have to survive
// being written inside a GraphQL string.
const inGraphQLString = (dimension) => dimension.replaceAll('"', '\\"');


// Until the merchant turns on analytics for the Campaign type, the reference fails with
// `Metafield Column Not Found`. The probe reads one day and needs no stamped product.
export const dimensionProbeQuery = (dimension) => `
  query CampaignDimensionProbe {
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales GROUP BY ${inGraphQLString(dimension)} SINCE -1d UNTIL today LIMIT 1"
    ) {
      parseErrors
    }
  }
`;


// `AS` names the column. Without it the column is the whole dotted expression. A day
// bucket splits the window into the campaign's products and everything else.
export const promotedByQuery = (campaign, dimension) => `
  query CampaignPromotedSplit {
    shopifyqlQuery(
      query: "FROM sales SHOW ${inGraphQLString(dimension)} AS promoted_by, total_sales GROUP BY promoted_by TIMESERIES day ${campaignWindow(campaign)} ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


export const tierRankQuery = ({size, skip = 0}) => `
  query AudienceRanking {
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales WHERE customer_id IS NOT NULL GROUP BY customer_id SINCE -365d UNTIL today ORDER BY total_sales DESC LIMIT ${size}${skip > 0 ? ` OFFSET ${skip}` : ''}"
    ) {
      tableData {
        rows
      }
      parseErrors
    }
  }
`;


// Every customer who holds the tier now, from any campaign. The customers dataset
// needs no date range. ORDER BY keeps the pages stable as OFFSET walks them.
export const HOLDERS_PAGE_SIZE = 1000;
export const tierHoldersQuery = (dimension, tier, offset = 0) => `
  query CampaignTierHolders {
    shopifyqlQuery(
      query: "FROM customers SHOW total_number_of_orders WHERE ${inGraphQLString(dimension)} = '${tier}' GROUP BY customer_id ORDER BY customer_id ASC LIMIT ${HOLDERS_PAGE_SIZE}${offset > 0 ? ` OFFSET ${offset}` : ''}"
    ) {
      tableData {
        rows
      }
      parseErrors
    }
  }
`;


export const tierSalesQuery = (campaign, dimension, tier) => `
  query CampaignAudienceSales {
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales, orders WHERE ${inGraphQLString(dimension)} = '${tier}' TIMESERIES day ${campaignWindow(campaign)} ORDER BY day ASC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


// `LIMIT` and `OFFSET` page a long campaign's orders. A refund or an edit adds a row on
// its own day. The filters keep each order's original sale, as it stood at creation.
export const ORDER_PAGE_SIZE = 250;


export const orderDaysQuery = (campaign, offset) => `
  query CampaignOrders {
    shopifyqlQuery(
      query: "FROM sales SHOW total_sales WHERE excludes_post_order_adjustments = true AND is_sales_reversal = false AND is_sale_adjustment = false GROUP BY order_id, day ${campaignWindow(campaign)} ORDER BY day ASC, order_id ASC LIMIT ${ORDER_PAGE_SIZE} OFFSET ${offset}"
    ) {
      tableData {
        rows
      }
      parseErrors
    }
  }
`;


export const touchSalesQuery = (campaign, dimension) => `
  query CampaignTouchSales {
    shopifyqlQuery(
      query: "FROM sales SHOW ${inGraphQLString(dimension)} AS touch, total_sales, orders GROUP BY touch ${campaignWindow(campaign)} ORDER BY total_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


// `discount_code` is built in. Rolling codes up to one creator is the app's job.
export const codeSalesQuery = (campaign, filter) => `
  query CampaignCodeSales {
    shopifyqlQuery(
      query: "FROM sales SHOW discount_code, total_sales, orders WHERE ${filter} GROUP BY discount_code ${campaignWindow(campaign)} ORDER BY total_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


// The QR app stamps every scan with utm_medium=qr and the code's handle as
// utm_campaign. The store's attribution model does the rest.
export const qrSalesQuery = (campaign, filter) => `
  query CampaignQRSales {
    shopifyqlQuery(
      query: "FROM campaign_sales SHOW utm_campaign, campaign_last_non_direct_click_total_sales, campaign_last_non_direct_click_order_count WHERE utm_medium = 'qr' AND ${filter} GROUP BY utm_campaign ${campaignWindow(campaign)} ORDER BY campaign_last_non_direct_click_total_sales DESC"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


// Sessions count every visit a scan started, including visits with no order.
export const qrFunnelQuery = (campaign, filter) => `
  query CampaignQRFunnel {
    shopifyqlQuery(
      query: "FROM campaign_sessions SHOW campaign_sessions, campaign_added_to_cart_rate, campaign_conversion_rate WHERE ${filter} GROUP BY utm_campaign ${campaignWindow(campaign)}"
    ) {
      tableData {
        columns {
          name
          dataType
          displayName
        }
        rows
      }
      parseErrors
    }
  }
`;


export const OBJECT_DEFINITION_QUERY = `
  query ObjectDefinition($type: String!) {
    definition: metaobjectDefinitionByType(type: $type) {
      id
      fieldDefinitions {
        key
      }
    }
  }
`;


export const OBJECT_FIELDS_ADD = `
  mutation AddObjectFields($id: ID!, $definition: MetaobjectDefinitionUpdateInput!) {
    metaobjectDefinitionUpdate(id: $id, definition: $definition) {
      metaobjectDefinition {
        id
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


export const OBJECT_DEFINITION_CREATE = `
  mutation CreateObjectDefinition($definition: MetaobjectDefinitionCreateInput!) {
    metaobjectDefinitionCreate(definition: $definition) {
      metaobjectDefinition {
        id
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


export const CAMPAIGN_CREATE = `
  mutation CreateCampaign($metaobject: MetaobjectCreateInput!) {
    metaobjectCreate(metaobject: $metaobject) {
      metaobject {
        ${CAMPAIGN_NODE}
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


// Creates the entry when the handle is new. Seeds rely on it to stay single.
export const CAMPAIGN_UPSERT = `
  mutation UpsertCampaign($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
    metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
      metaobject {
        ${CAMPAIGN_NODE}
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


// Writes only the fields it receives. The rest keep their values.
export const CAMPAIGN_UPDATE = `
  mutation UpdateCampaign($id: ID!, $metaobject: MetaobjectUpdateInput!) {
    metaobjectUpdate(id: $id, metaobject: $metaobject) {
      metaobject {
        ${CAMPAIGN_NODE}
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


export const CREATOR_BY_HANDLE = `
  query CreatorByHandle($handle: MetaobjectHandleInput!) {
    metaobjectByHandle(handle: $handle) {
      ${CREATOR_NODE}
    }
  }
`;


export const CREATOR_UPSERT = `
  mutation UpsertCreator($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
    metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
      metaobject {
        ${CREATOR_NODE}
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


export const CAMPAIGN_DELETE = `
  mutation DeleteCampaign($id: ID!) {
    metaobjectDelete(id: $id) {
      deletedId
      userErrors {
        field
        message
        code
      }
    }
  }
`;
```

## /app/campaigns.js

```javascript
import {isTargetedTier, tierLabel} from './audiences';
import {CHANNELS} from './channels';


// Annotation text is merchant-facing. It takes the registry's label.
const channelLabel = (channel) => CHANNELS[channel]?.label ?? channel;


export const productId = (gid) => String(gid).split('/').at(-1);


// The filter string interpolates the ID. Only digits get through.
export function promotedFilter(campaign) {
  const ids = (campaign.products ?? [])
    .map((product) => productId(product.id))
    .filter((id) => /^\d+$/.test(id));
  if (ids.length === 0) return null;
  return `product_id IN (${ids.join(', ')})`;
}


export const promotedNames = (campaign) =>
  (campaign.products ?? []).map((product) => product.title).join(', ');


// Each code's handle is its utm_campaign. The filter interpolates it, and a merchant
// can rename a handle in the admin. Only slug characters get through.
export const qrHandles = (campaign) =>
  (campaign.qrCodes ?? [])
    .map((qrCode) => qrCode.handle)
    .filter((handle) => typeof handle === 'string' && /^[a-z0-9_-]+$/.test(handle));


export const qrHandleFilter = (campaign) => {
  const handles = qrHandles(campaign).map((handle) => `'${handle}'`);
  return handles.length ? `utm_campaign IN (${handles.join(', ')})` : null;
};


// The key holds the handle. A rewrite finds the mark of a code that moved away.
export const QR_MARK_PREFIX = 'qr:';


const qrCodeOf = (campaign, handle) =>
  (campaign.qrCodes ?? []).find((qrCode) => qrCode.handle === handle);


export const qrTitle = (campaign, handle) => qrCodeOf(campaign, handle)?.title || handle;


// A code can go live after the campaign starts. A day outside the window falls back
// to the campaign's first day.
export function qrLiveOn(campaign, handle) {
  const day = qrCodeOf(campaign, handle)?.liveOn;
  return inWindow(campaign, day) ? day : campaign.startDate;
}


export const inWindow = (campaign, day) =>
  isCalendarDate(day) && day >= campaign.startDate && day <= campaign.endDate;


const STATUS_LABELS = {
  planned: 'Planned',
  running: 'Active',
  closed: 'Closed',
  canceled: 'Canceled',
};


export const statusLabel = (status) => STATUS_LABELS[status] ?? status;


// `Number('abc')` is NaN, and a bare `<= 0` waves it through.
export const isPositiveAmount = (value) =>
  Number.isFinite(Number(value)) && Number(value) > 0;


// A typed target amount has at most two decimals and no exponent, such as `1e3`.
export const isTargetAmount = (value) =>
  /^\d+(\.\d{1,2})?$/.test(value) && isPositiveAmount(value);


// Every date reaches a ShopifyQL range or a target's startedAt. A string that
// isn't a real calendar day fails there, and the error never names the field.
export function isCalendarDate(value) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) return false;
  const date = new Date(`${value}T00:00:00Z`);
  return (
    !Number.isNaN(date.getTime()) &&
    date.toISOString().slice(0, 10) === String(value)
  );
}


// The selection arrives as a JSON string. A payload the server can't read returns
// null, and an empty array is a cleared selection.
export function parseProducts(input) {
  let parsed;
  try {
    parsed = JSON.parse(String(input ?? '[]'));
  } catch {
    return null;
  }
  if (!Array.isArray(parsed)) return null;
  return parsed
    .filter((product) => product && typeof product === 'object')
    .map((product) => ({
      id: String(product.id ?? ''),
      title: String(product.title ?? '').trim().slice(0, 255),
    }))
    .filter((product) => /^gid:\/\/shopify\/Product\/\d+$/.test(product.id))
    .slice(0, 5);
}


// Local midnight. A bare date string is UTC, and formats a day early west of it.
export const shortDate = (day, locale = 'en') =>
  new Intl.DateTimeFormat(locale, {month: 'short', day: 'numeric'}).format(
    new Date(`${day}T00:00:00`),
  );


export function safeLocale(value) {
  if (!value) return 'en';
  try {
    Intl.NumberFormat.supportedLocalesOf(value);
    return value;
  } catch {
    return 'en';
  }
}


// `today` is a token ShopifyQL resolves in the store's timezone, and this date is UTC.
// A store west of UTC is still on the previous day. Wait a day before using the token.
export function campaignWindow(campaign) {
  const today = new Date(Date.now()).toISOString().slice(0, 10);
  const running = campaign.startDate < today;
  const until = running && campaign.endDate > today ? 'today' : campaign.endDate;
  return `SINCE ${campaign.startDate} UNTIL ${until}`;
}


// Timestamps read back in the shop's timezone, and midnight UTC lands on the
// previous day east of it. Resolve a calendar date to 9am there.
export function shopMorning(date, timeZone) {
  const guess = new Date(`${date}T09:00:00Z`);
  if (!timeZone) return guess.getTime();
  const shift =
    new Date(guess.toLocaleString('en-US', {timeZone: 'UTC'})).getTime() -
    new Date(guess.toLocaleString('en-US', {timeZone})).getTime();
  return guess.getTime() + shift;
}


// `app_events` holds 30 days, and a window reaching further back fails outright.
// Clamp to relative tokens. ShopifyQL resolves them in the store's timezone.
const RECENT_DAYS = 29;


export function recentWindow(campaign) {
  const day = (offset) =>
    new Date(Date.now() - offset * 86400000).toISOString().slice(0, 10);
  // The UTC date can trail the store's date by a day. One day stricter covers every timezone.
  const floor = day(RECENT_DAYS - 1);
  // A campaign that ended before the retained days has no events left to count.
  if (campaign.endDate < floor) return `SINCE -${RECENT_DAYS}d UNTIL -${RECENT_DAYS}d`;
  const since = campaign.startDate < floor ? `-${RECENT_DAYS}d` : campaign.startDate;
  const running = campaign.startDate <= day(0);
  const until = running && campaign.endDate > day(0) ? 'today' : campaign.endDate;
  return `SINCE ${since} UNTIL ${until}`;
}


// A target has to end after the day it starts. A one-day window ends the next day.
export const targetEnd = (startDate, endDate) =>
  !startDate || endDate > startDate
    ? endDate
    : new Date(Date.parse(startDate) + 86400000).toISOString().slice(0, 10);


// Only the owning app can change an app-owned definition. Writes take the `$app:`
// alias. Reads and analytics columns take the namespace it resolves to.
export const DIMENSION_NAMESPACE = '$app:campaign_dashboard';


export const dimensionNamespace = (appId) =>
  `app--${String(appId).split('/').at(-1)}--campaign_dashboard`;


export const metafieldDimension = (appId, owner, key) =>
  `${owner}.metafields."${dimensionNamespace(appId)}".${key}`;


// UTC arithmetic. A local midnight date reports as the previous day east of UTC.
export function withinWindow(campaign, offsetDays) {
  const [year, month, date] = campaign.startDate.split('-').map(Number);
  const shifted = new Date(Date.UTC(year, month - 1, date + offsetDays));
  const day = shifted.toISOString().slice(0, 10);
  return day > campaign.endDate ? campaign.endDate : day;
}


// List the names while they fit the annotation's cap, then fall back to a count.
const nameList = (names) =>
  names.length > 3
    ? `${names.length} creators`
    : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;


export function campaignMarks(campaign) {
  const channels = campaign.channels ?? [];
  const tier = tierLabel(campaign.audience);
  const creators = [
    ...new Set(creatorCodes(campaign).map((pair) => pair.creator)),
  ];


  const marks = [
    {
      key: 'campaign',
      type: 'campaign',
      title: campaign.title,
      description: `Runs on ${channels.map(channelLabel).join(', ') || 'no channels'}.`,
      date: campaign.startDate,
      // The only span. Everything else happened at an instant.
      ranged: true,
    },
    // No products, nothing goes on sale.
    ...(campaign.products?.length
      ? [
          {
            key: 'launch',
            // Defaults to a plain product. `resolveLaunchMark` upgrades it for a bundle.
            type: 'product_launch',
            title: `${campaign.title} products on sale`,
            description: 'Promoted products go on sale.',
            date: campaign.startDate,
          },
        ]
      : []),
    // Early access goes to a tier. Everyone has no tier to let in first.
    ...(isTargetedTier(campaign.audience)
      ? [
          {
            key: 'vip',
            // The closest documented type. The text names the tier.
            type: 'loyalty_program_offer',
            title: `Early access for ${tier}`,
            description: `${tier} customers get early access.`,
            date: withinWindow(campaign, 1),
          },
        ]
      : []),
    ...(creators.length
      ? [
          {
            key: 'creator',
            type: 'influencer_collaboration',
            // `title` names the campaign on the store's chart, beside other
            // campaigns' marks. `label` is the in-app short form.
            label: 'Influencer drop',
            title: `Influencer drop for ${campaign.title}`,
            description:
              creators.length === 1
                ? `${creators[0]} posted a personal discount code.`
                : `${nameList(creators)} each posted a personal discount code.`,
            date: withinWindow(campaign, 11),
          },
        ]
      : []),
    // The offer is on the promoted products. No products, no offer.
    ...(campaign.products?.length
      ? [
          {
            key: 'promo',
            type: 'buy_x_get_y_discount',
            label: 'BOGO offer',
            title: `BOGO offer on ${campaign.title}`,
            description: 'Buy two, get one free.',
            date: withinWindow(campaign, 8),
          },
        ]
      : []),
    // One per assigned code, on the day it goes live.
    ...qrHandles(campaign).map((handle) => ({
      key: `${QR_MARK_PREFIX}${handle}`,
      type: 'campaign',
      label: 'QR code live',
      title: `${qrTitle(campaign, handle)} QR code for ${campaign.title}`,
      description: `Sales from this code's scans count toward ${campaign.title}.`,
      date: qrLiveOn(campaign, handle),
    })),
  ];


  // The events report `SEQUENCE`. Both the day and the name come from it.
  // A mark named for a send that never went out reads as that send on the chart.
  const sends = {
    email: {offset: 0, title: 'Announcement', did: 'Email announcement to'},
    push: {offset: 5, title: 'App exclusive', did: 'Push to'},
    sms: {offset: 13, title: 'Final hours', did: 'Final SMS to'},
  };
  for (const channel of channels) {
    const send = sends[channel] ?? {offset: 0, title: channelLabel(channel), did: 'Sent to'};
    marks.push({
      key: channel,
      type: 'campaign',
      label: send.title,
      title: `${send.title} for ${campaign.title}`,
      description: `${send.did} ${tier}.`,
      date: withinWindow(campaign, send.offset),
    });
  }


  // Titles cap at 75 characters, descriptions at 150. Both hold a name the user typed.
  return marks.map((mark) => ({
    ...mark,
    title: clamp(mark.title, 75),
    description: clamp(mark.description, 150),
  }));
}


export const clamp = (value, max) =>
  value.length <= max ? value : `${value.slice(0, max - 1)}…`;


// Only marks that reached a customer can attribute an order. The collection going on
// sale isn't a touch.
const TOUCH_KEYS = ['email', 'push', 'creator', 'promo', 'sms'];


// The touches the creators drove. Match on title, the field the order stores.
const CREATOR_KEYS = ['creator'];


export const creatorTouches = (campaign) =>
  touchMarks(campaign)
    .filter((mark) => CREATOR_KEYS.includes(mark.key))
    .map((mark) => mark.title);


export const touchMarks = (campaign) =>
  campaignMarks(campaign)
    .filter((mark) => TOUCH_KEYS.includes(mark.key))
    .sort((first, second) => first.date.localeCompare(second.date));


export const lastTouch = (touches, day) =>
  touches.filter((mark) => mark.date <= day).at(-1)?.title ?? null;


export const sanitizeCode = (code) =>
  String(code).toUpperCase().replace(/[^A-Z0-9_-]/g, '');


export function parseCreatorCodes(input) {
  return String(input ?? '')
    .split(',')
    .map((entry) => {
      const [code, creator] = entry.split(':');
      return {code: sanitizeCode(code), creator: (creator ?? code ?? '').trim()};
    })
    .filter((pair) => pair.code && pair.creator);
}


// Each referenced creator entry lists its codes. The panels work code by code.
export const creatorCodes = (campaign) =>
  (campaign.creators ?? []).flatMap((creator) =>
    creator.codes.map((code) => ({code, creator: creator.name})),
  );


// The codes as ShopifyQL sees them. Sanitized on the way in, leaving quoting as
// the only job here.
export const codeFilter = (campaign) => {
  const codes = creatorCodes(campaign).map((pair) => `'${pair.code}'`);
  return codes.length ? `discount_code IN (${codes.join(', ')})` : null;
};
```

## /app/routes/app.campaigns.\_index.jsx

```jsx
import {Fragment, useEffect, useState} from 'react';
import {isRouteErrorResponse, useFetcher, useLoaderData, useRouteError} from 'react-router';
import {useAppBridge} from '@shopify/app-bridge-react';
import {boundary} from '@shopify/shopify-app-react-router/server';
import {authenticate} from '../shopify.server';
import {
  createCampaign,
  ensureCampaign,
  readCampaigns,
  upsertCreators,
  withReturnedErrors,
} from '../campaigns.server';
import {enableDimension, readDimensions} from '../dimensions.server';
import {
  isCalendarDate,
  isTargetAmount,
  parseCreatorCodes,
  parseProducts,
  safeLocale,
  statusLabel,
} from '../campaigns';
import {AUDIENCE_TIERS, isTargetedTier, tierLabel} from '../audiences';
import {CHANNELS, CHANNEL_KEYS} from '../channels';


export const loader = async ({request}) => {
  const {admin: client, session} = await authenticate.admin(request);
  const admin = withReturnedErrors(client);
  const {appId, campaigns, userErrors} = await ensureCampaign(admin, session.shop);


  const dimensions = await readDimensions(admin, appId);


  return {
    campaigns,
    dimensions,
    // The admin's locale rides in on the request. Formatting here matches the client.
    locale: safeLocale(new URL(request.url).searchParams.get('locale')),
    seedErrors: userErrors.map((error) => error.message),
  };
};


export const action = async ({request}) => {
  const {admin: client, session, redirect} = await authenticate.admin(request);
  const admin = withReturnedErrors(client);
  const form = await request.formData();


  // Setup for every campaign, sharing this route's action with the create form.
  if (String(form.get('intent')) === 'enable-dimension') {
    const which = String(form.get('which'));
    const {failed, appId} = await readCampaigns(admin, session.shop);
    if (failed.length) return {problems: failed.map((error) => error.message)};
    const dimensions = await readDimensions(admin, appId);
    const definition = Object.hasOwn(dimensions, which) ? dimensions[which] : null;
    if (!definition) return {problems: [`No dimension named ${which}.`]};
    const errors = await enableDimension(admin, definition);
    return errors.length
      ? {problems: errors.map((error) => error.message)}
      : {ok: true};
  }


  const products = parseProducts(form.get('products'));
  // Sanitize on the way in. A code that can't be queried shouldn't reach the record.
  const creatorPairs = parseCreatorCodes(form.get('creatorCodes'));
  const fields = {
    title: String(form.get('title') || '').trim(),
    startDate: String(form.get('startDate') || ''),
    endDate: String(form.get('endDate') || ''),
    // A choice list with `multiple` submits one value for each selection.
    channels: form.getAll('channels').map(String),
    // JSON, because a form can't submit an array of objects.
    products: products ?? [],
    audience: String(form.get('audience') || 'everyone'),
    // Blank means no target. Activation skips it.
    revenueTarget: String(form.get('revenueTarget') || '').trim(),
    productTarget: String(form.get('productTarget') || '').trim(),
    audienceTarget: String(form.get('audienceTarget') || '').trim(),
    qrTarget: String(form.get('qrTarget') || '').trim(),
  };


  // The form hides a target activation can't create. Drop one that arrives anyway.
  if (!isTargetedTier(fields.audience)) fields.audienceTarget = '';
  if (fields.products.length === 0) fields.productTarget = '';


  const problems = [];
  if (!fields.title) problems.push('Enter a campaign name.');
  if (!fields.startDate || !fields.endDate) {
    problems.push('Enter a start date and an end date.');
  } else if (
    !isCalendarDate(fields.startDate) ||
    !isCalendarDate(fields.endDate)
  ) {
    problems.push('Enter valid dates, such as 2026-09-10.');
  } else if (fields.endDate < fields.startDate) {
    problems.push("End date can't be before the start date.");
  }
  if (fields.channels.length === 0) {
    problems.push('Select at least one channel.');
  } else if (fields.channels.some((key) => !CHANNEL_KEYS.includes(key))) {
    problems.push('Select channels from the list.');
  }
  if (products === null) {
    problems.push("Couldn't read the selected products. Select them again.");
  }
  // The tier becomes a ShopifyQL comparison and a target filter, both built as strings.
  // Only a value this app defines can reach them.
  if (!AUDIENCE_TIERS.some((tier) => tier.value === fields.audience)) {
    problems.push('Select a customer segment.');
  }
  // Expected values have to be above zero, and the mutation rejects a zero outright.
  // A blank amount is no target. A filled one has to be one activation can create.
  const amounts = [
    [fields.revenueTarget, 'Store sales target'],
    [fields.productTarget, 'Promoted product target'],
    [fields.audienceTarget, 'Customer segment target'],
    [fields.qrTarget, 'QR code target'],
  ];
  for (const [amount, name] of amounts) {
    if (amount && !isTargetAmount(amount)) {
      problems.push(`${name} must be an amount above 0 with at most two decimals. Leave it blank for no target.`);
    }
  }
  if (problems.length > 0) return {problems};


  // Each name becomes a shared Creator entry, and the campaign references it.
  const creators = await upsertCreators(admin, creatorPairs);
  if (creators.userErrors.length > 0) {
    return {problems: creators.userErrors.map((error) => error.message)};
  }


  const {campaign, userErrors} = await createCampaign(admin, session.shop, {
    ...fields,
    creators: creators.creators,
  });
  if (userErrors.length > 0) {
    return {problems: userErrors.map((error) => error.message)};
  }


  return redirect(`/app/campaigns/${campaign.id}`);
};


// The store from the earliest campaign onwards, capped at 90 days of daily rows.
// ShopifyQL resolves the relative token in the store's timezone.
const OVERVIEW_DAYS = 90;


function overviewSince(campaigns) {
  const earliest = campaigns.map((campaign) => campaign.startDate).sort()[0];
  if (!earliest) return null;
  const floor = new Date(Date.now() - OVERVIEW_DAYS * 86400000)
    .toISOString()
    .slice(0, 10);
  return earliest < floor ? `-${OVERVIEW_DAYS}d` : earliest;
}


export default function CampaignsIndex() {
  const {campaigns, dimensions, locale, seedErrors} = useLoaderData();
  const fetcher = useFetcher();
  const shopify = useAppBridge();
  // A separate fetcher keeps a dimension error off the create form.
  const dimensionFetcher = useFetcher();
  const enabling =
    dimensionFetcher.state !== 'idle' && dimensionFetcher.formData?.get('which');
  const dimensionProblems = dimensionFetcher.data?.problems ?? [];
  const [products, setProducts] = useState([]);
  const [channels, setChannels] = useState(CHANNEL_KEYS);
  const [audience, setAudience] = useState('everyone');
  const [composing, setComposing] = useState(false);
  // A fetcher keeps its last result. Cancel marks that result as read.
  const [discarded, setDiscarded] = useState(null);
  const problems =
    fetcher.data && fetcher.data !== discarded ? fetcher.data.problems ?? [] : [];
  const saving = fetcher.state !== 'idle';


  // Local midnight. A bare date string is UTC, and formats a day early west of it.
  const shortDate = (day) =>
    new Intl.DateTimeFormat(locale, {month: 'short', day: 'numeric'}).format(
      new Date(`${day}T00:00:00`),
    );


  // A metric card needs `window.shopify.shopifyQL`, installed after load. Poll
  // until it lands.
  const [cardReady, setCardReady] = useState(false);
  useEffect(() => {
    const poll = setInterval(() => {
      if (window.shopify?.shopifyQL) {
        setCardReady(true);
        clearInterval(poll);
      }
    }, 100);
    return () => clearInterval(poll);
  }, []);


  const since = overviewSince(campaigns);
  const cardQuery = (metric) =>
    [
      'FROM sales',
      `SHOW ${metric}`,
      'TIMESERIES day',
      `SINCE ${since} UNTIL today`,
      'WITH TOTALS, PERCENT_CHANGE',
      'COMPARE TO previous_period',
      `VISUALIZE ${metric} TYPE line`,
    ].join(' ');




  // The link needs a campaign to point at. Prefer one past planning. A planned campaign
  // has no window of sales to report.
  const reported =
    campaigns.find((campaign) => campaign.status !== 'planned') ?? campaigns[0];


  // The app never builds a product list.
  async function pickProducts() {
    const selection = await shopify.resourcePicker({
      type: 'product',
      // Reopening with the current selection keeps the checkboxes in step.
      selectionIds: products.map((product) => ({id: product.id})),
      multiple: 5,
    });


    // Canceling returns undefined. Don't clear the selection.
    if (!selection) return;


    // Keep both. A target filters on the GID and the annotations use the title.
    setProducts(
      selection.map((product) => ({id: product.id, title: product.title})),
    );
  }


  // Cancel discards the draft. The form remounts with its defaults, and state has to match.
  function discard() {
    setProducts([]);
    setChannels(CHANNEL_KEYS);
    setAudience('everyone');
    setDiscarded(fetcher.data);
    setComposing(false);
  }


  function submit(event) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    form.set('products', JSON.stringify(products));
    // The choice list submits under its `name`. Clear it, or channels arrive twice.
    form.delete('channels');
    for (const channel of channels) form.append('channels', channel);
    fetcher.submit(form, {method: 'post'});
  }


  return (
    <form onSubmit={submit}>
      <s-page heading="Campaigns">
        {composing ? (
          <s-button slot="secondary-actions" onClick={discard}>
            Cancel
          </s-button>
        ) : (
          <s-button
            slot="primary-action"
            variant="primary"
            onClick={() => setComposing(true)}
          >
            New campaign
          </s-button>
        )}


        {composing && (
          // The form wraps `s-page`, which spaces these sections as its own children.
          <Fragment>
            <s-section heading="Campaign details">
              <s-box paddingBlockStart="base" />
              {problems.length > 0 && (
                <s-banner tone="critical" heading="Campaign not created">
                  <s-unordered-list>
                    {problems.map((problem) => (
                      <s-list-item key={problem}>{problem}</s-list-item>
                    ))}
                  </s-unordered-list>
                </s-banner>
              )}


              <s-stack direction="block" gap="base">
                <s-text-field
                  label="Campaign name"
                  name="title"
                  placeholder="Summer launch"
                  details="Names the campaign in reports."
                />
                <s-stack direction="inline" gap="base">
                  <s-date-field label="Start date" name="startDate" />
                  <s-date-field label="End date" name="endDate" />
                </s-stack>


                <s-select
                  label="Customer segment"
                  name="audience"
                  value={audience}
                  onChange={(event) => setAudience(event.currentTarget.value)}
                  details="The customer segment that the campaign targets."
                >
                  {AUDIENCE_TIERS.map((tier) => (
                    <s-option key={tier.value} value={tier.value}>
                      {tier.label}
                    </s-option>
                  ))}
                </s-select>


                {/* The codes exist in Discounts. This records who hands each code out. */}
                <s-text-field
                  label="Creator codes"
                  name="creatorCodes"
                  placeholder="AVERY10:Avery, JUNO15:Juno"
                  details="Enter CODE:Creator pairs. Separate the pairs with commas. A creator can have more than one code."
                />
              </s-stack>
            </s-section>


            <s-box paddingBlockStart="base" />


            <s-section heading="Promoted products">
              <s-box paddingBlockStart="base" />
              <s-paragraph>
                Select up to five products.
              </s-paragraph>
              {products.length > 0 && (
                <s-unordered-list>
                  {products.map((product) => (
                    <s-list-item key={product.id}>{product.title}</s-list-item>
                  ))}
                </s-unordered-list>
              )}
              <s-button onClick={pickProducts}>
                {products.length > 0 ? 'Change products' : 'Select products'}
              </s-button>
            </s-section>


            <s-box paddingBlockStart="base" />


              <s-section heading="Channels">
              <s-box paddingBlockStart="base" />
              <s-choice-list
                label="Send on"
                name="channels"
                multiple
                onChange={(event) => setChannels([...event.currentTarget.values])}
              >
                {CHANNEL_KEYS.map((key) => (
                  <s-choice
                    key={key}
                    value={key}
                    defaultSelected={channels.includes(key) || undefined}
                  >
                    {CHANNELS[key].label}
                  </s-choice>
                ))}
              </s-choice-list>
              <s-box paddingBlockStart="base" />
              <s-stack direction="inline" gap="base">
                <s-button variant="primary" type="submit" loading={saving}>
                  Create campaign
                </s-button>
              </s-stack>
            </s-section>


            <s-box paddingBlockStart="base" />
          </Fragment>
        )}


        {since && (
          <s-section heading="Store sales overview">
            <s-box paddingBlockStart="base" />
            {cardReady ? (
              <s-metrics-bar accessibilityLabel="Store sales over the campaign period">
                <s-shopifyql-metric-card
                  heading="Total sales"
                  query={cardQuery('total_sales')}
                />
                <s-shopifyql-metric-card
                  heading="Orders"
                  query={cardQuery('orders')}
                />
                <s-shopifyql-metric-card
                  heading="Average order value"
                  query={cardQuery('average_order_value')}
                />
              </s-metrics-bar>
            ) : (
              <s-metrics-bar loading accessibilityLabel="Loading store sales" />
            )}
          </s-section>
        )}


        <s-section>
          {seedErrors.length > 0 && (
            <s-banner
              tone="critical"
              heading="Campaign setup failed"
            >
              <s-unordered-list>
                {seedErrors.map((problem) => (
                  <s-list-item key={problem}>{problem}</s-list-item>
                ))}
              </s-unordered-list>
            </s-banner>
          )}
          {campaigns.length === 0 ? (
            <s-paragraph>No campaigns yet.</s-paragraph>
          ) : (
            <s-table variant="auto">
              <s-table-header-row>
                <s-table-header listSlot="primary">Campaign</s-table-header>
                <s-table-header listSlot="labeled">Dates</s-table-header>
                <s-table-header listSlot="labeled">Segment and channels</s-table-header>
                  <s-table-header listSlot="inline">Status</s-table-header>
              </s-table-header-row>
              <s-table-body>
                {campaigns.map((campaign) => {
                      return (
                    <s-table-row key={campaign.id}>
                      <s-table-cell>
                        <s-link href={`/app/campaigns/${campaign.id}`}>
                          {campaign.title}
                        </s-link>
                      </s-table-cell>
                      <s-table-cell>
                        {shortDate(campaign.startDate)} to{' '}
                        {shortDate(campaign.endDate)}
                      </s-table-cell>
                      <s-table-cell>
                        {tierLabel(campaign.audience)} on{' '}
                        {(campaign.channels ?? [])
                          .map((key) => CHANNELS[key]?.label ?? key)
                          .join(', ')}
                      </s-table-cell>
                      <s-table-cell>
                        <s-badge
                          tone={
                            {
                              planned: 'info',
                              running: 'success',
                              closed: 'neutral',
                              canceled: 'neutral',
                            }[campaign.status]
                          }
                        >
                          {statusLabel(campaign.status)}
                        </s-badge>
                      </s-table-cell>
                    </s-table-row>
                  );
                })}
              </s-table-body>
            </s-table>
          )}
        </s-section>


        <s-section heading="Analytics dimensions">
          <s-box paddingBlockStart="base" />
          <s-paragraph>
            The app writes campaign values to products, customers, and orders as
            metafields. Create a dimension from each metafield. Campaign reports
            group sales by each dimension. Each dimension works for every
            campaign.
          </s-paragraph>
          {dimensionProblems.length > 0 && (
            <s-banner tone="critical" heading="Dimension not created">
              <s-unordered-list>
                {dimensionProblems.map((problem) => (
                  <s-list-item key={problem}>{problem}</s-list-item>
                ))}
              </s-unordered-list>
            </s-banner>
          )}
          {dimensions.product.reference &&
            dimensions.product.defined &&
            !dimensions.product.enabled && (
              <s-paragraph>
                Turn on analytics for the Campaign metaobject to use the product
                dimension.
              </s-paragraph>
            )}
          <s-table variant="auto">
            <s-table-header-row>
              <s-table-header listSlot="primary">Dimension</s-table-header>
              <s-table-header listSlot="labeled">Status</s-table-header>
              <s-table-header listSlot="labeled">Report</s-table-header>
              <s-table-header listSlot="inline" />
            </s-table-header-row>
            <s-table-body>
              {['product', 'customer', 'order'].map((which) => {
                const definition = dimensions[which];
                return (
                  <s-table-row key={which}>
                    <s-table-cell>
                      <s-text>{definition.name}</s-text>
                    </s-table-cell>
                    <s-table-cell>
                      {definition.enabled
                        ? 'Queryable'
                        : !definition.defined
                          ? 'Not created'
                          : definition.reference
                            ? 'Analytics off for Campaign'
                            : definition.eligible
                              ? 'Created, not queryable'
                              : 'Not eligible. Check the field type and owner type.'}
                    </s-table-cell>
                    <s-table-cell>
                      {reported && definition.enabled ? (
                        <s-link
                          href={`/app/campaigns/${reported.id}#${definition.anchor}`}
                        >
                          {definition.report}
                        </s-link>
                      ) : (
                        definition.report
                      )}
                    </s-table-cell>
                    <s-table-cell>
                      <s-stack direction="inline" gap="base" alignItems="center">
                        {!definition.enabled &&
                          (!definition.defined ||
                            (definition.eligible && !definition.reference)) && (
                            <s-button
                              loading={enabling === which}
                              disabled={Boolean(enabling) && enabling !== which}
                              onClick={() =>
                                dimensionFetcher.submit(
                                  {intent: 'enable-dimension', which},
                                  {method: 'post'},
                                )
                              }
                            >
                              {definition.defined
                                ? 'Enable as dimension'
                                : 'Create dimension'}
                            </s-button>
                          )}
                        {definition.href && (
                          <s-link href={definition.href} target="_blank">
                            {definition.reference && definition.defined && !definition.enabled
                              ? 'Turn on analytics'
                              : 'View definition'}
                          </s-link>
                        )}
                      </s-stack>
                    </s-table-cell>
                  </s-table-row>
                );
              })}
            </s-table-body>
          </s-table>
        </s-section>
      </s-page>
    </form>
  );
}


// React Router replaces the nearest route with a boundary. Without one here, a failed
// load takes AppProvider and the app's navigation with it.
export function ErrorBoundary() {
  const error = useRouteError();
  // A response Shopify threw, such as a re-auth, renders the way the library expects.
  if (isRouteErrorResponse(error)) return boundary.error(error);
  const detail = error?.message ?? 'The load failed.';
  return (
    <s-page heading="Campaigns">
      <s-section>
        <s-banner tone="critical" heading="Couldn't load campaigns">
          <s-paragraph>{String(detail)}</s-paragraph>
        </s-banner>
      </s-section>
    </s-page>
  );
}


export const headers = (headersArgs) => boundary.headers(headersArgs);
```

## /app/routes/app.campaigns.$campaignId.jsx

```jsx
import {isRouteErrorResponse, useLoaderData, useRouteError} from 'react-router';
import {boundary} from '@shopify/shopify-app-react-router/server';
import {authenticate} from '../shopify.server';
import {findCampaign, readCampaigns, withReturnedErrors} from '../campaigns.server';
import {
  codeSalesQuery,
  emptyTable,
  promotedByQuery,
  qrFunnelQuery,
  qrSalesQuery,
  runOptional,
  salesQuery,
  topProductsQuery,
  tierSalesQuery,
  touchSalesQuery,
} from '../campaign-queries.server';
import {campaignWindow, codeFilter, qrHandleFilter, safeLocale, statusLabel} from '../campaigns';
import {CHANNELS} from '../channels';
import {
  SalesAndOrders,
  TopProducts,
  topProductsShape,
  useMetricCardsReady,
} from '../panels/sales';
import {
  CreatorRevenue,
  OrderAttribution,
  PromotedProductSales,
  QRCodeSales,
  SegmentSales,
  creatorShape,
  promotedShape,
  qrShape,
  tierShape,
  touchShape,
} from '../panels/dimensions';
import {isTargetedTier, tierLabel} from '../audiences';
import {analyticsReportHref} from '../annotations';
import {readDimensions} from '../dimensions.server';
import {queryShopifyQL} from '../shopifyql.server';


export async function loader({request, params}) {
  // Redirect on a miss. A thrown response renders above this route's layout
  // and takes the app's navigation with it. The redirect keeps App Bridge's search params.
  const {admin: client, session, redirect} = await authenticate.admin(request);
  const admin = withReturnedErrors(client);
  const {appId, campaigns, failed} = await readCampaigns(admin, session.shop);
  // Report a failed read. A redirect would treat the campaign as deleted.
  if (failed.length) {
    return {
      campaign: {title: 'Campaign'},
      fatal: failed.map((error) => error.message).join(' '),
      fatalHeading: "Couldn't load campaign",
    };
  }
  const campaign = findCampaign(campaigns, params.campaignId);
  if (!campaign) return redirect('/app/campaigns');


  // The admin's locale rides in on the request. Formatting here matches the client.
  const locale = safeLocale(new URL(request.url).searchParams.get('locale'));


  const dimensions = await readDimensions(admin, appId);
  const queryWindow = campaignWindow(campaign);


  const [
    salesBody,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
  ] = await Promise.all([
    queryShopifyQL(admin, salesQuery(campaign)),
    dimensions.product.enabled
      ? runOptional(admin, promotedByQuery(campaign, dimensions.product.dimension))
      : emptyTable(),
    dimensions.customer.enabled && isTargetedTier(campaign.audience)
      ? runOptional(
          admin,
          tierSalesQuery(campaign, dimensions.customer.dimension, campaign.audience),
        )
      : emptyTable(),
    dimensions.order.enabled
      ? runOptional(admin, touchSalesQuery(campaign, dimensions.order.dimension))
      : emptyTable(),
    // `discount_code` is built in. Only an empty code list skips this.
    codeFilter(campaign)
      ? runOptional(admin, codeSalesQuery(campaign, codeFilter(campaign)))
      : emptyTable(),
    // utm_campaign is built in. Each assigned code's handle is its value.
    qrHandleFilter(campaign)
      ? runOptional(admin, qrSalesQuery(campaign, qrHandleFilter(campaign)))
      : emptyTable(),
    qrHandleFilter(campaign)
      ? runOptional(admin, qrFunnelQuery(campaign, qrHandleFilter(campaign)))
      : emptyTable(),
    runOptional(admin, topProductsQuery(campaign)),
  ]);


  // Two failure paths. A missing scope nulls the field and lands in `errors`. A string
  // that doesn't parse resolves the field and reports on `parseErrors`.
  const sales = salesBody.data?.shopifyqlQuery;
  const fatal = !sales
    ? (salesBody.errors?.[0]?.message ??
      'The sales query returned no data. Check the read_reports scope and Level 2 protected customer data access.')
    : sales.parseErrors?.length
      ? sales.parseErrors.join(' ')
      : null;
  if (fatal) return {campaign, locale, fatal};


  return {
    campaign,
    locale,
    fatal: null,
    // The sales schema has no currency column. Read it off the shop field.
    currencyCode: salesBody.data?.shop?.currencyCode ?? 'USD',
    queryWindow,
    dimensions,
    rows: sales.tableData.rows,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
  };
}


// A query that never ran looks like a store that sold nothing. Only the failure is actionable.
function Unavailable({reason, heading}) {
  if (!reason) return null;
  const note = (
    <s-banner tone="info" heading="This report didn't load">
      <s-paragraph>Reload the page to try again. The store returned: {reason}</s-paragraph>
    </s-banner>
  );
  return heading ? <s-section heading={heading}>{note}</s-section> : note;
}


// WITH TOTALS and PERCENT_CHANGE name their columns after the metric and the period.
// A percent is null when the period it compares against sold nothing.
export function salesTotals(rows) {
  const totals = rows[0] ?? {};
  const change = (percent, before) =>
    percent == null || Number(before ?? 0) === 0 ? null : Number(percent);
  return {
    totalSales: totals['total_sales__totals'] ?? '0',
    percentChange: change(
      totals['percent_change_total_sales__previous_period__totals'],
      totals['comparison_total_sales__previous_period__totals'],
    ),
    yearChange: change(
      totals['percent_change_total_sales__previous_year__totals'],
      totals['comparison_total_sales__previous_year__totals'],
    ),
    newCustomers: Number(totals['new_customers__totals'] ?? 0),
    firstTimeSales: totals['total_sales_first_time__totals'] ?? '0',
  };
}


export default function CampaignDashboard() {
  const data = useLoaderData();


  const cardReady = useMetricCardsReady();


  const {campaign, locale, fatal, fatalHeading} = data;


  if (fatal) {
    return (
      <s-page heading={campaign.title}>
        <s-section>
          <s-banner tone="critical" heading={fatalHeading ?? "Couldn't load sales"}>
            <s-paragraph>{fatal}</s-paragraph>
          </s-banner>
          <s-box paddingBlockStart="base">
            <s-link href="/app/campaigns">Back to campaigns</s-link>
          </s-box>
        </s-section>
      </s-page>
    );
  }


  const {
    currencyCode,
    queryWindow,
    dimensions,
    rows,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
  } = data;


  // Generated column names are the contract.
  const cell = (row, name) => row[name];


  // Money comes back as a string for precision. Format it here.
  const currency = (value) =>
    new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: currencyCode,
      maximumFractionDigits: 0,
    }).format(value ?? 0);


  // A bare YYYY-MM-DD is UTC midnight, a day early west of it. Append a time.
  const asLocalDay = (day) => new Date(`${day}T00:00:00`);


  const shortDate = (day) =>
    new Intl.DateTimeFormat(locale, {month: 'short', day: 'numeric'}).format(
      asLocalDay(day),
    );


  const percent = (value) =>
    value === null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(1)}%`;


  const {totalSales, percentChange, yearChange, newCustomers, firstTimeSales} =
    salesTotals(rows);


  const {productRows, otherSales} = topProductsShape({topProducts, cell});


  const rate = (value, base) => (base > 0 ? (value / base) * 100 : 0);


  const exploreHref = analyticsReportHref({window: queryWindow, annotations: []});


  const {promotedRows, promotedSales, promotedShare} = promotedShape({
    promoted,
    campaign,
    totalSales,
    cell,
    rate,
  });


  const {tierRows, tierTotal, tierShare} = tierShape({
    tierSales,
    totalSales,
    cell,
    rate,
  });


  const touchRows = touchShape({touchSales, campaign, cell});


  const {creatorRows, codeTotal, touchedTotal} = creatorShape({
    codeSales,
    campaign,
    touchRows,
    cell,
  });


  const {qrRows, qrTotal, qrShare} = qrShape({
    qrSales,
    qrFunnel,
    campaign,
    totalSales,
    cell,
    rate,
  });


  const statusLine = (
    <s-stack direction="inline" gap="base" alignItems="center">
      <s-badge
        tone={
          {
            planned: 'info',
            running: 'success',
            closed: 'neutral',
            canceled: 'neutral',
          }[campaign.status]
        }
      >
        {statusLabel(campaign.status)}
      </s-badge>
      <s-text color="subdued">
        {shortDate(campaign.startDate)} to {shortDate(campaign.endDate)}
      </s-text>
      <s-text color="subdued">
        {campaign.channels.map((key) => CHANNELS[key].label).join(', ')}
      </s-text>
    </s-stack>
  );


  return (
    <s-page heading={campaign.title}>
      <s-section>{statusLine}</s-section>


      <SalesAndOrders
        campaign={campaign}
        totalSales={totalSales}
        percentChange={percentChange}
        yearChange={yearChange}
        newCustomers={newCustomers}
        firstTimeSales={firstTimeSales}
        rows={rows}
        cell={cell}
        shortDate={shortDate}
        exploreHref={exploreHref}
        currency={currency}
        percent={percent}
      />


      <Unavailable heading="Top products" reason={topProducts.unavailable} />


      <TopProducts
        productRows={productRows}
        otherSales={otherSales}
        currency={currency}
      />


      <Unavailable
        heading="Promoted product sales"
        reason={promoted.unavailable}
      />


      <PromotedProductSales
        promotedRows={promotedRows}
        promotedSales={promotedSales}
        promotedShare={promotedShare}
        totalSales={totalSales}
        dimensions={dimensions}
        currency={currency}
        shortDate={shortDate}
      />


      <Unavailable
        heading={`${tierLabel(campaign.audience)} segment sales`}
        reason={tierSales.unavailable}
      />


      <SegmentSales
        campaign={campaign}
        tierRows={tierRows}
        tierTotal={tierTotal}
        tierShare={tierShare}
        totalSales={totalSales}
        currency={currency}
        shortDate={shortDate}
      />


      <Unavailable heading="Order attribution" reason={touchSales.unavailable} />


      <OrderAttribution
        touchRows={touchRows}
        dimensions={dimensions}
        currency={currency}
      />


      <Unavailable heading="Creator revenue" reason={codeSales.unavailable} />


      <CreatorRevenue
        creatorRows={creatorRows}
        codeTotal={codeTotal}
        touchedTotal={touchedTotal}
        currency={currency}
      />


      <Unavailable heading="QR code sales" reason={qrSales.unavailable} />


      <Unavailable heading="QR code sessions" reason={qrFunnel.unavailable} />


      <QRCodeSales
        campaign={campaign}
        cardReady={cardReady}
        qrRows={qrRows}
        qrTotal={qrTotal}
        qrShare={qrShare}
        totalSales={totalSales}
        currency={currency}
      />
    </s-page>
  );
}


// React Router replaces the nearest route with a boundary. Without one here, a failed
// load takes AppProvider and the app's navigation with it.
export function ErrorBoundary() {
  const error = useRouteError();
  // A response Shopify threw, such as a re-auth, renders the way the library expects.
  if (isRouteErrorResponse(error)) return boundary.error(error);
  const detail = error?.message ?? 'The load failed.';
  return (
    <s-page heading="Campaign">
      <s-section>
        <s-banner tone="critical" heading="Couldn't load campaign">
          <s-paragraph>{String(detail)}</s-paragraph>
        </s-banner>
        <s-box paddingBlockStart="base">
          <s-link href="/app/campaigns">Back to campaigns</s-link>
        </s-box>
      </s-section>
    </s-page>
  );
}


export const headers = (headersArgs) => boundary.headers(headersArgs);
```

## /app/audiences.js

```javascript
// A scalar, because that's what a ShopifyQL comparison holds.
export const AUDIENCE_TIERS = [
  {
    value: 'vip',
    label: 'VIP',
    // Ranked from the sales schema, because CustomerSortKeys has no spend key.
    description: 'The 10 customers who spent the most in the last year.',
    size: 10,
  },
  {
    value: 'regulars',
    label: 'Regulars',
    // The 25 below the VIPs. `skip` becomes the ranking query's OFFSET.
    description: 'The next 25 customers by spend, below the VIPs.',
    size: 25,
    skip: 10,
  },
  {
    value: 'everyone',
    label: 'Everyone',
    description: 'No tier filter. The segment target matches the store-wide one.',
    size: 0,
  },
];


export const TIER_METAFIELD = {key: 'tier'};


export const tierLabel = (value) =>
  AUDIENCE_TIERS.find((tier) => tier.value === value)?.label ?? value;


// `everyone` measures the whole store. Nothing to filter on, and no customer gets a tier.
export const isTargetedTier = (value) => Boolean(value) && value !== 'everyone';
```

## /app/channels.js

```javascript
// No metric name matches the handle that produces it.
export const CHANNELS = {
  email: {
    label: 'Email',
    stages: [
      {label: 'Sent', metric: 'emails_sent'},
      {label: 'Delivered', metric: 'emails_delivered'},
      {label: 'Opened', metric: 'emails_opened'},
      {label: 'Clicked', metric: 'email_clicks'},
    ],
    metrics: {
      sent: 'emails_sent',
      reached: 'emails_delivered',
      engaged: 'emails_opened',
      // A bounce never arrived. An unsubscribe arrived and opted out.
      bounced: 'emails_bounced',
      lost: 'email_unsubscribes',
    },
    // What engagement means here. Three channels don't count the same act.
    action: 'opened',
  },
  sms: {
    label: 'SMS',
    stages: [
      {label: 'Sent', metric: 'sms_messages_sent'},
      {label: 'Delivered', metric: 'sms_messages_delivered'},
      {label: 'Clicked', metric: 'sms_clicks'},
    ],
    metrics: {
      sent: 'sms_messages_sent',
      reached: 'sms_messages_delivered',
      engaged: 'sms_clicks',
      lost: 'sms_unsubscribes',
    },
    action: 'clicked',
  },
  push: {
    label: 'Push',
    stages: [
      {label: 'Sent', metric: 'push_notifications_sent'},
      {label: 'Opened', metric: 'push_notifications_opened'},
    ],
    // Push has no delivery receipt. Reach reads off the send count.
    metrics: {
      sent: 'push_notifications_sent',
      reached: 'push_notifications_sent',
      engaged: 'push_notifications_opened',
    },
    action: 'opened',
  },
};


export const CHANNEL_KEYS = Object.keys(CHANNELS);


// Bounces and unsubscribes come off the same rows as the stages. A column left
// out reads as zero.
export const FUNNEL_METRICS = CHANNEL_KEYS.flatMap((key) => [
  ...CHANNELS[key].stages.map((stage) => stage.metric),
  CHANNELS[key].metrics.bounced,
  CHANNELS[key].metrics.lost,
]).filter((metric, index, all) => metric && all.indexOf(metric) === index);


const SCORED = ['sent', 'reached', 'engaged', 'lost'];


export const SEQUENCE_METRICS = CHANNEL_KEYS.flatMap((key) =>
  SCORED.map((slot) => CHANNELS[key].metrics[slot]),
).filter((metric, index, all) => metric && all.indexOf(metric) === index);
```

## /app/dimensions.server.js

```javascript
import {
  DIMENSION_NAMESPACE,
  dimensionNamespace,
  metafieldDimension,
} from './campaigns';
import {
  CAMPAIGN_TYPE,
  DEFINITION_CREATE,
  DEFINITION_QUERY,
  DEFINITION_UPDATE,
  dimensionProbeQuery,
} from './campaign-queries.server';
import {ensureDefinition, transportErrors} from './campaigns.server';
import {queryShopifyQL} from './shopifyql.server';


const DIMENSIONS = {
  // A reference to the campaign entry. Analytics groups by its analytics-compatible fields,
  // and the merchant turns that on for the Campaign type in the admin.
  product: {
    owner: 'products',
    ownerType: 'PRODUCT',
    key: 'campaign',
    name: 'Campaign',
    description: 'The campaign that promoted this product.',
    adminAccess: 'MERCHANT_READ',
    reference: true,
    field: 'campaign_id',
    // The report this blocks, and the section `id` the list links to.
    report: 'Promoted product sales',
    anchor: 'promoted',
  },
  customer: {
    owner: 'customers',
    ownerType: 'CUSTOMER',
    key: 'tier',
    name: 'Campaign tier',
    description: 'The campaign segment this customer was last in.',
    adminAccess: 'MERCHANT_READ',
    report: 'Segment sales',
    anchor: 'audience-sales',
  },
  order: {
    owner: 'orders',
    ownerType: 'ORDER',
    key: 'campaign_touch',
    name: 'Campaign touch',
    description: 'The campaign send or event this order followed.',
    adminAccess: 'MERCHANT_READ',
    report: 'Order attribution',
    anchor: 'touch-sales',
  },
};


export async function readDimensions(admin, appId) {
  const body = await (
    await admin.graphql(DEFINITION_QUERY, {
      variables: {namespace: dimensionNamespace(appId)},
    })
  ).json();


  const read = (which) => {
    const spec = DIMENSIONS[which];
    const node = body.data?.[spec.owner]?.nodes?.find(
      (definition) => definition.key === spec.key,
    );
    const capability = node?.capabilities?.analyticsQueryable;
    // The admin path takes the owner type lowercased and the numeric part of the GID.
    const id = node ? String(node.id).split('/').at(-1) : null;
    const owner = spec.ownerType.toLowerCase();
    const column = metafieldDimension(appId, which, spec.key);
    return {
      ...spec,
      defined: Boolean(node),
      eligible: Boolean(capability?.eligible),
      enabled: Boolean(capability?.enabled),
      dimension: spec.field ? `${column}.${spec.field}` : column,
      href: id && `shopify://admin/settings/custom_data/${owner}/metafields/${id}`,
    };
  };


  const product = read('product');
  // The analytics setting lives on the Campaign type, and the API doesn't expose it.
  // A query that parses is the only signal.
  if (product.defined) {
    const probe = await queryShopifyQL(admin, dimensionProbeQuery(product.dimension));
    const parseErrors = probe.data?.shopifyqlQuery?.parseErrors;
    product.eligible = true;
    // A probe still throttled after its retry says nothing about the setting. Report it
    // as on. A false off hides panels, and a panel that fails shows its own reason.
    product.enabled = transportErrors(probe).length > 0 || parseErrors?.length === 0;
  }
  product.href = `shopify://admin/settings/custom_data/metaobjects/${CAMPAIGN_TYPE}`;


  return {product, customer: read('customer'), order: read('order')};
}


// Until a definition becomes a dimension, naming it in a query fails to parse. A rejected
// mutation returns `errors` and null `data`, which an unchecked call reads as success.
export async function enableDimension(admin, definition) {
  const {ownerType} = definition;


  // The reference validates against the Campaign definition. Only the merchant can
  // make it queryable, and the dimension list links to the setting.
  if (definition.reference) {
    if (definition.defined) {
      return [
        {message: 'Turn on analytics for the Campaign type in the admin, then reload.'},
      ];
    }
    const {definitionId, userErrors} = await ensureDefinition(admin);
    if (!definitionId) return userErrors;
    const body = await (
      await admin.graphql(DEFINITION_CREATE, {
        variables: {
          definition: {
            name: definition.name,
            description: definition.description,
            namespace: DIMENSION_NAMESPACE,
            key: definition.key,
            type: 'metaobject_reference',
            ownerType,
            access: {admin: definition.adminAccess},
            validations: [{name: 'metaobject_definition_id', value: definitionId}],
          },
        },
      })
    ).json();
    return [
      ...transportErrors(body),
      ...(body.data?.metafieldDefinitionCreate?.userErrors ?? []),
    ];
  }


  if (!definition.defined) {
    const body = await (
      await admin.graphql(DEFINITION_CREATE, {
        variables: {
          definition: {
            name: definition.name,
            description: definition.description,
            namespace: DIMENSION_NAMESPACE,
            key: definition.key,
            type: 'single_line_text_field',
            ownerType,
            access: {admin: definition.adminAccess},
            capabilities: {analyticsQueryable: {enabled: true}},
          },
        },
      })
    ).json();
    return [
      ...transportErrors(body),
      ...(body.data?.metafieldDefinitionCreate?.userErrors ?? []),
    ];
  }


  const body = await (
    await admin.graphql(DEFINITION_UPDATE, {
      variables: {
        definition: {
          namespace: DIMENSION_NAMESPACE,
          key: definition.key,
          ownerType,
          capabilities: {analyticsQueryable: {enabled: true}},
        },
      },
    })
  ).json();
  return [
    ...transportErrors(body),
    ...(body.data?.metafieldDefinitionUpdate?.userErrors ?? []),
  ];
}
```

## /app/annotations.js

```javascript
// ANNOTATE takes `category.type`. The GraphQL Admin API takes the bare type.
export const CATEGORIES = {
  campaign: 'marketing_events',
  product_launch: 'product_events',
  bundle_launch: 'product_events',
  landing_page_launch: 'online_store_events',
  buy_x_get_y_discount: 'discount_events',
  loyalty_program_offer: 'discount_events',
  ad_spend_change: 'marketing_events',
  influencer_collaboration: 'marketing_events',
  brand_collaboration: 'marketing_events',
  revenue_milestone: 'operation_events',
  other: 'custom_events',
};


export function annotateClause(types) {
  return [
    ...new Set(
      [...types, 'revenue_milestone', 'other']
        .filter((type) => CATEGORIES[type])
        .map((type) => `${CATEGORIES[type]}.${type}`),
    ),
  ].join(', ');
}


export function exploreLink(query) {
  return `shopify://admin/analytics/reports/explore?ql=${encodeURIComponent(query)}`;
}


// The sales chart's query. The report opens on the chart the merchant just saw, targets
// included.
export function exploreQuery({window, types}) {
  return [
    'FROM sales',
    'SHOW total_sales, orders',
    'TIMESERIES day',
    window,
    'COMPARE TO TARGETS',
    'VISUALIZE total_sales TYPE bar, orders TYPE line',
    `ANNOTATE ${annotateClause(types)}`,
  ].join('\n');
}


export function analyticsReportHref({window, annotations}) {
  return exploreLink(
    exploreQuery({
      window,
      types: annotations.map((annotation) => annotation.type),
    }),
  );
}
```

## /app/panels/sales.jsx

```jsx
import {Fragment, useEffect, useState} from 'react';


import {campaignMarks, campaignWindow} from '../campaigns';


import {PanelHeader} from './shared';


const SINCE_BY_RANGE = {
  today: 'today',
  last7days: 'startOfDay(-7d)',
  last30days: 'startOfDay(-30d)',
  last90days: 'startOfDay(-90d)',
  last365days: 'startOfDay(-365d)',
};


// A metric card needs `window.shopify.shopifyQL`, installed after load. A card that
// mounts before it lands stays in an error state.
export function useMetricCardsReady() {
  const [cardReady, setCardReady] = useState(false);
  useEffect(() => {
    let canceled = false;
    const poll = setInterval(() => {
      if (!canceled && window.shopify?.shopifyQL) {
        setCardReady(true);
        clearInterval(poll);
      }
    }, 100);
    return () => {
      canceled = true;
      clearInterval(poll);
    };
  }, []);
  return cardReady;
}


export function metricCardQuery(range) {
  const since = SINCE_BY_RANGE[range];
  return (metric) =>
    [
      'FROM sales',
      `SHOW ${metric}`,
      'TIMESERIES day WITH TOTALS, PERCENT_CHANGE',
      `SINCE ${since} UNTIL today`,
      'COMPARE TO previous_period',
      `VISUALIZE ${metric} TYPE line`,
    ].join(' ');
}


// VISUALIZE sets a TYPE per metric. ANNOTATE ALL overlays every annotation on the store,
// and COMPARE TO TARGETS measures the results against the store's targets.
export function salesCardQuery(campaign) {
  return [
    'FROM sales',
    'SHOW total_sales, orders',
    'TIMESERIES day',
    campaignWindow(campaign),
    'COMPARE TO TARGETS',
    'VISUALIZE total_sales TYPE bar, orders TYPE line ANNOTATE ALL',
  ].join(' ');
}


// The marks this campaign should have, matched against what the store actually holds.
export function annotationStatus({campaign, annotations, appId}) {
  const marks = campaignMarks(campaign);


  // Only the app's annotations count toward the rewrite. It can't update or
  // delete another app's.
  const ours = annotations.filter(
    (annotation) => annotation.createdByApp?.id === appId,
  );


  // Base marks recorded on this campaign that the current marks no longer cover. Another
  // campaign's marks, milestones, and incidents stay out of the count.
  const current = new Set(marks.map((mark) => mark.key));
  const present = new Set(ours.map((annotation) => annotation.id));
  const outdated = Object.entries(campaign.markers ?? {}).filter(
    ([key, id]) =>
      !current.has(key) &&
      !key.startsWith('milestone:') &&
      !key.startsWith('incident:') &&
      present.has(id),
  ).length;


  return {
    marks,
    ours,
    outdated,
    // Marker IDs on the record pick this campaign's span out of the app's annotations.
    openCampaign:
      ours.find(
        (annotation) =>
          !annotation.endedAt &&
          Object.values(campaign.markers ?? {}).includes(annotation.id),
      ) ?? null,
    // Read off the record. The annotations query is windowed and would miss a duplicate.
    incidentFlagged: Object.keys(campaign.markers ?? {}).some((key) =>
      key.startsWith('incident:'),
    ),
  };
}


export function StoreTotals({statusLine, cardReady, range, setRange, cardQuery}) {
  return (
    <s-section>
      <PanelHeader
        title="Store totals"
        uses={{
          components:
            'Shopify metric cards, each running the query it displays.',
          shopifyql:
            "Each card's query, including the VISUALIZE clause.",
        }}
      />
      <s-box paddingBlockEnd="large">{statusLine}</s-box>
      {cardReady ? (
        <s-metrics-bar accessibilityLabel="Store totals over the selected range">
          <s-metrics-bar-date-picker
            value={range}
            onChange={(event) => setRange(event.currentTarget.value)}
          />
          <s-shopifyql-metric-card
            heading="Total sales"
            query={cardQuery('total_sales')}
          />
          <s-shopifyql-metric-card heading="Orders" query={cardQuery('orders')} />
          <s-shopifyql-metric-card
            heading="Average order value"
            query={cardQuery('average_order_value')}
          />
        </s-metrics-bar>
      ) : (
        <s-metrics-bar loading accessibilityLabel="Loading store totals" />
      )}
    </s-section>
  );
}


function ChangeBadge({change, against, percent}) {
  if (change === null) return null;
  return (
    <s-badge
      tone={change >= 0 ? 'success' : 'critical'}
      icon={change >= 0 ? 'arrow-up' : 'arrow-down'}
    >
      {percent(change)} vs {against}
    </s-badge>
  );
}


export function SalesAndOrders({
  campaign,
  totalSales,
  percentChange,
  yearChange,
  newCustomers,
  firstTimeSales,
  rows,
  cell,
  shortDate,
  sends = new Map(),
  marks,
  ours,
  running,
  markNote,
  exploreHref,
  busy,
  submit,
  rewrote,
  currency,
  percent,
}) {
  const cardReady = useMetricCardsReady();
  const [showDays, setShowDays] = useState(false);
  const showSends = [...sends.values()].some((value) => value > 0);


  return (
    <s-section>
      <PanelHeader
        title={`Store sales during ${campaign.title}`}
        uses={{
          shopifyql:
            'One row per day for sales, orders, and new customers, with the previous period and the same days last year beside it.',
          annotations:
            'ANNOTATE ALL draws every store annotation on the chart.',
        }}
      />
      {/* The percentages are Shopify's, from PERCENT_CHANGE. */}
      <s-box paddingBlockEnd="large-200">
        <s-stack direction="block" gap="small-200">
          <s-stack direction="inline" gap="base" alignItems="center">
            <s-text color="subdued">Total sales:</s-text>
            <s-heading>{currency(totalSales)}</s-heading>
            <ChangeBadge change={percentChange} against="previous period" percent={percent} />
            <ChangeBadge change={yearChange} against="last year" percent={percent} />
          </s-stack>
          <s-text color="subdued">
            New customers: {newCustomers}. First-time customer sales:{' '}
            {currency(firstTimeSales)}.
          </s-text>
        </s-stack>
      </s-box>


      {/* The card fills its container's height, or the chart collapses to its minimum.
          Padding counts inside `blockSize`, so the outer box holds the gap. */}
      <s-box paddingBlockEnd="base">
        <s-box blockSize="320px">
          {cardReady ? (
            <s-shopifyql-metric-card
              heading="Sales and orders by day"
              query={salesCardQuery(campaign)}
            />
          ) : (
            <s-spinner accessibilityLabel="Loading the sales chart" />
          )}
        </s-box>
      </s-box>


      {/* The rows render as the query returned them. `cell` reads the generated columns. */}
      <s-button variant="tertiary" onClick={() => setShowDays(!showDays)}>
        {showDays ? 'Hide daily breakdown' : 'Show daily breakdown'}
      </s-button>
      {showDays && (
        <s-box paddingBlockStart="base">
          <s-table variant="auto">
            <s-table-header-row>
              <s-table-header listSlot="primary">Day</s-table-header>
              {showSends && (
                <s-table-header listSlot="labeled">Messages sent</s-table-header>
              )}
              <s-table-header listSlot="labeled">Orders</s-table-header>
              <s-table-header listSlot="labeled">Sales</s-table-header>
            </s-table-header-row>
            <s-table-body>
              {rows.map((row) => (
                <s-table-row key={String(cell(row, 'day'))}>
                  <s-table-cell>{shortDate(cell(row, 'day'))}</s-table-cell>
                  {showSends && (
                    <s-table-cell>{sends.get(String(cell(row, 'day'))) ?? 0}</s-table-cell>
                  )}
                  <s-table-cell>{Number(cell(row, 'orders') ?? 0)}</s-table-cell>
                  <s-table-cell>{currency(cell(row, 'total_sales'))}</s-table-cell>
                </s-table-row>
              ))}
            </s-table-body>
          </s-table>
        </s-box>
      )}


      <s-stack direction="block" gap="base">
        {/* Replacing what the app already wrote, or the store's timeline accumulates
            every version of the story. */}
        {running && (
          <Fragment>
            {rewrote != null && (
              <s-banner tone="success" heading="Annotations rewritten">
                <s-paragraph>
                  The app replaced earlier annotations with {rewrote} current
                  ones.
                </s-paragraph>
              </s-banner>
            )}
            <s-text color="subdued">
              {markNote ??
                `Rewriting replaces this app's earlier annotations with the current ${marks.length}.`}
            </s-text>
            <s-stack direction="inline">
              <s-button
                loading={busy === 'rewrite-marks'}
                disabled={Boolean(busy)}
                onClick={() => submit('rewrite-marks')}
              >
                {ours.length > 0 ? 'Rewrite annotations' : 'Write annotations'}
              </s-button>
            </s-stack>
          </Fragment>
        )}


        <s-link href={exploreHref} target="_blank">
          Open in Analytics
        </s-link>
      </s-stack>
    </s-section>
  );
}


// The remainder row has no title. Its rowMetadata names the column TOP 5 rolled up.
export function topProductsShape({topProducts, cell}) {
  const metadata = topProducts.tableData.rowMetadata ?? [];
  const rows = (topProducts.tableData.rows ?? []).map((row, index) => ({
    title: String(cell(row, 'product_title_at_time_of_sale') ?? ''),
    sales: Number(cell(row, 'total_sales') ?? 0),
    remainder: (metadata[index]?.topNRemainderColumnNames ?? []).includes(
      'product_title_at_time_of_sale',
    ),
  }));
  return {
    productRows: rows
      .filter((row) => !row.remainder && row.sales > 0)
      .sort((first, second) => second.sales - first.sales),
    otherSales: rows
      .filter((row) => row.remainder)
      .reduce((running, row) => running + row.sales, 0),
  };
}


export function TopProducts({productRows, otherSales, currency}) {
  if (productRows.length === 0) return null;


  return (
    <s-section id="top-products">
      <PanelHeader
        title="Top products"
        uses={{
          shopifyql:
            'GROUP BY TOP 5 ranks products by sales and totals the rest in one row.',
        }}
      />
      <s-table variant="auto">
        <s-table-header-row>
          <s-table-header listSlot="primary">Product</s-table-header>
          <s-table-header listSlot="labeled">Sales</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {productRows.map((row) => (
            <s-table-row key={row.title}>
              <s-table-cell>{row.title}</s-table-cell>
              <s-table-cell>{currency(row.sales)}</s-table-cell>
            </s-table-row>
          ))}
          {/* Label the remainder row and render it last. The column then sums to store sales. */}
          {otherSales !== 0 && (
            <s-table-row>
              <s-table-cell>Other products</s-table-cell>
              <s-table-cell>{currency(otherSales)}</s-table-cell>
            </s-table-row>
          )}
        </s-table-body>
      </s-table>
    </s-section>
  );
}
```

## /app/panels/dimensions.jsx

```jsx
import {useState} from 'react';


import {annotateClause, exploreLink} from '../annotations';
import {
  campaignWindow,
  creatorCodes,
  creatorTouches,
  qrHandleFilter,
  qrTitle,
  touchMarks,
} from '../campaigns';
import {tierLabel} from '../audiences';


import {PanelHeader, sharePercent} from './shared';


export function promotedShape({promoted, campaign, totalSales, cell, rate}) {
  const promotedByDay = new Map();
  const otherByDay = new Map();
  for (const row of promoted.tableData.rows ?? []) {
    const day = String(cell(row, 'day'));
    // The reference resolves to the entry's campaign_id, as a string. Titles repeat.
    const bucket =
      String(cell(row, 'promoted_by') ?? '') === String(campaign.id)
        ? promotedByDay
        : otherByDay;
    bucket.set(day, (bucket.get(day) ?? 0) + Number(cell(row, 'total_sales') ?? 0));
  }


  const promotedDays = [
    ...new Set([...otherByDay.keys(), ...promotedByDay.keys()]),
  ].sort();
  const promotedSales = [...promotedByDay.values()].reduce(
    (running, sales) => running + sales,
    0,
  );


  return {
    promotedRows: promotedDays.map((day) => ({
      day,
      promoted: promotedByDay.get(day) ?? 0,
      rest: otherByDay.get(day) ?? 0,
    })),
    promotedSales,
    promotedShare: rate(promotedSales, Number(totalSales)),
  };
}


// A tier the app wrote is a dimension like any other once it's queryable.
export function tierShape({tierSales, totalSales, cell, rate}) {
  const tierRows = (tierSales.tableData.rows ?? []).map((row) => ({
    day: String(cell(row, 'day')),
    sales: Number(cell(row, 'total_sales') ?? 0),
  }));
  const tierTotal = tierRows.reduce((running, row) => running + row.sales, 0);


  return {tierRows, tierTotal, tierShare: rate(tierTotal, Number(totalSales))};
}


// The column holds every closed campaign. The filter narrows it to this one.
export function touchShape({touchSales, campaign, cell}) {
  const titles = touchMarks(campaign).map((mark) => mark.title);
  return (touchSales.tableData.rows ?? [])
    .map((row) => ({
      touch: String(cell(row, 'touch') ?? ''),
      sales: Number(cell(row, 'total_sales') ?? 0),
      orders: Number(cell(row, 'orders') ?? 0),
    }))
    .filter((row) => titles.includes(row.touch));
}


// `discount_code` is built in. The store has no idea two codes are one creator's.
export function creatorShape({codeSales, campaign, touchRows, cell}) {
  const byCode = new Map(
    (codeSales.tableData.rows ?? []).map((row) => [
      String(cell(row, 'discount_code') ?? ''),
      {
        sales: Number(cell(row, 'total_sales') ?? 0),
        orders: Number(cell(row, 'orders') ?? 0),
      },
    ]),
  );
  const byCreator = new Map();
  for (const pair of creatorCodes(campaign)) {
    const found = byCode.get(pair.code) ?? {sales: 0, orders: 0};
    const running = byCreator.get(pair.creator) ?? {codes: [], sales: 0, orders: 0};
    byCreator.set(pair.creator, {
      codes: [...running.codes, {code: pair.code, ...found}],
      sales: running.sales + found.sales,
      orders: running.orders + found.orders,
    });
  }
  const creatorRows = [...byCreator.entries()]
    .map(([creator, totals]) => ({creator, ...totals}))
    .sort((first, second) => second.sales - first.sales);


  // What a code proves against what the creator moved. The gap is the halo.
  const creatorTouchTitles = creatorTouches(campaign);


  return {
    creatorRows,
    codeTotal: creatorRows.reduce((running, row) => running + row.sales, 0),
    touchedTotal: touchRows
      .filter((row) => creatorTouchTitles.includes(row.touch))
      .reduce((running, row) => running + row.sales, 0),
  };
}


// Funnel rates are fractions of each code's sessions. A code with scans and no
// order still gets a row.
export function qrShape({qrSales, qrFunnel, totalSales, cell, rate}) {
  const funnel = new Map(
    (qrFunnel?.tableData.rows ?? []).map((row) => [
      String(cell(row, 'utm_campaign') ?? ''),
      {
        sessions: Number(cell(row, 'campaign_sessions') ?? 0),
        cartRate: Number(cell(row, 'campaign_added_to_cart_rate') ?? 0),
        conversionRate: Number(cell(row, 'campaign_conversion_rate') ?? 0),
      },
    ]),
  );
  const sold = (qrSales.tableData.rows ?? [])
    .map((row) => ({
      code: String(cell(row, 'utm_campaign') ?? ''),
      sales: Number(cell(row, 'campaign_last_non_direct_click_total_sales') ?? 0),
      orders: Number(cell(row, 'campaign_last_non_direct_click_order_count') ?? 0),
    }))
    .filter((row) => row.sales > 0);
  const scannedOnly = [...funnel.entries()]
    .filter(([code, visits]) => visits.sessions > 0 && !sold.some((row) => row.code === code))
    .map(([code]) => ({code, sales: 0, orders: 0}));
  const qrRows = [...sold, ...scannedOnly].map((row) => ({
    ...row,
    ...(funnel.get(row.code) ?? {sessions: null, cartRate: null, conversionRate: null}),
  }));
  const qrTotal = qrRows.reduce((running, row) => running + row.sales, 0);
  return {qrRows, qrTotal, qrShare: rate(qrTotal, Number(totalSales))};
}


export function PromotedProductSales({
  promotedRows,
  promotedSales,
  promotedShare,
  totalSales,
  dimensions,
  currency,
  shortDate,
}) {
  const [showDays, setShowDays] = useState(false);
  if (!promotedRows.some((row) => row.promoted > 0)) return null;


  return (
    <s-section id="promoted">
      <PanelHeader
        title="Promoted product sales"
        uses={{
          shopifyql:
            "Groups the store's daily sales by the campaign that promoted each product.",
          metafields: 'Links each promoted product to its campaign.',
        }}
      />
      {Number(totalSales) > 0 ? (
        <s-paragraph>
          Promoted products earned {currency(promotedSales)},{' '}
          {sharePercent(promotedShare)} of {currency(totalSales)} in store sales.
        </s-paragraph>
      ) : (
        <s-paragraph>
          Promoted products earned {currency(promotedSales)}. Store sales totals
          aren&rsquo;t available for this period yet.
        </s-paragraph>
      )}
      <s-paragraph>
        This report groups sales by the campaign ID on each product&rsquo;s{' '}
        <s-text type="strong">{dimensions.product.name}</s-text> metafield.
      </s-paragraph>
      <s-button variant="tertiary" onClick={() => setShowDays(!showDays)}>
        {showDays ? 'Hide daily breakdown' : 'Show daily breakdown'}
      </s-button>
      {showDays && (
        <s-box paddingBlockStart="base">
          <s-table variant="auto">
            <s-table-header-row>
              <s-table-header listSlot="primary">Day</s-table-header>
              <s-table-header listSlot="labeled">Promoted products</s-table-header>
              <s-table-header listSlot="labeled">Other products</s-table-header>
            </s-table-header-row>
            <s-table-body>
              {promotedRows.map((row) => (
                <s-table-row key={row.day}>
                  <s-table-cell>{shortDate(row.day)}</s-table-cell>
                  <s-table-cell>{currency(row.promoted)}</s-table-cell>
                  <s-table-cell>{currency(row.rest)}</s-table-cell>
                </s-table-row>
              ))}
            </s-table-body>
          </s-table>
        </s-box>
      )}
    </s-section>
  );
}


// The tier came from the app, the sales from the store.
export function SegmentSales({
  campaign,
  tierRows,
  tierTotal,
  tierShare,
  totalSales,
  currency,
  shortDate,
}) {
  const [showDays, setShowDays] = useState(false);
  if (tierRows.length === 0) return null;


  return (
    <s-section id="audience-sales">
      <PanelHeader
        title={`${tierLabel(campaign.audience)} segment sales`}
        uses={{
          shopifyql:
            "Filters the store's daily sales to the tier this campaign targeted.",
          metafields:
            'Writes the tier onto each customer to make it queryable.',
        }}
      />
      {Number(totalSales) > 0 ? (
        <s-paragraph>
          {tierLabel(campaign.audience)} customers spent {currency(tierTotal)},{' '}
          {sharePercent(tierShare)} of {currency(totalSales)} in store sales.
        </s-paragraph>
      ) : (
        <s-paragraph>
          {tierLabel(campaign.audience)} customers spent {currency(tierTotal)}. Store
          sales totals aren&rsquo;t available for this period yet.
        </s-paragraph>
      )}
      <s-button variant="tertiary" onClick={() => setShowDays(!showDays)}>
        {showDays ? 'Hide daily breakdown' : 'Show daily breakdown'}
      </s-button>
      {showDays && (
        <s-box paddingBlockStart="base">
          <s-table variant="auto">
            <s-table-header-row>
              <s-table-header listSlot="primary">Day</s-table-header>
              <s-table-header listSlot="labeled">Sales</s-table-header>
            </s-table-header-row>
            <s-table-body>
              {tierRows.map((row) => (
                <s-table-row key={row.day}>
                  <s-table-cell>{shortDate(row.day)}</s-table-cell>
                  <s-table-cell>{currency(row.sales)}</s-table-cell>
                </s-table-row>
              ))}
            </s-table-body>
          </s-table>
        </s-box>
      )}
    </s-section>
  );
}


export function OrderAttribution({touchRows, dimensions, currency}) {
  if (touchRows.length === 0) return null;


  return (
    <s-section id="touch-sales">
      <PanelHeader
        title="Order attribution"
        uses={{
          shopifyql: "Groups the campaign's sales by the touch each order followed.",
          metafields: 'Writes the touch onto each order when the campaign closes.',
        }}
      />
      <s-paragraph>
        When the campaign closes, the app writes each order&rsquo;s last campaign touch to the
        order&rsquo;s <s-text type="strong">{dimensions.order.name}</s-text> metafield. This
        report groups sales by the last touch.
      </s-paragraph>
      <s-table variant="auto">
        <s-table-header-row>
          <s-table-header listSlot="primary">Last touch</s-table-header>
          <s-table-header listSlot="labeled">Orders</s-table-header>
          <s-table-header listSlot="labeled">Sales</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {touchRows.map((row) => (
            <s-table-row key={row.touch}>
              <s-table-cell>{row.touch}</s-table-cell>
              <s-table-cell>{row.orders}</s-table-cell>
              <s-table-cell>{currency(row.sales)}</s-table-cell>
            </s-table-row>
          ))}
        </s-table-body>
      </s-table>
    </s-section>
  );
}


// What the codes prove against what the posts moved.
export function CreatorRevenue({creatorRows, codeTotal, touchedTotal, currency}) {
  if (creatorRows.length === 0) return null;


  return (
    <s-section id="creator-codes">
      <PanelHeader
        title="Creator revenue"
        uses={{
          shopifyql:
            'Filters sales to the discount codes this campaign gave its creators.',
        }}
      />
      <s-paragraph>
        This report groups sales from this campaign&rsquo;s discount codes by the
        creator who shares each code.
      </s-paragraph>
      <s-table variant="auto">
        <s-table-header-row>
          <s-table-header listSlot="primary">Creator</s-table-header>
          <s-table-header listSlot="secondary">Codes</s-table-header>
          <s-table-header listSlot="labeled">Orders</s-table-header>
          <s-table-header listSlot="labeled">Sales</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {creatorRows.map((row) => (
            <s-table-row key={row.creator}>
              <s-table-cell>{row.creator}</s-table-cell>
              <s-table-cell>
                {row.codes.map((entry) => entry.code).join(', ')}
              </s-table-cell>
              <s-table-cell>{row.orders}</s-table-cell>
              <s-table-cell>{currency(row.sales)}</s-table-cell>
            </s-table-row>
          ))}
        </s-table-body>
      </s-table>
      {touchedTotal > 0 && (
        <s-paragraph>
          Sales with a creator code: {currency(codeTotal)}. Sales from orders whose
          last campaign touch was a creator post:{' '}
          <s-text type="strong">{currency(touchedTotal)}</s-text>.
        </s-paragraph>
      )}
    </s-section>
  );
}


// The same query draws the in-app card and, through the explore link, the full
// report in the store's analytics dashboard, annotations included.
export function qrCardQuery(campaign) {
  return [
    'FROM campaign_sales',
    'SHOW campaign_last_non_direct_click_total_sales',
    'TIMESERIES day',
    `WHERE utm_medium = 'qr' AND ${qrHandleFilter(campaign)}`,
    campaignWindow(campaign),
    'VISUALIZE campaign_last_non_direct_click_total_sales TYPE bar',
    `ANNOTATE ${annotateClause(['campaign'])}`,
  ].join(' ');
}


// The scans came from the QR app, the attribution from the store.
export function QRCodeSales({
  campaign,
  cardReady,
  qrRows,
  qrTotal,
  qrShare,
  totalSales,
  currency,
}) {
  if (qrRows.length === 0) return null;


  const ofSessions = (value) => (value == null ? '—' : `${(value * 100).toFixed(1)}%`);


  return (
    <s-section id="qr-sales">
      <PanelHeader
        title="QR code sales"
        uses={{
          shopifyql:
            "Filters attributed sales to orders that started from a scan of this campaign's QR codes, and sessions to the visits a scan started.",
          annotations:
            "ANNOTATE draws the campaign's marks from the store's timeline onto the chart.",
          components: 'A Shopify metric card, running the query it displays.',
        }}
      />
      <s-box paddingBlockEnd="base">
        <s-box blockSize="320px">
          {cardReady ? (
            <s-shopifyql-metric-card
              heading="QR code sales by day"
              query={qrCardQuery(campaign)}
            />
          ) : (
            <s-spinner accessibilityLabel="Loading the QR sales chart" />
          )}
        </s-box>
      </s-box>
      {/* Attribution can credit an order whose session predates the window. The QR
          total can outrun the store's daily dataset. No total, no share. */}
      {Number(totalSales) > 0 ? (
        <s-paragraph>
          QR code orders earned {currency(qrTotal)}, {sharePercent(qrShare)} of{' '}
          {currency(totalSales)} in store sales.
        </s-paragraph>
      ) : (
        <s-paragraph>
          QR code orders earned {currency(qrTotal)}. Store sales totals
          aren&rsquo;t available for this period yet.
        </s-paragraph>
      )}
      <s-table variant="auto">
        <s-table-header-row>
          <s-table-header listSlot="primary">QR code</s-table-header>
          <s-table-header listSlot="labeled">Sessions</s-table-header>
          <s-table-header listSlot="labeled">Added to cart</s-table-header>
          <s-table-header listSlot="labeled">Conversion</s-table-header>
          <s-table-header listSlot="labeled">Orders</s-table-header>
          <s-table-header listSlot="labeled">Attributed sales</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {qrRows.map((row) => (
            <s-table-row key={row.code}>
              <s-table-cell>{qrTitle(campaign, row.code)}</s-table-cell>
              <s-table-cell>{row.sessions ?? '—'}</s-table-cell>
              <s-table-cell>{ofSessions(row.cartRate)}</s-table-cell>
              <s-table-cell>{ofSessions(row.conversionRate)}</s-table-cell>
              <s-table-cell>{row.orders}</s-table-cell>
              <s-table-cell>{currency(row.sales)}</s-table-cell>
            </s-table-row>
          ))}
        </s-table-body>
      </s-table>
      <s-box paddingBlockStart="base">
        <s-link href={exploreLink(qrCardQuery(campaign))} target="_blank">
          Open in Analytics
        </s-link>
      </s-box>
    </s-section>
  );
}
```

## /app/panels/shared.jsx

```jsx
import {Fragment} from 'react';


// What produced the number changes what it means. Every panel names its capabilities.
export const CAPABILITIES = {
  shopifyql: {icon: 'database', label: 'ShopifyQL'},
  appEvents: {icon: 'apps', label: 'App Events'},
  metafields: {icon: 'metafields', label: 'Metafields'},
  annotations: {icon: 'note', label: 'Annotations'},
  targets: {icon: 'target', label: 'Targets'},
  components: {icon: 'code', label: 'Web components'},
};


// Rounded to a whole number, a small share reads as 0% and looks like nothing sold.
export const sharePercent = (share) =>
  share > 0 && share < 1 ? 'less than 1%' : `${Math.round(share)}%`;


// `s-chip` takes no `interestFor`. The clickable variant triggers the tooltip. `s-section`'s
// `heading` takes no elements, and each panel renders its heading in the body.
export function PanelHeader({title, uses}) {
  const slug = title.replaceAll(/[^a-z0-9]+/gi, '-').toLowerCase();
  return (
    <Fragment>
      <s-stack direction="inline" gap="base" alignItems="center">
        <s-heading>{title}</s-heading>
        {Object.entries(uses).map(([key, note]) => {
          const id = `uses-${slug}-${key}`;
          return (
            <Fragment key={key}>
              <s-tooltip id={id}>{note}</s-tooltip>
              <s-clickable-chip
                color="subdued"
                interestFor={id}
                accessibilityLabel={`Built on ${CAPABILITIES[key].label}`}
              >
                <s-icon slot="graphic" type={CAPABILITIES[key].icon} size="small" />
                {CAPABILITIES[key].label}
              </s-clickable-chip>
            </Fragment>
          );
        })}
      </s-stack>
      {/* The stack drops a style prop. The box holds the gap under the header. */}
      <s-box paddingBlockStart="base" />
    </Fragment>
  );
}
```

## /app/campaign-state.server.js

```javascript
import db from './db.server';


// The IDs of the annotations and targets this app created, and the App Events send count.
// They live in the app's database. The merchant can edit or clear the Campaign entry.
// Prisma drops an undefined filter. A missing shop would read or delete every shop's rows.
function ownShop(shop) {
  if (!shop) throw new TypeError('Campaign records need a shop.');
  return shop;
}


const key = (shop, campaignId) => ({shop_campaignId: {shop: ownShop(shop), campaignId: Number(campaignId)}});


const parsed = (text) => {
  try {
    const value = JSON.parse(text);
    return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
  } catch {
    return {};
  }
};


const stateOf = (row) => ({
  gid: row.campaignGid,
  markers: parsed(row.markers),
  targets: parsed(row.targets),
  sentEvents: row.sentEvents,
  stampOperation: row.stampOperation ?? null,
});


// A campaign created in the admin has no row, and no annotations or targets yet.
export const emptyState = () => ({markers: {}, targets: {}, sentEvents: null, stampOperation: null});


export async function readStates(shop, campaignIds) {
  const rows = await db.campaignState.findMany({
    where: {shop: ownShop(shop), campaignId: {in: campaignIds.map(Number)}},
  });
  return new Map(rows.map((row) => [row.campaignId, stateOf(row)]));
}


export async function readState(shop, campaignId) {
  const row = await db.campaignState.findUnique({where: key(shop, campaignId)});
  return row ? stateOf(row) : null;
}


// A row left by an entry the merchant deleted in the admin belongs to that entry. A new
// entry with the same campaign ID starts empty.
async function claimRow(client, shop, campaignId, gid) {
  const id = Number(campaignId);
  await client.campaignState.deleteMany({where: {shop: ownShop(shop), campaignId: id, campaignGid: {not: gid}}});
  return client.campaignState.upsert({
    where: key(shop, id),
    create: {shop, campaignId: id, campaignGid: gid},
    update: {},
  });
}


// Replaces each field the change names. Pass a markers or targets object.
export async function saveState(shop, campaignId, gid, {markers, targets}) {
  const data = {
    ...(markers && {markers: JSON.stringify(markers)}),
    ...(targets && {targets: JSON.stringify(targets)}),
  };
  return db.$transaction(async (tx) => {
    await claimRow(tx, shop, campaignId, gid);
    return stateOf(await tx.campaignState.update({where: key(shop, campaignId), data}));
  });
}


// Each annotation ID lands as its mark saves. Merge into the stored IDs, not the ones in hand.
export async function mergeMarkers(shop, campaignId, gid, patch) {
  return db.$transaction(async (tx) => {
    const row = await claimRow(tx, shop, campaignId, gid);
    const markers = {...parsed(row.markers), ...patch};
    await tx.campaignState.update({
      where: key(shop, campaignId),
      data: {markers: JSON.stringify(markers)},
    });
    return markers;
  });
}


// Zero marks a send in progress. Only the request that moves the count off null sends.
export async function claimSend(shop, campaignId, gid) {
  await claimRow(db, shop, campaignId, gid);
  const {count} = await db.campaignState.updateMany({
    where: {shop, campaignId: Number(campaignId), sentEvents: null},
    data: {sentEvents: 0},
  });
  return count === 1;
}


export const recordSend = async (shop, campaignId, sent) =>
  db.campaignState.update({where: key(shop, campaignId), data: {sentEvents: sent}});


// Nothing landed, so the campaign can send again.
export const releaseSend = async (shop, campaignId) =>
  db.campaignState.updateMany({
    where: {shop: ownShop(shop), campaignId: Number(campaignId), sentEvents: 0},
    data: {sentEvents: null},
  });


export const forgetCampaign = async (shop, campaignId) =>
  db.campaignState.deleteMany({where: {shop: ownShop(shop), campaignId: Number(campaignId)}});


// Everything the app holds for a shop, in one transaction.
export const forgetShop = async (shop) =>
  db.$transaction([
    db.session.deleteMany({where: {shop: ownShop(shop)}}),
    db.campaignLock.deleteMany({where: {shop}}),
    db.campaignState.deleteMany({where: {shop}}),
    db.shopState.deleteMany({where: {shop}}),
  ]);


// The bulk operation stamping a closed campaign's orders. A retry replaces it.
export async function recordStampOperation(shop, campaignId, gid, id) {
  await claimRow(db, shop, campaignId, gid);
  return db.campaignState.update({where: key(shop, campaignId), data: {stampOperation: id}});
}


// Only the operation the page read. A retry that started since keeps its own ID.
export const clearStampOperation = async (shop, campaignId, id) =>
  db.campaignState.updateMany({
    where: {shop: ownShop(shop), campaignId: Number(campaignId), stampOperation: id},
    data: {stampOperation: null},
  });


// Raise the mark to `id` without ever lowering it.
export async function spendThrough(shop, id) {
  await db.shopState.upsert({where: {shop: ownShop(shop)}, create: {shop, lastCampaignId: id}, update: {}});
  await db.shopState.updateMany({
    where: {shop, lastCampaignId: {lt: id}},
    data: {lastCampaignId: id},
  });
}


// `floor` is the highest ID on the store's entries. It covers entries made before this
// row existed, or after a redact deleted it. The increment is atomic, and two requests
// never get the same ID.
export async function allocateCampaignId(shop, floor) {
  await spendThrough(shop, floor);
  const {lastCampaignId} = await db.shopState.update({
    where: {shop},
    data: {lastCampaignId: {increment: 1}},
  });
  return lastCampaignId;
}


export const hasSpentIds = async (shop) =>
  Boolean(await db.shopState.findUnique({where: {shop}}));


// A request that dies holding the lock leaves its row. The next request takes over
// after five minutes.
const LOCK_TTL_MS = 5 * 60 * 1000;


// Returns the owner token, or null while another request holds the campaign.
export async function takeLock(shop, campaignId) {
  const id = Number(campaignId);
  const now = new Date();
  await db.campaignLock.deleteMany({where: {shop: ownShop(shop), campaignId: id, expiresAt: {lt: now}}});
  const owner = crypto.randomUUID();
  try {
    await db.campaignLock.create({
      data: {shop, campaignId: id, owner, expiresAt: new Date(now.getTime() + LOCK_TTL_MS)},
    });
    return owner;
  } catch (error) {
    // P2002 is a unique constraint failure. Another request holds the row.
    if (error?.code === 'P2002') return null;
    throw error;
  }
}


// Deletes only this request's row. A takeover after the TTL keeps the new owner's.
export const releaseLock = async (shop, campaignId, owner) =>
  db.campaignLock.deleteMany({where: {shop: ownShop(shop), campaignId: Number(campaignId), owner}});
```

## /app/shopifyql.server.js

```javascript
// A query over the ShopifyQL budget gets HTTP 429, and the library drops that response's
// body. Successful responses carry `windowResetAt`, the time the budget refills. Keep the
// latest one per request client, wait until then, and retry once.
export const MAX_THROTTLE_WAIT_MS = 10_000;
// With no refill time seen yet, wait a second.
const UNKNOWN_RESET_WAIT_MS = 1_000;
const resets = new WeakMap();


const isThrottled = (body) =>
  (body.errors ?? []).some((error) => error.extensions?.code === 'THROTTLED');


const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));


async function run(admin, query) {
  const body = await (await admin.graphql(query)).json();
  const resetAt = body.extensions?.shopifyqlCost?.windowResetAt;
  if (resetAt) resets.set(admin, resetAt);
  return body;
}


export async function queryShopifyQL(admin, query) {
  const body = await run(admin, query);
  if (!isThrottled(body)) return body;


  const resetAt = Date.parse(resets.get(admin) ?? '');
  const wait = Number.isNaN(resetAt) ? UNKNOWN_RESET_WAIT_MS : Math.max(0, resetAt - Date.now());
  if (wait > MAX_THROTTLE_WAIT_MS) {
    const at = new Date(resetAt).toISOString().slice(11, 19);
    return {
      ...body,
      errors: [{message: `This store's ShopifyQL budget refills at ${at} UTC. Try again then.`, extensions: {code: 'THROTTLED'}}],
    };
  }
  await pause(wait);
  return run(admin, query);
}


// A parse error answers 200 with null `tableData`. Check both fields before reading rows.
export const shopifyqlErrors = (body, field = 'shopifyqlQuery') => [
  ...(body.errors ?? []).map((error) => ({message: error.message})),
  ...(body.data?.[field]?.parseErrors ?? []).map((message) => ({message})),
];
```

## /prisma/schema.prisma

```prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema


generator client {
  provider = "prisma-client-js"
}


datasource db {
  provider = "sqlite"
  url      = "file:dev.sqlite"
}


model Session {
  id                  String    @id
  shop                String
  state               String
  isOnline            Boolean   @default(false)
  scope               String?
  expires             DateTime?
  accessToken         String
  userId              BigInt?
  firstName           String?
  lastName            String?
  email               String?
  accountOwner        Boolean   @default(false)
  locale              String?
  collaborator        Boolean?  @default(false)
  emailVerified       Boolean?  @default(false)
  refreshToken        String?
  refreshTokenExpires DateTime?
}


// The highest campaign ID this shop has handed out. App Events keeps a deleted
// campaign's events, and the ID stays spent.
model ShopState {
  shop           String @id
  lastCampaignId Int
}


// What the app tracks for each campaign entry. The merchant can edit the entry, and these
// IDs are the app's only handle on the annotations and targets it created.
// SQLite has no JSON column. `markers` and `targets` hold JSON objects as text.
model CampaignState {
  shop           String
  campaignId     Int
  campaignGid    String
  markers        String   @default("{}")
  targets        String   @default("{}")
  sentEvents     Int?
  // The bulk operation stamping a closed campaign's orders, until it finishes clean.
  stampOperation String?
  updatedAt      DateTime @updatedAt


  @@id([shop, campaignId])
}


// One row per campaign write in progress. The primary key lets one request in.
model CampaignLock {
  shop       String
  campaignId Int
  owner      String
  expiresAt  DateTime


  @@id([shop, campaignId])
}
```

## /app/routes/app.qrcodes.\_index.jsx

```jsx
import { useLoaderData } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";


import { authenticate } from "../shopify.server";
import { getQRCodes } from "../models/QRCode.server";
import { campaignOf, readCampaigns, withReturnedErrors } from "../campaigns.server";


export async function loader({ request }) {
  const { admin, session } = await authenticate.admin(request);
  const [qrCodes, { campaigns }] = await Promise.all([
    getQRCodes(admin.graphql, session.shop),
    readCampaigns(withReturnedErrors(admin), session.shop),
  ]);


  // Each code's `campaign` field holds the link. A failed read lists every code without one.
  return {
    qrCodes: qrCodes.map((qrCode) => {
      const campaign = campaignOf(campaigns, qrCode.id);
      return {
        ...qrCode,
        campaign: campaign && { id: campaign.id, title: campaign.title },
      };
    }),
  };
}


const EmptyQRCodeState = () => (
  <s-section accessibilityLabel="Empty state section">
    <s-grid gap="base" justifyItems="center" paddingBlock="large-400">
      <s-box maxInlineSize="200px" maxBlockSize="200px">
        <s-image
          aspectRatio="1/0.5"
          src="https://cdn.shopify.com/s/files/1/0262/4071/2726/files/emptystate-files.png"
          alt="A stylized graphic of a document"
        />
      </s-box>
      <s-grid justifyItems="center" maxBlockSize="450px" maxInlineSize="450px">
        <s-heading>Create unique QR codes for your products</s-heading>
        <s-paragraph>
          Allow customers to scan codes and buy products using their phones.
        </s-paragraph>
        <s-stack
          gap="small-200"
          justifyContent="center"
          padding="base"
          paddingBlockEnd="none"
          direction="inline"
        >
          <s-button href="/app/qrcodes/new" variant="primary">
            Create QR code
          </s-button>
        </s-stack>
      </s-grid>
    </s-grid>
  </s-section>
);


function truncate(str, { length = 25 } = {}) {
  if (!str) return "";
  if (str.length <= length) return str;
  return str.slice(0, length) + "…";
}


const QRTable = ({ qrCodes }) => (
  <s-section padding="none" accessibilityLabel="QR code table">
    <s-table>
      <s-table-header-row>
        <s-table-header listSlot="primary">Title</s-table-header>
        <s-table-header>Product</s-table-header>
        <s-table-header>Campaign</s-table-header>
        <s-table-header>Date created</s-table-header>
        <s-table-header>Scans</s-table-header>
      </s-table-header-row>
      <s-table-body>
        {qrCodes.map((qrCode) => (
          <QRTableRow key={qrCode.handle} qrCode={qrCode} />
        ))}
      </s-table-body>
    </s-table>
  </s-section>
);


const QRTableRow = ({ qrCode }) => (
  <s-table-row id={qrCode.handle}>
    <s-table-cell>
      <s-stack direction="inline" gap="small" alignItems="center">
        <s-clickable
          href={`/app/qrcodes/${qrCode.handle}`}
          accessibilityLabel={`Edit the ${qrCode.title} QR code`}
          border="base"
          borderRadius="base"
          overflow="hidden"
          inlineSize="20px"
          blockSize="20px"
        >
          {qrCode.productImage ? (
            <s-image objectFit="cover" src={qrCode.productImage}></s-image>
          ) : (
            <s-icon type="image" />
          )}
        </s-clickable>
        <s-link href={`/app/qrcodes/${qrCode.handle}`}>
          {truncate(qrCode.title)}
        </s-link>
      </s-stack>
    </s-table-cell>
    <s-table-cell>
      {qrCode.productDeleted ? (
        <s-badge icon="alert-diamond" tone="critical">
          Product has been deleted
        </s-badge>
      ) : (
        truncate(qrCode.productTitle)
      )}
    </s-table-cell>
    <s-table-cell>
      {qrCode.campaign ? (
        <s-link href={`/app/campaigns/${qrCode.campaign.id}`}>
          {truncate(qrCode.campaign.title)}
        </s-link>
      ) : (
        "None"
      )}
    </s-table-cell>
    <s-table-cell>
      {new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" }).format(
        new Date(qrCode.createdAt),
      )}
    </s-table-cell>
    <s-table-cell>{qrCode.scans}</s-table-cell>
  </s-table-row>
);


export default function Index() {
  const { qrCodes } = useLoaderData();


  return (
    <s-page heading="QR codes">
      <s-link slot="secondary-actions" href="/app/qrcodes/new">
        Create QR code
      </s-link>
      {qrCodes.length === 0 ? (
        <EmptyQRCodeState />
      ) : (
        <QRTable qrCodes={qrCodes} />
      )}
    </s-page>
  );
}


export const headers = (headersArgs) => {
  return boundary.headers(headersArgs);
};
```

## /app/routes/app.jsx

```jsx
import {Outlet, useLoaderData, useRouteError} from 'react-router';
import {boundary} from '@shopify/shopify-app-react-router/server';
import {AppProvider} from '@shopify/shopify-app-react-router/react';


import {authenticate} from '../shopify.server';


export const loader = async ({request}) => {
  await authenticate.admin(request);


  return {apiKey: process.env.SHOPIFY_API_KEY || ''};
};


export default function App() {
  const {apiKey} = useLoaderData();


  return (
    <AppProvider apiKey={apiKey}>
      {/* AppProvider leaves out the analytics UI script. It registers s-metrics-bar
          and s-shopifyql-metric-card. */}
      <script src="https://cdn.shopify.com/shopifycloud/analytics-ui.js" />
      <s-app-nav>
        <s-link href="/app/campaigns">Campaigns</s-link>
        <s-link href="/app/qrcodes">QR codes</s-link>
      </s-app-nav>
      <Outlet />
    </AppProvider>
  );
}


// React Router has to catch some thrown Shopify responses to keep their headers.
export function ErrorBoundary() {
  return boundary.error(useRouteError());
}


export const headers = (headersArgs) => {
  return boundary.headers(headersArgs);
};
```

## /app/routes/app.\_index.jsx

```jsx
import {boundary} from '@shopify/shopify-app-react-router/server';
import {authenticate} from '../shopify.server';


// Authenticate before redirecting, or an embedded load skips token exchange.
export async function loader({request}) {
  const {redirect} = await authenticate.admin(request);
  // This redirect carries the request's search params, which hold App Bridge's shop and host.
  return redirect('/app/campaigns');
}


export const headers = (headersArgs) => boundary.headers(headersArgs);
```

## /app/routes/webhooks.customers.data\_request.jsx

```jsx
import {authenticate} from '../shopify.server';
import {customerTier, withReturnedErrors} from '../campaigns.server';


// The app holds a customer's tier metafield. A real app sends it to the merchant within
// 30 days. This sample logs it.
export const action = async ({request}) => {
  const {admin, shop, topic, payload} = await authenticate.webhook(request);


  console.log(`Received ${topic} for ${shop}`);


  const id = payload.customer?.id;
  if (!admin || !id) return new Response();


  const {tier, userErrors} = await customerTier(
    withReturnedErrors(admin),
    `gid://shopify/Customer/${id}`,
  );
  if (userErrors.length) {
    console.error(userErrors.map((error) => error.message).join('. '));
  } else {
    console.log(`Customer ${id} holds tier ${tier ? `"${tier}"` : 'none'} from this app`);
  }


  return new Response();
};
```

## /app/routes/webhooks.customers.redact.jsx

```jsx
import {authenticate} from '../shopify.server';
import {forgetCustomer, withReturnedErrors} from '../campaigns.server';


// Activating a targeted campaign writes a tier metafield on each customer in the tier.
// Redaction deletes it.
export const action = async ({request}) => {
  const {admin, shop, topic, payload} = await authenticate.webhook(request);


  console.log(`Received ${topic} for ${shop}`);


  // Without a session the app can't reach the store.
  if (!admin) return new Response();


  const id = payload.customer?.id;
  if (!id) return new Response();


  const errors = await forgetCustomer(withReturnedErrors(admin), `gid://shopify/Customer/${id}`);
  // A 200 confirms receipt, and that's all a compliance webhook owes. The 30 days to
  // finish belong to the app, and a real app would queue this retry.
  if (errors.length) {
    console.error(errors.map((error) => error.message).join('. '));
  }


  return new Response();
};
```

## /app/routes/webhooks.shop.redact.jsx

```jsx
import {authenticate} from '../shopify.server';
import {forgetShop} from '../campaign-state.server';


// 48 hours after an uninstall. The campaign entries belong to the store and stay in
// the admin. The app deletes its sessions, campaign records, locks, and spent IDs.
export const action = async ({request}) => {
  const {shop, topic} = await authenticate.webhook(request);


  console.log(`Received ${topic} for ${shop}`);


  await forgetShop(shop);


  return new Response();
};
```

## /app/models/QRCode.server.js

```javascript
import qrcode from "qrcode";
import invariant from "tiny-invariant";


const METAOBJECT_TYPE = "$app:qrcode";


export async function getQRCode(handle, graphql, shop) {
  const response = await graphql(
    `
      query GetQRCode($handle: MetaobjectHandleInput!) {
        metaobjectByHandle(handle: $handle) {
          id
          handle
          createdAt
          title: field(key: "title") { jsonValue }
          product: field(key: "product") {
            jsonValue
            reference {
              ... on Product {
                handle
                title
                media(first: 1) {
                  nodes {
                    preview {
                      image { url altText }
                    }
                  }
                }
              }
            }
          }
          productVariant: field(key: "product_variant") {
            reference {
              ... on ProductVariant { id legacyResourceId }
            }
          }
          destination: field(key: "destination") { jsonValue }
          scans: field(key: "scans") { jsonValue }
          liveOn: field(key: "live_on") { jsonValue }
        }
      }
    `,
    {
      variables: {
        handle: { type: METAOBJECT_TYPE, handle },
      },
    },
  );


  const { data } = await response.json();
  const metaobject = data?.metaobjectByHandle;


  if (!metaobject) {
    return null;
  }


  return transformMetaobject(metaobject, shop);
}


export async function getQRCodes(graphql, shop) {
  const response = await graphql(
    `
      query GetQRCodes($type: String!) {
        metaobjects(type: $type, first: 50, sortKey: "updated_at", reverse: true) {
          nodes {
            id
            handle
            createdAt
            title: field(key: "title") { jsonValue }
            product: field(key: "product") {
              jsonValue
              reference {
                ... on Product {
                  handle
                  title
                  media(first: 1) {
                    nodes {
                      preview {
                        image { url altText }
                      }
                    }
                  }
                }
              }
            }
            productVariant: field(key: "product_variant") {
              reference {
                ... on ProductVariant { id legacyResourceId }
              }
            }
            destination: field(key: "destination") { jsonValue }
            scans: field(key: "scans") { jsonValue }
          }
        }
      }
    `,
    {
      variables: { type: METAOBJECT_TYPE },
    },
  );


  const { data } = await response.json();
  const metaobjects = data?.metaobjects?.nodes ?? [];


  return Promise.all(metaobjects.map((mo) => transformMetaobject(mo, shop)));
}


async function transformMetaobject(metaobject, shop) {
  const product = metaobject.product?.reference;
  const variant = metaobject.productVariant?.reference;
  const productId = metaobject.product?.jsonValue;


  const qrCode = {
    id: metaobject.id,
    handle: metaobject.handle,
    title: metaobject.title?.jsonValue,
    productId,
    productVariantId: variant?.id,
    productHandle: product?.handle,
    productVariantLegacyId: variant?.legacyResourceId,
    destination: metaobject.destination?.jsonValue,
    scans: metaobject.scans?.jsonValue ?? 0,
    liveOn: metaobject.liveOn?.jsonValue ?? "",
    createdAt: metaobject.createdAt,
    productDeleted: productId && !product,
    productTitle: product?.title,
    productImage: product?.media?.nodes[0]?.preview?.image?.url,
    productAlt: product?.media?.nodes[0]?.preview?.image?.altText,
  };


  // A deleted product leaves nothing to build a URL from.
  qrCode.destinationUrl =
    product && variant ? getDestinationUrl(qrCode, shop) : null;
  qrCode.image = await getQRCodeImage(metaobject.handle, shop);


  return qrCode;
}


export async function getQRCodeImage(handle, shop) {
  const url = new URL(
    `/qrcodes/${handle}/scan`,
    process.env.SHOPIFY_APP_URL,
  );
  url.searchParams.set("shop", shop);
  return qrcode.toDataURL(url.href);
}


export function getQRCodeUTM(handle) {
  return {
    campaign: handle,
    medium: "qr",
    source: "qr-code-demo",
  };
}


// Both destinations land on the storefront, where Shopify attribution reads
// the UTM parameters. The cart permalink keeps them through checkout.
export function getDestinationUrl(qrCode, shop) {
  let url;
  if (qrCode.destination === "product") {
    url = new URL(`/products/${qrCode.productHandle}`, `https://${shop}`);
    url.searchParams.set("variant", qrCode.productVariantLegacyId);
  } else {
    invariant(qrCode.productVariantLegacyId, "Unrecognised product variant ID");
    url = new URL(`/cart/${qrCode.productVariantLegacyId}:1`, `https://${shop}`);
  }


  const utm = getQRCodeUTM(qrCode.handle);
  url.searchParams.set("utm_campaign", utm.campaign);
  url.searchParams.set("utm_medium", utm.medium);
  url.searchParams.set("utm_source", utm.source);
  return url.href;
}


export async function saveQRCode(handle, data, graphql) {
  const response = await graphql(
    `
      mutation UpsertQRCode($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
        metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
          metaobject { id handle }
          userErrors { field message }
        }
      }
    `,
    {
      variables: {
        handle: { type: METAOBJECT_TYPE, handle },
        metaobject: {
          fields: [
            { key: "title", value: data.title },
            { key: "product", value: data.productId },
            { key: "product_variant", value: data.productVariantId },
            { key: "destination", value: data.destination },
            // Left out, the upsert keeps the stored value. Blank clears it.
            ...(data.campaign === undefined
              ? []
              : [{ key: "campaign", value: data.campaign }]),
            ...(data.liveOn === undefined
              ? []
              : [{ key: "live_on", value: data.liveOn }]),
          ],
        },
      },
    },
  );


  const { data: responseData } = await response.json();
  const { metaobjectUpsert } = responseData;


  if (metaobjectUpsert.userErrors.length) {
    throw new Error(metaobjectUpsert.userErrors[0].message);
  }


  return metaobjectUpsert.metaobject;
}


export async function upsertMarketingActivity(handle, title, shop, graphql) {
  const remoteUrl = new URL(
    `/qrcodes/${handle}/scan`,
    process.env.SHOPIFY_APP_URL,
  );
  remoteUrl.searchParams.set("shop", shop);


  const response = await graphql(
    `
      mutation UpsertQRCodeMarketingActivity($input: MarketingActivityUpsertExternalInput!) {
        marketingActivityUpsertExternal(input: $input) {
          marketingActivity { id }
          userErrors { field message }
        }
      }
    `,
    {
      variables: {
        input: {
          marketingChannelType: "REFERRAL",
          remoteId: handle,
          remoteUrl: remoteUrl.href,
          status: "ACTIVE",
          tactic: "AD",
          title,
          utm: getQRCodeUTM(handle),
        },
      },
    },
  );


  const { data } = await response.json();
  const result = data?.marketingActivityUpsertExternal;


  if (result?.userErrors?.length) {
    throw new Error(result.userErrors[0].message);
  }


  return result?.marketingActivity;
}


export async function endMarketingActivity(handle, shop, graphql) {
  const remoteUrl = new URL(
    `/qrcodes/${handle}/scan`,
    process.env.SHOPIFY_APP_URL,
  );
  remoteUrl.searchParams.set("shop", shop);


  const response = await graphql(
    `
      mutation EndQRCodeMarketingActivity($input: MarketingActivityUpsertExternalInput!) {
        marketingActivityUpsertExternal(input: $input) {
          marketingActivity { id }
          userErrors { field message }
        }
      }
    `,
    {
      variables: {
        input: {
          marketingChannelType: "REFERRAL",
          remoteId: handle,
          remoteUrl: remoteUrl.href,
          // The title is required. The deleted activity keeps the handle as its label.
          status: "DELETED_EXTERNALLY",
          tactic: "AD",
          title: handle,
          utm: getQRCodeUTM(handle),
        },
      },
    },
  );


  const { data } = await response.json();
  const result = data?.marketingActivityUpsertExternal;


  if (result?.userErrors?.length) {
    throw new Error(result.userErrors[0].message);
  }
}


export async function deleteQRCode(id, graphql) {
  const response = await graphql(
    `
      mutation DeleteQRCode($id: ID!) {
        metaobjectDelete(id: $id) {
          deletedId
          userErrors { field message }
        }
      }
    `,
    {
      variables: { id },
    },
  );


  const { data } = await response.json();


  if (data.metaobjectDelete.userErrors.length) {
    throw new Error(data.metaobjectDelete.userErrors[0].message);
  }
}


// Read, then write. Concurrent scans can lose a count, which a sample tolerates.
export async function incrementQRCodeScans(id, currentScans, graphql) {
  const response = await graphql(
    `
      mutation IncrementScans($id: ID!, $metaobject: MetaobjectUpdateInput!) {
        metaobjectUpdate(id: $id, metaobject: $metaobject) {
          metaobject { id }
          userErrors { field message }
        }
      }
    `,
    {
      variables: {
        id,
        metaobject: {
          fields: [{ key: "scans", value: String(currentScans + 1) }],
        },
      },
    },
  );
  const { data } = await response.json();
  if (data?.metaobjectUpdate?.userErrors?.length) {
    throw new Error(data.metaobjectUpdate.userErrors[0].message);
  }
}


function slugify(text) {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "");
}


export function generateHandle(title) {
  return `${slugify(title)}-${Date.now().toString(36)}`;
}


export function validateQRCode(data) {
  const errors = {};


  if (!data.title) {
    errors.title = "Title is required";
  }


  if (!data.productId) {
    errors.productId = "Product is required";
  }


  if (!data.destination) {
    errors.destination = "Destination is required";
  }


  if (Object.keys(errors).length) {
    return errors;
  }
}
```

## /app/routes/app.qrcodes.$id.jsx

```jsx
import { useState, useEffect, useRef } from "react";
import {
  useActionData,
  useLoaderData,
  useSubmit,
  useParams,
  useRouteError,
  isRouteErrorResponse,
} from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import { boundary } from "@shopify/shopify-app-react-router/server";


import {
  getQRCode,
  validateQRCode,
  saveQRCode,
  upsertMarketingActivity,
  deleteQRCode,
  endMarketingActivity,
  generateHandle,
} from "../models/QRCode.server";
import {
  campaignOf,
  heldByTargets,
  readCampaigns,
  lockCampaigns,
  lockQRCodeMove,
  withReturnedErrors,
} from "../campaigns.server";
import { isCalendarDate, shortDate, statusLabel } from "../campaigns";


// Codes join and leave a campaign while it's planned.
const assignable = (campaign) => campaign.status === "planned";


async function campaignChoices(admin, shop, qrCodeId) {
  const { campaigns, failed } = await readCampaigns(withReturnedErrors(admin), shop);
  const current = qrCodeId ? campaignOf(campaigns, qrCodeId) : null;
  const held = current ? heldByTargets(current) : null;
  return {
    failed,
    current,
    held,
    locked: Boolean(current && (!assignable(current) || held)),
    campaigns: campaigns
      .filter((campaign) => assignable(campaign) || campaign === current)
      .map((campaign) => ({
        gid: campaign.gid,
        title: campaign.title,
        startDate: campaign.startDate,
        endDate: campaign.endDate,
      })),
  };
}


// Blank goes live with the campaign.
function liveOnError(liveOn, choice) {
  if (!liveOn) return null;
  if (!choice) return "Select a campaign to set a live date.";
  return !isCalendarDate(liveOn) || liveOn < choice.startDate || liveOn > choice.endDate
    ? `Select a day from ${shortDate(choice.startDate)} to ${shortDate(choice.endDate)}.`
    : null;
}


const CAMPAIGNS_FAILED = "Couldn't load campaigns. Reload to try again.";


async function qrCodeIdOf(admin, handle) {
  const response = await admin.graphql(
    `query QRCodeId($handle: MetaobjectHandleInput!) {
      metaobjectByHandle(handle: $handle) { id }
    }`,
    { variables: { handle: { type: "$app:qrcode", handle } } },
  );
  const { data } = await response.json();
  return data?.metaobjectByHandle?.id ?? null;
}


export async function loader({ request, params }) {
  const { admin, session } = await authenticate.admin(request);


  if (params.id === "new") {
    const { campaigns, failed } = await campaignChoices(admin, session.shop, null);
    // A link can pick the campaign with `?campaign=<gid>`.
    const picked = new URL(request.url).searchParams.get("campaign");
    return {
      destination: "product",
      title: "",
      campaign: campaigns.some((option) => option.gid === picked) ? picked : "",
      liveOn: "",
      campaigns,
      campaignsFailed: failed.length > 0,
      campaignError: failed.length ? CAMPAIGNS_FAILED : null,
      shop: session.shop,
    };
  }


  const qrCode = await getQRCode(params.id, admin.graphql, session.shop);
  if (!qrCode) throw new Response("No QR code has this handle.", { status: 404 });
  const { campaigns, current, failed, held, locked } = await campaignChoices(
    admin,
    session.shop,
    qrCode?.id,
  );
  return {
    ...qrCode,
    campaign: current?.gid ?? "",
    liveOn: (current && qrCode?.liveOn) || "",
    campaigns,
    campaignLocked: locked,
    campaignHeld: held,
    campaignsFailed: failed.length > 0,
    campaignError: failed.length ? CAMPAIGNS_FAILED : null,
    shop: session.shop,
  };
}


export async function action({ request, params }) {
  const { admin, session, redirect } = await authenticate.admin(request);


  const data = Object.fromEntries(await request.formData());
  // The URL's handle names the code. A posted ID could name any metaobject on the shop.
  const qrCodeId = params.id === "new" ? null : await qrCodeIdOf(admin, params.id);


  if (data.action === "delete") {
    if (!qrCodeId) return redirect("/app/qrcodes");
    const refuse = (campaign) =>
      new Response(JSON.stringify({ errors: { campaign } }), {
        status: 422,
        headers: { "Content-Type": "application/json" },
      });
    const before = await campaignChoices(admin, session.shop, qrCodeId);
    if (before.failed.length) return refuse("Couldn't load campaigns. Try again.");
    const lock = before.current
      ? await lockCampaigns(session.shop, [before.current.id])
      : { release: async () => {} };
    if (lock.problems) return refuse(lock.problems[0].message);
    try {
      // An active or closed campaign reports on its codes, and a canceled one replans with
      // them. The lock stops another request from activating the campaign before the delete.
      const choices = await campaignChoices(admin, session.shop, qrCodeId);
      if (choices.failed.length) return refuse("Couldn't load campaigns. Try again.");
      if (choices.locked) {
        return refuse(
          choices.held ??
            `Only a planned campaign's codes can be deleted. This code's campaign is ${statusLabel(choices.current.status).toLowerCase()}.`,
        );
      }
      // A failed delete leaves the code and its campaign as they were.
      try {
        await deleteQRCode(qrCodeId, admin.graphql);
      } catch (error) {
        console.error("Could not delete the QR code", error);
        return refuse("Couldn't delete the code. Try again.");
      }
    } finally {
      await lock.release();
    }
    try {
      // On this page params.id is the code's handle, the activity's remoteId.
      await endMarketingActivity(params.id, session.shop, admin.graphql);
    } catch (error) {
      console.error("Could not end the marketing activity", error);
    }
    return redirect("/app/qrcodes");
  }


  const errors = validateQRCode(data);


  if (errors) {
    return new Response(JSON.stringify({ errors }), {
      status: 422,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }


  // The form posts the campaign and live date it loaded with. Unchanged fields stay as
  // stored. The server checks both, because the form can post any GID or day.
  const campaign = data.campaign || null;
  const liveOn = campaign ? data.liveOn || "" : "";
  const reassigned = campaign !== (data.initialCampaign || null);
  const moved = reassigned || liveOn !== (data.initialLiveOn || "");
  const choices = moved
    ? await campaignChoices(admin, session.shop, qrCodeId)
    : null;
  const choice = choices?.campaigns.find((option) => option.gid === campaign);
  const campaignError = !choices
    ? null
    : choices.failed.length
      ? "Couldn't load campaigns. Try again."
      : choices.locked
        ? choices.held ?? "QR codes move only between planned campaigns."
        : campaign && !choice
          ? "Select a planned campaign."
          : null;
  const dateError = choices && !campaignError ? liveOnError(liveOn, choice) : null;
  if (campaignError || dateError) {
    return new Response(
      JSON.stringify({
        errors: campaignError ? { campaign: campaignError } : { liveOn: dateError },
      }),
      { status: 422, headers: { "Content-Type": "application/json" } },
    );
  }


  const handle =
    params.id === "new" ? generateHandle(data.title) : params.id;


  // A code's single `campaign` field holds the link. A code has one campaign at most.
  const lock = moved
    ? await lockQRCodeMove(withReturnedErrors(admin), session.shop, qrCodeId, campaign)
    : { release: async () => {} };
  if (lock.problems) {
    return new Response(
      JSON.stringify({ errors: { campaign: lock.problems[0].message } }),
      { status: 422, headers: { "Content-Type": "application/json" } },
    );
  }
  let metaobject;
  try {
    metaobject = await saveQRCode(
      handle,
      {
        ...data,
        campaign: reassigned ? campaign ?? "" : undefined,
        liveOn: moved ? liveOn : undefined,
      },
      admin.graphql,
    );
  } finally {
    await lock.release();
  }


  try {
    await upsertMarketingActivity(
      metaobject.handle,
      data.title,
      session.shop,
      admin.graphql,
    );
  } catch (error) {
    // The code is already saved. The next save retries the registration.
    console.error("Could not register the marketing activity", error);
  }


  return redirect(`/app/qrcodes/${metaobject.handle}`);
}


export default function QRCodeForm() {
  const shopify = useAppBridge();
  const { id } = useParams();


  const loaderData = useLoaderData();
  const qrCode = loaderData;
  const [initialFormState, setInitialFormState] = useState(qrCode);
  const [formState, setFormState] = useState(qrCode);
  const errors = useActionData()?.errors || {};
  const chosenCampaign = (loaderData.campaigns ?? []).find(
    (option) => option.gid === formState.campaign,
  );
  const isDirty =
    JSON.stringify(formState) !== JSON.stringify(initialFormState);


  async function selectProduct() {
    const products = await shopify.resourcePicker({
      type: "product",
      action: "select",
      filter: { variants: true },
      selectionIds: formState.productId
        ? [
            {
              id: formState.productId,
              variants: formState.productVariantId
                ? [{ id: formState.productVariantId }]
                : [],
            },
          ]
        : [],
    });


    if (products) {
      const { images, id, variants, title } = products[0];


      setFormState({
        ...formState,
        productId: id,
        productVariantId: variants[0].id,
        productTitle: title,
        productAlt: images[0]?.altText,
        productImage: images[0]?.originalSrc,
      });
    }
  }


  function removeProduct() {
    setFormState({
      title: formState.title,
      destination: formState.destination,
      campaign: formState.campaign,
      liveOn: formState.liveOn,
    });
  }


  const productUrl = formState.productId
    ? `shopify://admin/products/${formState.productId.split("/").at(-1)}`
    : "";


  const submit = useSubmit();


  function handleSave(e) {
    e.preventDefault();


    const data = {
      title: formState.title,
      productId: formState.productId || "",
      productVariantId: formState.productVariantId || "",
      destination: formState.destination,
      campaign: formState.campaign || "",
      // A new code starts on no campaign, even one picked from a campaign page.
      initialCampaign: initialFormState.id ? initialFormState.campaign || "" : "",
      liveOn: formState.campaign ? formState.liveOn || "" : "",
      initialLiveOn: initialFormState.liveOn || "",
      metaobjectId: initialFormState.id || "",
    };


    submit(data, { method: "post" });
  }


  function handleDelete(e) {
    e.preventDefault();
    submit(
      { action: "delete", metaobjectId: initialFormState.id },
      { method: "post" },
    );
  }


  const saveBarRef = useRef(null);


  function handleReset() {
    setFormState(initialFormState);
    saveBarRef.current?.hide();
  }


  useEffect(() => {
    const saveBar = saveBarRef.current;
    if (!saveBar) return;


    if (isDirty) {
      saveBar.show();
    } else {
      saveBar.hide();
    }
  }, [isDirty]);


  useEffect(() => {
    setInitialFormState(qrCode);
    setFormState(qrCode);
  }, [id, qrCode]);


  return (
    <>
      <ui-save-bar ref={saveBarRef} id="qr-code-form">
        <button variant="primary" onClick={handleSave}></button>
        <button onClick={handleReset}></button>
      </ui-save-bar>
      <form onSubmit={handleSave} onReset={handleReset}>
        <s-page heading={initialFormState.title || "Create QR code"}>
          <s-link
            href="/app/qrcodes"
            slot="breadcrumb-actions"
          >
            QR codes
          </s-link>
          {initialFormState.handle &&
            <s-button
              slot="secondary-actions"
              commandFor="delete-qr-code"
              command="--show"
              disabled={loaderData.campaignLocked}
            >
              Delete
            </s-button>}
          {initialFormState.handle &&
            <s-modal id="delete-qr-code" heading="Delete this QR code?">
              <s-paragraph>
                Scans of the printed code stop reaching the product. You can’t undo this.
              </s-paragraph>
              <s-button slot="secondary-actions" commandFor="delete-qr-code" command="--hide">
                Go back
              </s-button>
              <s-button
                slot="primary-action"
                variant="primary"
                tone="critical"
                commandFor="delete-qr-code"
                command="--hide"
                onClick={handleDelete}
              >
                Delete QR code
              </s-button>
            </s-modal>}
          <s-section heading="QR code information">
            <s-stack gap="base">
              <s-text-field
                label="Title"
                details="Shows on the code's public page"
                error={errors.title}
                autocomplete="off"
                name="title"
                value={formState.title}
                onInput={(e) =>
                  setFormState({ ...formState, title: e.target.value })
                }
              ></s-text-field>
              <s-stack gap="base" justifyContent="space-between" alignItems="start">
                <s-select
                  name="destination"
                  label="Scan destination"
                  value={formState.destination}
                  onChange={(e) =>
                    setFormState({ ...formState, destination: e.target.value })
                  }
                >
                  <s-option
                    value="product"
                    selected={formState.destination === "product"}
                  >
                    Link to product page
                  </s-option>
                  <s-option
                    value="cart"
                    selected={formState.destination === "cart"}
                  >
                    Link to checkout page with product in the cart
                  </s-option>
                </s-select>
                {initialFormState.destinationUrl ? (
                  <s-link
                    href={initialFormState.destinationUrl}
                    target="_blank"
                  >
                    Go to destination URL
                  </s-link>
                ) : null}
              </s-stack>
              <s-select
                name="campaign"
                label="Campaign"
                details={
                  loaderData.campaignHeld ??
                  (loaderData.campaignLocked
                    ? "QR codes move only between planned campaigns. A code whose campaign has left planning can't be deleted."
                    : "The campaign page reports this code's attributed sales.")
                }
                disabled={loaderData.campaignLocked || loaderData.campaignsFailed}
                error={errors.campaign ?? loaderData.campaignError}
                value={formState.campaign || ""}
                onChange={(e) =>
                  setFormState({ ...formState, campaign: e.currentTarget.value, liveOn: "" })
                }
              >
                <s-option value="">No campaign</s-option>
                {(loaderData.campaigns ?? []).map((campaign) => (
                  <s-option key={campaign.gid} value={campaign.gid}>
                    {campaign.title}
                  </s-option>
                ))}
              </s-select>
              {formState.campaign ? (
                <s-date-field
                  name="liveOn"
                  label="Live on"
                  details={
                    chosenCampaign
                      ? `${shortDate(chosenCampaign.startDate)} to ${shortDate(chosenCampaign.endDate)}. Leave blank to go live with the campaign.`
                      : "Leave blank to go live with the campaign."
                  }
                  disabled={loaderData.campaignLocked || loaderData.campaignsFailed}
                  error={errors.liveOn}
                  allow={chosenCampaign ? `${chosenCampaign.startDate}--${chosenCampaign.endDate}` : undefined}
                  value={formState.liveOn || ""}
                  onChange={(e) =>
                    setFormState({ ...formState, liveOn: e.currentTarget.value })
                  }
                />
              ) : null}
              <s-stack gap="small-400">
                <s-stack direction="inline" gap="small-100" justifyContent="space-between">
                  <s-text color="subdued">Product</s-text>
                  {formState.productId ? (
                    <s-link
                      onClick={removeProduct}
                      accessibilityLabel="Remove the product from this QR code"
                      tone="neutral"
                    >
                      Clear
                    </s-link>
                  ) : null}
                </s-stack>
                {formState.productId ? (
                  <s-stack
                    direction="inline"
                    justifyContent="space-between"
                    alignItems="center"
                  >
                    <s-stack
                      direction="inline"
                      gap="small-100"
                      alignItems="center"
                    >
                      <s-clickable
                        href={productUrl}
                        target="_blank"
                        accessibilityLabel={`Go to the product page for ${formState.productTitle}`}
                        borderRadius="base"
                      >
                        <s-box
                          padding="small-200"
                          border="base"
                          borderRadius="base"
                          background="subdued"
                          inlineSize="38px"
                          blockSize="38px"
                        >
                          {formState.productImage ? (
                            <s-image src={formState.productImage}></s-image>
                          ) : (
                            <s-icon type="product" />
                          )}
                        </s-box>
                      </s-clickable>
                      <s-link href={productUrl} target="_blank">
                        {formState.productTitle}
                      </s-link>
                    </s-stack>
                    <s-stack direction="inline" gap="small">
                      <s-button
                        onClick={selectProduct}
                        accessibilityLabel="Change the product the QR code should be for"
                      >
                        Change
                      </s-button>
                    </s-stack>
                  </s-stack>
                ) : (
                  <s-button
                    onClick={selectProduct}
                    accessibilityLabel="Select the product the QR code should be for"
                  >
                    Select product
                  </s-button>
                )}
                {errors.productId ? (
                  <s-stack direction="inline" alignItems="center" gap="small-300">
                    <s-icon type="alert-circle" tone="critical" size="small" />
                    <s-text tone="critical">{errors.productId}</s-text>
                  </s-stack>
                ) : null}
              </s-stack>
            </s-stack>
          </s-section>
          <s-box slot="aside">
            <s-section heading="Preview">
              <s-stack gap="base">
                <s-box
                  padding="base"
                  border="none"
                  borderRadius="base"
                  background="subdued"
                >
                  {initialFormState.image ? (
                    <s-image
                      aspectRatio="1/0.8"
                      src={initialFormState.image}
                      alt="The QR code for the current form"
                    />
                  ) : (
                    <s-stack
                      direction="inline"
                      alignItems="center"
                      justifyContent="center"
                      blockSize="198px"
                    >
                      <s-text color="subdued">
                        See a preview once you save
                      </s-text>
                    </s-stack>
                  )}
                </s-box>
                <s-stack
                  gap="small"
                  direction="inline"
                  alignItems="center"
                  justifyContent="space-between"
                >
                  <s-button
                    disabled={!initialFormState.handle}
                    href={`/qrcodes/${initialFormState.handle}?shop=${loaderData.shop}`}
                    target="_blank"
                  >
                    Go to public URL
                  </s-button>
                  <s-button
                    disabled={!initialFormState?.image}
                    href={initialFormState?.image}
                    download={`${initialFormState?.handle ?? "qr-code"}.png`}
                    variant="primary"
                  >
                    Download
                  </s-button>
                </s-stack>
              </s-stack>
            </s-section>
          </s-box>
        </s-page>
      </form>
    </>
  );
}


// Without a boundary here, a failed load takes AppProvider and the app's navigation with it.
export function ErrorBoundary() {
  const error = useRouteError();
  // A response Shopify threw, such as a re-auth, renders the way the library expects.
  if (isRouteErrorResponse(error)) return boundary.error(error);
  const detail = error?.message ?? "The load failed.";
  return (
    <s-page heading="QR code">
      <s-section>
        <s-banner tone="critical" heading="Couldn't load the QR code">
          <s-paragraph>{String(detail)}</s-paragraph>
        </s-banner>
        <s-box paddingBlockStart="base">
          <s-link href="/app/qrcodes">Back to QR codes</s-link>
        </s-box>
      </s-section>
    </s-page>
  );
}


export const headers = (headersArgs) => {
  return boundary.headers(headersArgs);
};
```

## /app/routes/qrcodes.$id.scan.jsx

```jsx
import { redirect } from "react-router";
import invariant from "tiny-invariant";


import { unauthenticated } from "../shopify.server";
import {
  getDestinationUrl,
  incrementQRCodeScans,
} from "../models/QRCode.server";


export const loader = async ({ request, params }) => {
  invariant(params.id, "Could not find QR code destination");


  const url = new URL(request.url);
  const shop = url.searchParams.get("shop");
  invariant(shop, "Missing shop parameter");


  const { admin } = await unauthenticated.admin(shop);


  const response = await admin.graphql(
    `
      query GetQRCodeScan($handle: MetaobjectHandleInput!) {
        metaobjectByHandle(handle: $handle) {
          id
          product: field(key: "product") {
            reference {
              ... on Product { handle }
            }
          }
          productVariant: field(key: "product_variant") {
            reference {
              ... on ProductVariant { legacyResourceId }
            }
          }
          destination: field(key: "destination") { jsonValue }
          scans: field(key: "scans") { jsonValue }
        }
      }
    `,
    {
      variables: {
        handle: { type: "$app:qrcode", handle: params.id },
      },
    },
  );


  const { data } = await response.json();
  const metaobject = data?.metaobjectByHandle;
  invariant(metaobject, "Could not find QR code destination");


  const currentScans = metaobject.scans?.jsonValue ?? 0;
  try {
    await incrementQRCodeScans(metaobject.id, currentScans, admin.graphql);
  } catch (error) {
    // A lost count shouldn't lose the redirect.
    console.error("Could not increment the scan count", error);
  }


  const qrCode = {
    handle: params.id,
    destination: metaobject.destination?.jsonValue,
    productHandle: metaobject.product?.reference?.handle,
    productVariantLegacyId: metaobject.productVariant?.reference?.legacyResourceId,
  };


  // A deleted product can't be a destination.
  invariant(
    qrCode.destination === "cart"
      ? qrCode.productVariantLegacyId
      : qrCode.productHandle,
    "Could not find QR code destination",
  );


  return redirect(getDestinationUrl(qrCode, shop));
};
```

## Part one complete!

You built a campaign dashboard on Shopify Analytics. Each campaign page compares sales with the prior period and last year. The page also reports new customers, top products, creator code sales, and the sessions and sales from QR code scans.

In part two, you'll set revenue targets, mark the campaign on the store's charts, and group sales by promoted product and customer segment.

### Next steps

[Add targets and annotations\
\
](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-targets-and-annotations)

[Set revenue targets that Shopify measures against the store's sales, mark the campaign on the store's analytics timeline, and record the campaign's revenue.](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-targets-and-annotations)

[ShopifyQL\
\
](https://shopify.dev/docs/api/shopifyql)

[Read the full query syntax, the schemas that you can query, and the limits that apply.](https://shopify.dev/docs/api/shopifyql)
