---
title: Add targets and annotations
description: >-
  Set revenue targets that Shopify measures, annotate a campaign on the store's
  analytics timeline, and then close the campaign and record the campaign's
  revenue. Part two of three.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-targets-and-annotations?extension=react
  md: >-
    https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-targets-and-annotations.md?extension=react
api_name: admin
---

# Add targets and annotations

In this tutorial, you'll add revenue targets and annotations to the campaign dashboard. When an app user activates a campaign, your app creates the targets that the user set and marks the campaign's dates on the store's charts.

## What you'll learn

You'll learn how to:

* Set optional [revenue targets](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsTargetCreate) for the campaign and each QR code.
* Mark the campaign's key dates on the store's sales charts with [annotations](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationCreate).
* Show the campaign's sales and key metrics in your app with [analytics web components](https://shopify.dev/docs/api/shopifyql/latest/web-components).
* Break the campaign's revenue down by promoted product and by customer segment.
* Close the campaign, record its revenue, and stamp its orders with a [bulk operation](https://shopify.dev/docs/apps/build/apis/graphql-admin/bulk-operations/imports).

## Requirements

[Complete part one](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-sales)

You've built the campaign dashboard from [part one](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-sales).

[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).

## Project

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

## Set campaign revenue targets

A target is a revenue goal that Shopify measures against the store's sales. Add the access scopes, and then create targets for the store, the promoted products, the customer segment, and each QR code.

### Add the access scopes

To create targets, your app must have the `write_reports` scope. To create annotations, your app must have the `write_analytics_annotations` scope. In `shopify.app.toml`, replace the scopes line under `[access_scopes]`:

## shopify.app.toml

```toml
scopes = "write_reports,write_analytics_annotations,write_products,write_customers,write_orders,write_metaobject_definitions,write_metaobjects,write_marketing_events"
```

While `shopify app dev` runs, Shopify CLI syncs the new scopes. Open the app in the Shopify admin and accept the updated scopes.

***

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

## /shopify.app.toml

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

### Add the part two files

Copy these files from the sample repository's `part-2` branch into your app directory:

* `app/campaign-targets.server.js` measures each target's progress.
* `app/campaign-mutations.server.js` defines the mutations that your app runs.
* `app/campaign-report.server.js` builds the result that your app writes to the store's timeline when a user closes a campaign.
* `app/bulk-stamp.server.js` writes the order metafields in a bulk operation and reads the operation's status.
* `app/panels/targets.jsx` renders a card for each target.
* `app/panels/close.jsx` renders the **Campaign actions** and **Order attribution** sections.
* `app/panels/confirm.jsx` exports `ConfirmButton`, which opens a confirmation dialog before the form submits a destructive or one-way action.
* `app/routes/app.campaigns.$campaignId.stamping.jsx` returns the status of the bulk operation that stamps the campaign's orders.

Replace `app/routes/app.campaigns.$campaignId.jsx` and `app/routes/app.campaigns._index.jsx` with the part two versions. The part two campaign list has target fields on the creation form and two new columns: **Target progress** and **Annotations and targets**.

### Collect the target amounts on the creation form

The **New campaign** form has a **Targets** section after **Channels**. The section has one `s-money-field` for each target:

* **Store sales target**
* **Promoted product target**
* **Customer segment target**, for the **VIP** and **Regulars** segments
* **QR code target**

Your app skips the target for a blank field. Before activation, the user can change the amounts under **Targets** on the campaign page.

`isTargetAmount` checks each amount on the form and on the campaign page. It accepts only a positive amount with at most two decimals.

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

```jsx
... (425 unchanged lines)
            {/* Each amount becomes a target at activation. The form offers only the
                targets this campaign can create. */}
            <s-section heading="Targets">
              <s-box paddingBlockStart="base" />
              <s-stack direction="block" gap="base">
                <s-paragraph>
                  Activation creates a target for each amount. Leave an amount
                  blank for no target.
                </s-paragraph>
                <s-money-field
                  label="Store sales target"
                  name="revenueTarget"
                  placeholder="5000.00"
                  details="Measures total sales."
                />
                <s-money-field
                  label="Promoted product target"
                  name="productTarget"
                  placeholder="5000.00"
                  disabled={products.length === 0}
                  details={
                    products.length > 0
                      ? 'Sales of the promoted products.'
                      : 'Select promoted products to set this target.'
                  }
                />
                {isTargetedTier(audience) && (
                  <s-money-field
                    label="Customer segment target"
                    name="audienceTarget"
                    placeholder="2500.00"
                    disabled={!dimensions.customer.enabled}
                    details={
                      dimensions.customer.enabled
                        ? `Sales to the ${tierLabel(audience)} segment.`
                        : 'Make the customer dimension queryable under Analytics dimensions to set this target.'
                    }
                  />
                )}
                <s-money-field
                  label="QR code target"
                  name="qrTarget"
                  placeholder="1000.00"
                  details="One target for each QR code. Pick this campaign in each QR code's Campaign field before you activate."
                />
                <s-stack direction="inline" gap="base">
                  <s-button variant="primary" type="submit" loading={saving}>
                    Create campaign
                  </s-button>
                </s-stack>
              </s-stack>
            </s-section>
... (240 unchanged lines)
```

### Create the store-wide target

Use [`analyticsTargetCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsTargetCreate) with the `total_sales` metric, the target amount, and the campaign's dates. A target without `filters` measures the whole store. Save the returned ID in the campaign's `CampaignState` row.

***

[analytics​Target​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsTargetCreate)

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

```javascript
... (235 unchanged lines)
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
      }
    }
  }
`;
... (713 unchanged lines)
```

### Narrow a target to part of the store

Add a `filters` string to limit a target to the promoted products or the customer segment:

```json
"filters": "product_id IN (7263, 7264)"
```

```json
"filters": "customer.metafields.\"app--{app_id}--campaign_dashboard\".tier = 'vip'"
```

`product_id` takes the number at the end of a product GID. Wrap the metafield namespace in double quotes because the namespace contains `--`.

## /app/campaigns.js

```javascript
... (6 unchanged lines)
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(', ');
... (321 unchanged lines)
```

### Read the query that a target generates

Read the campaign's targets with the [`nodes`](https://shopify.dev/docs/api/admin-graphql/latest/queries/nodes) query on the saved IDs. Your app saves a target and an annotation for each QR code. The `nodes` query accepts at most 250 IDs. `readNodes` sends the IDs in batches of 250 or fewer. Run each target's generated query in one [`shopifyqlQuery`](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyqlQuery) request under aliases such as `t0` and `t1`.

`COMPARE TO TARGETS` doesn't return target values through `shopifyqlQuery`. Read the target values from the target nodes.

***

[nodes](https://shopify.dev/docs/api/admin-graphql/latest/queries/nodes) [COMPARE TO TARGETS](https://shopify.dev/docs/api/shopifyql/latest/syntax/compare-to)

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

```javascript
... (285 unchanged lines)
// 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);


... (641 unchanged lines)
```

### Set a goal for each QR code

Give each QR code a separate target that filters on the QR code's handle. Call `analyticsTargetCreate` once for each QR code in the campaign:

* `metric`: `campaign_last_non_direct_click_total_sales`.
* `filters`: `utm_campaign = '<handle>'`.
* `expectedValue`: The campaign's QR code target.
* `startDate` and `endDate`: The QR code's **Live on** day through the campaign's last day.

Save each target ID in the campaign's `CampaignState` row under `qr:<handle>`.

***

[analytics​Target​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsTargetCreate)

## /app/campaigns.js

```javascript
... (40 unchanged lines)
// 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;
... (291 unchanged lines)
```

### Render a target as a share of the target amount

Show each target's progress as a percentage of the target amount, and display the running total with an `s-shopifyql-metric-card`.

***

[Box](https://shopify.dev/docs/api/app-home/web-components/layout-and-structure/box) [Badge](https://shopify.dev/docs/api/app-home/web-components/feedback-and-status-indicators/badge)

## /app/panels/targets.jsx

```jsx
... (8 unchanged lines)
// A running total from the target's first day. ANNOTATE ALL adds the store's annotations,
// and COMPARE TO TARGETS matches a target only when its base metric is visualized.
export function targetChartQuery(target) {
  // QR targets measure a `campaign_sales` metric, which `sales` doesn't have.
  const from =
    target.shopifyqlQuery?.match(/^\s*FROM (\w+)/)?.[1] ??
    (target.metric.startsWith('campaign_') ? 'campaign_sales' : 'sales');
  return [
    `FROM ${from}`,
    `SHOW ${target.metric}`,
    target.filters ? `WHERE ${target.filters}` : null,
    'TIMESERIES day WITH CUMULATIVE_VALUES',
    `SINCE ${target.startDate} UNTIL ${target.endDate}`,
    'COMPARE TO TARGETS',
    `VISUALIZE ${target.metric} TYPE bar, ${target.metric}__cumulative TYPE line ANNOTATE ALL`,
  ]
    .filter(Boolean)
    .join(' ');
}
... (199 unchanged lines)
```

### Raise the store-wide target after the campaign passes it

When a running campaign passes the store-wide target, the user can click **Raise target** to set a higher goal. Your app sets the new target to the next round amount above the campaign's sales, and then sends that amount to `analyticsTargetUpdate` as `expectedValue`.

***

[analytics​Target​Update](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsTargetUpdate)

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

```javascript
... (134 unchanged lines)
export async function raiseTarget(admin, target) {
  const actual = Number(target.actual ?? 0);
  const step = Math.max(1000, 10 ** (Math.floor(Math.log10(actual)) - 1));
  // The next increment above the amount. An exact multiple has to move too.
  const raised = String((Math.floor(actual / step) + 1) * step);


  const body = await (
    await admin.graphql(TARGET_UPDATE, {
      variables: {id: target.id, input: {expectedValue: raised}},
    })
  ).json();
  return [
    ...transportErrors(body),
    ...(body.data?.analyticsTargetUpdate?.userErrors ?? []),
  ];
}
... (398 unchanged lines)
```

## Annotate the campaign and activate it

An annotation is a dated note on the store's sales charts. When a user clicks **Activate campaign**, your app writes the annotations, targets, and metafields in one action.

### Write an annotation for each campaign mark

Use `analyticsAnnotationCreate` to write one annotation for each dated event in the campaign, such as the launch or a channel's send date. Take each `type` from the [annotation categories and types](https://shopify.dev/docs/api/shopifyql/latest/syntax/annotate#annotation-categories-and-types) reference.

The type sets the annotation's category. `ANNOTATE` draws annotations by category or by `category.type`.

Only the whole-campaign annotation has a duration. Pass `null` as the annotation's `endedAt` to keep the annotation open while the campaign runs.

Click **Activate campaign** on a `planned` campaign, and then open the store's sales report in the Shopify admin. The annotations appear on the date axis.

***

[analytics​Annotation​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationCreate) [Annotation categories and types](https://shopify.dev/docs/api/shopifyql/latest/syntax/annotate#annotation-categories-and-types)

## /app/campaigns.js

```javascript
... (181 unchanged lines)
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)}…`;
... (46 unchanged lines)
```

### Mark the day each QR code goes live

Add an annotation on each QR code's **Live on** day in `campaignMarks`. When a user clicks **Rewrite annotations**, your app reruns `writeMarks`. `writeMarks` deletes the campaign's planned annotations, and then writes the campaign's current marks. `writeMarks` doesn't delete milestone or incident annotations.

***

[analytics​Annotation​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationCreate) [analytics​Annotation​Delete](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationDelete)

## /app/campaigns.js

```javascript
... (32 unchanged lines)
// 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;
... (301 unchanged lines)
```

### Update an annotation in place

Use [`analyticsAnnotationUpdate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationUpdate) to rewrite an annotation's title and set `endedAt` in one call, without deleting and recreating the annotation. The mutation changes only the fields that you pass.

***

[analytics​Annotation​Update](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationUpdate)

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

```javascript
... (431 unchanged lines)
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
      }
    }
  }
`;
... (548 unchanged lines)
```

### Query the annotations

Query `analyticsAnnotations` on `shop` for the campaign's dates. The results include annotations from every app. Check `createdByApp` before you edit an annotation.

***

[Analytics​Annotation​Connection](https://shopify.dev/docs/api/admin-graphql/latest/connections/AnalyticsAnnotationConnection)

##### `analyticsAnnotationCreate` rejects a type that you found in the reference

Only the `ANNOTATE` clause takes the dotted `category.type` form. Pass the mutation the bare type name, such as `campaign`.

The mutation also rejects a type that's read-only for apps. Check the operations column in the annotation categories and types reference.

##### A mutation returns `NOT_FOUND` for an ID that you just read

The connection returns annotations from every installed app. The update and delete mutations reach only your app's annotations. For another app's annotation, the mutations return `NOT_FOUND`. Check `createdByApp` before you show an edit or delete control.

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

```javascript
... (478 unchanged lines)
// 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
        }
      }
    }
  }
`;
... (483 unchanged lines)
```

### Handle activation and cancellation in one action

Handle **Activate campaign** and **Cancel campaign** in the campaign page's route action. On activation, your app writes the annotations, targets, and metafields, and then sets the status to `running`.

On cancellation, your app reads the saved target and annotation IDs. Then your app deletes the annotations with [`analyticsAnnotationDelete`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationDelete) and the targets with [`analyticsTargetsDelete`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsTargetsDelete). `deleteTargets` sends the target IDs in batches of 250 or fewer.

***

* Save the target IDs in the `CampaignState` row before you change the status. The saved IDs limit a cancellation to this campaign's targets.
* When a user activates the campaign again after a failure, reuse the saved IDs and create only the missing targets.
* Block edits to the products, segment, and amounts while the `CampaignState` row stores target IDs. To change the products, segment, or amounts, the user cancels and replans the campaign.
* If Shopify rejects a target, then your app finishes the activation and lists the target under **Completed with warnings**.

***

[analytics​Target​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsTargetCreate) [analytics​Annotation​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationCreate) [metafields​Set](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldsSet)

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

