---
title: Report on a campaign with App Events
description: >-
  Declare standard marketing events, report your app's funnel to Shopify
  Analytics, and then query the messages sent beside the store's sales with
  ShopifyQL. Part three of three.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-app-events?extension=react
  md: >-
    https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-app-events.md?extension=react
api_name: app-events
---

# Report on a campaign with App Events

In this tutorial, you'll report a campaign's email, SMS, and push funnel with App Events. A funnel counts the messages at each stage, such as sent, delivered, opened, and clicked. A send is one message, such as an announcement email, that your app sends to the campaign's audience on one channel. Your app reads every funnel stage with one query, and the dashboard shows each day's sends beside that day's sales.

## What you'll learn

You'll learn how to:

* Declare the events that your app sends in an [Analytics App Events extension](https://shopify.dev/docs/apps/build/app-events/analytics/toml-configuration).
* Report the messages that your app sends, with the attributes that the [standard event registry](https://shopify.dev/docs/apps/build/app-events/analytics/standard-event-registry) defines.
* Query the `app_events` schema for one campaign's funnel.
* Flag a delivery problem in the events that your app reports.
* Summarize all the store's campaigns on one page.

## Requirements

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

You've built the campaign dashboard from [part one](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-sales) and added the targets and annotations from [part two](https://shopify.dev/docs/apps/build/analytics/campaign-dashboard-targets-and-annotations). Your app has the part two access scopes and Level 2 access to protected customer data.

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

## Report the campaign funnel to App Events

Declare the marketing events that your app reports from the [standard event registry](https://shopify.dev/docs/apps/build/app-events/analytics/standard-event-registry), and then post one event for each recipient at each stage.

### Add the App Events files

Copy these four files from the sample repository's `main` branch into your app directory:

* `extensions/app-events/shopify.extension.toml` declares the standard events that your app sends.
* `app/app-events.server.js` defines the campaign's sends and posts them.
* `app/panels/app-events.jsx` renders the funnel panels.
* `app/panels/incident.jsx` renders the bounce banner and the **Flag incident** button.

Then replace `app/campaign-report.server.js` and `app/campaign-mutations.server.js` with the versions from the sample repository's `main` branch.

### Mount the App Events panels

Replace `app/routes/app.campaigns.$campaignId.jsx` with the version from the sample repository's `main` branch. The route adds the send controls, the incident banner, and the **Channel metrics**, **Send performance**, and **Deliverability** panels.

A planned campaign's page also has a **QR codes** section. The section shows each assigned code's image and links to the new QR code form, which preselects the campaign. A canceled campaign's page has **Delete campaign**. The button deletes the campaign entry and the campaign's `CampaignState` row.

### Start the app

Run `shopify app dev` from your app's directory. If the command is already running, then stop it and run it again. Shopify CLI loads the App Events extension when you start the app:

## Terminal

```shell
shopify app dev
```

### Declare the events and deploy them

`extensions/app-events/shopify.extension.toml` lists each event by the event's registry handle. With `shopify app dev` running, open a second terminal and deploy the extension. Shopify validates the declaration:

## Terminal

```shell
shopify app deploy
```

***

* Shopify rejects a deploy that renames or drops an event that you've released.
* Shopify Analytics makes a standard event available only after your app declares and releases it.

***

[App Events in Analytics](https://shopify.dev/docs/apps/build/app-events/analytics) [Analytics App Events TOML reference](https://shopify.dev/docs/apps/build/app-events/analytics/toml-configuration)

## /extensions/app-events/shopify.extension.toml

```toml
[[extensions]]
type = "analytics_app_events"
handle = "app-events"


# The email funnel, in order.
[[extensions.events]]
name = "shopify.marketing.email_sent"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_delivered"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_opened"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_clicked"
kind = "standard"


# The two ways a send goes wrong. A funnel without these reports only wins.
[[extensions.events]]
name = "shopify.marketing.email_bounced"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_unsubscribed"
kind = "standard"


# The follow-up touch, on a second channel.
[[extensions.events]]
name = "shopify.marketing.sms_sent"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.sms_delivered"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.sms_clicked"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.sms_unsubscribed"
kind = "standard"


# The last touch, on a third channel. device_platform splits it by iOS and
# Android without a second pair of events.
[[extensions.events]]
name = "shopify.marketing.push_sent"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.push_opened"
kind = "standard"
```

### Check the App Events schema

Press `g` in the `shopify app dev` terminal to open GraphiQL, and then query a metric that your extension declares:

## GraphQL

```graphql
query {
  shopifyqlQuery(
    query: "FROM app_events SHOW emails_sent SINCE -29d UNTIL today"
  ) {
    tableData {
      columns {
        name
        dataType
        displayName
      }
      rows
    }
    parseErrors
  }
}
```

A `Column Not Found` parse error means that the store doesn't have your event declaration yet. After your app sends events, confirm the requests in the [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard/monitoring-and-logs).

***

[Standard event registry](https://shopify.dev/docs/apps/build/app-events/analytics/standard-event-registry)

### Get an App Events token

App Events uses an app-level token, separate from the admin session. `app/app-events.server.js` requests the token with your app's client ID and client secret in a `client_credentials` grant.

***

[Authenticate with the App Events API](https://shopify.dev/docs/apps/build/app-events/send-events#step-1-authenticate-with-the-app-events-api)

## /app/app-events.server.js

```javascript
... (3 unchanged lines)
let cached = {token: null, expiresAt: 0};


async function getAppEventsToken() {
  if (cached.token && Date.now() < cached.expiresAt) return cached.token;


  const response = await fetch('https://api.shopify.com/auth/access_token', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      client_id: process.env.SHOPIFY_API_KEY,
      client_secret: process.env.SHOPIFY_API_SECRET,
      grant_type: 'client_credentials',
    }),
  });


  if (!response.ok) {
    throw new Error(`Token request failed: ${response.status}`);
  }


  const {access_token: token, expires_in: expiresIn} = await response.json();
  // Tokens last 60 minutes. Refresh a minute early.
  cached = {token, expiresAt: Date.now() + ((expiresIn ?? 3600) - 60) * 1000};
  return token;
}
... (286 unchanged lines)
```

### Describe the campaign as sends and stages

Each attribute on an event becomes a column that you can group by. Your app puts six shared attributes on every event: the channel, message type, flow ID, template ID, consent state, and the campaign ID from the campaign entry.

***

* Keep names, email addresses, phone numbers, and other personal data out of the attribute values. Use anonymized identifiers instead.

***

[Standard event registry](https://shopify.dev/docs/apps/build/app-events/analytics/standard-event-registry)

## /app/app-events.server.js

```javascript
... (28 unchanged lines)
const FLOW_ID = 5501;


function marketing(channel, template, extra = {}) {
  return {
    channel,
    message_type: 'campaign',
    flow_id: FLOW_ID,
    template_id: template,
    consent_state: 'opted_in',
    ...extra,
  };
}
... (273 unchanged lines)
```

### Never reuse a campaign ID

If your app reuses an existing campaign's ID for a new campaign, then the new campaign's queries return the old campaign's events. The App Events API can't retract an event.

`allocateCampaignId` adds one to the app's stored ID or to a higher ID on the store's campaign entries. The increment is atomic. Two requests never get the same ID.

If another entry already uses the new entry's handle, then Shopify adds a suffix to the handle. `createCampaign` deletes the suffixed entry and retries with the next ID.

## /app/campaigns.server.js

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

### Post one event for each recipient

Post one event in each request to the `events` endpoint of the [App Events API](https://shopify.dev/docs/api/app-events), and send the requests in sequence. A `202` response means that Shopify received the event.

Shopify counts requests with the same `idempotency_key` within 24 hours as one event. Compose one key of at most 64 characters for each recipient at each stage.

When a request gets a 409, 429, or 5xx response, or the connection drops, `sendWithRetry` retries the request twice with the same idempotency key. The send stops at the first event that still fails.

***

[Using App Events](https://shopify.dev/docs/apps/build/app-events/send-events) [App Events API reference](https://shopify.dev/docs/api/app-events)

## /app/app-events.server.js

```javascript
... (233 unchanged lines)
async function sendEvent({token, shopId, stage, campaignId, start, index}) {
  // A network failure counts as one failed event. Throwing would lose the received count.
  try {
    return await postEvent({token, shopId, stage, campaignId, start, index});
  } catch (error) {
    return {ok: false, status: 'network', body: error.message};
  }
}


async function postEvent({token, shopId, stage, campaignId, start, index}) {
  const response = await fetch(`https://api.shopify.com/app/${apiVersion}/events`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      shop_id: shopId,
      // Must match the shopify.extension.toml declaration exactly.
      event_handle: stage.handle,
      timestamp: stageTimestamp(start, stage.day, stage.hour, index),
      idempotency_key: `${shopId.split('/').pop()}-${campaignId}-${RUN}-${stage.code}-${index}`,
      // `campaign_id` is the join, the one dimension both categories accept.
      attributes: {campaign_id: campaignId, ...stage.attributes(index)},
    }),
  });


  if (response.status === 202) return {ok: true};
  return {ok: false, status: response.status, body: await response.text()};
}
... (16 unchanged lines)


export async function sendCampaignFunnel({
  shopId,
  campaignId,
  startDate,
  timeZone,
  channels,
}) {
  const token = await getAppEventsToken();
  const start = shopMorning(startDate, timeZone);
  const accepted = {};
  const failures = [];


  // The API takes one event per request and asks for them in sequence. A real send
  // would work off a queue. The send stops at the first event that still fails after
  // its retries, which keeps a sustained 429 from holding the request open.
  for (const stage of selected(channels)) {
    let sent = 0;
    let failed = null;
    for (let index = 0; index < stage.count && !failed; index += 1) {
      const result = await sendWithRetry({token, shopId, stage, campaignId, start, index});
      if (result.ok) sent += 1;
      else failed = result;
    }
    // Several sends share one handle. This accumulates.
    accepted[stage.handle] = (accepted[stage.handle] ?? 0) + sent;
    if (failed) {
      failures.push(`${stage.handle}: ${failed.status} ${failed.body}`);
      break;
    }
  }


  return {accepted, failures};
}
```

### Replay the sends from the app

The sample app replays a fixed schedule of sends from the campaign page. A production app posts each event as the send happens. Click **Send events**, confirm, and reload the page after a few minutes to see **Channel metrics**.

The replay spreads its sends over the 14 days after the campaign's start date. Allow a replay only for an active campaign that runs at least 14 days and started at least 14 days ago. A shorter or newer campaign gets sends dated after its end date or after today.

If the app user replays the sends after the 24-hour idempotency window, then Shopify counts every event twice. With `claimSend`, only one request can run the campaign's send. If the send fails to start or Shopify receives no events, then your app releases the claim and the app user can send again.

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

```jsx
... (393 unchanged lines)
  if (intent === 'send-events') {
    // A planned campaign would date its sends in the future.
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can send events.']};
    }
    const blocked = sendBlocker(
      campaign,
      timeZone,
      safeLocale(new URL(request.url).searchParams.get('locale')),
    );
    if (blocked) return {problems: [blocked]};
    if (!shopId) return {problems: ["Couldn't read the store ID. Reload and try again."]};
    // The App Events API can't retract an event, and a second replay doubles every funnel
    // denominator. The claim gates it, because the funnel read lags ingestion by minutes.
    const claimed = await claimSend(shop, campaign.id, campaign.gid).catch((error) => {
      console.error('Could not claim the send', error);
      return null;
    });
    if (claimed === null) return {problems: ["Couldn't check for an earlier send. Try again."]};
    if (!claimed) {
      return {
        problems: [
          'Events already sent. Create a new campaign to send them again.',
        ],
      };
    }
    // A failed event comes back in `failures`. A throw, such as a failed token request,
    // happens before the first event, and releasing the claim lets the user retry.
    const funnel = await sendCampaignFunnel({
      shopId,
      campaignId: campaign.id,
      startDate: campaign.startDate,
      timeZone,
      channels: campaign.channels,
    }).catch((error) => {
      console.error('Could not start the send', error);
      return null;
    });
    if (!funnel) {
      await releaseSend(shop, campaign.id).catch((error) =>
        console.error('Could not release the send', error),
      );
      return {problems: ["Couldn't start the send. Try again."]};
    }
    const {accepted, failures} = funnel;
    const sent = Object.values(accepted).reduce((total, n) => total + n, 0);
    // Record what landed before reporting failures. Idempotency keys expire after 24 hours,
    // and an unrecorded partial send would let a later run duplicate it.
    const landed = sent
      ? [
          `Shopify received ${sent} of ${eventTotal(campaign.channels)} events. The funnel reports the events that pass validation, and a second send would count them twice.`,
        ]
      : [];
    if (sent > 0) {
      // A failed record keeps the claim, and a second send stays blocked.
      const recorded = await recordSend(shop, campaign.id, sent).then(
        () => true,
        (error) => {
          console.error('Could not record the send', error);
          return false;
        },
      );
      if (!recorded) {
        return {
          heading: "Events sent, but the campaign didn't record them",
          problems: [...landed, ...failures, "Couldn't save the campaign's records."],
        };
      }
    } else {
      // Nothing landed. Releasing the claim lets the user send again.
      await releaseSend(shop, campaign.id).catch((error) =>
        console.error('Could not release the send', error),
      );
    }
    if (failures.length > 0) {
      return {heading: sent ? "Some events didn't send" : undefined, problems: [...landed, ...failures]};
    }
    return {ok: true};
  }
... (1216 unchanged lines)
```

## Query the funnel with Shopify​QL

Query `app_events` the same way that you query `sales`, and filter on the campaign ID that your app attached to each event.

### Query the `app_events` schema

Request the campaign's funnel metrics in one statement, and use `TIMESERIES day` to return a row for each day. The query covers the campaign's dates within the last 30 days.

***

* Shopify builds the `app_events` columns for each store from the events that the store's apps declare.
* Limit a query that filters or groups by your attributes to the last 30 days. Shopify keeps the attribute values for only 30 days.

***

[Shopify​QL](https://shopify.dev/docs/api/shopifyql) [Standard event registry](https://shopify.dev/docs/apps/build/app-events/analytics/standard-event-registry)

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

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

### Total the funnel across the campaign

Sum each metric across the daily rows for the campaign's totals and rates. Then add up each day's sends across channels for the **Messages sent** column in the sales table.

## /app/panels/app-events.jsx

```jsx
... (7 unchanged lines)
export function funnelTotals(funnel, cell) {
  const total = (metric) =>
    (funnel.tableData.rows ?? []).reduce(
      (running, row) => running + Number(cell(row, metric) ?? 0),
      0,
    );


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


  const sent = total('emails_sent') + total('sms_messages_sent') + total('push_notifications_sent');
  const reached = total('emails_delivered') + total('sms_messages_delivered') + total('push_notifications_sent');
  const bounced = total('emails_bounced');
  const unsubscribed = total('email_unsubscribes') + total('sms_unsubscribes');


  const health = {
    bounceRate: rate(bounced, total('emails_sent')),
    unsubRate: rate(unsubscribed, reached),
    reach: rate(reached, sent),
  };


  return {total, rate, sent, reached, bounced, unsubscribed, health};
}
... (264 unchanged lines)
```

## Break the funnel down by channel and by send

Each event includes the channel and the send as attributes. Group by the attributes to compare channels and sends.

### Report bounces and unsubscribes by channel

**Deliverability** shows the bounces and unsubscribes for each channel. The sample app doesn't report SMS bounces, and the standard event registry doesn't define push bounce or unsubscribe events. The panel shows `Not reported` for a missing count.

## /app/panels/app-events.jsx

```jsx
... (30 unchanged lines)
export function reachRowsFor(campaign, total) {
  const channelTotal = (key, slot) => total(CHANNELS[key].metrics[slot]);


  return campaign.channels
    .map((key) => {
      const metrics = CHANNELS[key].metrics;
      return {
        key,
        label: CHANNELS[key].label,
        sent: channelTotal(key, 'sent'),
        reached: channelTotal(key, 'reached'),
        bounced: metrics.bounced ? channelTotal(key, 'bounced') : null,
        unsubscribed: metrics.lost ? channelTotal(key, 'lost') : null,
      };
    })
    .filter((row) => row.sent > 0);
}
... (246 unchanged lines)
```

### Group the funnel by the attributes that you sent

A `GROUP BY template_id` clause returns one row for each send. Filter on `app_name` too, because two apps can use the same campaign ID. Your app reads its name from `currentAppInstallation.app.title`:

## ShopifyQL

```shopifyql
FROM app_events
  SHOW emails_sent, emails_delivered, emails_opened, email_unsubscribes
  WHERE campaign_id = 4210
    AND app_name = 'Campaign dashboard'
  GROUP BY template_id
  SINCE -29d UNTIL today
  ORDER BY template_id ASC
```

The sample app's query requests every sequence metric from `app/channels.js`, including the SMS and push metrics.

To label and order the rows, convert each template ID to a number. Look up the number in the send sequence in `app/app-events.server.js`. For example, the send sequence labels template `4101` as `Announcement`.

Run a separate bounce query that groups by `recipient_status` to separate permanent failures from temporary failures.

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

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

### List reach and engagement by send

Render one row for each send with the reached count and an engagement rate. Leave bounces to the **Deliverability** panel.

Open the campaign. If the funnel shows all zeros after a few minutes, then check your app's event requests in the [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard/monitoring-and-logs) against the [App Events requirements](https://shopify.dev/docs/apps/build/app-events/analytics#requirements).

## /app/panels/app-events.jsx

```jsx
... (185 unchanged lines)
// Reach falls because lists shrink. Engagement tracks the offer.
export function SendPerformance({sequence, integer}) {
  if (sequence.length === 0) return null;


  return (
    <s-section>
      <PanelHeader
        title="Send performance"
        uses={{
          appEvents: 'One event stream per send, published by this app at send time.',
          shopifyql:
            'Groups the events by send and reports the reach of each send.',
        }}
      />
      <s-table>
        <s-table-header-row>
          <s-table-header listSlot="primary">Message</s-table-header>
          <s-table-header listSlot="secondary">Channel</s-table-header>
          <s-table-header listSlot="labeled">Sent</s-table-header>
          <s-table-header listSlot="labeled">Reached</s-table-header>
          <s-table-header listSlot="labeled">Engagement</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {sequence.map((touch) => (
            <s-table-row key={touch.template}>
              <s-table-cell>
                {touch.position}. {touch.name}
              </s-table-cell>
              <s-table-cell>{CHANNELS[touch.channel].label}</s-table-cell>
              <s-table-cell>{integer(touch.sent)}</s-table-cell>
              <s-table-cell>
                {/* Push has no delivery receipt. Repeating the sent count here would
                    read as a perfect delivery rate. */}
                {CHANNELS[touch.channel].metrics.reached ===
                CHANNELS[touch.channel].metrics.sent
                  ? 'Not reported'
                  : integer(touch.reached)}
              </s-table-cell>
              <s-table-cell>
                {integer(touch.engaged)} {touch.action}, {Math.round(touch.rate)}%
              </s-table-cell>
            </s-table-row>
          ))}
        </s-table-body>
      </s-table>
    </s-section>
  );
}
... (60 unchanged lines)
```

### Flag a problem in the events that your app reported

When the funnel shows bounced emails, show a banner with the bounce rate. The banner shows the top bounce reason from the `recipient_status` rows of the bounce query. The banner shows a **Flag incident** button while the campaign is active and today falls inside the campaign's dates. The button adds an `other` annotation to the store's charts. Click **Flag incident**, and the banner shows **Incident flagged.**

***

* On the server, rerun the funnel and bounce queries, and build the annotation's description from the bounce rate and each `recipient_status`.
* Record the flag's annotation ID under `incident:<day>` in the campaign's markers in your app's database. Your app rejects a second flag for the same day.

***

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

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

```javascript
... (69 unchanged lines)
export const sumColumn = (table, metric) =>
  (table.tableData?.rows ?? []).reduce(
    (total, row) => total + Number(row[metric] ?? 0),
    0,
  );


export function detectIncident(campaign, funnel, bounces) {
  const sent = sumColumn(funnel, 'emails_sent');
  const bounced = sumColumn(funnel, 'emails_bounced');
  if (sent === 0 || bounced === 0) return null;


  const rate = (bounced / sent) * 100;
  const reasons = (bounces.tableData?.rows ?? [])
    .filter((row) => Number(row['emails_bounced']) > 0)
    .map(
      (row) =>
        `${row['emails_bounced']} ${String(
          row['recipient_status'] ?? 'unrecorded',
        ).replace(/_/g, ' ')}`,
    )
    .join(', ');


  return {
    rate,
    title: clamp(`${campaign.title} email bounces`, 75),
    description: clamp(
      `${bounced} of ${sent} emails bounced, ${rate.toFixed(1)}%.${
        reasons ? ` Reasons: ${reasons}.` : ''
      }`,
      150,
    ),
  };
}
```

## Report the campaigns on one page

The campaign list summarizes every campaign with one App Events query that groups by `campaign_id` and `app_name`. Replace `app/routes/app.campaigns._index.jsx` with the version from the sample repository's `main` branch. The list shows a new **Messages sent** column, and **Messages sent by campaign** appears after the first replay.

### Read the targets and sends for every campaign

`readAllTargets` collects the target IDs that your app's database stores for each campaign and removes the duplicates. It passes the IDs to `readTargets`, which sends the IDs to the `nodes` query in batches of 250.

Group one App Events query by `campaign_id` and `app_name`. A campaign ID is unique only within one app.

***

[nodes](https://shopify.dev/docs/api/admin-graphql/latest/queries/nodes) [Input limits](https://shopify.dev/docs/api/usage/limits#input-limits) [GROUP BY](https://shopify.dev/docs/api/shopifyql/latest/syntax/group-by)

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

```javascript
... (357 unchanged lines)
// 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}`}]};
  }
}
... (629 unchanged lines)
```

### Summarize the campaigns in one table

Give each campaign a row with the dates, segment, sends, target progress, and annotation and target counts. Report progress against only the store sales target, which already includes product and QR code sales.

Convert the event row's `campaign_id` and the campaign entry's `campaign_id` to strings before you compare them.

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

```jsx
... (186 unchanged lines)
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),
  };
}


