# Shipment tracking

> Shipments with their status and the tracking timeline of the selected one, newest event first.

- Block: [shipments](https://bettersupabase.com/blocks/shipments.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/shipment-tracking

## Install the block

```bash
npx better-supabase add shipments
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input skeleton table
```

From `apps/examples/nextjs/src/features/field/components/shipment-tracking.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/field/components/shipment-tracking.tsx):

```tsx
/**
 * Render inside `<Suspense>`: the latest shipments with their status, and the
 * tracking timeline of the selected one, newest event first. `?shipment=<id>`
 * picks the shipment.
 */
export async function ShipmentTracking({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const [session, t, format, query] = await Promise.all([
    getSession(),
    getExtracted("field"),
    getFormatter(),
    searchParams,
    connection(),
  ]);

  if (!can(session, "shipments.read")) {
    forbidden();
  }

  const picked = v.safeParse(ShipmentParam, query["shipment"]);

  const tracking = await settle(() =>
    loadShipmentTracking(picked.success ? picked.output : undefined),
  );

  if (tracking.kind === "no-organization") {
    return null;
  }

  const newShipment = can(session, "shipments.manage") ? (
    <NewShipmentDialog />
  ) : null;

  const selected = tracking.kind === "ok" ? tracking.data.selected : null;

  return (
    <div data-recipe="shipment-tracking" className="flex flex-col gap-6">
      <Card>
        <CardHeader>
          <CardTitle>{t("Shipments")}</CardTitle>
          <CardDescription>
            {t("The status follows the latest tracking event.")}
          </CardDescription>
          {newShipment ? <CardAction>{newShipment}</CardAction> : null}
        </CardHeader>
        <CardContent>
          {tracking.kind === "error" ? (
            <FieldLoadError title={t("The shipments didn't load")} />
          ) : tracking.data.shipments.length === 0 ? (
            <Empty className="border">
              <EmptyHeader>
                <EmptyMedia variant="icon">
                  <PackageIcon />
                </EmptyMedia>
                <EmptyTitle>{t("No shipments yet")}</EmptyTitle>
                <EmptyDescription>
                  {t("Create a shipment, then buy its label from a carrier.")}
                </EmptyDescription>
              </EmptyHeader>
              {newShipment ? <EmptyContent>{newShipment}</EmptyContent> : null}
            </Empty>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>{t("Reference")}</TableHead>
                  <TableHead>{t("Recipient")}</TableHead>
                  <TableHead>{t("Destination")}</TableHead>
                  <TableHead>{t("Tracking number")}</TableHead>
                  <TableHead>{t("Status")}</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {tracking.data.shipments.map((shipment) => (
                  <TableRow
                    key={shipment.id}
                    data-state={
                      shipment.id === selected?.id ? "selected" : undefined
                    }
                  >
                    <TableCell>
                      <Link
                        href={`/field/shipments?shipment=${shipment.id}`}
                        className="text-sm font-medium underline-offset-4 transition-colors hover:underline"
                        aria-current={
                          shipment.id === selected?.id ? "true" : undefined
                        }
                      >
                        {shipment.reference}
                      </Link>
                    </TableCell>
                    <TableCell className="text-sm">
                      {shipment.recipient}
                    </TableCell>
                    <TableCell className="text-sm text-muted-foreground">
                      {shipment.destination}
                    </TableCell>
                    <TableCell className="text-sm tabular-nums">
                      {shipment.trackingNumber ?? ""}
                    </TableCell>
                    <TableCell>
                      <ShipmentStatusBadge status={shipment.status} />
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
      {tracking.kind === "ok" && selected ? (
        <Card>
          <CardHeader>
            <CardTitle>
              {t("Tracking for {reference}", { reference: selected.reference })}
            </CardTitle>
            <CardDescription>
              {selected.carrier
                ? t("Events from {carrier}, newest first.", {
                    carrier: selected.carrier,
                  })
                : t("Events show once the shipment has a label.")}
            </CardDescription>
          </CardHeader>
          <CardContent>
            {tracking.data.timeline.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <TruckIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No tracking events yet")}</EmptyTitle>
                  <EmptyDescription>
                    {t(
                      "The carrier's webhook adds events as the package moves.",
                    )}
                  </EmptyDescription>
                </EmptyHeader>
                <EmptyContent>
                  <Link
                    href={`/field/shipments?shipment=${selected.id}`}
                    className="text-sm font-medium underline-offset-4 transition-colors hover:underline"
                  >
                    {t("Check again")}
                  </Link>
                </EmptyContent>
              </Empty>
            ) : (
              <ol className="flex flex-col divide-y">
                {tracking.data.timeline.map((step) => (
                  <li
                    key={step.id}
                    className="flex flex-wrap items-center gap-3 py-3"
                  >
                    <ShipmentStatusBadge status={step.status} />
                    <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                      <span className="text-sm">
                        {step.description ?? step.location ?? ""}
                      </span>
                      {step.description && step.location ? (
                        <span className="text-xs text-muted-foreground">
                          {step.location}
                        </span>
                      ) : null}
                    </div>
                    <span className="text-xs text-muted-foreground tabular-nums">
                      {format.dateTime(new Date(step.occurredAt), {
                        dateStyle: "medium",
                        timeStyle: "short",
                      })}
                    </span>
                  </li>
                ))}
              </ol>
            )}
          </CardContent>
        </Card>
      ) : null}
    </div>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Shipment tracking" in a React web app that uses shadcn/ui components. Shipments with their status and the tracking timeline of the selected one, newest event first.

It uses the better-supabase `shipments` block (Shipments). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add shipments` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button card dialog empty field input skeleton table`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the screen. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/field/components/shipment-tracking.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/shipment-tracking.md
- Block docs: https://bettersupabase.com/docs/blocks/shipments
```