```jsx
... (216 unchanged lines)
// One action for every write. The lock holds off a second write to the campaign until
// this one ends.
export async function action({request, params}) {
  const {admin: client, session, redirect} = await authenticate.admin(request);
  const admin = withReturnedErrors(client);
  const lock = await lockCampaign(session.shop, params.campaignId);
  if (lock.problems) return {problems: lock.problems.map((error) => error.message)};
  try {
    return await runIntent(admin, session.shop, request, params, redirect);
  } finally {
    await lock.release();
  }
}


async function runIntent(admin, shop, request, params, redirect) {
  const form = await request.formData();
  const intent = String(form.get('intent'));


  const [{appId, campaigns, failed}, shopBody] = await Promise.all([
    readCampaigns(admin, shop),
    admin.graphql(SHOP_QUERY).then((response) => response.json()),
  ]);
  // Redirecting on a failed read would report the campaign gone.
  if (failed.length) return {problems: failed.map((error) => error.message)};


  const campaign = findCampaign(campaigns, params.campaignId);
  if (!campaign) return redirect('/app/campaigns');


  const timeZone = shopBody.data?.shop?.ianaTimezone ?? 'Etc/UTC';


  // This writes annotation text, and the sales schema has no currency column. Take the
  // currency off the shop. Formatting on an unknown code throws.
  const money = (value) =>
    new Intl.NumberFormat('en', {
      style: 'currency',
      currency: shopBody.data?.shop?.currencyCode ?? 'USD',
      maximumFractionDigits: 0,
    }).format(value);


  const markedAt = (date) =>
    new Date(shopMorning(date, timeZone)).toISOString();


  // `en-CA` formats as YYYY-MM-DD. Every date on a record is already in that shape.
  const shopToday = new Intl.DateTimeFormat('en-CA', {
    timeZone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(new Date());


  // A failed activation leaves the campaign planned with its targets saved, and a retry
  // reuses them as they were. Edits wait for a cancel, which deletes them.
  const savedTargets = Object.keys(campaign.targets ?? {}).length > 0;
  const savedTargetsProblem = {
    problems: ['This campaign already has targets from an activation. Cancel the campaign, then make the change.'],
  };


  if (intent === 'set-products' || intent === 'set-audience') {
    if (campaign.status !== 'planned') {
      return {problems: ['Only planned campaigns can change products or segment.']};
    }
    if (savedTargets) return savedTargetsProblem;
  }


  if (intent === 'set-products') {
    const products = parseProducts(form.get('products'));
    if (products === null) {
      return {problems: ["Couldn't read the selected products. Select them again."]};
    }
    const {userErrors} = await setProducts(admin, campaign, products);
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'set-audience') {
    const audience = String(form.get('audience'));
    // 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 === audience)) {
      return {problems: ['Select a customer segment.']};
    }
    const {userErrors} = await setAudience(admin, campaign, audience);
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'set-targets') {
    // Activation turns the amounts into targets. Later edits would drift from them.
    if (campaign.status !== 'planned') {
      return {problems: ['Targets can change only before the campaign is activated.']};
    }
    if (savedTargets) return savedTargetsProblem;
    const amounts = {};
    // The form omits targets this campaign can't activate. An absent amount keeps its
    // saved value, and a blank one clears the target.
    for (const key of ['revenueTarget', 'productTarget', 'audienceTarget', 'qrTarget']) {
      if (!form.has(key)) continue;
      const value = String(form.get(key)).trim();
      if (value && !isTargetAmount(value)) {
        return {
          problems: ['Enter a target above zero, or leave it blank.'],
        };
      }
      amounts[key] = value;
    }
    const {userErrors} = await setTargetAmounts(admin, campaign, amounts);
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  // Read by the activate and close branches below.
  const dimensions = await readDimensions(admin, appId);


  if (intent === 'activate') {
    // Gate on the record's status. A target the user set on the same date isn't
    // evidence that this campaign is active.
    if (campaign.status !== 'planned') {
      return {
        problems: [
          `This campaign is ${statusLabel(campaign.status).toLowerCase()}. Only a planned campaign can be activated.`,
        ],
      };
    }


    const {markers, problems: unmarked} = await writeMarks(
      admin,
      campaign,
      appId,
      markedAt,
    );
    if (unmarked.length) return {problems: unmarked};


    // A failed activation left its target IDs on the record. Create only the missing
    // targets, or the retry makes a second set the app can never reach.
    const targets = {...(campaign.targets ?? {})};
    const rejected = [];
    const missing = [
      ['store', () => createStoreTarget(admin, campaign)],
      ['product', () => createProductTarget(admin, campaign)],
      ['audience', () => createAudienceTarget(admin, campaign, dimensions.customer)],
    ].filter(([key]) => !targets[key]);
    const results = await Promise.all(missing.map(([, create]) => create()));
    const created = {};
    missing.forEach(([key], index) => {
      if (results[index].id) created[key] = results[index].id;
      rejected.push(...results[index].userErrors);
    });
    const qr = await createQrTargets(admin, {
      ...campaign,
      qrCodes: campaign.qrCodes.filter(
        (qrCode) => !targets[`${QR_MARK_PREFIX}${qrCode.handle}`],
      ),
    });
    Object.assign(created, qr.targets);
    rejected.push(...qr.userErrors);


    // Save the IDs before the stamps run, and delete what was created if that
    // write fails.
    if (Object.keys(created).length) {
      Object.assign(targets, created);
      const {userErrors: unsaved} = await updateCampaign(admin, campaign, {
        targets,
      });
      if (unsaved.length) {
        const rollback = await deleteTargets(admin, Object.values(created));
        return {
          problems: [
            ...unsaved,
            ...transportErrors(rollback),
            ...(rollback.data?.analyticsTargetsDelete?.userErrors ?? []),
          ].map((error) => error.message),
        };
      }
    }


    const unstamped = await stampProducts(admin, campaign);
    const {userErrors: untiered} = await stampTier(admin, campaign, dimensions.customer);
    const {userErrors} = await updateCampaign(admin, campaign, {
      status: 'running',
      markers,
      targets,
    });
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }


    // A rejected target or stamp is a warning beside an activation that went through.
    const problems = [...rejected, ...unstamped, ...untiered].map(
      (error) => error.message,
    );
    return problems.length ? {ok: true, problems} : {ok: true};
  }


  // Replace what's on the store. Read the annotations this app wrote, delete them, and put
  // the current marks up in their place.
  if (intent === 'rewrite-marks') {
    // Closing moved the span's `endedAt` off null and put the outcome in the title.
    // Rewriting reopens that span. Offered while the campaign runs.
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can rewrite annotations.']};
    }
    const {markers, problems} = await writeMarks(
      admin,
      campaign,
      appId,
      markedAt,
    );
    if (problems.length) return {problems};


    const {userErrors} = await updateCampaign(admin, campaign, {markers});
    return userErrors.length
      ? {problems: userErrors.map((error) => error.message)}
      : {ok: true, rewrote: Object.keys(markers).filter(isPlannedMark).length};
  }


  if (intent === 'raise-target') {
    // Targets exist from activation to close. Raising one after the outcome measured it
    // would leave the recorded result and the target disagreeing.
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can raise targets.']};
    }
    const targetsBody = await readTargets(admin, campaign);
    const target = recordedTarget(campaign, targetsBody, form.get('targetId'));
    if (!target?.shopifyqlQuery) return {problems: ["Couldn't measure this target."]};


    const measured = await queryShopifyQL(admin, measureQuery(target.shopifyqlQuery));
    const actual = progressValue(measured.data?.shopifyqlQuery);
    // A raise is permanent. An unmeasured target would move the goal to the floor.
    if (actual === null) {
      return {problems: ["Couldn't measure this target. Reload and try again."]};
    }
    if (actual < Number(target.expectedValue)) {
      return {problems: ['This target isn\'t reached yet.']};
    }
    const userErrors = await raiseTarget(admin, {...target, actual});
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'milestone') {
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can mark milestones.']};
    }
    const day = String(form.get('day'));
    if (!inWindow(campaign, day)) {
      return {problems: ['Pick a day inside the campaign.']};
    }
    const targetsBody = await readTargets(admin, campaign);
    const target = recordedTarget(campaign, targetsBody, form.get('targetId'));
    if (!target) return {problems: ['That target no longer exists.']};


    // One mark per target per day. A second write would leave the first annotation
    // on the timeline with nothing pointing at it.
    const key = `milestone:${productId(target.id)}:${day}`;
    if (campaign.markers?.[key]) {
      return {problems: ['This target already has a milestone for this day.']};
    }


    const result = await markMilestone(admin, target, markedAt(day), money);
    if (result.userErrors?.length) {
      return {problems: result.userErrors.map((error) => error.message)};
    }
    // The saved ID is the handle a rewrite or a cancel needs. An unchecked write
    // leaves the annotation on the timeline with nothing pointing at it.
    const saved = await recordMark(
      admin,
      campaign,
      key,
      result.analyticsAnnotation?.id,
    );
    if (saved?.userErrors?.length) {
      return {problems: saved.userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'close') {
    // The outcome annotation is a closed campaign's permanent record. The status write
    // lands last, and a failed attempt stays retryable.
    if (campaign.status !== 'running') {
      return {problems: ['You can close only active campaigns.']};
    }
    if (shopToday < campaign.startDate) {
      return {problems: ["This campaign hasn't started. Cancel it instead."]};
    }
    // A campaign can close before its window runs out. Everything below reads the
    // window that ran.
    const closedOn =
      shopToday < campaign.endDate ? shopToday : campaign.endDate;
    const closing = {...campaign, endDate: closedOn};


    // Shorten the targets first, or they keep measuring days the campaign didn't run.
    // A target that can't move keeps its end date, and the close reports it.
    const {problems: stillOpen, warnings: kept} =
      closedOn < campaign.endDate
        ? await endTargetsOn(admin, campaign, closedOn)
        : {problems: [], warnings: []};
    if (stillOpen.length) {
      return {problems: stillOpen.map((error) => error.message)};
    }


    const targetsBody = await readTargets(admin, closing);


    // A failed read returns no nodes, the same shape as a deleted target. Closing
    // is permanent. Only the deleted case is allowed through.
    if (targetsBody.errors?.length) {
      return {problems: ["Couldn't read targets. Reload and close the campaign again."]};
    }


    // The ID activation recorded. A target the user made on the same date
    // measures the same days without being this campaign's.
    const store = targetNodes(targetsBody).find(
      (node) => node.id === campaign.targets?.store,
    );
    // Measure the days that ran. A one-day target's query ends a day later and would
    // count a day outside the campaign.
    const measured = await queryShopifyQL(
      admin,
      measureQuery(
        `FROM sales SHOW total_sales SINCE ${closing.startDate} UNTIL ${closedOn}`,
      ),
    );
    const revenue = progressValue(measured.data?.shopifyqlQuery);
    // Closing is permanent, and a failed read would record the campaign at zero.
    if (revenue === null) {
      return {problems: ["Couldn't measure sales. Reload and close the campaign again."]};
    }


    const text = outcomeText({
      campaign: closing,
      revenue,
      target: store?.expectedValue,
      sequence: [],
      money,
    });


    const errors = await closeCampaign(
      admin,
      campaign,
      text,
      markedAt(closedOn),
    );
    if (errors.length) return {problems: errors.map((error) => error.message)};


    // An early close leaves the planned marks for days that never ran. A recorded mark
    // the campaign's fields no longer produce never runs either.
    const ran = new Set(
      campaignMarks(campaign)
        .filter((mark) => mark.date <= closedOn)
        .map((mark) => mark.key),
    );
    const unrun = Object.keys(campaign.markers ?? {}).filter(
      (key) => isPlannedMark(key) && !ran.has(key),
    );
    const markers = {...campaign.markers};
    if (unrun.length) {
      const left = await clearOwnMarks(
        admin,
        unrun.map((key) => markers[key]),
        appId,
      );
      if (left.length) return {problems: left.map((error) => error.message)};
      for (const key of unrun) delete markers[key];
    }


    // Attribution needs the orders to exist, and close is the first point they do.
    // Skipped until the definition is a dimension.
    if (dimensions.order.enabled) {
      const started = await startStamping(admin, shop, {...closing, markers});
      if (started.problems) return started;
    }


    const {userErrors} = await updateCampaign(admin, campaign, {
      status: 'closed',
      endDate: closedOn,
      ...(unrun.length ? {markers} : {}),
    });
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return kept.length
      ? {ok: true, problems: kept.map((error) => error.message)}
      : {ok: true};
  }


  // A failed bulk operation leaves orders unstamped. Stamping again writes the same values.
  if (intent === 'stamp-orders') {
    if (campaign.status !== 'closed' || !dimensions.order.enabled) {
      return {problems: ['Only closed campaigns with order attribution can stamp orders.']};
    }
    const current = await readStamping(admin, campaign);
    if (current?.state === 'running') {
      return {problems: ['Orders are still being stamped. Wait for that to finish.']};
    }
    return startStamping(admin, shop, campaign);
  }


  if (intent === 'cancel') {
    // A closed campaign keeps its outcome on the store's charts. A cancel only takes
    // down a planned or running one.
    if (campaign.status !== 'planned' && campaign.status !== 'running') {
      return {problems: ['You can cancel only planned or active campaigns.']};
    }
    // Delete only the annotations this app created. Delete the targets by the IDs that
    // activation recorded.
    const undone = await clearOwnMarks(
      admin,
      Object.values(campaign.markers ?? {}),
      appId,
    );
    const kept = undone.map((error) => error.id);
    const markers = Object.fromEntries(
      Object.entries(campaign.markers ?? {}).filter(
        ([, id]) => kept.includes(null) || kept.includes(id),
      ),
    );


    const targets = {...campaign.targets};
    const read = await readTargets(admin, campaign);
    const unread = transportErrors(read);
    undone.push(...unread);
    const ours = unread.length ? [] : targetNodes(read);
    if (ours.length > 0) {
      const body = await deleteTargets(admin, ours.map((target) => target.id));
      const result = body.data?.analyticsTargetsDelete;
      const deletedIds = result?.deletedIds ?? [];
      const failed = [...transportErrors(body), ...(result?.userErrors ?? [])];
      if (!failed.length && ours.some((target) => !deletedIds.includes(target.id))) {
        failed.push({message: "Couldn't delete every target. Cancel the campaign again."});
      }
      undone.push(...failed);
      for (const deleted of deletedIds) {
        const key = Object.keys(targets).find((name) => targets[name] === deleted);
        if (key) delete targets[key];
      }
    }
    // Clearing the record on a failed delete abandons what's still on the timeline. The
    // IDs are the only handle the app has on it.
    if (undone.length) {
      const {userErrors} = await updateCampaign(admin, campaign, {
        markers,
        targets,
      });
      return {problems: [...undone, ...userErrors].map((error) => error.message)};
    }


    // Canceling undoes the annotations and the targets, and nothing else.
    const {userErrors} = await updateCampaign(admin, campaign, {
      status: 'canceled',
      markers: {},
      targets: {},
    });
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'replan') {
    // Activating records new target IDs over the old ones. Replanning a live campaign
    // would leave its targets on the store with nothing pointing at them.
    if (campaign.status !== 'canceled') {
      return {problems: ['You can replan only canceled campaigns.']};
    }
    // Canceling removed the annotations and targets. The promoted products keep their
    // `campaign` metafield, and the next activation writes it again.
    const {userErrors} = await updateCampaign(admin, campaign, {status: 'planned'});
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  return {problems: ['Unknown action.']};
}
... (674 unchanged lines)
```

### Lock the campaign during a write

Two requests can read the same `planned` campaign and both activate it. `lockCampaign` locks the campaign with a `CampaignLock` row in your app's database until the first request finishes. Take the lock before each form submission, and release the lock in a `finally` block.

## /app/campaigns.server.js

```javascript
... (654 unchanged lines)
// 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();
    },
  };
}
... (144 unchanged lines)
```

## Chart the campaign with annotations and web components

Annotations mark each campaign event on the sales chart, beside the day's sales.

### Check the annotations on the charts

The sales card query from part one already includes `COMPARE TO TARGETS` and `ANNOTATE ALL`. The QR code sales chart's query ends with an `ANNOTATE` clause.

Activate a campaign with a QR code, and then open the campaign. The sales chart shows the annotations, and the QR code chart shows each code's annotation on the **Live on** day.

***

[ANNOTATE](https://shopify.dev/docs/api/shopifyql/latest/syntax/annotate)

## /app/panels/sales.jsx

```jsx
... (47 unchanged lines)
// 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(' ');
}
... (281 unchanged lines)
```

### Render the key metrics with web components

Part one's `app/panels/sales.jsx` renders an `s-metrics-bar` above the chart. Inside the bar, the panel renders an `s-metrics-bar-date-picker` and one `s-shopifyql-metric-card` for each key metric.

When a user picks a range, the date picker returns the range as an alias, such as `last7days`. The panel maps each alias to a `SINCE` expression, and then rebuilds every card's query.

***

[Analytics web components](https://shopify.dev/docs/api/shopifyql/latest/web-components) [Metrics bar](https://shopify.dev/docs/api/shopifyql/latest/web-components/metrics-bar) [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)
```

### Link the chart to the store's report

Part one's `app/annotations.js` builds a `shopify://admin/analytics/reports/explore` link from the sales chart's query. The link's `ANNOTATE` clause lists the `category.type` of each annotation. The link opens the report under **Analytics** in the Shopify admin.

***

[ANNOTATE](https://shopify.dev/docs/api/shopifyql/latest/syntax/annotate)

## /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(', ');
}
... (27 unchanged lines)
```

## Group the campaign's sales by product and customer segment

Your app writes campaign values onto products, customers, and orders as metafields. You can group a report's sales by the `tier` and `campaign_touch` metafields after your app turns on analytics for each metafield definition. You can group a report's sales by the product `campaign` metafield after a user turns on analytics for the Campaign metaobject definition.

### Define the three app-reserved dimensions

Part one's `app/dimensions.server.js` defines one metafield definition for each resource:

* A `campaign` definition on products.
* A `tier` definition on customers.
* A `campaign_touch` definition on orders.

The three metafield definitions use the app-reserved namespace `$app:campaign_dashboard` with admin access set to `MERCHANT_READ`. Your app creates each definition when a user clicks **Create dimension** under **Analytics dimensions**.

`campaign` is a `metaobject_reference`. You can group a report's sales by a field on the referenced campaign entry when the field's type supports analytics, such as the `number_integer` field `campaign_id`.

**Note:**

After you create the `campaign` metafield definition, you can't change the metaobject definition that the metafield references.

## /app/dimensions.server.js

```javascript
... (15 unchanged lines)
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')};
}
... (78 unchanged lines)
```

### Make a metafield definition queryable

Set `capabilities.analyticsQueryable.enabled` to `true` on the tier and campaign touch definitions. Create the product definition without the analytics queryable capability, because the metaobject reference type doesn't support the capability.

Check `capabilities.analyticsQueryable.eligible` to confirm that a definition supports analytics. Eligibility depends on the metafield type and the owner. Customers, orders, products, and product variants are the supported owners.

***

[metafield​Definition​Create](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldDefinitionCreate) [metafield​Definition​Update](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldDefinitionUpdate) [Metafield definitions](https://shopify.dev/docs/apps/build/metafields/definitions)

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

```javascript
... (590 unchanged lines)
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
      }
    }
  }