// Matched on the app too, because IDs aren't unique across apps.
function ownRow(campaign, rows, appName) {
  // Events outlive the record that emitted them, and a reused ID inherits the sends of
  // whatever held it before. The status says whether this one sent.
  if (campaign.status === 'planned') return null;


  return rows.find(
    (entry) =>
      String(entry['campaign_id']) === String(campaign.id) &&
      String(entry['app_name']) === String(appName),
  );
}


function sendsFor(campaign, rows, appName) {
  const row = ownRow(campaign, rows, appName);
  if (!row) return null;
  const emails = Number(row['emails_sent'] ?? 0);
  const opens = Number(row['emails_opened'] ?? 0);
  return {
    sent:
      emails +
      Number(row['sms_messages_sent'] ?? 0) +
      Number(row['push_notifications_sent'] ?? 0),
    openRate: emails > 0 ? Math.round((opens / emails) * 100) : null,
  };
}


// One row per campaign that sent, with the per-channel split beside it.
function messageMix(campaigns, rows, appName) {
  return campaigns
    .map((campaign) => ({campaign, row: ownRow(campaign, rows, appName)}))
    .filter(({row}) => row)
    .map(({campaign, row}) => ({
      title: campaign.title,
      email: Number(row['emails_sent'] ?? 0),
      sms: Number(row['sms_messages_sent'] ?? 0),
      push: Number(row['push_notifications_sent'] ?? 0),
    }));
}


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

