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 + 1is idempotent on(user_id, slug)andfirst_tried_atis 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/totalratio 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 inwp_delete_user's cleanup; seeMigrations.php). order_idis 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_idon the order. When it does, swap the user id inStampingHook::on_order_completedto 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_ledgerwithreason='passport_unlock'when we add it.