`;
... (300 unchanged lines)
```

### Ask the user to turn on analytics

In API version 2026-10, apps can't turn on analytics for a metaobject definition. The user turns on **Filter or group data in Analytics** for the Campaign definition in the Shopify admin. The **Turn on analytics** link under **Analytics dimensions** opens the Campaign definition.

The setting takes effect about 30 seconds after the user saves. Until then, ShopifyQL returns `Metafield Column Not Found` for the product column.

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

```jsx
... (596 unchanged lines)
        <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>
... (24 unchanged lines)
```

### Stamp the promoted products and group the sales

On activation, write the campaign entry's GID to the `campaign` metafield on each promoted product. Send up to 25 products in each `metafieldsSet` call.

Each product references one campaign. If a later campaign promotes the same product, then your app replaces the reference.

**Promoted product sales** groups daily sales by the entry's `campaign_id` field:

## ShopifyQL

```shopifyql
FROM sales
  SHOW product.metafields."app--{app_id}--campaign_dashboard".campaign.campaign_id AS promoted_by,
    total_sales
  GROUP BY promoted_by
  TIMESERIES day
  SINCE 2026-07-22 UNTIL 2026-08-04
  ORDER BY day ASC
```

ShopifyQL returns `promoted_by` as a string, such as `"4210"`. Rows that match this campaign's ID are the promoted products. Every other row counts as the rest of the store.

***