### Report the sends without another request

Pivot the sends from the campaign list's App Events query into one **Messages sent by campaign** table, with a row for each campaign and a column for each channel.

***

* If no campaign has any sends, then the pivot produces a table of zeros. Render the table only after at least one row contains a send.
* Mount each metric card after `window.shopify.shopifyQL` resolves, or the card stays in an error state.

***

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

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

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


  // Nothing sent is a table of zeros. Don't draw one.
  const mix = messageMix(campaigns, sends, appName);
  const anySends = mix.some((row) => row.email + row.sms + row.push > 0);
... (505 unchanged lines)
```

## Run the app

Confirm that your app's events and annotations reach Shopify Analytics.

### Run the app and check the panels

Press `p` in the `shopify app dev` terminal to open the preview URL.

Open an active campaign that started 14 to 28 days ago and ends after today. To create one, give the campaign a start date 14 to 28 days ago and an end date after today. Then click **Activate campaign**.

If you haven't sent the campaign's events yet, then click **Send events** and confirm. Reload the page after a few minutes.

The sales table shows a new **Messages sent** column. **Channel metrics** breaks the stages out by channel. **Send performance** lists each send's reach and engagement. **Deliverability** totals the bounces and unsubscribes.

***

* The sales panels show data only when the store has orders inside the campaign's dates. Bogus Gateway test orders don't reach `sales`. On a development store, create orders with the [`orderCreate`](https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderCreate) mutation and a `processedAt` date inside the campaign's dates.
* The [example app's README](https://github.com/Shopify/example-analytics--campaign-dashboard--react#use-the-dashboard) describes a campaign with sample data, from start to close.

***

[shopify app dev](https://shopify.dev/docs/api/shopify-cli/app/app-dev) [App Events in Analytics](https://shopify.dev/docs/apps/build/app-events/analytics)

## /extensions/app-events/shopify.extension.toml

```toml
[[extensions]]
type = "analytics_app_events"
handle = "app-events"


# The email funnel, in order.
[[extensions.events]]
name = "shopify.marketing.email_sent"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_delivered"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_opened"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_clicked"
kind = "standard"


