MPS-21: public profile page, offer-history identity, shop offers inbox, actions hub rebuild

- Offer history & offer page show the buyer's display name (User.display_name
  = the public `name` handle; `full_name` is private) linked to a profile
  page — never the email. _serialize_offer drops buyer_email; events carry
  actor_name/actor_handle/actor_id, header carries buyer_name/buyer_handle.
- New public profile page: GET /profile/{handle} (views/user.py:user_profile,
  template profile.j2). Shows gravatar (User.gravatar_url(size) — forced
  identicon unless the user opted into Gravatar), member-since, owned/edited
  shops, and a <details> "Show email" that is server-gated: only the user
  themselves, or a shop owner/editor viewing in that shop's context
  (?shop={shop_id}) when the profile user has transacted there (an offer or
  an invoice).
- New operator offers inbox: GET /s/{shop_id}/offers (@shop_editor_required,
  shop_offers.j2) — open offers first, each row links to /o/{id} and the
  buyer's profile. Reachable from /actions/view via a new "Offers" button
  (shown when shop.offer_enabled).
- /actions/view rebuilt: one flat .action-button-grid (Grid auto-fit,
  minmax(15rem,1fr)) inside a properly-padded .action-columns well — fixes
  the off-balance two-column layout and buttons overflowing the well; no
  <br> spacers. Styleguide gains profile-card and action-button-grid
  patterns.
- offer.j2: buyer name shown (linked to profile); "Buyer:"/"Seller:" message
  lines renamed "Buyer note:"/"Seller note:" to disambiguate.

Tests: 11 new functional tests (profile render + email gating, offers inbox,
actions button, styleguide). 989 passed.
This commit is contained in:
russell@unturf.com 2026-05-12 17:47:24 -04:00
parent cb61227b2c
commit e97d18bccd
No known key found for this signature in database
14 changed files with 654 additions and 68 deletions

View file

@ -232,7 +232,7 @@ All components are documented with live examples at `/styleguide`. The styleguid
| Wells | `#wells` | Content wells and containers |
| Alerts | `#alerts` | Success, info, warning, danger alerts |
| Status | `#status` | Status indicators |
| Product Cards | `#cards` | Product grid cards |
| Product Cards | `#cards` | Product grid cards, profile card (`.profile-card-header` / `.profile-avatar` / `.profile-handle` / `.profile-email-reveal`), action button grid (`.action-columns` / `.action-button-grid`) |
| Cart | `#cart` | Cart and checkout components |
| Gift Cards | `#gift-cards` | Gift card purchase, balance check, management |
| Comments | `#comments` | Comment form and list |

View file

@ -111,11 +111,34 @@ POST /o/{offer_id}/accept accept current amount (terminal)
POST /o/{offer_id}/decline decline current amount (terminal)
POST /o/{offer_id}/withdraw buyer-only terminal pull
POST /o/{offer_id}/checkout buyer pays accepted offer
GET /s/{shop_id}/offers operator inbox of all offers for the shop
```
`offer_open` is registered before the `product_slug` catch-all so
`/p/{id}/offer` is not shadowed.
### Operator inbox (`/s/{shop_id}/offers`)
`views/offer.py:shop_offers` (`@shop_editor_required`) lists every offer
for the shop — open (pending/countered) first, sorted by last action, then
terminal offers — in `shop_offers.j2`. Each row links to `/o/{id}` and to
the buyer's profile (`/profile/{handle}?shop={shop_id}`). Reachable from
`/actions/view` via the "🤝 Offers" button (shown when `shop.offer_enabled`).
Incoming offers still email the shop owners (`send_offer_received_email`);
this inbox is the in-app counterpart.
### Identity / privacy
Offer history and the offer page show the buyer's **display name**
(`User.display_name`, which is the public `name` handle — `full_name` is
private) linked to `/profile/{handle}`, never the email. The profile page
reveals the email only to the user themselves, or to a shop owner/editor
viewing in that shop's context (`?shop={shop_id}`) when the profile user
has actually transacted there (an offer or an invoice) — see
`views/user.py:user_profile`. `_serialize_offer` carries `buyer_name` /
`buyer_handle` and per-event `actor_name` / `actor_handle` / `actor_id`
(no email).
### Capability-driven presentation
Every POST route works as a plain browser form submit: the server flashes