[metafields​Set](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldsSet) [FROM](https://shopify.dev/docs/api/shopifyql/latest/syntax/from-and-show#as)

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

```javascript
... (311 unchanged lines)
export async function stampProducts(admin, campaign) {
  // The entry's GID. Analytics groups by the entry's fields of analytics-compatible types,
  // such as text, numbers, and dates.
  const metafields = campaign.products.map((product) => ({
    ownerId: product.id,
    namespace: DIMENSION_NAMESPACE,
    key: 'campaign',
    type: 'metaobject_reference',
    value: campaign.gid,
  }));


  const userErrors = [];
  for (let start = 0; start < metafields.length; start += 25) {
    const batch = await (
      await admin.graphql(METAFIELDS_SET, {
        variables: {metafields: metafields.slice(start, start + 25)},
      })
    ).json();
    userErrors.push(
      ...transportErrors(batch),
      ...(batch.data?.metafieldsSet?.userErrors ?? []),
    );
  }
  return userErrors;
}
... (212 unchanged lines)
```

### Rank the customer segment and filter the sales

[`CustomerSortKeys`](https://shopify.dev/docs/api/admin-graphql/latest/enums/CustomerSortKeys) has no spend key. To rank customers by spend, write a `sales` query that does the following:

* Groups by `customer_id`.
* Orders by `total_sales DESC`.
* Limits the result to the segment's size.

The **Regulars** segment skips the 10 **VIP** customers with `OFFSET 10`.

Write the segment to the `tier` metafield on each ranked customer. A rerank can push customers out of the segment. Query the `customers` schema for the tier's customers, 1,000 at a time with `OFFSET`. Delete the tier metafield from customers outside the new ranking.

All campaigns use the same tier metafield. When a user activates another **VIP** or **Regulars** campaign, your app reranks customers. The new ranking can change a running campaign's segment sales.

Filter the campaign's sales on `tier`:

## ShopifyQL

```shopifyql
FROM sales
  SHOW total_sales, orders
  WHERE customer.metafields."app--{app_id}--campaign_dashboard".tier = 'vip'
  TIMESERIES day
  SINCE 2026-07-22 UNTIL 2026-08-04
  ORDER BY day ASC
```

***

[LIMIT](https://shopify.dev/docs/api/shopifyql/latest/syntax/limit) [WHERE](https://shopify.dev/docs/api/shopifyql/latest/syntax/where)

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

```javascript
... (734 unchanged lines)
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
    }
  }
`;
... (217 unchanged lines)
```

### Change a `planned` campaign's segment

A user can change the segment while the campaign is `planned`. On activation, `stampTier` writes `tier` on the new segment's customers and removes `tier` from customers outside the new ranking.

Accept only segment values that your app defines. Your app builds each value into ShopifyQL queries and target filters as a string.

## /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';
```

## Close the campaign and record the revenue

After the campaign ends, rewrite the campaign's annotation with the campaign's revenue.

### Write the result onto the campaign's annotation

Measure the campaign's sales from the start date through the close date, and compare the sales with the store-wide target. Then send the new title, description, and end time in one `analyticsAnnotationUpdate` call.

For example, the title `Summer launch` becomes `Summer launch: 169% of target`. Without a target, the title becomes `Summer launch: $4,200 earned`.

***

* Truncate titles to 75 characters and descriptions to 150 characters.
* When a user closes a campaign early, end each target on the close date and delete the annotations planned for days after the close date. A target that starts on or after the close date, such as a QR code target with a later **Live on** date, ends the day after the target starts.

***

[analytics​Annotation​Update](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationUpdate)

### Split the campaign's revenue across the touches

A touch is a campaign event that reached the customer, such as a send, the creator drop, or the product promotion. After the campaign closes, write each order's last touch to the order's `campaign_touch` metafield.

**Order attribution** groups sales by touch:

## ShopifyQL

```shopifyql
FROM sales
  SHOW order.metafields."app--{app_id}--campaign_dashboard".campaign_touch AS touch,
    total_sales, orders
  GROUP BY touch
  SINCE 2026-07-22 UNTIL 2026-08-04
  ORDER BY total_sales DESC
```

***

* Group by `order_id` and `day` to date each order, and match each order to the last touch on or before the order's date.
* Filter with `excludes_post_order_adjustments = true`, `is_sales_reversal = false`, and `is_sale_adjustment = false`. Shopify adds a sales row on the day of each refund or edit. Without the filters, a refund or edit row can date a pre-campaign order inside the campaign.
* Page the query 250 orders at a time.
* The `write_orders` scope covers only orders created in the last 60 days.

***

[metafields​Set](https://shopify.dev/docs/api/admin-graphql/latest/mutations/metafieldsSet) [sales](https://shopify.dev/docs/api/shopifyql/latest/schemas/sales_revenue/sales#dimensions) [Orders permissions](https://shopify.dev/docs/api/usage/access-scopes#orders-permissions)

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

```javascript
... (781 unchanged lines)
// `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
    }
  }
`;
... (182 unchanged lines)
```

### Stamp the orders in a bulk operation

A campaign can span thousands of orders. Write the `campaign_touch` metafields in one bulk operation. Upload a JSONL file with up to 25 metafields on each line. Then pass `metafieldsSet` to [`bulkOperationRunMutation`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/bulkOperationRunMutation).

When a user closes the campaign, your app starts the operation and saves the operation's ID in your app's database. `OrderStamping` polls the stamping route every five seconds. The route reads only the bulk operation and doesn't count against the store's ShopifyQL limit.

Read the operation's result file and report each rejected line's `userErrors`. The user can click **Stamp orders again** to rerun the operation, which writes the same value to every order in the campaign.

***

[Bulk import data with the Graph​QL Admin API](https://shopify.dev/docs/apps/build/apis/graphql-admin/bulk-operations/imports) [bulk​Operation​Run​Mutation](https://shopify.dev/docs/api/admin-graphql/latest/mutations/bulkOperationRunMutation)

## /app/bulk-stamp.server.js

```javascript
... (3 unchanged lines)
// A bulk mutation reads its variables from a JSONL file uploaded to a staged target.
export const STAGE_BULK_VARIABLES = `
  mutation StageBulkVariables($input: [StagedUploadInput!]!) {
    stagedUploadsCreate(input: $input) {
      stagedTargets {
        url
        parameters {
          name
          value
        }
      }
      userErrors {
        field
        message
      }
    }
  }
`;


// Shopify runs this once for each line of the file, with the line as its variables.
export const BULK_METAFIELDS_SET = `mutation SetMetafieldsInBulk($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id } userErrors { field message code } } }`;


export const RUN_BULK_MUTATION = `
  mutation RunBulkMutation($mutation: String!, $stagedUploadPath: String!) {
    bulkOperationRunMutation(mutation: $mutation, stagedUploadPath: $stagedUploadPath) {
      bulkOperation {
        id
        status
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


// `metafieldsSet` takes up to 25 metafields per call, so each line holds up to 25.
export const METAFIELDS_PER_LINE = 25;


export function metafieldsJsonl(metafields) {
  const lines = [];
  for (let start = 0; start < metafields.length; start += METAFIELDS_PER_LINE) {
    lines.push(JSON.stringify({metafields: metafields.slice(start, start + METAFIELDS_PER_LINE)}));
  }
  return `${lines.join('\n')}\n`;
}


// Returns the operation's ID. The page polls it, and the request doesn't wait.
export async function startBulkMetafieldsSet(admin, metafields, filename) {
  if (!metafields.length) return {operationId: null, userErrors: []};


  const staged = await (
    await admin.graphql(STAGE_BULK_VARIABLES, {
      variables: {
        input: [{resource: 'BULK_MUTATION_VARIABLES', filename, mimeType: 'text/jsonl', httpMethod: 'POST'}],
      },
    })
  ).json();
  const target = staged.data?.stagedUploadsCreate?.stagedTargets?.[0];
  const unstaged = [...transportErrors(staged), ...(staged.data?.stagedUploadsCreate?.userErrors ?? [])];
  if (unstaged.length) return {operationId: null, userErrors: unstaged};
  if (!target?.url) {
    return {operationId: null, userErrors: [{message: "Shopify returned no upload target. Try again."}]};
  }


  // Every parameter goes into the form, and the file has to come last.
  const form = new FormData();
  for (const {name, value} of target.parameters) form.append(name, value);
  form.append('file', new Blob([metafieldsJsonl(metafields)], {type: 'text/jsonl'}), filename);
  const uploaded = await fetch(target.url, {method: 'POST', body: form}).catch((error) => ({
    ok: false,
    status: error.message,
  }));
  if (!uploaded.ok) {
    return {operationId: null, userErrors: [{message: `The upload failed (${uploaded.status}). Try again.`}]};
  }


  // The staged upload path is the `key` parameter.
  const stagedUploadPath = target.parameters.find((parameter) => parameter.name === 'key')?.value;
  const run = await (
    await admin.graphql(RUN_BULK_MUTATION, {
      variables: {mutation: BULK_METAFIELDS_SET, stagedUploadPath},
    })
  ).json();
  const result = run.data?.bulkOperationRunMutation;
  const userErrors = [...transportErrors(run), ...(result?.userErrors ?? [])];
  const operationId = userErrors.length ? null : (result?.bulkOperation?.id ?? null);
  if (!operationId && !userErrors.length) {
    userErrors.push({message: "Shopify didn't start the bulk operation. Try again."});
  }
  return {operationId, userErrors};
}
... (70 unchanged lines)
```

### Record the day a campaign meets a target

After sales reach the store-wide target, the user records the day with **Mark milestone**. Rerun the target's query to confirm that sales still meet the target, and then write a `revenue_milestone` annotation for today.

***

[Analytics​Annotation](https://shopify.dev/docs/api/admin-graphql/latest/objects/AnalyticsAnnotation) [shopifyql​Query](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyqlQuery)

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

```javascript
... (496 unchanged lines)
// A milestone is one moment. Leave `endedAt` off.
export async function markMilestone(admin, target, markedAt, money) {
  const body = await queryShopifyQL(admin, measureQuery(target.shopifyqlQuery));
  // A parse error has no value to compare. It isn't a target that fell short.
  const failed = shopifyqlErrors(body);
  if (failed.length) return {userErrors: failed};
  const actual = progressValue(body.data?.shopifyqlQuery);
  if (actual === null) return {userErrors: [{message: "Couldn't measure the target."}]};
  if (actual < Number(target.expectedValue)) {
    return {userErrors: [{message: 'Target not reached yet.'}]};
  }


  const created = await (
    await admin.graphql(ANNOTATION_CREATE, {
      variables: {
        input: {
          startedAt: markedAt,
          type: 'revenue_milestone',
          title: clamp(`${target.name} reached`, 75),
          description: `Reached ${money(actual)} against a ${money(
            Number(target.expectedValue),
          )} target.`,
        },
      },
    })
  ).json();


  const result = created.data?.analyticsAnnotationCreate;
  return {
    ...result,
    userErrors: [...transportErrors(created), ...(result?.userErrors ?? [])],
  };
}
... (19 unchanged lines)
```

### Run the app

Run the app on your development store:

## Terminal

```shell
shopify app dev
```

Under **Analytics dimensions**, click **Create dimension** on each row. On the product row, click **Turn on analytics**, and then turn on **Filter or group data in Analytics**.

Create a campaign with a **Store sales target** amount, and then click **Activate campaign**. The target cards show progress. The sales card shows the campaign's annotations.

Create a second campaign with no target amounts and a start date in the past. Your app closes only a campaign that has started. Click **Activate campaign** and **Close campaign**, and then confirm. The annotation's title changes to `<campaign title>: <amount> earned`.

## /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 = "write_reports,write_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/campaign-targets.server.js

```javascript
import {progressQuery, progressValue, targetNodes as readNodes} from './campaign-queries.server';
import {queryShopifyQL} from './shopifyql.server';


// Results read back by position. `measurable.indexOf` maps each result to its target.
export async function measureTargets(admin, campaign, targetsBody) {
  const recorded = new Set(Object.values(campaign.targets ?? {}));
  const targetNodes = readNodes(targetsBody).filter(
    (target) => recorded.has(target.id),
  );
  const measurable = targetNodes.filter((target) => target.shopifyqlQuery);
  const body = measurable.length
    ? await queryShopifyQL(admin, progressQuery(measurable))
    : {};
  const progress = body.data ?? {};
  // A throttled or failed read shows on every card. Silence reads as no progress.
  const unread = body.errors?.[0]?.message ?? null;


  return targetNodes.map((target) => {
    const index = measurable.indexOf(target);
    const actual = index === -1 ? null : progressValue(progress[`t${index}`]);
    const expected = Number(target.expectedValue);
    return {
      ...target,
      actual,
      percent:
        actual === null || expected <= 0
          ? null
          : Math.round((actual / expected) * 100),
      queryError:
        index === -1
          ? "Couldn't measure this target. Reload to try again."
          : (progress[`t${index}`]?.parseErrors?.[0] ??
            (actual === null ? unread : null)),
    };
  });
}
```

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

```javascript
import {
  ANNOTATION_CREATE,
  ANNOTATION_DELETE,
  ANNOTATION_UPDATE,
  BUNDLE_QUERY,
  HOLDERS_PAGE_SIZE,
  METAFIELDS_DELETE,
  METAFIELDS_SET,
  ORDER_PAGE_SIZE,
  RECORDED_ANNOTATIONS,
  TARGET_CREATE,
  TARGET_UPDATE,
  measureQuery,
  orderDaysQuery,
  progressValue,
  readNodes,
  readTargets,
  targetNodes,
  tierHoldersQuery,
  tierRankQuery,
} from './campaign-queries.server';
import {
  rememberMarkers,
  transportErrors,
  updateCampaign,
} from './campaigns.server';
import {startBulkMetafieldsSet} from './bulk-stamp.server';
import {queryShopifyQL, shopifyqlErrors} from './shopifyql.server';
import {
  DIMENSION_NAMESPACE,
  QR_MARK_PREFIX,
  campaignMarks,
  clamp,
  isPositiveAmount,
  lastTouch,
  promotedFilter,
  qrHandles,
  qrLiveOn,
  qrTitle,
  targetEnd,
  touchMarks,
} from './campaigns';
import {AUDIENCE_TIERS, TIER_METAFIELD, isTargetedTier, tierLabel} from './audiences';


// A skipped target and a rejected one both have no ID. The errors say which.
const NO_TARGET = {id: null, userErrors: []};


// Metric, dates, and filters identify a target. A duplicate returns TAKEN with the
// existing target, and that target belongs to someone else. Keep no ID beside an error.
async function createTarget(admin, input, measures) {
  const body = await (
    await admin.graphql(TARGET_CREATE, {
      variables: {input: {...input, endDate: targetEnd(input.startDate, input.endDate)}},
    })
  ).json();
  const created = body.data?.analyticsTargetCreate;
  const userErrors = [
    ...transportErrors(body),
    ...(created?.userErrors ?? []).map((error) =>
      error.code === 'TAKEN'
        ? {...error, message: `This campaign has no ${measures} target. Another target on the same dates already measures the same sales, and Shopify identifies a target by its metric, dates, and filters.`}
        : error,
    ),
  ];
  return {
    id: userErrors.length ? null : created?.analyticsTarget?.id ?? null,
    userErrors,
  };
}


// A blank amount is no target. Activation skips it.
export const createStoreTarget = (admin, campaign) =>
  isPositiveAmount(campaign.revenueTarget)
    ? createTarget(admin, {
        name: `${campaign.title}: store-wide sales`,
        metric: 'total_sales',
        expectedValue: campaign.revenueTarget,
        startDate: campaign.startDate,
        endDate: campaign.endDate,
      }, 'store-wide sales')
    : NO_TARGET;


export async function createProductTarget(admin, campaign) {
  const filters = promotedFilter(campaign);
  if (!filters || !isPositiveAmount(campaign.productTarget)) return NO_TARGET;


  return createTarget(admin, {
    name: `${campaign.title}: promoted products`,
    metric: 'total_sales',
    expectedValue: campaign.productTarget,
    startDate: campaign.startDate,
    endDate: campaign.endDate,
    filters,
  }, 'promoted products');
}


// Skipped until the definition is queryable.
export async function createAudienceTarget(admin, campaign, definition) {
  if (!isTargetedTier(campaign.audience)) return NO_TARGET;
  if (!definition.enabled) return NO_TARGET;
  if (!isPositiveAmount(campaign.audienceTarget)) return NO_TARGET;


  return createTarget(admin, {
    name: `${campaign.title}: ${tierLabel(campaign.audience)} segment`,
    metric: 'total_sales',
    expectedValue: campaign.audienceTarget,
    startDate: campaign.startDate,
    endDate: campaign.endDate,
    filters: `${definition.dimension} = '${campaign.audience}'`,
  }, `${tierLabel(campaign.audience)} segment`);
}


// One target per code, from its live day. The attribution metric credits an order to the
// code's utm_campaign, the handle the QR app wrote into the scan URL.
export async function createQrTargets(admin, campaign) {
  const targets = {};
  const userErrors = [];
  if (!isPositiveAmount(campaign.qrTarget)) return {targets, userErrors};


  for (const handle of qrHandles(campaign)) {
    const result = await createTarget(admin, {
      name: `${campaign.title}: ${qrTitle(campaign, handle)} QR code`,
      metric: 'campaign_last_non_direct_click_total_sales',
      expectedValue: campaign.qrTarget,
      startDate: qrLiveOn(campaign, handle),
      endDate: campaign.endDate,
      filters: `utm_campaign = '${handle}'`,
    }, `${qrTitle(campaign, handle)} QR code`);
    if (result.id) targets[`${QR_MARK_PREFIX}${handle}`] = result.id;
    userErrors.push(...result.userErrors);
  }
  return {targets, userErrors};
}


export async function raiseTarget(admin, target) {
  const actual = Number(target.actual ?? 0);
  const step = Math.max(1000, 10 ** (Math.floor(Math.log10(actual)) - 1));
  // The next increment above the amount. An exact multiple has to move too.
  const raised = String((Math.floor(actual / step) + 1) * step);


  const body = await (
    await admin.graphql(TARGET_UPDATE, {
      variables: {id: target.id, input: {expectedValue: raised}},
    })
  ).json();
  return [
    ...transportErrors(body),
    ...(body.data?.analyticsTargetUpdate?.userErrors ?? []),
  ];
}


// An early close moves each target's end to the close day. Shopify rebuilds each query.
// A code not yet live keeps a one-day target. A target has to end after it starts.
export async function endTargetsOn(admin, campaign, day) {
  const read = await readTargets(admin, campaign);
  const problems = [...transportErrors(read)];
  const warnings = [];
  for (const target of problems.length ? [] : targetNodes(read)) {
    const endDate = targetEnd(target.startDate, day);
    const body = await (
      await admin.graphql(TARGET_UPDATE, {
        variables: {id: target.id, input: {endDate}},
      })
    ).json();
    const failed = transportErrors(body);
    const userErrors = body.data?.analyticsTargetUpdate?.userErrors ?? [];
    // TAKEN means another target already has this metric, start, end, and filters.
    // The target keeps its end date, and the close goes on without it.
    const taken = userErrors.length && userErrors.every((error) => error.code === 'TAKEN');
    if (!failed.length && taken) {
      warnings.push({
        message: `${target.name} keeps its end date of ${target.endDate}. Another target already measures the same sales from ${target.startDate} to ${endDate}.`,
      });
      continue;
    }
    problems.push(...failed, ...userErrors);
  }
  return {problems, warnings};
}


// A form can name any target ID. Match against the recorded ones before writing.
export function recordedTarget(campaign, body, id) {
  const recorded = new Set(Object.values(campaign.targets ?? {}));
  return targetNodes(body).find(
    (node) => node.id === String(id) && recorded.has(node.id),
  );
}


// A bundle needs `bundle_launch`.
const PLAIN_LAUNCH = {type: 'product_launch', note: ''};


async function resolveLaunchMark(admin, campaign) {
  const ids = campaign.products.map((product) => product.id);
  if (ids.length === 0) return PLAIN_LAUNCH;


  const body = await (
    await admin.graphql(BUNDLE_QUERY, {variables: {ids}})
  ).json();
  const bundles = (body.data?.nodes ?? []).filter(
    (node) => node?.hasVariantsThatRequiresComponents,
  );
  if (bundles.length === 0) return PLAIN_LAUNCH;


  return {
    type: 'bundle_launch',
    note: ` Sold as a bundle. Sales report against its components.`,
  };
}


// By recorded ID. A date-window search misses marks after a date edit, and the rewrite
// doubles them. Each problem names the annotation it left. A failed read names none.
export async function clearOwnMarks(admin, ids, appId) {
  const recorded = ids.filter(
    (id) => typeof id === 'string' && id.startsWith('gid://shopify/AnalyticsAnnotation/'),
  );
  if (!recorded.length) return [];
  const body = await readNodes(admin, RECORDED_ANNOTATIONS, 'annotations', recorded);
  const failed = transportErrors(body);
  if (failed.length) return failed.map((error) => ({...error, id: null}));


  const problems = [];
  for (const annotation of body.data?.annotations ?? []) {
    if (annotation?.createdByApp?.id !== appId) continue;
    const deleted = await (
      await admin.graphql(ANNOTATION_DELETE, {variables: {id: annotation.id}})
    ).json();
    const errors = [
      ...transportErrors(deleted),
      ...(deleted.data?.analyticsAnnotationDelete?.userErrors ?? []),
    ];
    problems.push(...errors.map((error) => ({...error, id: annotation.id})));
  }
  return problems;
}


// Milestones and incidents each mark a day that happened. Every other key holds a mark
// the campaign's fields produced.
export const isPlannedMark = (key) =>
  !key.startsWith('milestone:') && !key.startsWith('incident:');


// Clear before writing. Activating and rewriting become the same operation.
// Each ID persists as it lands. A retry or a cancel can then finish a partial write.
export async function writeMarks(admin, campaign, appId, markedAt) {
  // A rewrite keeps milestones and incidents. It clears every planned mark, including
  // one whose field changed since the last write, like a reassigned code or removed products.
  const marks = campaignMarks(campaign);
  const keys = new Set([
    ...marks.map((mark) => mark.key),
    ...Object.keys(campaign.markers ?? {}).filter(isPlannedMark),
  ]);
  const stale = await clearOwnMarks(
    admin,
    Object.entries(campaign.markers ?? {})
      .filter(([key]) => keys.has(key))
      .map(([, id]) => id),
    appId,
  );
  if (stale.length) {
    return {markers: campaign.markers ?? {}, problems: stale.map((error) => error.message)};
  }


  // Save the pruned record first. A create that fails next leaves no deleted IDs behind.
  const markers = Object.fromEntries(
    Object.entries(campaign.markers ?? {}).filter(([key]) => !keys.has(key)),
  );
  const pruned = await updateCampaign(admin, campaign, {markers});
  if (pruned.userErrors.length) {
    return {markers, problems: pruned.userErrors.map((error) => error.message)};
  }


  const launch = await resolveLaunchMark(admin, campaign);
  for (const mark of marks) {
    const isLaunch = mark.key === 'launch';
    const body = await (
      await admin.graphql(ANNOTATION_CREATE, {
        variables: {
          input: {
            startedAt: markedAt(mark.date),
            // Omitted `endedAt` is a moment. Explicit null is an unfinished span.
            ...(mark.ranged ? {endedAt: null} : {}),
            type: isLaunch ? launch.type : mark.type,
            title: mark.title,
            description: isLaunch
              ? `${mark.description}${launch.note}`
              : mark.description,
          },
        },
      })
    ).json();
    const created = body.data?.analyticsAnnotationCreate;
    const errors = [...transportErrors(body), ...(created?.userErrors ?? [])];
    if (errors.length) {
      return {markers, problems: errors.map((error) => error.message)};
    }
    const id = created?.analyticsAnnotation?.id;
    if (id) {
      const saved = await rememberMarkers(campaign, {[mark.key]: id});
      // An ID the record doesn't hold is an annotation nothing can find again.
      if (saved.userErrors.length) {
        const orphaned = await clearOwnMarks(admin, [id], appId);
        return {
          markers,
          problems: [...saved.userErrors, ...orphaned].map((error) => error.message),
        };
      }
      markers[mark.key] = id;
    }
  }
  return {markers, problems: []};
}


export async function stampProducts(admin, campaign) {
  // The entry's GID. Analytics groups by the entry's fields of analytics-compatible types,
  // such as text, numbers, and dates.
  const metafields = campaign.products.map((product) => ({
    ownerId: product.id,
    namespace: DIMENSION_NAMESPACE,
    key: 'campaign',
    type: 'metaobject_reference',
    value: campaign.gid,
  }));


  const userErrors = [];
  for (let start = 0; start < metafields.length; start += 25) {
    const batch = await (
      await admin.graphql(METAFIELDS_SET, {
        variables: {metafields: metafields.slice(start, start + 25)},
      })
    ).json();
    userErrors.push(
      ...transportErrors(batch),
      ...(batch.data?.metafieldsSet?.userErrors ?? []),
    );
  }
  return userErrors;
}


// GROUP BY customer_id returns a numeric ID. Every mutation below wants a GID.
async function customerIds(admin, query) {
  const body = await queryShopifyQL(admin, query);
  const userErrors = shopifyqlErrors(body);
  const rows = body.data?.shopifyqlQuery?.tableData?.rows ?? [];
  const ids = rows
    .map((row) => row.customer_id)
    .filter(Boolean)
    .map((id) => `gid://shopify/Customer/${id}`);
  return {ids: userErrors.length ? [] : ids, rowCount: rows.length, userErrors};
}


// A tier can outgrow one page. A page shorter than the limit is the last one. The cap
// stops a query that keeps returning full pages.
const MAX_PAGES = 200;
const tooManyPages = {message: `Stopped after ${MAX_PAGES} pages. Narrow the campaign and try again.`};


async function tierHolders(admin, dimension, tier) {
  const ids = [];
  for (let offset = 0; offset < MAX_PAGES * HOLDERS_PAGE_SIZE; offset += HOLDERS_PAGE_SIZE) {
    const page = await customerIds(admin, tierHoldersQuery(dimension, tier, offset));
    if (page.userErrors.length) return {ids: [], userErrors: page.userErrors};
    ids.push(...page.ids);
    if (page.rowCount < HOLDERS_PAGE_SIZE) return {ids, userErrors: []};
  }
  return {ids: [], userErrors: [tooManyPages]};
}


// A re-rank moves customers out of the tier. ShopifyQL finds who holds the tier now,
// and the stamps come off everyone outside the new rank.
export async function stampTier(admin, campaign, definition) {
  const tier = AUDIENCE_TIERS.find((entry) => entry.value === campaign.audience);
  if (!tier?.size) return {userErrors: []};


  // A failed rank read finds no customers. Returning no errors would read as a tier
  // with nobody in it.
  const ranked = await customerIds(admin, tierRankQuery(tier));
  if (ranked.userErrors.length) return {userErrors: ranked.userErrors};


  if (ranked.ids.length) {
    const stamped = await (
      await admin.graphql(METAFIELDS_SET, {
        variables: {
          metafields: ranked.ids.map((id) => ({
            ownerId: id,
            namespace: DIMENSION_NAMESPACE,
            key: TIER_METAFIELD.key,
            type: 'single_line_text_field',
            value: campaign.audience,
          })),
        },
      })
    ).json();
    const unstamped = [
      ...transportErrors(stamped),
      ...(stamped.data?.metafieldsSet?.userErrors ?? []),
    ];
    if (unstamped.length) return {userErrors: unstamped};
  }


  // Until the tier is queryable, ShopifyQL can't find its holders. A later activation clears them.
  if (!definition.enabled) return {userErrors: []};
  const holders = await tierHolders(admin, definition.dimension, campaign.audience);
  const dropped = holders.ids.filter((id) => !ranked.ids.includes(id));
  if (!dropped.length) return {userErrors: holders.userErrors};


  // A failed clear leaves the stamps in place. The next activation in this tier retries.
  const cleared = await (
    await admin.graphql(METAFIELDS_DELETE, {
      variables: {
        metafields: dropped.map((id) => ({
          ownerId: id,
          namespace: DIMENSION_NAMESPACE,
          key: TIER_METAFIELD.key,
        })),
      },
    })
  ).json();
  return {
    userErrors: [
      ...transportErrors(cleared),
      ...(cleared.data?.metafieldsDelete?.userErrors ?? []),
    ],
  };
}


// A store can have thousands of orders in the window, so one bulk operation writes the
// stamps. The close request starts it and returns. The campaign page polls it.
export async function stampOrders(admin, campaign) {
  // The shortened window moves a later send onto the close date. Only a recorded mark
  // reached customers.
  const touches = touchMarks(campaign).filter((mark) => campaign.markers?.[mark.key]);
  // The query skips refund and edit rows. An order placed before the window has no
  // row, and each remaining order's first row is the day it was placed.
  const stamped = new Set();
  const metafields = [];
  for (let offset = 0; ; offset += ORDER_PAGE_SIZE) {
    if (offset >= MAX_PAGES * ORDER_PAGE_SIZE) return {operationId: null, count: 0, userErrors: [tooManyPages]};
    const body = await queryShopifyQL(admin, orderDaysQuery(campaign, offset));
    const unread = shopifyqlErrors(body);
    if (unread.length) return {operationId: null, count: 0, userErrors: unread};


    const rows = body.data?.shopifyqlQuery?.tableData?.rows ?? [];
    metafields.push(
      ...rows
        .map((row) => ({id: row.order_id, touch: lastTouch(touches, row.day)}))
        .filter((order) => order.id && !stamped.has(order.id) && stamped.add(order.id))
        .filter((order) => order.touch)
        .map((order) => ({
          ownerId: `gid://shopify/Order/${order.id}`,
          namespace: DIMENSION_NAMESPACE,
          key: 'campaign_touch',
          type: 'single_line_text_field',
          value: order.touch,
        })),
    );
    // A page shorter than the limit is the last one.
    if (rows.length < ORDER_PAGE_SIZE) break;
  }


  // No orders to stamp starts no operation.
  const started = await startBulkMetafieldsSet(
    admin,
    metafields,
    `campaign-${campaign.id}-order-stamps.jsonl`,
  );
  return {...started, count: metafields.length};
}


// Editable until activation. The target filters on these IDs and the annotation uses the
// titles.
export const setProducts = (admin, campaign, products) =>
  updateCampaign(admin, campaign, {products});


// Activation turns an amount into a target. A blank amount is no target.
export const setTargetAmounts = (admin, campaign, amounts) =>
  updateCampaign(admin, campaign, amounts);


export const setAudience = (admin, campaign, audience) =>
  updateCampaign(admin, campaign, {audience});


export async function closeCampaign(admin, campaign, text, endedAt) {
  const id = campaign.markers?.campaign;
  // No span means nothing to close. Returning no errors would read as success.
  if (!id) {
    return [{message: 'No campaign annotation to close. Rewrite annotations, then close.'}];
  }


  const body = await (
    await admin.graphql(ANNOTATION_UPDATE, {
      variables: {id, input: {endedAt, title: text.title, description: text.description}},
    })
  ).json();
  return [
    ...transportErrors(body),
    ...(body.data?.analyticsAnnotationUpdate?.userErrors ?? []),
  ];
}


// A milestone is one moment. Leave `endedAt` off.
export async function markMilestone(admin, target, markedAt, money) {
  const body = await queryShopifyQL(admin, measureQuery(target.shopifyqlQuery));
  // A parse error has no value to compare. It isn't a target that fell short.
  const failed = shopifyqlErrors(body);
  if (failed.length) return {userErrors: failed};
  const actual = progressValue(body.data?.shopifyqlQuery);
  if (actual === null) return {userErrors: [{message: "Couldn't measure the target."}]};
  if (actual < Number(target.expectedValue)) {
    return {userErrors: [{message: 'Target not reached yet.'}]};
  }


  const created = await (
    await admin.graphql(ANNOTATION_CREATE, {
      variables: {
        input: {
          startedAt: markedAt,
          type: 'revenue_milestone',
          title: clamp(`${target.name} reached`, 75),
          description: `Reached ${money(actual)} against a ${money(
            Number(target.expectedValue),
          )} target.`,
        },
      },
    })
  ).json();


  const result = created.data?.analyticsAnnotationCreate;
  return {
    ...result,
    userErrors: [...transportErrors(created), ...(result?.userErrors ?? [])],
  };
}


// Milestones and incidents repeat. Their keys hold what makes them distinct.
// An ID the record doesn't hold is an annotation nothing can find again.
export async function recordMark(admin, campaign, key, id) {
  if (!id) return null;
  const saved = await rememberMarkers(campaign, {[key]: id});
  if (!saved.userErrors.length) return saved;
  const deleted = await (
    await admin.graphql(ANNOTATION_DELETE, {variables: {id}})
  ).json();
  return {
    campaign: null,
    userErrors: [
      ...saved.userErrors,
      ...transportErrors(deleted),
      ...(deleted.data?.analyticsAnnotationDelete?.userErrors ?? []),
    ],
  };
}
```

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

```javascript
import {CHANNELS} from './channels';
import {clamp} from './campaigns';


export function outcomeText({campaign, revenue, target, sequence, money}) {
  const goal = Number(target ?? 0);
  const percent = goal > 0 ? Math.round((revenue / goal) * 100) : null;


  const title =
    percent === null
      ? `${campaign.title}: ${money(revenue)} earned`
      : `${campaign.title}: ${percent}% of target`;


  const sentences = [
    goal > 0
      ? `Earned ${money(revenue)} against a ${money(goal)} target`
      : `Earned ${money(revenue)}`,
  ];


  // The strongest send, named with its channel. First against last would compare an
  // email open rate to an SMS click rate and report the channel as the change.
  const best = sequence.reduce(
    (top, touch) => (!top || touch.rate > top.rate ? touch : top),
    null,
  );
  if (best && sequence.length > 1) {
    sentences.push(
      `Top send: ${best.name} on ${
        CHANNELS[best.channel].label
      }, ${Math.round(best.rate)}% engagement`,
    );
  }


  return {
    title: clamp(title, 75),
    description: clamp(`${sentences.join('. ')}.`, 150),
  };
}
```

## /app/bulk-stamp.server.js

```javascript
import {clearStampOperation} from './campaign-state.server';
import {transportErrors} from './campaigns.server';


// A bulk mutation reads its variables from a JSONL file uploaded to a staged target.
export const STAGE_BULK_VARIABLES = `
  mutation StageBulkVariables($input: [StagedUploadInput!]!) {
    stagedUploadsCreate(input: $input) {
      stagedTargets {
        url
        parameters {
          name
          value
        }
      }
      userErrors {
        field
        message
      }
    }
  }
`;


// Shopify runs this once for each line of the file, with the line as its variables.
export const BULK_METAFIELDS_SET = `mutation SetMetafieldsInBulk($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id } userErrors { field message code } } }`;


export const RUN_BULK_MUTATION = `
  mutation RunBulkMutation($mutation: String!, $stagedUploadPath: String!) {
    bulkOperationRunMutation(mutation: $mutation, stagedUploadPath: $stagedUploadPath) {
      bulkOperation {
        id
        status
      }
      userErrors {
        field
        message
        code
      }
    }
  }
`;


// `metafieldsSet` takes up to 25 metafields per call, so each line holds up to 25.
export const METAFIELDS_PER_LINE = 25;


export function metafieldsJsonl(metafields) {
  const lines = [];
  for (let start = 0; start < metafields.length; start += METAFIELDS_PER_LINE) {
    lines.push(JSON.stringify({metafields: metafields.slice(start, start + METAFIELDS_PER_LINE)}));
  }
  return `${lines.join('\n')}\n`;
}


// Returns the operation's ID. The page polls it, and the request doesn't wait.
export async function startBulkMetafieldsSet(admin, metafields, filename) {
  if (!metafields.length) return {operationId: null, userErrors: []};


  const staged = await (
    await admin.graphql(STAGE_BULK_VARIABLES, {
      variables: {
        input: [{resource: 'BULK_MUTATION_VARIABLES', filename, mimeType: 'text/jsonl', httpMethod: 'POST'}],
      },
    })
  ).json();
  const target = staged.data?.stagedUploadsCreate?.stagedTargets?.[0];
  const unstaged = [...transportErrors(staged), ...(staged.data?.stagedUploadsCreate?.userErrors ?? [])];
  if (unstaged.length) return {operationId: null, userErrors: unstaged};
  if (!target?.url) {
    return {operationId: null, userErrors: [{message: "Shopify returned no upload target. Try again."}]};
  }


  // Every parameter goes into the form, and the file has to come last.
  const form = new FormData();
  for (const {name, value} of target.parameters) form.append(name, value);
  form.append('file', new Blob([metafieldsJsonl(metafields)], {type: 'text/jsonl'}), filename);
  const uploaded = await fetch(target.url, {method: 'POST', body: form}).catch((error) => ({
    ok: false,
    status: error.message,
  }));
  if (!uploaded.ok) {
    return {operationId: null, userErrors: [{message: `The upload failed (${uploaded.status}). Try again.`}]};
  }


  // The staged upload path is the `key` parameter.
  const stagedUploadPath = target.parameters.find((parameter) => parameter.name === 'key')?.value;
  const run = await (
    await admin.graphql(RUN_BULK_MUTATION, {
      variables: {mutation: BULK_METAFIELDS_SET, stagedUploadPath},
    })
  ).json();
  const result = run.data?.bulkOperationRunMutation;
  const userErrors = [...transportErrors(run), ...(result?.userErrors ?? [])];
  const operationId = userErrors.length ? null : (result?.bulkOperation?.id ?? null);
  if (!operationId && !userErrors.length) {
    userErrors.push({message: "Shopify didn't start the bulk operation. Try again."});
  }
  return {operationId, userErrors};
}


export const BULK_OPERATION = `
  query StampOperation($id: ID!) {
    bulkOperation(id: $id) {
      id
      status
      errorCode
      objectCount
      url
    }
  }
`;


const RUNNING = new Set(['CREATED', 'RUNNING', 'CANCELING']);


// A completed operation can still reject lines. Each output line holds that line's
// `userErrors`, or top-level `errors` for variables that didn't fit the mutation.
async function rejectedLines(url) {
  const response = await fetch(url).catch(() => null);
  if (!response?.ok) return [{message: "Couldn't read the bulk operation's results. Reload to try again."}];
  return (await response.text())
    .split('\n')
    .filter(Boolean)
    .flatMap((line) => {
      try {
        const result = JSON.parse(line);
        return [...(result.errors ?? []), ...(result.data?.metafieldsSet?.userErrors ?? [])];
      } catch {
        return [{message: "A line in the bulk operation's results didn't parse."}];
      }
    })
    .map((error) => ({message: error.message}));
}


// `running`, `done`, `failed`, or `unread`. Only `failed` offers a retry.
export async function readBulkOperation(admin, id) {
  const body = await (await admin.graphql(BULK_OPERATION, {variables: {id}})).json();
  const unread = transportErrors(body);
  if (unread.length) return {state: 'unread', objectCount: 0, problems: unread};


  const operation = body.data?.bulkOperation;
  if (!operation) {
    return {state: 'failed', objectCount: 0, problems: [{message: 'Shopify has no record of the bulk operation.'}]};
  }
  const objectCount = Number(operation.objectCount ?? 0);
  if (RUNNING.has(operation.status)) return {state: 'running', objectCount, problems: []};
  if (operation.status !== 'COMPLETED') {
    const reason = operation.errorCode ? ` (${operation.errorCode})` : '';
    return {
      state: 'failed',
      objectCount,
      problems: [{message: `The bulk operation ended ${operation.status.toLowerCase()}${reason}.`}],
    };
  }
  // A completed operation with no input lines has no output file.
  const problems = operation.url ? await rejectedLines(operation.url) : [];
  return {state: problems.length ? 'failed' : 'done', objectCount, problems};
}


// A clean finish clears the record, and the next load shows nothing. A failure stays for a retry.
export async function readStamping(admin, campaign) {
  if (campaign.status !== 'closed' || !campaign.stampOperation) return null;
  const stamping = await readBulkOperation(admin, campaign.stampOperation);
  if (stamping.state === 'done') {
    await clearStampOperation(campaign.shop, campaign.id, campaign.stampOperation).catch((error) =>
      console.error('Could not clear the stamp operation', error),
    );
  }
  return stamping;
}
```

## /app/panels/targets.jsx

```jsx
import {inWindow, promotedNames, qrTitle} from '../campaigns';
import {tierLabel} from '../audiences';
import {exploreLink} from '../annotations';


import {ConfirmButton} from './confirm';
import {PanelHeader} from './shared';
import {useMetricCardsReady} from './sales';


// A running total from the target's first day. ANNOTATE ALL adds the store's annotations,
// and COMPARE TO TARGETS matches a target only when its base metric is visualized.
export function targetChartQuery(target) {
  // QR targets measure a `campaign_sales` metric, which `sales` doesn't have.
  const from =
    target.shopifyqlQuery?.match(/^\s*FROM (\w+)/)?.[1] ??
    (target.metric.startsWith('campaign_') ? 'campaign_sales' : 'sales');
  return [
    `FROM ${from}`,
    `SHOW ${target.metric}`,
    target.filters ? `WHERE ${target.filters}` : null,
    'TIMESERIES day WITH CUMULATIVE_VALUES',
    `SINCE ${target.startDate} UNTIL ${target.endDate}`,
    'COMPARE TO TARGETS',
    `VISUALIZE ${target.metric} TYPE bar, ${target.metric}__cumulative TYPE line ANNOTATE ALL`,
  ]
    .filter(Boolean)
    .join(' ');
}


function TargetCard({target, scope, by, format, cardReady}) {
  const {percent, actual, expectedValue, queryError} = target;
  const met = percent !== null && percent >= 100;
  const query = targetChartQuery(target);
  // A QR code's chart stays blank until an order arrives through its link.
  const noSales = !actual && target.filters?.startsWith('utm_campaign');
  return (
    <s-box padding="base" background="subdued" borderRadius="base" blockSize="100%">
      <s-stack direction="block" gap="base" blockSize="100%" justifyContent="space-between">
        <s-stack direction="block" gap="small-200">
          <s-stack direction="inline" gap="small-200" alignItems="center" justifyContent="space-between">
            <s-text color="subdued">{scope}</s-text>
            <s-badge tone={met ? 'success' : 'info'}>
              {percent === null ? 'Not measured' : `${percent}%`}
            </s-badge>
          </s-stack>
          <s-text type="strong">
            {actual === null
              ? `Target ${format(expectedValue)} by ${by}`
              : `${format(actual)} of ${format(expectedValue)} by ${by}`}
          </s-text>


          {/* The card fills its container's height. */}
          <s-box blockSize="200px">
            {noSales ? (
              <s-paragraph color="subdued">
                No sales from this code yet. Sales appear here after customers check out through
                the code’s link.
              </s-paragraph>
            ) : cardReady ? (
              <s-shopifyql-metric-card
                heading={scope}
                query={query}
                summary="none"
                reportLinkDisabled
              />
            ) : (
              <s-spinner accessibilityLabel="Loading the target chart" />
            )}
          </s-box>


          {queryError && (
            <s-banner tone="warning">
              <s-paragraph>{queryError}</s-paragraph>
            </s-banner>
          )}
        </s-stack>


        <s-link
          href={exploreLink(query)}
          target="_blank"
          accessibilityLabel={`Open ${scope} in Analytics`}
        >
          Open in Analytics
        </s-link>
      </s-stack>
    </s-box>
  );
}


function targetScope(target, campaign, dimensions) {
  if (!target.filters) return 'Total sales';
  if (target.filters.startsWith('product_id')) {
    const names = promotedNames(campaign);
    return names ? `Sales of ${names}` : 'Sales of the promoted products';
  }
  const qrHandle = target.filters.match(/^utm_campaign = '([a-z0-9_-]+)'$/)?.[1];
  if (qrHandle) return `Sales from the ${qrTitle(campaign, qrHandle)} QR code`;
  if (target.filters.includes(dimensions.customer.key)) {
    return `Sales to ${tierLabel(campaign.audience)} customers`;
  }
  return 'Filtered sales';
}


export function splitTargets(targets) {
  return {
    storeTarget: targets.find((target) => !target.filters) ?? null,
    scopedTargets: targets.filter((target) => target.filters),
  };
}


export function TargetsPanel({
  targets,
  storeTarget,
  scopedTargets,
  campaign,
  dimensions,
  pace,
  running,
  busy,
  submit,
  today,
  currency,
  shortDate,
}) {
  const cardReady = useMetricCardsReady();
  if (targets.length === 0) return null;
  // Once the window ends, the pace is the result.
  const onPace =
    pace &&
    (pace.left === 0 ? (storeTarget?.percent ?? 0) >= 100 : pace.perDay >= pace.needed);
  const reached = pace && pace.left > 0 && pace.needed === 0;


  const card = (target) => (
    <TargetCard
      key={target.id}
      target={target}
      scope={targetScope(target, campaign, dimensions)}
      by={shortDate(target.endDate ?? campaign.endDate)}
      format={currency}
      cardReady={cardReady}
    />
  );


  return (
    <s-section>
      <PanelHeader
        title="Targets"
        uses={{
          targets:
            'Every target here is a record on the store, created by this app.',
          shopifyql:
            "Each chart runs its target's filter as a running total, with COMPARE TO TARGETS and ANNOTATE ALL.",
        }}
      />
      <s-paragraph>
        Each line is the running total since its target started, against the target.
        Each chart marks the store’s annotations, including the day each QR code went
        live.
      </s-paragraph>


      <s-stack direction="block" gap="base">
        {/* What the store target needs per day against what it's averaged. The
            percentage alone can't say whether it's behind. */}
        {storeTarget && pace && (
          <s-stack direction="inline" gap="base" alignItems="center">
            <s-badge
              icon={onPace ? 'check-circle' : 'clock'}
              tone={onPace ? 'success' : 'info'}
            >
              {pace.left === 0
                ? 'Ended'
                : reached
                  ? 'Target reached'
                  : onPace
                    ? 'On pace'
                    : 'Behind pace'}
            </s-badge>
            <s-text color="subdued">
              {pace.left === 0
                ? `Averaged ${currency(pace.perDay)} a day.`
                : reached
                  ? `Averaging ${currency(pace.perDay)} a day.`
                  : `Averaging ${currency(pace.perDay)} a day. The store target needs ${currency(
                    pace.needed,
                  )} a day for the ${pace.left} ${pace.left === 1 ? 'day' : 'days'} left.`}
            </s-text>
          </s-stack>
        )}
        {running && storeTarget?.percent !== null && storeTarget?.percent >= 100 && (
          <s-stack direction="inline" gap="base">
            <s-button
              loading={busy === 'milestone'}
              disabled={Boolean(busy) || !inWindow(campaign, today)}
              onClick={() => submit('milestone', {targetId: storeTarget.id, day: today})}
            >
              Mark milestone
            </s-button>
            <ConfirmButton
              id="raise-target"
              label="Raise target"
              heading="Raise the store target?"
              body="The store target moves to the next round amount above sales so far. You can't lower it again from the app."
              confirmLabel="Raise target"
              loading={busy === 'raise-target'}
              disabled={Boolean(busy)}
              onConfirm={() => submit('raise-target', {targetId: storeTarget.id})}
            />
            {!inWindow(campaign, today) && (
              <s-text color="subdued">
                Milestones mark a day from {shortDate(campaign.startDate)} to{' '}
                {shortDate(campaign.endDate)}.
              </s-text>
            )}
          </s-stack>
        )}


        {/* The container query needs a query container to measure. */}
        <s-query-container>
          <s-grid gridTemplateColumns="@container (inline-size > 560px) 1fr 1fr, 1fr" gap="base">
            {storeTarget && card(storeTarget)}
            {scopedTargets.map(card)}
          </s-grid>
        </s-query-container>
      </s-stack>
    </s-section>
  );
}
```

## /app/panels/close.jsx

```jsx
import {useEffect} from 'react';
import {useFetcher, useRevalidator} from 'react-router';
import {ConfirmButton} from './confirm';


// The writes that end a campaign sit after the evidence they act on.
export function CampaignLifecycle({
  campaign,
  openCampaign,
  closesOn,
  running,
  busy,
  submit,
  shortDate,
  stampsOrders,
  children,
}) {
  // A closed campaign has no controls left. A planned one shows Cancel beside Activate
  // at the top of the page.
  if (campaign.status === 'closed' || campaign.status === 'planned') return null;


  return (
    <s-section heading="Campaign actions">
      <s-box paddingBlockStart="base" />
      <s-stack direction="block" gap="base">
        {openCampaign && (
          <s-paragraph>
            Closing ends the <s-text>{openCampaign.title}</s-text> annotation on{' '}
            {shortDate(closesOn)}. The annotation title then shows the campaign
            result.{' '}
            {stampsOrders
              ? 'Each order placed after a campaign touch gets a metafield with the last touch before the order. Order attribution fills in after you close.'
              : 'Closing records no order attribution. Make the order dimension queryable before you close to record it.'}
          </s-paragraph>
        )}
        {campaign.status === 'canceled' && (
          <s-paragraph>
            Canceled. The app removed this campaign’s annotations and targets.
          </s-paragraph>
        )}
        <s-stack direction="inline" gap="base">
          {running && (
            <ConfirmButton
              id="close-campaign"
              variant="primary"
              label="Close campaign"
              heading="Close this campaign?"
              body={
                stampsOrders
                  ? `The campaign ends on ${shortDate(closesOn)} and records each order's last campaign touch. You can't reopen it.`
                  : `The campaign ends on ${shortDate(closesOn)}. You can't reopen it.`
              }
              confirmLabel="Close campaign"
              loading={busy === 'close'}
              disabled={Boolean(busy)}
              onConfirm={() => submit('close')}
            />
          )}
          {campaign.status === 'canceled' && (
            <s-button
              variant="primary"
              loading={busy === 'replan'}
              disabled={Boolean(busy)}
              onClick={() => submit('replan')}
            >
              Replan campaign
            </s-button>
          )}
          {running && (
            <ConfirmButton
              id="cancel-running"
              tone="critical"
              label="Cancel campaign"
              heading="Cancel this campaign?"
              body="Canceling removes this campaign's annotations and targets from Shopify Analytics."
              confirmLabel="Cancel campaign"
              loading={busy === 'cancel'}
              disabled={Boolean(busy)}
              onConfirm={() => submit('cancel')}
            />
          )}
          {children}
        </s-stack>
      </s-stack>
    </s-section>
  );
}


// Stamping runs after the close returns. Poll the status route while it runs, then reload
// the page once it ends. A failure offers a retry.
const STAMP_POLL_MS = 5000;


export function OrderStamping({campaignId, stamping, busy, submit}) {
  const revalidator = useRevalidator();
  const poll = useFetcher();
  const running = stamping?.state === 'running';
  const polled = poll.data?.state;
  useEffect(() => {
    if (!running) return undefined;
    const timer = setInterval(() => {
      if (poll.state === 'idle') poll.load(`/app/campaigns/${campaignId}/stamping`);
    }, STAMP_POLL_MS);
    return () => clearInterval(timer);
  }, [running, campaignId, poll]);
  useEffect(() => {
    if (running && polled && polled !== 'running' && revalidator.state === 'idle') {
      revalidator.revalidate();
    }
  }, [running, polled, revalidator]);


  if (!stamping) return null;
  const problems = stamping.problems.map((problem) => problem.message).join(' ');


  return (
    <s-section heading="Order attribution">
      {running && (
        <s-banner tone="info" heading="Stamping orders">
          <s-paragraph>
            Shopify is writing each order’s last campaign touch. {(poll.data ?? stamping).objectCount} done so
            far. This page updates when it finishes.
          </s-paragraph>
        </s-banner>
      )}
      {stamping.state === 'done' && (
        <s-banner tone="success" heading="Orders stamped">
          <s-paragraph>Order attribution covers this campaign’s orders.</s-paragraph>
        </s-banner>
      )}
      {stamping.state === 'unread' && (
        <s-banner tone="warning" heading="Couldn't check order stamping">
          <s-paragraph>Reload the page to try again. The store returned: {problems}</s-paragraph>
        </s-banner>
      )}
      {stamping.state === 'failed' && (
        <s-banner tone="critical" heading="Some orders weren't stamped">
          <s-paragraph>{problems}</s-paragraph>
          <s-button
            slot="secondary-actions"
            loading={busy === 'stamp-orders'}
            disabled={Boolean(busy)}
            onClick={() => submit('stamp-orders')}
          >
            Stamp orders again
          </s-button>
        </s-banner>
      )}
    </s-section>
  );
}
```

## /app/panels/confirm.jsx

```jsx
// A destructive or one-way action asks once before it posts.
export function ConfirmButton({
  id,
  label,
  heading,
  body,
  confirmLabel,
  tone,
  variant,
  loading,
  disabled,
  onConfirm,
}) {
  return (
    <>
      <s-button
        tone={tone}
        variant={variant}
        loading={loading}
        disabled={disabled}
        commandFor={id}
        command="--show"
      >
        {label}
      </s-button>
      <s-modal id={id} heading={heading}>
        <s-paragraph>{body}</s-paragraph>
        <s-button slot="secondary-actions" commandFor={id} command="--hide">
          Go back
        </s-button>
        <s-button
          slot="primary-action"
          variant="primary"
          tone={tone}
          commandFor={id}
          command="--hide"
          onClick={onConfirm}
        >
          {confirmLabel}
        </s-button>
      </s-modal>
    </>
  );
}
```

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

```jsx
import {Fragment, useEffect, useRef, 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 {
  findCampaign,
  lockCampaign,
  readCampaigns,
  transportErrors,
  updateCampaign,
  withReturnedErrors,
} from '../campaigns.server';
import {outcomeText} from '../campaign-report.server';
import {
  SHOP_QUERY,
  annotationsQuery,
  codeSalesQuery,
  deleteTargets,
  emptyTable,
  measureQuery,
  progressValue,
  promotedByQuery,
  qrFunnelQuery,
  qrSalesQuery,
  readTargets,
  runIfSupported,
  runOptional,
  salesQuery,
  topProductsQuery,
  targetNodes,
  tierSalesQuery,
  touchSalesQuery,
} from '../campaign-queries.server';
import {clearStampOperation, recordStampOperation} from '../campaign-state.server';
import {readStamping} from '../bulk-stamp.server';
import {queryShopifyQL} from '../shopifyql.server';
import {
  QR_MARK_PREFIX,
  campaignMarks,
  campaignWindow,
  codeFilter,
  isPositiveAmount,
  isTargetAmount,
  parseProducts,
  productId,
  promotedFilter,
  promotedNames,
  qrHandleFilter,
  qrHandles,
  safeLocale,
  inWindow,
  shopMorning,
  statusLabel,
} from '../campaigns';
import {CHANNELS} from '../channels';
import {
  clearOwnMarks,
  closeCampaign,
  createAudienceTarget,
  createProductTarget,
  createQrTargets,
  createStoreTarget,
  endTargetsOn,
  markMilestone,
  raiseTarget,
  recordMark,
  recordedTarget,
  setAudience,
  setTargetAmounts,
  setProducts,
  stampOrders,
  stampProducts,
  stampTier,
  isPlannedMark,
  writeMarks,
} from '../campaign-mutations.server';
import {CampaignLifecycle, OrderStamping} from '../panels/close';
import {ConfirmButton} from '../panels/confirm';
import {
  SalesAndOrders,
  StoreTotals,
  TopProducts,
  annotationStatus,
  metricCardQuery,
  topProductsShape,
  useMetricCardsReady,
} from '../panels/sales';
import {
  CreatorRevenue,
  OrderAttribution,
  PromotedProductSales,
  QRCodeSales,
  SegmentSales,
  creatorShape,
  promotedShape,
  qrShape,
  tierShape,
  touchShape,
} from '../panels/dimensions';
import {TargetsPanel, splitTargets} from '../panels/targets';
import {measureTargets} from '../campaign-targets.server';
import {AUDIENCE_TIERS, isTargetedTier, tierLabel} from '../audiences';
import {analyticsReportHref} from '../annotations';
import {readDimensions} from '../dimensions.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,
    targetsBody,
    annotationsBody,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
  ] = await Promise.all([
    queryShopifyQL(admin, salesQuery(campaign)),
    readTargets(admin, campaign).catch(() => ({data: null})),
    runIfSupported(admin, annotationsQuery(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 write_reports scope and Level 2 protected customer data access.')
    : sales.parseErrors?.length
      ? sales.parseErrors.join(' ')
      : null;
  if (fatal) return {campaign, locale, fatal};


  const [targets, stamping] = await Promise.all([
    measureTargets(admin, campaign, targetsBody),
    readStamping(admin, campaign),
  ]);


  const annotations = annotationsBody.data?.shop?.analyticsAnnotations?.nodes ?? [];


  return {
    campaign,
    locale,
    fatal: null,
    appId,
    // The sales schema has no currency column. Read it off the shop field.
    currencyCode: salesBody.data?.shop?.currencyCode ?? 'USD',
    // TIMESERIES buckets by the store's timezone. The browser's would misdate an annotation.
    timeZone: salesBody.data?.shop?.ianaTimezone ?? 'Etc/UTC',
    queryWindow,
    dimensions,
    rows: sales.tableData.rows,
    targets,
    annotations,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
    stamping,
    // Read the clock on the server. Render runs twice and must return the same day.
    now: Date.now(),
  };
}




// One action for every write. The lock holds off a second write to the campaign until
// this one ends.
export async function action({request, params}) {
  const {admin: client, session, redirect} = await authenticate.admin(request);
  const admin = withReturnedErrors(client);
  const lock = await lockCampaign(session.shop, params.campaignId);
  if (lock.problems) return {problems: lock.problems.map((error) => error.message)};
  try {
    return await runIntent(admin, session.shop, request, params, redirect);
  } finally {
    await lock.release();
  }
}


async function runIntent(admin, shop, request, params, redirect) {
  const form = await request.formData();
  const intent = String(form.get('intent'));


  const [{appId, campaigns, failed}, shopBody] = await Promise.all([
    readCampaigns(admin, shop),
    admin.graphql(SHOP_QUERY).then((response) => response.json()),
  ]);
  // Redirecting on a failed read would report the campaign gone.
  if (failed.length) return {problems: failed.map((error) => error.message)};


  const campaign = findCampaign(campaigns, params.campaignId);
  if (!campaign) return redirect('/app/campaigns');


  const timeZone = shopBody.data?.shop?.ianaTimezone ?? 'Etc/UTC';


  // This writes annotation text, and the sales schema has no currency column. Take the
  // currency off the shop. Formatting on an unknown code throws.
  const money = (value) =>
    new Intl.NumberFormat('en', {
      style: 'currency',
      currency: shopBody.data?.shop?.currencyCode ?? 'USD',
      maximumFractionDigits: 0,
    }).format(value);


  const markedAt = (date) =>
    new Date(shopMorning(date, timeZone)).toISOString();


  // `en-CA` formats as YYYY-MM-DD. Every date on a record is already in that shape.
  const shopToday = new Intl.DateTimeFormat('en-CA', {
    timeZone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(new Date());


  // A failed activation leaves the campaign planned with its targets saved, and a retry
  // reuses them as they were. Edits wait for a cancel, which deletes them.
  const savedTargets = Object.keys(campaign.targets ?? {}).length > 0;
  const savedTargetsProblem = {
    problems: ['This campaign already has targets from an activation. Cancel the campaign, then make the change.'],
  };


  if (intent === 'set-products' || intent === 'set-audience') {
    if (campaign.status !== 'planned') {
      return {problems: ['Only planned campaigns can change products or segment.']};
    }
    if (savedTargets) return savedTargetsProblem;
  }


  if (intent === 'set-products') {
    const products = parseProducts(form.get('products'));
    if (products === null) {
      return {problems: ["Couldn't read the selected products. Select them again."]};
    }
    const {userErrors} = await setProducts(admin, campaign, products);
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'set-audience') {
    const audience = String(form.get('audience'));
    // 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 === audience)) {
      return {problems: ['Select a customer segment.']};
    }
    const {userErrors} = await setAudience(admin, campaign, audience);
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'set-targets') {
    // Activation turns the amounts into targets. Later edits would drift from them.
    if (campaign.status !== 'planned') {
      return {problems: ['Targets can change only before the campaign is activated.']};
    }
    if (savedTargets) return savedTargetsProblem;
    const amounts = {};
    // The form omits targets this campaign can't activate. An absent amount keeps its
    // saved value, and a blank one clears the target.
    for (const key of ['revenueTarget', 'productTarget', 'audienceTarget', 'qrTarget']) {
      if (!form.has(key)) continue;
      const value = String(form.get(key)).trim();
      if (value && !isTargetAmount(value)) {
        return {
          problems: ['Enter a target above zero, or leave it blank.'],
        };
      }
      amounts[key] = value;
    }
    const {userErrors} = await setTargetAmounts(admin, campaign, amounts);
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  // Read by the activate and close branches below.
  const dimensions = await readDimensions(admin, appId);


  if (intent === 'activate') {
    // Gate on the record's status. A target the user set on the same date isn't
    // evidence that this campaign is active.
    if (campaign.status !== 'planned') {
      return {
        problems: [
          `This campaign is ${statusLabel(campaign.status).toLowerCase()}. Only a planned campaign can be activated.`,
        ],
      };
    }


    const {markers, problems: unmarked} = await writeMarks(
      admin,
      campaign,
      appId,
      markedAt,
    );
    if (unmarked.length) return {problems: unmarked};


    // A failed activation left its target IDs on the record. Create only the missing
    // targets, or the retry makes a second set the app can never reach.
    const targets = {...(campaign.targets ?? {})};
    const rejected = [];
    const missing = [
      ['store', () => createStoreTarget(admin, campaign)],
      ['product', () => createProductTarget(admin, campaign)],
      ['audience', () => createAudienceTarget(admin, campaign, dimensions.customer)],
    ].filter(([key]) => !targets[key]);
    const results = await Promise.all(missing.map(([, create]) => create()));
    const created = {};
    missing.forEach(([key], index) => {
      if (results[index].id) created[key] = results[index].id;
      rejected.push(...results[index].userErrors);
    });
    const qr = await createQrTargets(admin, {
      ...campaign,
      qrCodes: campaign.qrCodes.filter(
        (qrCode) => !targets[`${QR_MARK_PREFIX}${qrCode.handle}`],
      ),
    });
    Object.assign(created, qr.targets);
    rejected.push(...qr.userErrors);


    // Save the IDs before the stamps run, and delete what was created if that
    // write fails.
    if (Object.keys(created).length) {
      Object.assign(targets, created);
      const {userErrors: unsaved} = await updateCampaign(admin, campaign, {
        targets,
      });
      if (unsaved.length) {
        const rollback = await deleteTargets(admin, Object.values(created));
        return {
          problems: [
            ...unsaved,
            ...transportErrors(rollback),
            ...(rollback.data?.analyticsTargetsDelete?.userErrors ?? []),
          ].map((error) => error.message),
        };
      }
    }


    const unstamped = await stampProducts(admin, campaign);
    const {userErrors: untiered} = await stampTier(admin, campaign, dimensions.customer);
    const {userErrors} = await updateCampaign(admin, campaign, {
      status: 'running',
      markers,
      targets,
    });
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }


    // A rejected target or stamp is a warning beside an activation that went through.
    const problems = [...rejected, ...unstamped, ...untiered].map(
      (error) => error.message,
    );
    return problems.length ? {ok: true, problems} : {ok: true};
  }


  // Replace what's on the store. Read the annotations this app wrote, delete them, and put
  // the current marks up in their place.
  if (intent === 'rewrite-marks') {
    // Closing moved the span's `endedAt` off null and put the outcome in the title.
    // Rewriting reopens that span. Offered while the campaign runs.
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can rewrite annotations.']};
    }
    const {markers, problems} = await writeMarks(
      admin,
      campaign,
      appId,
      markedAt,
    );
    if (problems.length) return {problems};


    const {userErrors} = await updateCampaign(admin, campaign, {markers});
    return userErrors.length
      ? {problems: userErrors.map((error) => error.message)}
      : {ok: true, rewrote: Object.keys(markers).filter(isPlannedMark).length};
  }


  if (intent === 'raise-target') {
    // Targets exist from activation to close. Raising one after the outcome measured it
    // would leave the recorded result and the target disagreeing.
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can raise targets.']};
    }
    const targetsBody = await readTargets(admin, campaign);
    const target = recordedTarget(campaign, targetsBody, form.get('targetId'));
    if (!target?.shopifyqlQuery) return {problems: ["Couldn't measure this target."]};


    const measured = await queryShopifyQL(admin, measureQuery(target.shopifyqlQuery));
    const actual = progressValue(measured.data?.shopifyqlQuery);
    // A raise is permanent. An unmeasured target would move the goal to the floor.
    if (actual === null) {
      return {problems: ["Couldn't measure this target. Reload and try again."]};
    }
    if (actual < Number(target.expectedValue)) {
      return {problems: ['This target isn\'t reached yet.']};
    }
    const userErrors = await raiseTarget(admin, {...target, actual});
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'milestone') {
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can mark milestones.']};
    }
    const day = String(form.get('day'));
    if (!inWindow(campaign, day)) {
      return {problems: ['Pick a day inside the campaign.']};
    }
    const targetsBody = await readTargets(admin, campaign);
    const target = recordedTarget(campaign, targetsBody, form.get('targetId'));
    if (!target) return {problems: ['That target no longer exists.']};


    // One mark per target per day. A second write would leave the first annotation
    // on the timeline with nothing pointing at it.
    const key = `milestone:${productId(target.id)}:${day}`;
    if (campaign.markers?.[key]) {
      return {problems: ['This target already has a milestone for this day.']};
    }


    const result = await markMilestone(admin, target, markedAt(day), money);
    if (result.userErrors?.length) {
      return {problems: result.userErrors.map((error) => error.message)};
    }
    // The saved ID is the handle a rewrite or a cancel needs. An unchecked write
    // leaves the annotation on the timeline with nothing pointing at it.
    const saved = await recordMark(
      admin,
      campaign,
      key,
      result.analyticsAnnotation?.id,
    );
    if (saved?.userErrors?.length) {
      return {problems: saved.userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'close') {
    // The outcome annotation is a closed campaign's permanent record. The status write
    // lands last, and a failed attempt stays retryable.
    if (campaign.status !== 'running') {
      return {problems: ['You can close only active campaigns.']};
    }
    if (shopToday < campaign.startDate) {
      return {problems: ["This campaign hasn't started. Cancel it instead."]};
    }
    // A campaign can close before its window runs out. Everything below reads the
    // window that ran.
    const closedOn =
      shopToday < campaign.endDate ? shopToday : campaign.endDate;
    const closing = {...campaign, endDate: closedOn};


    // Shorten the targets first, or they keep measuring days the campaign didn't run.
    // A target that can't move keeps its end date, and the close reports it.
    const {problems: stillOpen, warnings: kept} =
      closedOn < campaign.endDate
        ? await endTargetsOn(admin, campaign, closedOn)
        : {problems: [], warnings: []};
    if (stillOpen.length) {
      return {problems: stillOpen.map((error) => error.message)};
    }


    const targetsBody = await readTargets(admin, closing);


    // A failed read returns no nodes, the same shape as a deleted target. Closing
    // is permanent. Only the deleted case is allowed through.
    if (targetsBody.errors?.length) {
      return {problems: ["Couldn't read targets. Reload and close the campaign again."]};
    }


    // The ID activation recorded. A target the user made on the same date
    // measures the same days without being this campaign's.
    const store = targetNodes(targetsBody).find(
      (node) => node.id === campaign.targets?.store,
    );
    // Measure the days that ran. A one-day target's query ends a day later and would
    // count a day outside the campaign.
    const measured = await queryShopifyQL(
      admin,
      measureQuery(
        `FROM sales SHOW total_sales SINCE ${closing.startDate} UNTIL ${closedOn}`,
      ),
    );
    const revenue = progressValue(measured.data?.shopifyqlQuery);
    // Closing is permanent, and a failed read would record the campaign at zero.
    if (revenue === null) {
      return {problems: ["Couldn't measure sales. Reload and close the campaign again."]};
    }


    const text = outcomeText({
      campaign: closing,
      revenue,
      target: store?.expectedValue,
      sequence: [],
      money,
    });


    const errors = await closeCampaign(
      admin,
      campaign,
      text,
      markedAt(closedOn),
    );
    if (errors.length) return {problems: errors.map((error) => error.message)};


    // An early close leaves the planned marks for days that never ran. A recorded mark
    // the campaign's fields no longer produce never runs either.
    const ran = new Set(
      campaignMarks(campaign)
        .filter((mark) => mark.date <= closedOn)
        .map((mark) => mark.key),
    );
    const unrun = Object.keys(campaign.markers ?? {}).filter(
      (key) => isPlannedMark(key) && !ran.has(key),
    );
    const markers = {...campaign.markers};
    if (unrun.length) {
      const left = await clearOwnMarks(
        admin,
        unrun.map((key) => markers[key]),
        appId,
      );
      if (left.length) return {problems: left.map((error) => error.message)};
      for (const key of unrun) delete markers[key];
    }


    // Attribution needs the orders to exist, and close is the first point they do.
    // Skipped until the definition is a dimension.
    if (dimensions.order.enabled) {
      const started = await startStamping(admin, shop, {...closing, markers});
      if (started.problems) return started;
    }


    const {userErrors} = await updateCampaign(admin, campaign, {
      status: 'closed',
      endDate: closedOn,
      ...(unrun.length ? {markers} : {}),
    });
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return kept.length
      ? {ok: true, problems: kept.map((error) => error.message)}
      : {ok: true};
  }


  // A failed bulk operation leaves orders unstamped. Stamping again writes the same values.
  if (intent === 'stamp-orders') {
    if (campaign.status !== 'closed' || !dimensions.order.enabled) {
      return {problems: ['Only closed campaigns with order attribution can stamp orders.']};
    }
    const current = await readStamping(admin, campaign);
    if (current?.state === 'running') {
      return {problems: ['Orders are still being stamped. Wait for that to finish.']};
    }
    return startStamping(admin, shop, campaign);
  }


  if (intent === 'cancel') {
    // A closed campaign keeps its outcome on the store's charts. A cancel only takes
    // down a planned or running one.
    if (campaign.status !== 'planned' && campaign.status !== 'running') {
      return {problems: ['You can cancel only planned or active campaigns.']};
    }
    // Delete only the annotations this app created. Delete the targets by the IDs that
    // activation recorded.
    const undone = await clearOwnMarks(
      admin,
      Object.values(campaign.markers ?? {}),
      appId,
    );
    const kept = undone.map((error) => error.id);
    const markers = Object.fromEntries(
      Object.entries(campaign.markers ?? {}).filter(
        ([, id]) => kept.includes(null) || kept.includes(id),
      ),
    );


    const targets = {...campaign.targets};
    const read = await readTargets(admin, campaign);
    const unread = transportErrors(read);
    undone.push(...unread);
    const ours = unread.length ? [] : targetNodes(read);
    if (ours.length > 0) {
      const body = await deleteTargets(admin, ours.map((target) => target.id));
      const result = body.data?.analyticsTargetsDelete;
      const deletedIds = result?.deletedIds ?? [];
      const failed = [...transportErrors(body), ...(result?.userErrors ?? [])];
      if (!failed.length && ours.some((target) => !deletedIds.includes(target.id))) {
        failed.push({message: "Couldn't delete every target. Cancel the campaign again."});
      }
      undone.push(...failed);
      for (const deleted of deletedIds) {
        const key = Object.keys(targets).find((name) => targets[name] === deleted);
        if (key) delete targets[key];
      }
    }
    // Clearing the record on a failed delete abandons what's still on the timeline. The
    // IDs are the only handle the app has on it.
    if (undone.length) {
      const {userErrors} = await updateCampaign(admin, campaign, {
        markers,
        targets,
      });
      return {problems: [...undone, ...userErrors].map((error) => error.message)};
    }


    // Canceling undoes the annotations and the targets, and nothing else.
    const {userErrors} = await updateCampaign(admin, campaign, {
      status: 'canceled',
      markers: {},
      targets: {},
    });
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  if (intent === 'replan') {
    // Activating records new target IDs over the old ones. Replanning a live campaign
    // would leave its targets on the store with nothing pointing at them.
    if (campaign.status !== 'canceled') {
      return {problems: ['You can replan only canceled campaigns.']};
    }
    // Canceling removed the annotations and targets. The promoted products keep their
    // `campaign` metafield, and the next activation writes it again.
    const {userErrors} = await updateCampaign(admin, campaign, {status: 'planned'});
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    return {ok: true};
  }


  return {problems: ['Unknown action.']};
}


// The page polls the recorded operation. An unrecorded one still runs, but nothing reports it.
async function startStamping(admin, shop, campaign) {
  const {operationId, userErrors} = await stampOrders(admin, campaign);
  if (userErrors.length) return {problems: userErrors.map((error) => error.message)};
  // Nothing left to stamp. Clear a failed operation, or its retry banner stays.
  if (!operationId) {
    if (campaign.stampOperation) {
      await clearStampOperation(shop, campaign.id, campaign.stampOperation).catch((error) =>
        console.error('Could not clear the stamp operation', error),
      );
    }
    return {ok: true};
  }
  const recorded = await recordStampOperation(shop, campaign.id, campaign.gid, operationId)
    .then(() => true)
    .catch((error) => {
      console.error('Could not record the stamp operation', error);
      return false;
    });
  return recorded
    ? {ok: true}
    : {problems: ["Started stamping orders, but couldn't save the operation to track it."]};
}


// 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;
}




const DONE = {
  activate: 'Campaign activated',
  close: 'Campaign closed',
  cancel: 'Campaign canceled',
  replan: 'Campaign replanned',
  'set-products': 'Products saved',
  'set-audience': 'Customer segment saved',
  'set-targets': 'Targets saved',
  'raise-target': 'Target raised',
  milestone: 'Milestone marked',
  'stamp-orders': 'Order stamping started',
};


// 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 fetcher = useFetcher();
  const shopify = useAppBridge();


  const cardReady = useMetricCardsReady();


  const [range, setRange] = useState('last30days');


  // Hooks run before the early return below. React needs the same hooks on every render.
  const problems = fetcher.data?.problems ?? [];
  // Problems alongside `ok` mean the action landed with something rejected inside it.
  const partial = Boolean(fetcher.data?.ok);
  // Only the in-flight intent's button spins.
  const busy = fetcher.state === 'idle' ? null : (fetcher.formData?.get('intent') ?? 'pending');
  const pending = useRef(null);
  useEffect(() => {
    if (busy) pending.current = busy;
  }, [busy]);
  useEffect(() => {
    if (fetcher.state !== 'idle' || !fetcher.data?.ok || fetcher.data.problems?.length) return;
    const message = DONE[pending.current];
    if (message) shopify.toast.show(message);
  }, [fetcher.state, fetcher.data, shopify]);
  // The select keeps the value picked in it. A rejected save puts the saved one back.
  const audienceSelect = useRef(null);
  const savedAudience = data.campaign?.audience;
  useEffect(() => {
    if (fetcher.state !== 'idle' || pending.current !== 'set-audience') return;
    if (fetcher.data?.problems?.length && audienceSelect.current) {
      audienceSelect.current.value = savedAudience;
    }
  }, [fetcher.state, fetcher.data, savedAudience]);


  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 {
    appId,
    currencyCode,
    timeZone,
    queryWindow,
    dimensions,
    rows,
    targets,
    annotations,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
    stamping,
  } = 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)}%`;


  // Written as 9am in the store's timezone. Slicing the UTC string is a day early.
  const shopDay = new Intl.DateTimeFormat('en-CA', {
    timeZone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  });
  const dayOf = (instant) => shopDay.format(new Date(instant));


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


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


  const {storeTarget, scopedTargets} = splitTargets(targets);


  const {marks, ours, outdated, openCampaign} = annotationStatus({
    campaign,
    annotations,
    appId,
  });


  // The same conditions activation applies. A blank amount is no target.
  const plannedTargetCount =
    (isPositiveAmount(campaign.revenueTarget) ? 1 : 0) +
    (promotedFilter(campaign) && isPositiveAmount(campaign.productTarget) ? 1 : 0) +
    (isTargetedTier(campaign.audience) &&
    dimensions.customer.enabled &&
    isPositiveAmount(campaign.audienceTarget)
      ? 1
      : 0) +
    (isPositiveAmount(campaign.qrTarget) ? qrHandles(campaign).length : 0);


  const cardQuery = metricCardQuery(range);


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


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


  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 today = dayOf(data.now);
  // Closing early ends it today. The action clamps the same.
  const closesOn = today < campaign.endDate ? today : campaign.endDate;


  const submit = (intent, fields = {}) =>
    fetcher.submit(
      {
        intent,
        ...fields,
      },
      {method: 'post'},
    );


  function saveTargets(event) {
    event.preventDefault();
    const values = new FormData(event.currentTarget);
    values.set('intent', 'set-targets');
    fetcher.submit(values, {method: 'post'});
  }


  async function changeProducts() {
    const selection = await shopify.resourcePicker({
      type: 'product',
      selectionIds: campaign.products.map((product) => ({id: product.id})),
      multiple: 5,
    });
    if (!selection) return;
    submit('set-products', {
      products: JSON.stringify(
        selection.map((product) => ({id: product.id, title: product.title})),
      ),
    });
  }


  const planned = campaign.status === 'planned';
  const running = campaign.status === 'running';
  // These panels report on annotations and targets a cancel already deleted.
  const canceled = campaign.status === 'canceled';


  // Beside the store's totals on an active campaign, and on its own before one.
  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>
  );


  // A percentage says nothing about whether the rest is reachable. This divides the
  // same two numbers by the days on either side of today.
  const dayGap = (from, to) =>
    Math.round(
      (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000,
    );
  const pace =
    storeTarget && storeTarget.percent !== null
      ? (() => {
          const span = dayGap(campaign.startDate, campaign.endDate) + 1;
          const elapsed = Math.min(
            Math.max(1, dayGap(campaign.startDate, today) + 1),
            span,
          );
          const left =
            running && today <= campaign.endDate ? dayGap(today, campaign.endDate) + 1 : 0;
          const earned = Number(storeTarget.actual ?? 0);
          const short = Math.max(0, Number(storeTarget.expectedValue) - earned);
          return {perDay: earned / elapsed, needed: left > 0 ? short / left : short, left};
        })()
      : null;


  const markNote =
    !planned && ours.length === 0
      ? 'No annotations for this campaign.'
      : outdated > 0
        ? `${outdated} ${outdated === 1 ? 'annotation is' : 'annotations are'} outdated. ` +
          `Rewriting replaces ${outdated === 1 ? 'it' : 'them'} with the current ${marks.length}.`
        : null;


  return (
    <s-page heading={campaign.title}>
      {problems.length > 0 && (
        <s-section>
          <s-banner
            tone={partial ? 'warning' : 'critical'}
            heading={
              partial
                ? 'Completed with warnings'
                : 'Action failed'
            }
          >
            <s-unordered-list>
              {problems.map((problem) => (
                <s-list-item key={problem}>{problem}</s-list-item>
              ))}
            </s-unordered-list>
          </s-banner>
        </s-section>
      )}


      {/* Before activation there are no totals to sit beside. Activate and Cancel
          are the only actions, and they share the top of the page. */}
      {planned && (
        <s-section>
          <s-stack direction="block" gap="base">
            {statusLine}
            <s-stack direction="inline" gap="base">
              <s-button
                variant="primary"
                loading={busy === 'activate'}
                disabled={Boolean(busy)}
                onClick={() => submit('activate')}
              >
                Activate campaign
              </s-button>
              <ConfirmButton
                id="cancel-planned"
                tone="critical"
                label="Cancel campaign"
                heading="Cancel this campaign?"
                body="The campaign stops before it starts. You can replan it afterward."
                confirmLabel="Cancel campaign"
                loading={busy === 'cancel'}
                disabled={Boolean(busy)}
                onConfirm={() => submit('cancel')}
              />
            </s-stack>
          </s-stack>
        </s-section>
      )}


      {planned && (
        <Fragment>
          {/* Everything activation writes, listed before it writes it. */}
          <s-section heading="Campaign setup">
            <s-box paddingBlockStart="base" />
            <s-paragraph>Activation creates:</s-paragraph>
            <s-unordered-list>
              <s-list-item>
                {marks.length} {marks.length === 1 ? 'annotation' : 'annotations'}.
                One covers the full campaign dates.
              </s-list-item>
              <s-list-item>
                {plannedTargetCount} analytics{' '}
                {plannedTargetCount === 1 ? 'target' : 'targets'} through{' '}
                {shortDate(campaign.endDate)}.
              </s-list-item>
              <s-list-item>
                A campaign metafield on{' '}
                {campaign.products.length === 0
                  ? 'the products you select'
                  : `${campaign.products.length} ${
                      campaign.products.length === 1 ? 'product' : 'products'
                    }`}
                {isTargetedTier(campaign.audience)
                  ? ` and a segment metafield on each ${tierLabel(campaign.audience)} customer.`
                  : '. The Everyone segment needs no customer metafields.'}
              </s-list-item>
            </s-unordered-list>


            <s-box paddingBlockStart="base" />


            <s-stack direction="block" gap="small-200">
              <s-text>
                Promoted products:{' '}
                {promotedNames(campaign) || 'None selected'}
              </s-text>
              <s-stack direction="inline" gap="base">
                <s-button
                  loading={busy === 'set-products'}
                  disabled={Boolean(busy)}
                  onClick={changeProducts}
                >
                  {campaign.products.length > 0
                    ? 'Change products'
                    : 'Select products'}
                </s-button>
              </s-stack>
              <s-select
                ref={audienceSelect}
                label="Customer segment"
                value={campaign.audience}
                disabled={Boolean(busy)}
                onChange={(event) =>
                  submit('set-audience', {audience: event.currentTarget.value})
                }
              >
                {AUDIENCE_TIERS.map((tier) => (
                  <s-option key={tier.value} value={tier.value}>
                    {tier.label}
                  </s-option>
                ))}
              </s-select>
            </s-stack>
          </s-section>


          {/* Each amount becomes a target at activation. A blank one is no target. */}
          <form onSubmit={saveTargets}>
            <s-section heading="Targets">
              <s-box paddingBlockStart="base" />
              <s-stack direction="block" gap="base">
                <s-money-field
                  label="Store sales target"
                  name="revenueTarget"
                  value={campaign.revenueTarget}
                  min={0}
                  details="Measures total sales. Leave blank for no target."
                />
                {/* A disabled field doesn't submit, and the action keeps its saved amount. */}
                <s-money-field
                  label="Promoted product target"
                  name="productTarget"
                  value={campaign.productTarget}
                  min={0}
                  disabled={campaign.products.length === 0}
                  details={
                    promotedNames(campaign) ||
                    'Select promoted products under Campaign setup to set this target.'
                  }
                />
                {isTargetedTier(campaign.audience) && (
                  <s-money-field
                    label="Customer segment target"
                    name="audienceTarget"
                    value={campaign.audienceTarget}
                    min={0}
                    disabled={!dimensions.customer.enabled}
                    details={
                      dimensions.customer.enabled
                        ? `Sales to the ${tierLabel(campaign.audience)} segment`
                        : 'Make the customer dimension queryable on the campaign list to set this target.'
                    }
                  />
                )}
                <s-money-field
                  label="QR code target"
                  name="qrTarget"
                  value={campaign.qrTarget}
                  min={0}
                  disabled={campaign.qrCodes.length === 0}
                  details={
                    campaign.qrCodes.length
                      ? 'Creates one target for each assigned QR code. Each target measures attributed sales.'
                      : 'Assign a QR code to this campaign to set this target.'
                  }
                />
                <s-button
                  variant="primary"
                  type="submit"
                  loading={busy === 'set-targets'}
                  disabled={Boolean(busy)}
                >
                  Save targets
                </s-button>
              </s-stack>
            </s-section>
          </form>
        </Fragment>
      )}


      {/* A planned or canceled campaign has nothing attributed to it. Reporting the
          store's sales for its window would read as a result the campaign produced. */}
      {!planned && !canceled && (
        <Fragment>
        <TargetsPanel
          targets={targets}
          storeTarget={storeTarget}
          scopedTargets={scopedTargets}
          campaign={campaign}
          dimensions={dimensions}
          pace={pace}
          running={running}
          busy={busy}
          submit={submit}
          today={today}
          currency={currency}
          shortDate={shortDate}
        />


        <StoreTotals
          statusLine={statusLine}
          cardReady={cardReady}
          range={range}
          setRange={setRange}
          cardQuery={cardQuery}
        />


        <SalesAndOrders
          campaign={campaign}
          totalSales={totalSales}
          percentChange={percentChange}
          yearChange={yearChange}
          newCustomers={newCustomers}
          firstTimeSales={firstTimeSales}
          rows={rows}
          cell={cell}
          shortDate={shortDate}
          marks={marks}
          ours={ours}
          running={running}
          markNote={markNote}
          exploreHref={exploreHref}
          busy={busy}
          submit={submit}
          rewrote={fetcher.data?.rewrote}
          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}
        />


        </Fragment>
      )}


      <OrderStamping campaignId={campaign.id} stamping={stamping} busy={busy} submit={submit} />


      <CampaignLifecycle
        campaign={campaign}
        openCampaign={openCampaign}
        closesOn={closesOn}
        running={running}
        busy={busy}
        submit={submit}
        shortDate={shortDate}
        stampsOrders={dimensions.order.enabled}
      />
    </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/routes/app.campaigns.$campaignId.stamping.jsx

```jsx
import {authenticate} from '../shopify.server';
import {readBulkOperation} from '../bulk-stamp.server';
import {readState} from '../campaign-state.server';
import {withReturnedErrors} from '../campaigns.server';


// The page polls this route while stamping runs. It reads one bulk operation and none of
// the page's reports, which keeps the poll off the store's ShopifyQL limit.
export async function loader({request, params}) {
  const {admin, session} = await authenticate.admin(request);
  const state = await readState(session.shop, params.campaignId);
  if (!state?.stampOperation) return {state: 'done', objectCount: 0, problems: []};
  return readBulkOperation(withReturnedErrors(admin), state.stampOperation);
}
```

## /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 {
  progressQuery,
  progressValue,
  readAllTargets,
  targetNodes,
} from '../campaign-queries.server';
import {enableDimension, readDimensions} from '../dimensions.server';
import {queryShopifyQL} from '../shopifyql.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, targetsBody] = await Promise.all([
    readDimensions(admin, appId),
    readAllTargets(admin, campaigns),
  ]);


  const owner = new Map();
  for (const campaign of campaigns) {
    for (const id of Object.values(campaign.targets ?? {})) {
      owner.set(id, String(campaign.id));
    }
  }


  // A target with no generated query has nothing to measure it. Drop it from the list.
  const nodes = targetNodes(targetsBody);
  const measurable = nodes.filter(
    (target) => target.shopifyqlQuery && owner.has(target.id),
  );
  const progressBody = measurable.length
    ? await queryShopifyQL(admin, progressQuery(measurable))
    : {};
  const progress = progressBody.data ?? {};


  // Alias back by position, indexed off `measurable`.
  const targets = measurable.map((target, index) => ({
    id: target.id,
    campaignId: owner.get(target.id),
    expected: Number(target.expectedValue),
    actual: progressValue(progress[`t${index}`]),
  }));
  // A failed read finds no targets, and a failed measure finds no sales. Report both.
  const targetsUnavailable =
    targetsBody.errors?.[0]?.message ?? progressBody.errors?.[0]?.message ?? null;


  return {
    campaigns,
    dimensions,
    targets,
    targetsUnavailable,
    // 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}`);
};


function writtenBack(campaign) {
  const markers = Object.keys(campaign.markers ?? {}).length;
  const count = Object.keys(campaign.targets ?? {}).length;
  if (markers + count === 0) return 'None';
  const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
  return `${plural(markers, 'annotation')}, ${plural(count, 'target')}`;
}


// Product and QR code sales are part of store sales. Summing them with the store
// target would count the same orders twice.
function targetProgress(campaign, targets, unavailable) {
  const mine = targets.filter(
    (target) => target.campaignId === String(campaign.id),
  );
  const store = mine.find((target) => target.id === campaign.targets?.store);
  // An unmeasured target isn't one at 0%.
  const recorded = Object.keys(campaign.targets ?? {}).length > 0;
  if ((unavailable && recorded) || store?.actual === null) {
    return {count: mine.length, percent: null, unmeasured: true};
  }
  if (!store || store.expected <= 0) return {count: mine.length, percent: null};
  return {
    count: mine.length,
    percent: Math.round((store.actual / store.expected) * 100),
  };
}


// 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, targets, targetsUnavailable, 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="Activation writes the segment to a metafield on each customer in the segment. The customer segment target measures sales to the segment."
                >
                  {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. The promoted product target measures
                sales of these 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-section>


            <s-box paddingBlockStart="base" />


            {/* Each amount becomes a target at activation. The form offers only the
                targets this campaign can create. */}
            <s-section heading="Targets">
              <s-box paddingBlockStart="base" />
              <s-stack direction="block" gap="base">
                <s-paragraph>
                  Activation creates a target for each amount. Leave an amount
                  blank for no target.
                </s-paragraph>
                <s-money-field
                  label="Store sales target"
                  name="revenueTarget"
                  placeholder="5000.00"
                  details="Measures total sales."
                />
                <s-money-field
                  label="Promoted product target"
                  name="productTarget"
                  placeholder="5000.00"
                  disabled={products.length === 0}
                  details={
                    products.length > 0
                      ? 'Sales of the promoted products.'
                      : 'Select promoted products to set this target.'
                  }
                />
                {isTargetedTier(audience) && (
                  <s-money-field
                    label="Customer segment target"
                    name="audienceTarget"
                    placeholder="2500.00"
                    disabled={!dimensions.customer.enabled}
                    details={
                      dimensions.customer.enabled
                        ? `Sales to the ${tierLabel(audience)} segment.`
                        : 'Make the customer dimension queryable under Analytics dimensions to set this target.'
                    }
                  />
                )}
                <s-money-field
                  label="QR code target"
                  name="qrTarget"
                  placeholder="1000.00"
                  details="One target for each QR code. Pick this campaign in each QR code's Campaign field before you activate."
                />
                <s-stack direction="inline" gap="base">
                  <s-button variant="primary" type="submit" loading={saving}>
                    Create campaign
                  </s-button>
                </s-stack>
              </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>
          )}
          {targetsUnavailable && (
            <s-banner tone="info" heading="Target progress didn't load">
              <s-paragraph>
                Reload the page to try again. The store returned: {targetsUnavailable}
              </s-paragraph>
            </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="labeled">Target progress</s-table-header>
                <s-table-header listSlot="labeled">Annotations and targets</s-table-header>
                <s-table-header listSlot="inline">Status</s-table-header>
              </s-table-header-row>
              <s-table-body>
                {campaigns.map((campaign) => {
                    const target = targetProgress(campaign, targets, targetsUnavailable);
                  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>
                        {target.unmeasured ? (
                          <s-text color="subdued">Couldn’t measure</s-text>
                        ) : target.percent !== null ? (
                          <s-text tone={target.percent >= 100 ? 'success' : 'info'}>
                            {target.percent}% of the store sales target
                          </s-text>
                        ) : target.count > 0 ? (
                          <s-text>
                            {target.count} {target.count === 1 ? 'target' : 'targets'}
                          </s-text>
                        ) : (
                          <s-text color="subdued">None</s-text>
                        )}
                      </s-table-cell>
                      <s-table-cell>{writtenBack(campaign)}</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/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/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/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/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/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/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/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';
```

## Part two complete!

You added revenue targets and annotations to the campaign dashboard. On activation, your app creates the user's targets, annotates the campaign's dates, and writes metafields to products and customers. On close, your app writes the campaign's revenue onto the campaign annotation and writes each order's last touch to a metafield.

### Next steps

[Report on a campaign with App Events\
\
](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-app-events)

[Next, you'll report your app's email, SMS, and push events to Shopify Analytics, and then query the event funnel with ShopifyQL alongside the store's sales.](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-app-events)

[analyticsAnnotationCreate\
\
](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationCreate)

[Read every input field for an annotation.](https://shopify.dev/docs/api/admin-graphql/latest/mutations/analyticsAnnotationCreate)

[Metafield capabilities\
\
](https://shopify.dev/docs/apps/build/metafields/use-metafield-capabilities)

[How `analyticsQueryable` works, and which definitions support it.](https://shopify.dev/docs/apps/build/metafields/use-metafield-capabilities)