# The two ways a send goes wrong. A funnel without these reports only wins.
[[extensions.events]]
name = "shopify.marketing.email_bounced"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.email_unsubscribed"
kind = "standard"


# The follow-up touch, on a second channel.
[[extensions.events]]
name = "shopify.marketing.sms_sent"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.sms_delivered"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.sms_clicked"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.sms_unsubscribed"
kind = "standard"


# The last touch, on a third channel. device_platform splits it by iOS and
# Android without a second pair of events.
[[extensions.events]]
name = "shopify.marketing.push_sent"
kind = "standard"


[[extensions.events]]
name = "shopify.marketing.push_opened"
kind = "standard"
```

## /app/app-events.server.js

```javascript
import {shopMorning} from './campaigns';
import {apiVersion} from './shopify.server';


let cached = {token: null, expiresAt: 0};


async function getAppEventsToken() {
  if (cached.token && Date.now() < cached.expiresAt) return cached.token;


  const response = await fetch('https://api.shopify.com/auth/access_token', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      client_id: process.env.SHOPIFY_API_KEY,
      client_secret: process.env.SHOPIFY_API_SECRET,
      grant_type: 'client_credentials',
    }),
  });


  if (!response.ok) {
    throw new Error(`Token request failed: ${response.status}`);
  }


  const {access_token: token, expires_in: expiresIn} = await response.json();
  // Tokens last 60 minutes. Refresh a minute early.
  cached = {token, expiresAt: Date.now() + ((expiresIn ?? 3600) - 60) * 1000};
  return token;
}


const FLOW_ID = 5501;


function marketing(channel, template, extra = {}) {
  return {
    channel,
    message_type: 'campaign',
    flow_id: FLOW_ID,
    template_id: template,
    consent_state: 'opted_in',
    ...extra,
  };
}


// Nine sends over two weeks. Engagement dips on repeats and recovers when the
// offer changes.
export const SEQUENCE = [
  {day: 0, channel: 'email', template: 4101, name: 'Announcement', audience: 52, engaged: 0.42, clicked: 0.28, lost: 1},
  {day: 1, channel: 'sms', template: 4102, name: 'Early access', audience: 21, engaged: 0.34, lost: 0},
  {day: 3, channel: 'email', template: 4103, name: 'Non-openers', audience: 33, engaged: 0.24, clicked: 0.22, lost: 3},
  {day: 5, channel: 'push', template: 4104, name: 'App exclusive', audience: 26, engaged: 0.38},
  {day: 7, channel: 'email', template: 4105, name: 'Bestsellers', audience: 51, engaged: 0.31, clicked: 0.3, lost: 2},
  {day: 8, channel: 'sms', template: 4106, name: 'BOGO offer', audience: 20, engaged: 0.41, lost: 1},
  {day: 11, channel: 'push', template: 4107, name: 'Influencer drop', audience: 25, engaged: 0.45},
  {day: 12, channel: 'email', template: 4108, name: 'Last chance', audience: 49, engaged: 0.36, clicked: 0.25, lost: 2},
  {day: 13, channel: 'sms', template: 4109, name: 'Final hours', audience: 19, engaged: 0.37, lost: 2},
];


// What each channel can report. Push has two stages where email has six, because the
// registry gives it no delivery receipt and no click.
const CHANNELS = {
  email: {
    delivery: 0.94,
    steps: {
      sent: {handle: 'shopify.marketing.email_sent', code: 'snt', hour: 0},
      delivered: {
        handle: 'shopify.marketing.email_delivered',
        code: 'dlv',
        hour: 1,
        extra: {recipient_status: 'delivered'},
      },
      bounced: {
        handle: 'shopify.marketing.email_bounced',
        code: 'bnc',
        hour: 1,
        extra: (index) => ({
          recipient_status: index % 3 === 0 ? 'hard_bounce' : 'soft_bounce',
        }),
      },
      engaged: {handle: 'shopify.marketing.email_opened', code: 'opn', hour: 14},
      clicked: {
        handle: 'shopify.marketing.email_clicked',
        code: 'clk',
        hour: 16,
        extra: {link_domain: 'shop.example.com'},
      },
      lost: {
        handle: 'shopify.marketing.email_unsubscribed',
        code: 'uns',
        hour: 18,
        extra: {recipient_status: 'unsubscribed'},
      },
    },
  },
  sms: {
    delivery: 0.97,
    steps: {
      sent: {
        handle: 'shopify.marketing.sms_sent',
        code: 'snt',
        hour: 0,
        extra: {country_code: 'US'},
      },
      delivered: {
        handle: 'shopify.marketing.sms_delivered',
        code: 'dlv',
        hour: 1,
        extra: {country_code: 'US', recipient_status: 'delivered'},
      },
      engaged: {
        handle: 'shopify.marketing.sms_clicked',
        code: 'clk',
        hour: 4,
        extra: {country_code: 'US', link_domain: 'shop.example.com'},
      },
      lost: {
        handle: 'shopify.marketing.sms_unsubscribed',
        code: 'uns',
        hour: 6,
        extra: {country_code: 'US', recipient_status: 'unsubscribed'},
      },
    },
  },
  push: {
    steps: {
      sent: {
        handle: 'shopify.marketing.push_sent',
        code: 'snt',
        hour: 0,
        extra: (index) => ({
          device_platform: index % 2 === 0 ? 'ios' : 'android',
        }),
      },
      engaged: {
        handle: 'shopify.marketing.push_opened',
        code: 'opn',
        hour: 2,
        extra: (index) => ({
          device_platform: index % 2 === 0 ? 'ios' : 'android',
        }),
      },
    },
  },
};


// Leave out what the channel can't report.
function expand(touch) {
  const channel = CHANNELS[touch.channel];
  const delivered = channel.delivery
    ? Math.round(touch.audience * channel.delivery)
    : touch.audience;
  const engaged = Math.round(delivered * touch.engaged);


  const counts = {
    sent: touch.audience,
    delivered,
    bounced: touch.audience - delivered,
    engaged,
    clicked: Math.round(engaged * (touch.clicked ?? 0)),
    lost: touch.lost ?? 0,
  };


  return Object.entries(counts).flatMap(([name, count]) => {
    const step = channel.steps[name];
    if (!step || count < 1) return [];
    return {
      handle: step.handle,
      channel: touch.channel,
      code: `d${touch.day}${step.code}`,
      count,
      day: touch.day,
      hour: step.hour,
      attributes: (index) =>
        marketing(
          touch.channel,
          touch.template,
          typeof step.extra === 'function' ? step.extra(index) : step.extra,
        ),
    };
  });
}


const STAGES = SEQUENCE.flatMap(expand);


// Every timestamp is an offset from the campaign's start date. The API rejects a
// timestamp more than 5 minutes in the future.
const LAST_OFFSET = Math.max(
  ...STAGES.map((stage) => stage.day * 86400000 + stage.hour * 3600000),
);


export const funnelWindowEnd = (startDate, timeZone) =>
  shopMorning(startDate, timeZone) + LAST_OFFSET;


// The schedule runs two weeks from the start date. A campaign that ends sooner would
// date sends after it closed, and the App Events API can't retract a submitted event.
export const funnelFitsWindow = (startDate, endDate, timeZone) =>
  funnelWindowEnd(startDate, timeZone) <= shopMorning(endDate, timeZone) + 86400000;


// Why an active campaign can't send yet, or null. The page disables the button
// with this reason, and the action checks it again.
export function sendBlocker(campaign, timeZone, locale, now = Date.now()) {
  if (!funnelFitsWindow(campaign.startDate, campaign.endDate, timeZone)) {
    return 'The send schedule runs 14 days, and this campaign is shorter. Send events from a campaign that runs at least 14 days.';
  }
  // The replay dates its events across the two weeks after the start date. A campaign
  // still mid-schedule would stamp the remainder ahead of today.
  const lastSend = funnelWindowEnd(campaign.startDate, timeZone);
  if (lastSend > now) {
    const day = new Intl.DateTimeFormat(locale, {month: 'short', day: 'numeric', timeZone}).format(lastSend);
    return `Available from ${day}, when the 14-day send schedule ends.`;
  }
  return null;
}


// No endpoint deletes an event. A channel the campaign didn't select would add numbers
// to a funnel the app never renders.
const selected = (channels) =>
  STAGES.filter((stage) => (channels ?? []).includes(stage.channel));


// Derived from `SEQUENCE`. A change to the send plan updates this count with it.
export const eventTotal = (channels) =>
  selected(channels).reduce((total, stage) => total + stage.count, 0);


// One send fans out into as many events as its channel reports.
export const sendCount = (channels) =>
  SEQUENCE.filter((touch) => (channels ?? []).includes(touch.channel)).length;


// Bump this to reseed inside the 24-hour idempotency window.
const RUN = 'r3';


