Skip to content

Fruit Passport

Status

Plugin-side and PWA shipped (2026-04-25); the stamping hook becomes active once the plugin is deployed to A2.

The Fruit Passport is the Fruit Plug answer to a loyalty card you actually want to fill in. Every unique fruit a customer has tasted earns a stamp on /account/passport. It is private to the customer and rewards exploration of the catalogue.

Schema

wp_fruitplug_passport:

Column Type Notes
id BIGINT UNSIGNED AUTO_INCREMENT surrogate primary key
user_id BIGINT UNSIGNED NOT NULL the WP user who tried the fruit
slug VARCHAR(64) NOT NULL canonical fruit-taxonomy slug, not the Woo product_id
product_id BIGINT UNSIGNED NULL denormalised — convenient for ops queries
order_id BIGINT UNSIGNED NULL the order that earned the stamp; null for legacy stamps
first_tried_at DATETIME locked on the first stamp; never overwritten
times_ordered INT UNSIGNED bumped on every repeat order via INSERT … ON DUPLICATE KEY UPDATE

The unique constraint that prevents duplicate stamps lives on (user_id, slug). Slugs are validated upstream against Fruitplug\Passport\PassportSlugs::ALL, which is kept in sync with apps/web/lib/fruit-taxonomy.ts. A re-listed product with a new product_id but the same slug still resolves to the same stamp.

Earning a stamp

flowchart LR
  A[woocommerce_order_status_completed] --> B[Fruitplug\\Passport\\StampingHook]
  B --> C{post_name in<br/>PassportSlugs::ALL?}
  C -- yes --> D[PassportStore::stamp]
  D --> E[(wp_fruitplug_passport)]
  C -- no --> X[skip — gift cards, addons]

Stamps earn via completed orders — not just placed; this prevents refunds from showing as stamps. Side benefits of using the _completed event:

  • The order's items have already passed through fulfilment, so the customer received the fruit. A taste they never had is never claimed.
  • Re-running the hook for the same order is safe: INSERT … ON DUPLICATE KEY UPDATE times_ordered = times_ordered + 1 is idempotent on (user_id, slug) and first_tried_at is locked on the first row.
  • The stamping hook runs at priority 30 on the same hook as PointsLedger::award_for_order (priority 10), so the points ledger row is always written first. Either side rerunning later (e.g. WP admin re-firing the event) is harmless.

REST endpoint

GET /wp-json/fruitplug/v1/passport — JWT-gated via BearerAuth, returns the caller's stamps.

{
  "user_id": 42,
  "count": 14,
  "stamps": [
    {
      "slug": "mangosteen",
      "first_tried_at": "2026-03-04 10:42:01",
      "order_id": 1083,
      "times_ordered": 3
    }
  ]
}

A request with no Authorization: Bearer … header gets 401; the global WP REST permission wrapper handles the response shape.

PWA surface

/account/passport (server component, JWT-gated):

  • Top card: a circular SVG progress ring + "You've tried X of 32" headline.
  • Filter chips for the 16 botanical families. Each chip carries a stamped/total ratio so customers can see which family they're closest to completing.
  • 32 cards in a responsive grid (2 cols on mobile, up to 5 cols on desktop) with two visual states:
  • Stamped — full-colour fruit image, magenta check badge, "Tried 4 Mar 2026" caption.
  • Locked — same image but desaturated and blurred behind a dark veil with a lock icon. Still tappable; links straight to /p/{slug} so a locked tile becomes a discovery surface.

The cards themselves are server components (they need node:fs via localFruitImage); the family-chip filter is the only client-side wrapper.

Privacy

  • Passport data is never shared with other customers. There is no public profile.
  • Stamps are deleted with the user via the standard WP user-deletion path (the id-keyed table participates in wp_delete_user's cleanup; see Migrations.php).
  • order_id is stored for ops triage only; the customer-facing UI shows the date, not the order number.

Open questions

  • Gift orders — today the buyer earns the stamp, even if the recipient is a different person. We can't fix this until the gifting flow stores a recipient_user_id on the order. When it does, swap the user id in StampingHook::on_order_completed to prefer that meta.
  • Backfill — historic completed orders aren't stamped automatically. A WP-CLI command (wp fruitplug passport backfill) is on the roadmap; for now the table starts empty for existing customers when the plugin first deploys.
  • Reward unlocks — the schema doesn't yet model "complete the Annonaceae row → free upgrade next box". That's a Loyalty concern; rewards will live in points_ledger with reason='passport_unlock' when we add it.