// A minute between recipients. The largest send finishes inside the hour it started.
function stageTimestamp(start, day, hour, index) {
  const offset = day * 86400000 + hour * 3600000 + index * 60000;
  return new Date(start + offset).toISOString();
}


async function sendEvent({token, shopId, stage, campaignId, start, index}) {
  // A network failure counts as one failed event. Throwing would lose the received count.
  try {
    return await postEvent({token, shopId, stage, campaignId, start, index});
  } catch (error) {
    return {ok: false, status: 'network', body: error.message};
  }
}


async function postEvent({token, shopId, stage, campaignId, start, index}) {
  const response = await fetch(`https://api.shopify.com/app/${apiVersion}/events`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      shop_id: shopId,
      // Must match the shopify.extension.toml declaration exactly.
      event_handle: stage.handle,
      timestamp: stageTimestamp(start, stage.day, stage.hour, index),
      idempotency_key: `${shopId.split('/').pop()}-${campaignId}-${RUN}-${stage.code}-${index}`,
      // `campaign_id` is the join, the one dimension both categories accept.
      attributes: {campaign_id: campaignId, ...stage.attributes(index)},
    }),
  });


  if (response.status === 202) return {ok: true};
  return {ok: false, status: response.status, body: await response.text()};
}


// A 409, a 429, a 5xx, or a dropped connection gets two more tries, each after a longer wait.
// The retry reuses the idempotency key, and the API counts the event once.
const RETRY_DELAYS_MS = [250, 1000];
const retryable = ({status}) => status === 409 || status === 429 || status === 'network' || status >= 500;


async function sendWithRetry(event) {
  let result = await sendEvent(event);
  for (const delay of RETRY_DELAYS_MS) {
    if (result.ok || !retryable(result)) break;
    await new Promise((resolve) => setTimeout(resolve, delay));
    result = await sendEvent(event);
  }
  return result;
}




export async function sendCampaignFunnel({
  shopId,
  campaignId,
  startDate,
  timeZone,
  channels,
}) {
  const token = await getAppEventsToken();
  const start = shopMorning(startDate, timeZone);
  const accepted = {};
  const failures = [];


  // The API takes one event per request and asks for them in sequence. A real send
  // would work off a queue. The send stops at the first event that still fails after
  // its retries, which keeps a sustained 429 from holding the request open.
  for (const stage of selected(channels)) {
    let sent = 0;
    let failed = null;
    for (let index = 0; index < stage.count && !failed; index += 1) {
      const result = await sendWithRetry({token, shopId, stage, campaignId, start, index});
      if (result.ok) sent += 1;
      else failed = result;
    }
    // Several sends share one handle. This accumulates.
    accepted[stage.handle] = (accepted[stage.handle] ?? 0) + sent;
    if (failed) {
      failures.push(`${stage.handle}: ${failed.status} ${failed.body}`);
      break;
    }
  }


  return {accepted, failures};
}
```

## /app/panels/app-events.jsx

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


import {CHANNELS} from '../channels';


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


export function funnelTotals(funnel, cell) {
  const total = (metric) =>
    (funnel.tableData.rows ?? []).reduce(
      (running, row) => running + Number(cell(row, metric) ?? 0),
      0,
    );


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


  const sent = total('emails_sent') + total('sms_messages_sent') + total('push_notifications_sent');
  const reached = total('emails_delivered') + total('sms_messages_delivered') + total('push_notifications_sent');
  const bounced = total('emails_bounced');
  const unsubscribed = total('email_unsubscribes') + total('sms_unsubscribes');


  const health = {
    bounceRate: rate(bounced, total('emails_sent')),
    unsubRate: rate(unsubscribed, reached),
    reach: rate(reached, sent),
  };


  return {total, rate, sent, reached, bounced, unsubscribed, health};
}


export function reachRowsFor(campaign, total) {
  const channelTotal = (key, slot) => total(CHANNELS[key].metrics[slot]);


  return campaign.channels
    .map((key) => {
      const metrics = CHANNELS[key].metrics;
      return {
        key,
        label: CHANNELS[key].label,
        sent: channelTotal(key, 'sent'),
        reached: channelTotal(key, 'reached'),
        bounced: metrics.bounced ? channelTotal(key, 'bounced') : null,
        unsubscribed: metrics.lost ? channelTotal(key, 'lost') : null,
      };
    })
    .filter((row) => row.sent > 0);
}


export function channelFunnels(campaign, total) {
  return campaign.channels
    .map((key) => ({
      key,
      label: CHANNELS[key].label,
      stages: CHANNELS[key].stages.map((stage) => [stage.label, total(stage.metric)]),
    }))
    .filter((entry) => entry.stages[0][1] > 0);
}


// One total per day, for the Messages sent column beside sales and orders.
export function sendsByDay(funnel, cell) {
  return new Map(
    (funnel.tableData.rows ?? []).map((row) => [
      String(cell(row, 'day')),
      Number(cell(row, 'emails_sent') ?? 0) +
        Number(cell(row, 'sms_messages_sent') ?? 0) +
        Number(cell(row, 'push_notifications_sent') ?? 0),
    ]),
  );
}


// The bounce query groups by recipient_status. Each row names one failure.
export function bounceReasons({bounces, cell, humanize}) {
  return (bounces.tableData.rows ?? [])
    .filter((row) => Number(cell(row, 'emails_bounced')) > 0)
    .map((row) => ({
      reason: humanize(cell(row, 'recipient_status') ?? 'unrecorded'),
      count: Number(cell(row, 'emails_bounced')),
    }));
}


// The only panel that writes to App Events.
export function SendEventsPanel({
  running,
  alreadySent,
  blocked,
  funnel,
  sendCount,
  eventTotal,
  busy,
  submit,
}) {
  // The record is the gate. A partial send closes it too, because idempotency keys
  // expire after 24 hours and a later resend would count every received event twice.
  if (alreadySent || !running) return null;


  // An undeclared column fails on `parseErrors` while the request itself succeeds.
  // Before the declaration propagates, that's the only signal App Events is unready.
  const unavailable =
    funnel.unavailable ??
    (funnel.parseErrors?.length ? funnel.parseErrors.join(' ') : null);


  return (
    <s-section>
      <PanelHeader
        title="Campaign events"
        uses={{
          appEvents:
            'The events this app published as the campaign ran, queryable like any schema.',
        }}
      />
      {unavailable ? (
        <s-banner tone="info" heading="Campaign events aren't available yet">
          {/* The event declarations define the columns. Without them, every column is missing. */}
          <s-paragraph>
            {/Column Not Found/i.test(unavailable)
              ? "The store doesn't have this app's event declarations yet. Run npm run deploy, then reload the page a few minutes later."
              : `Reload the page to try again. The store returned: ${unavailable}`}
          </s-paragraph>
        </s-banner>
      ) : (
        <Fragment>
          <s-paragraph>
            Send events to replay {sendCount} sends as {eventTotal} App Events.
            Sending takes a minute or two.
          </s-paragraph>
          <s-paragraph>
            Channel metrics appear a few minutes after sending. Reload the page to
            see them.
          </s-paragraph>
          <ConfirmButton
            id="send-events-modal"
            label="Send events"
            heading="Send this campaign's events?"
            body={`Shopify keeps all ${eventTotal} events, and no endpoint deletes them. The campaign can send only once.`}
            confirmLabel="Send events"
            loading={busy === 'send-events'}
            disabled={Boolean(busy || blocked)}
            onConfirm={() => submit('send-events')}
          />
          {blocked && <s-paragraph color="subdued">{blocked}</s-paragraph>}
        </Fragment>
      )}
    </s-section>
  );
}


export function ChannelMetrics({funnels, integer}) {
  if (funnels.length === 0) return null;


  return (
    <s-section>
      <PanelHeader
        title="Channel metrics"
        uses={{
          appEvents:
            'Sends, deliveries, and engagement, from the events this app published.',
          shopifyql: "Buckets the events by day across the campaign's window.",
        }}
      />
      <s-table>
        <s-table-header-row>
          <s-table-header listSlot="primary">Channel</s-table-header>
          <s-table-header listSlot="secondary">Stage</s-table-header>
          <s-table-header listSlot="labeled">Events</s-table-header>
          <s-table-header listSlot="labeled">% of sent</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {funnels.flatMap((entry) =>
            entry.stages.map(([label, value]) => (
              <s-table-row key={`${entry.key}-${label}`}>
                <s-table-cell>{entry.label}</s-table-cell>
                <s-table-cell>{label}</s-table-cell>
                <s-table-cell>{integer(value)}</s-table-cell>
                <s-table-cell>
                  {Math.round((value / entry.stages[0][1]) * 100)}%
                </s-table-cell>
              </s-table-row>
            )),
          )}
        </s-table-body>
      </s-table>
    </s-section>
  );
}


// Reach falls because lists shrink. Engagement tracks the offer.
export function SendPerformance({sequence, integer}) {
  if (sequence.length === 0) return null;


  return (
    <s-section>
      <PanelHeader
        title="Send performance"
        uses={{
          appEvents: 'One event stream per send, published by this app at send time.',
          shopifyql:
            'Groups the events by send and reports the reach of each send.',
        }}
      />
      <s-table>
        <s-table-header-row>
          <s-table-header listSlot="primary">Message</s-table-header>
          <s-table-header listSlot="secondary">Channel</s-table-header>
          <s-table-header listSlot="labeled">Sent</s-table-header>
          <s-table-header listSlot="labeled">Reached</s-table-header>
          <s-table-header listSlot="labeled">Engagement</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {sequence.map((touch) => (
            <s-table-row key={touch.template}>
              <s-table-cell>
                {touch.position}. {touch.name}
              </s-table-cell>
              <s-table-cell>{CHANNELS[touch.channel].label}</s-table-cell>
              <s-table-cell>{integer(touch.sent)}</s-table-cell>
              <s-table-cell>
                {/* Push has no delivery receipt. Repeating the sent count here would
                    read as a perfect delivery rate. */}
                {CHANNELS[touch.channel].metrics.reached ===
                CHANNELS[touch.channel].metrics.sent
                  ? 'Not reported'
                  : integer(touch.reached)}
              </s-table-cell>
              <s-table-cell>
                {integer(touch.engaged)} {touch.action}, {Math.round(touch.rate)}%
              </s-table-cell>
            </s-table-row>
          ))}
        </s-table-body>
      </s-table>
    </s-section>
  );
}


// A campaign can earn money and still cost the store contacts.
export function Deliverability({
  sent,
  health,
  reachRows,
  unsubscribed,
  rate,
  integer,
}) {
  if (sent === 0) return null;


  const listResult =
    unsubscribed === 0
      ? 'No unsubscribes.'
      : `${integer(unsubscribed)} ${unsubscribed === 1 ? 'unsubscribe' : 'unsubscribes'}.`;


  return (
    <s-section>
      <PanelHeader
        title="Deliverability"
        uses={{
          appEvents:
            'Bounces and unsubscribes, from the events this app published per send.',
          shopifyql: 'Filters the events to this campaign and totals them by send.',
        }}
      />
      <s-paragraph>
        {health.reach.toFixed(1)}% of {integer(sent)} messages were delivered. Push
        notifications count as sent, because push reports no delivery. {listResult}
      </s-paragraph>
      <s-table>
        <s-table-header-row>
          <s-table-header listSlot="primary">Channel</s-table-header>
          <s-table-header listSlot="labeled">Sent</s-table-header>
          <s-table-header listSlot="labeled">Bounced</s-table-header>
          <s-table-header listSlot="labeled">Unsubscribed</s-table-header>
        </s-table-header-row>
        <s-table-body>
          {reachRows.map((row) => (
            <s-table-row key={row.key}>
              <s-table-cell>{row.label}</s-table-cell>
              <s-table-cell>{integer(row.sent)}</s-table-cell>
              <s-table-cell>
                {row.bounced === null
                  ? 'Not reported'
                  : `${integer(row.bounced)}, ${rate(row.bounced, row.sent).toFixed(1)}% of sent`}
              </s-table-cell>
              <s-table-cell>
                {row.unsubscribed === null
                  ? 'Not reported'
                  : `${integer(row.unsubscribed)}, ${rate(row.unsubscribed, row.reached).toFixed(1)}% of reached`}
              </s-table-cell>
            </s-table-row>
          ))}
        </s-table-body>
      </s-table>
    </s-section>
  );
}
```

## /app/panels/incident.jsx

```jsx
export function IncidentBanner({
  health,
  bounced,
  bounceReasons,
  incidentFlagged,
  total,
  integer,
  busy,
  submit,
  today,
  canFlag,
}) {
  if (bounced === 0) return null;


  return (
    <s-section>
      <s-banner
        tone="info"
        heading={`${health.bounceRate.toFixed(1)}% email bounce rate`}
      >
        <s-paragraph>
          {integer(bounced)} of {integer(total('emails_sent'))} emails bounced
          {bounceReasons.length > 0 ? `. Top reason: ${bounceReasons[0].reason}` : ''}.
        </s-paragraph>
        {incidentFlagged ? (
          <s-box paddingBlockStart="base">
            <s-text color="subdued">Incident flagged.</s-text>
          </s-box>
        ) : canFlag && (
          <>
            <s-paragraph>
              Flag the incident to annotate the store’s sales charts.
            </s-paragraph>
            <s-box paddingBlockStart="base">
              <s-button
                loading={busy === 'flag-incident'}
                disabled={Boolean(busy)}
                onClick={() => submit('flag-incident', {day: today})}
              >
                Flag incident
              </s-button>
            </s-box>
          </>
        )}
      </s-banner>
    </s-section>
  );
}
```

## /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 {
  eventTotal,
  sendBlocker,
  sendCampaignFunnel,
  sendCount,
} from '../app-events.server';
import {
  deleteCampaign,
  findCampaign,
  lockCampaign,
  readCampaigns,
  transportErrors,
  updateCampaign,
  withReturnedErrors,
} from '../campaigns.server';
import {
  claimSend,
  clearStampOperation,
  forgetCampaign,
  recordSend,
  recordStampOperation,
  releaseSend,
} from '../campaign-state.server';
import {readStamping} from '../bulk-stamp.server';
import {queryShopifyQL} from '../shopifyql.server';
import {
  outcomeText,
  readSequence,
} from '../campaign-report.server';
import {
  SHOP_QUERY,
  annotationsQuery,
  appEventsQuery,
  bounceQuery,
  codeSalesQuery,
  deleteTargets,
  emptyTable,
  measureQuery,
  progressValue,
  promotedByQuery,
  qrFunnelQuery,
  qrSalesQuery,
  runIfSupported,
  runOptional,
  salesQuery,
  topProductsQuery,
  sequenceQuery,
  readTargets,
  targetNodes,
  tierSalesQuery,
  touchSalesQuery,
} from '../campaign-queries.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 {getQRCode} from '../models/QRCode.server';
import {
  ChannelMetrics,
  Deliverability,
  SendEventsPanel,
  SendPerformance,
  bounceReasons as readBounceReasons,
  channelFunnels,
  funnelTotals,
  reachRowsFor,
  sendsByDay,
} from '../panels/app-events';
import {
  clearOwnMarks,
  closeCampaign,
  createAudienceTarget,
  createProductTarget,
  createQrTargets,
  createStoreTarget,
  endTargetsOn,
  flagIncident,
  isPlannedMark,
  markMilestone,
  raiseTarget,
  recordMark,
  recordedTarget,
  setAudience,
  setTargetAmounts,
  setProducts,
  stampOrders,
  stampProducts,
  stampTier,
  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 {IncidentBanner} from '../panels/incident';
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, appName, 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,
    funnel,
    sequenceTable,
    bounces,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
  ] = await Promise.all([
    queryShopifyQL(admin, salesQuery(campaign)),
    readTargets(admin, campaign).catch(() => ({data: null})),
    runIfSupported(admin, annotationsQuery(campaign)),
    runOptional(admin, appEventsQuery(campaign, appName)),
    runOptional(admin, sequenceQuery(campaign, appName)),
    runOptional(admin, bounceQuery(campaign, appName)),
    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),
  ]);


  // Each image encodes the code's scan URL. Render it where the merchant
  // plans the campaign, ready to print.
  const qrCodes =
    campaign.status === 'planned'
      ? (
          await Promise.all(
            campaign.qrCodes
              .filter((qrCode) => qrCode.handle)
              .map((qrCode) => getQRCode(qrCode.handle, admin.graphql, session.shop)),
          )
        ).filter(Boolean)
      : [];


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


  return {
    campaign,
    locale,
    fatal: null,
    appId,
    eventTotal: eventTotal(campaign.channels),
    sendCount: sendCount(campaign.channels),
    // 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,
    funnel,
    sequence: readSequence(sequenceTable),
    bounces,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
    qrCodes,
    stamping,
    sendBlocked: sendBlocker(campaign, salesBody.data?.shop?.ianaTimezone ?? 'Etc/UTC', locale),
    // 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, appName, 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 shopId = shopBody.data?.shop?.id ?? null;
  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};
  }


  if (intent === 'send-events') {
    // A planned campaign would date its sends in the future.
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can send events.']};
    }
    const blocked = sendBlocker(
      campaign,
      timeZone,
      safeLocale(new URL(request.url).searchParams.get('locale')),
    );
    if (blocked) return {problems: [blocked]};
    if (!shopId) return {problems: ["Couldn't read the store ID. Reload and try again."]};
    // The App Events API can't retract an event, and a second replay doubles every funnel
    // denominator. The claim gates it, because the funnel read lags ingestion by minutes.
    const claimed = await claimSend(shop, campaign.id, campaign.gid).catch((error) => {
      console.error('Could not claim the send', error);
      return null;
    });
    if (claimed === null) return {problems: ["Couldn't check for an earlier send. Try again."]};
    if (!claimed) {
      return {
        problems: [
          'Events already sent. Create a new campaign to send them again.',
        ],
      };
    }
    // A failed event comes back in `failures`. A throw, such as a failed token request,
    // happens before the first event, and releasing the claim lets the user retry.
    const funnel = await sendCampaignFunnel({
      shopId,
      campaignId: campaign.id,
      startDate: campaign.startDate,
      timeZone,
      channels: campaign.channels,
    }).catch((error) => {
      console.error('Could not start the send', error);
      return null;
    });
    if (!funnel) {
      await releaseSend(shop, campaign.id).catch((error) =>
        console.error('Could not release the send', error),
      );
      return {problems: ["Couldn't start the send. Try again."]};
    }
    const {accepted, failures} = funnel;
    const sent = Object.values(accepted).reduce((total, n) => total + n, 0);
    // Record what landed before reporting failures. Idempotency keys expire after 24 hours,
    // and an unrecorded partial send would let a later run duplicate it.
    const landed = sent
      ? [
          `Shopify received ${sent} of ${eventTotal(campaign.channels)} events. The funnel reports the events that pass validation, and a second send would count them twice.`,
        ]
      : [];
    if (sent > 0) {
      // A failed record keeps the claim, and a second send stays blocked.
      const recorded = await recordSend(shop, campaign.id, sent).then(
        () => true,
        (error) => {
          console.error('Could not record the send', error);
          return false;
        },
      );
      if (!recorded) {
        return {
          heading: "Events sent, but the campaign didn't record them",
          problems: [...landed, ...failures, "Couldn't save the campaign's records."],
        };
      }
    } else {
      // Nothing landed. Releasing the claim lets the user send again.
      await releaseSend(shop, campaign.id).catch((error) =>
        console.error('Could not release the send', error),
      );
    }
    if (failures.length > 0) {
      return {heading: sent ? "Some events didn't send" : undefined, problems: [...landed, ...failures]};
    }
    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 === 'flag-incident') {
    if (campaign.status !== 'running') {
      return {problems: ['Only active campaigns can flag incidents.']};
    }
    const day = String(form.get('day'));
    if (!inWindow(campaign, day)) {
      return {problems: ['Pick a day inside the campaign.']};
    }
    if (campaign.markers?.[`incident:${day}`]) {
      return {problems: ['An incident is already flagged for this day.']};
    }


    const result = await flagIncident(admin, campaign, appName, markedAt(day));
    if (result.userErrors?.length) {
      return {problems: result.userErrors.map((error) => error.message)};
    }
    const saved = await recordMark(
      admin,
      campaign,
      `incident:${day}`,
      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 [sequenceTable, targetsBody] = await Promise.all([
      runOptional(admin, sequenceQuery(closing, appName)),
      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: readSequence(sequenceTable),
      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};
  }


  if (intent === 'delete') {
    // Canceling removed the annotations and targets. Deleting any other status would
    // abandon them on the charts. The promoted products keep their `campaign` metafield.
    if (campaign.status !== 'canceled') {
      return {problems: ['Only a canceled campaign can be deleted.']};
    }
    const {userErrors} = await deleteCampaign(admin, campaign);
    if (userErrors.length) {
      return {problems: userErrors.map((error) => error.message)};
    }
    // The entry is gone, and its ID stays spent. A failed delete leaves an unused row.
    await forgetCampaign(shop, campaign.id).catch((error) =>
      console.error('Could not delete campaign state', error),
    );
    return redirect('/app/campaigns');
  }


  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',
  'send-events': 'Events sent. Reload in a few minutes.',
  'raise-target': 'Target raised',
  milestone: 'Milestone marked',
  'flag-incident': 'Incident flagged',
  '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);
  // An action that landed part of its work names that in the heading.
  const landedHeading = fetcher.data?.heading;
  // 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,
    eventTotal,
    sendCount,
    currencyCode,
    timeZone,
    queryWindow,
    dimensions,
    rows,
    targets,
    annotations,
    funnel,
    sequence,
    bounces,
    sendBlocked,
    promoted,
    tierSales,
    touchSales,
    codeSales,
    qrSales,
    qrFunnel,
    topProducts,
    qrCodes,
    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);


  const integer = (value) =>
    new Intl.NumberFormat(locale).format(Number(value ?? 0));


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


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


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


  const humanize = (value) => String(value ?? '').replace(/_/g, ' ');


  // 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, incidentFlagged} = 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 {total, rate, sent, bounced, unsubscribed, health} = funnelTotals(funnel, cell);


  const reachRows = reachRowsFor(campaign, total);


  const funnels = channelFunnels(campaign, total);


  const sends = sendsByDay(funnel, cell);


  const bounceReasons = readBounceReasons({bounces, cell, humanize});


  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 || landedHeading ? 'warning' : 'critical'}
            heading={
              landedHeading
                ? landedHeading
                : 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 or delete 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>


          <s-section heading="QR codes">
            <s-box paddingBlockStart="base" />
            {(qrCodes ?? []).length > 0 ? (
              <s-stack direction="block" gap="base">
                <s-text>
                  Print the code or scan it from the screen. Each scan opens
                  the storefront with the code’s UTM parameters. QR code sales
                  reports orders placed after a scan.
                </s-text>
                {qrCodes.map((code) => (
                  <s-stack key={code.handle} direction="block" gap="small-200">
                    {code.productTitle && (
                      <s-text>
                        {code.destination === 'cart'
                          ? `Opens checkout with ${code.productTitle} in the cart.`
                          : `Opens the ${code.productTitle} product page.`}
                      </s-text>
                    )}
                    <s-stack
                      direction="block"
                      gap="small-200"
                      inlineSize="200px"
                    >
                      <s-image
                        aspectRatio="1/1"
                        src={code.image}
                        alt={`${code.title} QR code`}
                      />
                      <s-link href={`/app/qrcodes/${code.handle}`}>
                        Edit QR code
                      </s-link>
                    </s-stack>
                  </s-stack>
                ))}
              </s-stack>
            ) : (
              <s-text>
                No QR codes. QR code sales reports orders placed after a scan.
              </s-text>
            )}
            {/* Codes join a campaign before activation. */}
            <s-box paddingBlockStart="base" />
            <s-link href={`/app/qrcodes/new?campaign=${encodeURIComponent(campaign.gid)}`}>
              Create a QR code for this campaign
            </s-link>
          </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}
          sends={sends}
          marks={marks}
          ours={ours}
          running={running}
          markNote={markNote}
          rewrote={fetcher.data?.rewrote}
          exploreHref={exploreHref}
          busy={busy}
          submit={submit}
          currency={currency}
          percent={percent}
        />




        <SendEventsPanel
          running={running}
          funnel={funnel}
          sendCount={sendCount}
          eventTotal={eventTotal}
          alreadySent={campaign.sentEvents != null}
          blocked={sendBlocked}
          busy={busy}
          submit={submit}
        />


        <ChannelMetrics funnels={funnels} integer={integer} />


        <IncidentBanner
          health={health}
          bounced={bounced}
          bounceReasons={bounceReasons}
          incidentFlagged={incidentFlagged}
          total={total}
          integer={integer}
          busy={busy}
          submit={submit}
          today={today}
          canFlag={running && inWindow(campaign, today)}
        />


        <SendPerformance sequence={sequence} integer={integer} />


        <Deliverability
          sent={sent}
          health={health}
          reachRows={reachRows}
          unsubscribed={unsubscribed}
          rate={rate}
          integer={integer}
        />


        <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}
      >
        {campaign.status === 'canceled' && (
          <ConfirmButton
            id="delete-campaign"
            tone="critical"
            label="Delete campaign"
            heading="Delete this campaign?"
            body="Deleting removes the campaign entry. You can't undo this."
            confirmLabel="Delete campaign"
            loading={busy === 'delete'}
            disabled={Boolean(busy)}
            onConfirm={() => submit('delete')}
          />
        )}
      </CampaignLifecycle>
    </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/campaigns.server.js

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

## /app/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/channels.js

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


export const CHANNEL_KEYS = Object.keys(CHANNELS);


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


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


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

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

```javascript
import {SEQUENCE} from './app-events.server';
import {CHANNELS} from './channels';
import {clamp} from './campaigns';


export function readSequence(table) {
  const byTemplate = new Map(
    (table.tableData?.rows ?? []).map((row) => [Number(row['template_id']), row]),
  );


  return SEQUENCE.map((touch, index) => {
    const row = byTemplate.get(touch.template);
    if (!row) return null;


    const metrics = CHANNELS[touch.channel].metrics;
    const sent = Number(row[metrics.sent] ?? 0);
    // A channel this campaign didn't run on. A zero bar reads as a failed send.
    if (sent === 0) return null;


    const reached = Number(row[metrics.reached] ?? 0);
    const engaged = Number(row[metrics.engaged] ?? 0);


    return {
      ...touch,
      position: index + 1,
      sent,
      reached,
      engaged,
      lost: metrics.lost ? Number(row[metrics.lost] ?? 0) : 0,
      rate: reached > 0 ? (engaged / reached) * 100 : 0,
      action: CHANNELS[touch.channel].action,
    };
  }).filter(Boolean);
}


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),
  };
}


export const sumColumn = (table, metric) =>
  (table.tableData?.rows ?? []).reduce(
    (total, row) => total + Number(row[metric] ?? 0),
    0,
  );


export function detectIncident(campaign, funnel, bounces) {
  const sent = sumColumn(funnel, 'emails_sent');
  const bounced = sumColumn(funnel, 'emails_bounced');
  if (sent === 0 || bounced === 0) return null;


  const rate = (bounced / sent) * 100;
  const reasons = (bounces.tableData?.rows ?? [])
    .filter((row) => Number(row['emails_bounced']) > 0)
    .map(
      (row) =>
        `${row['emails_bounced']} ${String(
          row['recipient_status'] ?? 'unrecorded',
        ).replace(/_/g, ' ')}`,
    )
    .join(', ');


  return {
    rate,
    title: clamp(`${campaign.title} email bounces`, 75),
    description: clamp(
      `${bounced} of ${sent} emails bounced, ${rate.toFixed(1)}%.${
        reasons ? ` Reasons: ${reasons}.` : ''
      }`,
      150,
    ),
  };
}
```

## /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,
  appEventsQuery,
  bounceQuery,
  measureQuery,
  orderDaysQuery,
  progressValue,
  readNodes,
  readTargets,
  runOptional,
  targetNodes,
  tierHoldersQuery,
  tierRankQuery,
} from './campaign-queries.server';
import {
  rememberMarkers,
  transportErrors,
  updateCampaign,
} from './campaigns.server';
import {detectIncident} from './campaign-report.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 ?? []),
    ],
  };
}


// No bounce type in the vocabulary. The reason goes in the text.
export async function flagIncident(admin, campaign, appName, markedAt) {
  const [funnel, bounces] = await Promise.all([
    runOptional(admin, appEventsQuery(campaign, appName)),
    runOptional(admin, bounceQuery(campaign, appName)),
  ]);


  const incident = detectIncident(campaign, funnel, bounces);
  if (!incident) {
    return {userErrors: [{message: 'No email bounces to flag.'}]};
  }


  const body = await (
    await admin.graphql(ANNOTATION_CREATE, {
      variables: {
        input: {
          startedAt: markedAt,
          type: 'other',
          title: incident.title,
          description: incident.description,
        },
      },
    })
  ).json();


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

## /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,
  runOptional,
  sliceQuery,
  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, appName, userErrors} = await ensureCampaign(admin, session.shop);


  const [dimensions, targetsBody, sends] = await Promise.all([
    readDimensions(admin, appId),
    readAllTargets(admin, campaigns),
    runOptional(admin, sliceQuery()),
  ]);


  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,
    appName,
    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')),
    sends: sends.tableData.rows ?? [],
    // A failed read has no rows, the same as a store that sent nothing.
    sendsUnavailable: sends.unavailable,
    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),
  };
}


// Matched on the app too, because IDs aren't unique across apps.
function ownRow(campaign, rows, appName) {
  // Events outlive the record that emitted them, and a reused ID inherits the sends of
  // whatever held it before. The status says whether this one sent.
  if (campaign.status === 'planned') return null;


  return rows.find(
    (entry) =>
      String(entry['campaign_id']) === String(campaign.id) &&
      String(entry['app_name']) === String(appName),
  );
}


function sendsFor(campaign, rows, appName) {
  const row = ownRow(campaign, rows, appName);
  if (!row) return null;
  const emails = Number(row['emails_sent'] ?? 0);
  const opens = Number(row['emails_opened'] ?? 0);
  return {
    sent:
      emails +
      Number(row['sms_messages_sent'] ?? 0) +
      Number(row['push_notifications_sent'] ?? 0),
    openRate: emails > 0 ? Math.round((opens / emails) * 100) : null,
  };
}


// One row per campaign that sent, with the per-channel split beside it.
function messageMix(campaigns, rows, appName) {
  return campaigns
    .map((campaign) => ({campaign, row: ownRow(campaign, rows, appName)}))
    .filter(({row}) => row)
    .map(({campaign, row}) => ({
      title: campaign.title,
      email: Number(row['emails_sent'] ?? 0),
      sms: Number(row['sms_messages_sent'] ?? 0),
      push: Number(row['push_notifications_sent'] ?? 0),
    }));
}


// 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,
    appName,
    sends,
    sendsUnavailable,
  } = 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';
  const integer = (value) => new Intl.NumberFormat(locale).format(value ?? 0);


  // 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(' ');


  // Nothing sent is a table of zeros. Don't draw one.
  const mix = messageMix(campaigns, sends, appName);
  const anySends = mix.some((row) => row.email + row.sms + row.push > 0);


  // 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" />


            {/* Each channel reports a different set of stages, and a different funnel. */}
            <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-text slot="details">
                      Reports{' '}
                      {CHANNELS[key].stages
                        .map((stage) => stage.label.toLowerCase())
                        .join(', ')}
                      .
                    </s-text>
                  </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>
          )}
          {sendsUnavailable && (
            <s-banner tone="info" heading="Messages sent didn't load">
              <s-paragraph>
                Reload the page to try again. The store returned: {sendsUnavailable}
              </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">Messages sent</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 reach = sendsFor(campaign, sends, appName);
                  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>
                        {reach ? (
                          <Fragment>
                            {integer(reach.sent)} messages
                            {reach.openRate !== null &&
                              `, ${reach.openRate}% email open rate`}
                          </Fragment>
                        ) : sendsUnavailable && campaign.status !== 'planned' ? (
                          <s-text color="subdued">Couldn’t load</s-text>
                        ) : (
                          <s-text color="subdued">Nothing sent</s-text>
                        )}
                      </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>


        {anySends && (
          <s-section heading="Messages sent by campaign">
            <s-box paddingBlockStart="base" />
            <s-paragraph>
              Messages sent in the last 29 days. App Events keeps 30 days of
              data. Older campaigns don’t appear.
            </s-paragraph>
            <s-table variant="auto">
              <s-table-header-row>
                <s-table-header listSlot="primary">Campaign</s-table-header>
                <s-table-header listSlot="labeled">Email</s-table-header>
                <s-table-header listSlot="labeled">SMS</s-table-header>
                <s-table-header listSlot="labeled">Push</s-table-header>
              </s-table-header-row>
              <s-table-body>
                {mix.map((row) => (
                  <s-table-row key={row.title}>
                    <s-table-cell>{row.title}</s-table-cell>
                    <s-table-cell>{integer(row.email)}</s-table-cell>
                    <s-table-cell>{integer(row.sms)}</s-table-cell>
                    <s-table-cell>{integer(row.push)}</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);
```

## Part three complete!

You built a campaign dashboard that reports a campaign end to end. The app measures the campaign's sales against the previous period, the previous year, and a target. The app marks key dates on the store's analytics timeline. The app reports email, SMS, and push sends as App Events.

The dashboard shows the sends beside the store's sales. App users can flag delivery incidents from the dashboard. The dashboard also summarizes every campaign on one page.

### Next steps

[Standard event registry\
\
](https://shopify.dev/docs/apps/build/app-events/analytics/standard-event-registry)

[The standard events, the attributes that each event accepts, and the metric that each event reports.](https://shopify.dev/docs/apps/build/app-events/analytics/standard-event-registry)

[App Events\
\
](https://shopify.dev/docs/apps/build/app-events)

[Report the rest of your app's activity to Shopify, beyond the campaign's marketing funnel.](https://shopify.dev/docs/apps/build/app-events)

### More resources

* [Deploy your React Router app](https://shopify.dev/docs/apps/launch/deployment) to a testing or production environment.
* [Select a distribution method](https://shopify.dev/docs/apps/launch/distribution) to share your app with users.
