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:
parent
cb61227b2c
commit
e97d18bccd
14 changed files with 654 additions and 68 deletions
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -452,10 +452,34 @@ Form sections:
|
|||
Routes (registered before `product_slug` / `shop_slug` catch-alls):
|
||||
- `/a/{auction_id}` + `/a/{id}.json` + `/a/{id}/{bid,buy-now,watch,checkout}`
|
||||
- `/p/{product_id}/offer` (open) + `/o/{offer_id}` + `/o/{id}/{counter,accept,decline,withdraw,checkout}`
|
||||
- `/s/{shop_id}/offers` — operator inbox (`@shop_editor_required`), linked from `/actions/view`
|
||||
|
||||
Offer/auction POST routes are **capability-driven**: a plain browser
|
||||
submit gets a flash + `302` redirect; an AJAX submit (`X-Requested-With:
|
||||
XMLHttpRequest`) gets JSON. `static/js/offer.js` + `auction.js` are the
|
||||
enhancement layers. `offer.j2` shows a `.offer-state-notice` banner so
|
||||
the state is clear without a flash.
|
||||
|
||||
Identity/privacy: never render a user's email in offer/auction UI. Show
|
||||
`User.display_name` (= the public `name` handle; **`full_name` is
|
||||
private**) linked to `/profile/{handle}`. The public profile page
|
||||
(`views/user.py:user_profile`, route `user_profile` → `/profile/{name}`)
|
||||
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 transacted there. `User.gravatar_url(size)` forces an identicon
|
||||
unless the user opted into Gravatar (`user.gravatar`).
|
||||
|
||||
See `docs/auction-house.md` and `docs/make-offer.md` for full state
|
||||
machines and architecture.
|
||||
|
||||
### Actions hub (`/actions/view`)
|
||||
|
||||
`actions_view.j2` is one flat `.action-button-grid` (Grid `auto-fit`,
|
||||
`minmax(15rem, 1fr)`) of `.mps-button` links inside an `.action-columns`
|
||||
well — no `<br>` spacers, no fixed two-column split. Add new operator
|
||||
shortcuts as another `<a class="mps-button product-edit-button">` in that
|
||||
grid; it balances and wraps on its own.
|
||||
|
||||
## Feature Kill Switches (MPS-22)
|
||||
|
||||
Global feature flags live in `data/development.ini` (and override via env var
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import hashlib
|
||||
import uuid
|
||||
|
||||
import bcrypt
|
||||
|
|
@ -196,6 +197,31 @@ class User(RBase, Base):
|
|||
and self.s3_access_key and self.s3_secret_key
|
||||
)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Public-facing name — always the user-chosen handle (``name``).
|
||||
|
||||
``full_name`` is *private* (collected for billing/shipping), so it
|
||||
must never be surfaced here. We expose ``display_name`` in public
|
||||
contexts (offer history, profile page, emails to the user) instead
|
||||
of the email address."""
|
||||
return self.name
|
||||
|
||||
def gravatar_url(self, size=80):
|
||||
"""Deterministic avatar URL.
|
||||
|
||||
Always derived from the md5 of the (lowercased) email — the hash
|
||||
is one-way, so this does not disclose the address. When the user
|
||||
has NOT opted into Gravatar we force the generated identicon
|
||||
(``f=y``) so their real photo is never surfaced; opted-in users
|
||||
get their actual Gravatar with the identicon as the fallback."""
|
||||
email = (self.email or "").strip().lower()
|
||||
digest = hashlib.md5(email.encode("utf-8")).hexdigest()
|
||||
url = f"https://www.gravatar.com/avatar/{digest}?d=identicon&s={int(size)}"
|
||||
if not self.gravatar:
|
||||
url += "&f=y"
|
||||
return url
|
||||
|
||||
def set_active_shop(self, shop):
|
||||
self.active_shop_id = shop.id
|
||||
self.dbsession.add(self)
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ def includeme(config):
|
|||
config.add_route("shop_products", "/s/{shop_id}/products")
|
||||
|
||||
config.add_route("shop_sales", "/s/{shop_id}/sales")
|
||||
config.add_route("shop_offers", "/s/{shop_id}/offers")
|
||||
config.add_route("shop_comments", "/s/{shop_id}/comments")
|
||||
config.add_route("shop_analytics", "/s/{shop_id}/analytics")
|
||||
config.add_route("product_analytics", "/s/{shop_id}/analytics/{product_id}")
|
||||
|
|
@ -256,3 +257,9 @@ def includeme(config):
|
|||
config.add_route("offer_withdraw", "/o/{offer_id}/withdraw")
|
||||
config.add_route("offer_checkout", "/o/{offer_id}/checkout")
|
||||
config.add_route("offer_page", "/o/{offer_id}")
|
||||
|
||||
# Public user profile. Lives under /profile/{name} (not /u/{name}) so it
|
||||
# can never shadow the many specific /u/... routes. The email address is
|
||||
# only revealed to a shop owner/editor viewing in that shop's context
|
||||
# (?shop={shop_id}) when the profile user has actually transacted there.
|
||||
config.add_route("user_profile", "/profile/{user_name}")
|
||||
|
|
|
|||
|
|
@ -1684,6 +1684,64 @@ div.edit-page > section.edit-card-full {
|
|||
}
|
||||
.settings-form-actions .mps-submit { grid-column: 2; }
|
||||
|
||||
/* Public user profile (profile.j2). Grid only. */
|
||||
.profile-page { display: grid; gap: var(--space-4, 16px); }
|
||||
.profile-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-4, 16px);
|
||||
align-items: center;
|
||||
}
|
||||
.profile-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--surface-dim, #f9f9fa);
|
||||
}
|
||||
.profile-identity h1 { margin: 0; }
|
||||
.profile-handle,
|
||||
.profile-meta {
|
||||
margin: var(--space-1, 4px) 0 0;
|
||||
color: var(--text-muted, #777);
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
}
|
||||
.profile-email-reveal { margin-top: var(--space-3, 12px); }
|
||||
.profile-email-reveal summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.profile-email { margin: var(--space-2, 8px) 0 0; }
|
||||
.profile-shop-list {
|
||||
margin: var(--space-2, 8px) 0 0;
|
||||
padding-left: var(--space-4, 16px);
|
||||
display: grid;
|
||||
gap: var(--space-1, 4px);
|
||||
}
|
||||
|
||||
/* Shop offers inbox (shop_offers.j2). */
|
||||
.shop-offers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.shop-offers-table th,
|
||||
.shop-offers-table td {
|
||||
text-align: left;
|
||||
padding: var(--space-2, 8px) var(--space-3, 12px);
|
||||
border-bottom: 1px solid var(--input-border, #e0e0e0);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.shop-offers-table tr.offer-row-needs-action {
|
||||
background: var(--alert-info-bg, #dce8ff);
|
||||
}
|
||||
.shop-offers-table tr.offer-row-terminal { opacity: 0.7; }
|
||||
.offer-row-flag {
|
||||
margin-left: var(--space-2, 8px);
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-primary, #5871ad);
|
||||
}
|
||||
|
||||
/* Render order on the edit page (CSS order property reorders without
|
||||
changing HTML source order):
|
||||
1. Edit Title, Description, or Visibility (full width, top)
|
||||
|
|
@ -1967,21 +2025,22 @@ section.checkout-page .well {
|
|||
background-image: url("/static/img/trans-green.png");
|
||||
}
|
||||
|
||||
/* Action columns - true 50/50 equal columns */
|
||||
/* Action hub (actions_view.j2): one flat, balanced grid of buttons that
|
||||
wraps responsively. Track min-width (15rem) is comfortably wider than
|
||||
the widest button label so nowrap buttons never overflow the well.
|
||||
Grid only — no flex. */
|
||||
.action-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5, 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.action-columns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
}
|
||||
.action-button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: var(--space-3, 12px);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,66 +2,36 @@
|
|||
|
||||
{% block content -%}
|
||||
|
||||
{% if request.shop %}
|
||||
<section class="action-columns well">
|
||||
<div class="action-button-grid">
|
||||
|
||||
<div class="actions-left">
|
||||
{% if request.shop %}
|
||||
|
||||
<a href="/s/{{ request.shop.id }}/{{ request.shop.slug }}" class="mps-button product-edit-button">  View Shop</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/products" class="mps-button product-edit-button">  View Products</a>
|
||||
<a href="/s/{{ request.shop.id }}/{{ request.shop.slug }}" class="mps-button product-edit-button">View Shop</a>
|
||||
<a href="/s/{{ request.shop.id }}/products" class="mps-button product-edit-button">View Products</a>
|
||||
|
||||
{% if request.user in request.shop.owners %}
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">  View Coupons</a>
|
||||
|
||||
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">View Coupons</a>
|
||||
{% if request.shop.gift_card_enabled %}
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/gift-cards/manage" class="mps-button product-edit-button">  Gift Cards</a>
|
||||
<a href="/s/{{ request.shop.id }}/gift-cards/manage" class="mps-button product-edit-button">🎁 Gift Cards</a>
|
||||
{% endif %}
|
||||
{% if request.shop.offer_enabled %}
|
||||
<a href="/s/{{ request.shop.id }}/offers" class="mps-button product-edit-button">🤝 Offers</a>
|
||||
{% endif %}
|
||||
<a href="/s/{{ request.shop.id }}/users" class="mps-button product-edit-button">👤 Shop Users</a>
|
||||
<a href="/s/{{ request.shop.id }}/sales" class="mps-button product-edit-button">💰 Shop Sales</a>
|
||||
<a href="/s/{{ request.shop.id }}/comments" class="mps-button product-edit-button">💬 Shop Comments</a>
|
||||
<a href="/s/{{ request.shop.id }}/analytics" class="mps-button product-edit-button">📊 Shop Analytics</a>
|
||||
<a href="/s/{{ request.shop.id }}/settings" class="mps-button product-edit-button">⚙ Shop Settings</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="/s/locations" class="mps-button product-edit-button">🏠 Shop Locations</a>
|
||||
|
||||
{% if request.is_saas_domain %}
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/u/shops" class="mps-button product-edit-button">⛶ Switch Shop</a>
|
||||
<a href="/u/shops" class="mps-button product-edit-button">⛭ Switch Shop</a>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="actions-right">
|
||||
{% if request.shop and request.user in request.shop.owners %}
|
||||
<a href="/s/{{ request.shop.id }}/users" class="mps-button product-edit-button">👤   Shop Users</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/sales" class="mps-button product-edit-button">💰   Shop Sales</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/comments" class="mps-button product-edit-button">💬   Shop Comments</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/analytics" class="mps-button product-edit-button">📊   Shop Analytics</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="/s/{{ request.shop.id }}/settings" class="mps-button product-edit-button">⚙   Shop Settings</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
{% endif %}
|
||||
{% if request.shop %}
|
||||
<a href="/s/locations" class="mps-button product-edit-button">🏠   Shop Locations</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@
|
|||
·
|
||||
Round {{ round_count }}
|
||||
</p>
|
||||
<p><strong>Buyer:</strong> <a href="/profile/{{ buyer_handle }}{% if shop_id %}?shop={{ shop_id }}{% endif %}">{{ buyer_name }}</a></p>
|
||||
{% if buyer_message %}
|
||||
<p><em>Buyer:</em> {{ buyer_message }}</p>
|
||||
<p><em>Buyer note:</em> {{ buyer_message }}</p>
|
||||
{% endif %}
|
||||
{% if seller_message %}
|
||||
<p><em>Seller:</em> {{ seller_message }}</p>
|
||||
<p><em>Seller note:</em> {{ seller_message }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
@ -105,7 +106,7 @@
|
|||
{% for e in events %}
|
||||
<li class="offer-event">
|
||||
<strong>{{ e.event_human }}</strong>
|
||||
{% if e.actor_email %} by {{ e.actor_email }}{% else %} (system){% endif %}
|
||||
{% if e.actor_id %} by <a href="/profile/{{ e.actor_handle }}{% if shop_id %}?shop={{ shop_id }}{% endif %}">{{ e.actor_name }}</a>{% else %} <span class="offer-event-system">(system)</span>{% endif %}
|
||||
{% if e.amount is not none %} · ${{ "%.2f"|format(e.amount) }}{% endif %}
|
||||
{% if e.message %} — <em>{{ e.message }}</em>{% endif %}
|
||||
</li>
|
||||
|
|
|
|||
58
make_post_sell/templates/profile.j2
Normal file
58
make_post_sell/templates/profile.j2
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{%- block append_to_head_tag_section %}
|
||||
<title>{{ display_name }} — profile</title>
|
||||
{%- endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="one-column profile-page">
|
||||
|
||||
<div class="profile-card well">
|
||||
<div class="profile-card-header">
|
||||
<img class="profile-avatar" src="{{ gravatar_url }}" alt="" width="80" height="80" loading="lazy" />
|
||||
<div class="profile-identity">
|
||||
<h1 class="type-title">{{ display_name }}</h1>
|
||||
<p class="profile-handle">@{{ handle }}</p>
|
||||
{% if member_since_human %}<p class="profile-meta">Member since {{ member_since_human }}</p>{% endif %}
|
||||
<p class="profile-meta">{{ shop_count }} shop{{ '' if shop_count == 1 else 's' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if email %}
|
||||
{# Email is never shown by default — the viewer must opt to reveal it.
|
||||
<details> works with no JS. The server only sends `email` when the
|
||||
viewer is allowed to see it (self, or a shop operator viewing in
|
||||
that shop's context with prior transaction history). #}
|
||||
<details class="profile-email-reveal">
|
||||
<summary>Show email address</summary>
|
||||
<p class="profile-email"><a href="mailto:{{ email }}">{{ email }}</a></p>
|
||||
{% if not is_self and shop_context %}
|
||||
<p class="profile-meta">Visible to you as an operator of {{ shop_context.name }}.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% elif shop_context and viewer_is_shop_editor and not profile_has_shop_history %}
|
||||
<p class="profile-meta">This person hasn't transacted with {{ shop_context.name }} — their email isn't available here.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if shop_context and viewer_is_shop_editor and profile_has_shop_history %}
|
||||
<p class="profile-meta">
|
||||
With {{ shop_context.name }}:
|
||||
{{ shop_offer_count }} offer{{ '' if shop_offer_count == 1 else 's' }},
|
||||
{{ shop_invoice_count }} purchase{{ '' if shop_invoice_count == 1 else 's' }}.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if public_shops %}
|
||||
<div class="profile-shops well">
|
||||
<h2 class="type-title">Shops</h2>
|
||||
<ul class="profile-shop-list">
|
||||
{% for s in public_shops %}
|
||||
<li><a href="/s/{{ s.id }}/{{ s.slug }}">{{ s.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
56
make_post_sell/templates/shop_offers.j2
Normal file
56
make_post_sell/templates/shop_offers.j2
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{%- block append_to_head_tag_section %}
|
||||
<title>Offers — {{ shop.name }}</title>
|
||||
{%- endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="one-column shop-offers-page">
|
||||
|
||||
<div class="well">
|
||||
<h1 class="type-title">Offers for {{ shop.name }}</h1>
|
||||
<p class="profile-meta">
|
||||
{% if open_count %}{{ open_count }} open{% if open_count != offers|length %} of {{ offers|length }} total{% endif %}.{% else %}No open offers.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if offers %}
|
||||
<div class="well">
|
||||
<table class="shop-offers-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Buyer</th>
|
||||
<th>Amount</th>
|
||||
<th>Round</th>
|
||||
<th>State</th>
|
||||
<th>Last action</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in offers %}
|
||||
<tr class="{% if o.waiting_on_seller %}offer-row-needs-action{% elif o.is_terminal %}offer-row-terminal{% endif %}">
|
||||
<td>{{ o.product_title }}</td>
|
||||
<td>{% if o.buyer_handle %}<a href="/profile/{{ o.buyer_handle }}?shop={{ shop.id }}">{{ o.buyer_name }}</a>{% else %}{{ o.buyer_name }}{% endif %}</td>
|
||||
<td>${{ "%.2f"|format(o.current_amount) }}</td>
|
||||
<td>{{ o.round_count }}</td>
|
||||
<td>
|
||||
<span class="offer-state-badge offer-state-{{ o.state }}">{{ o.state_human }}</span>
|
||||
{% if o.waiting_on_seller %}<span class="offer-row-flag">your turn</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ o.last_action_human }}</td>
|
||||
<td><a href="/o/{{ o.id }}" class="mps-button-small">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="well">
|
||||
<p>No one has made an offer on a product in this shop yet. When they do, the negotiation shows up here — and you get an email.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -814,6 +814,59 @@ Dark mode overrides via --notice-*-bg and --notice-*-border tokens.</div>
|
|||
<div class="sg-code">.serp — auto-fit grid, minmax(160px, 1fr)
|
||||
.serp-item — hover: brightness change, transition 700ms
|
||||
.serp-thumbnail — border-radius: 4px, width: 100%</div>
|
||||
|
||||
<div class="sg-subsection">
|
||||
<div class="sg-label">Profile card (profile.j2)</div>
|
||||
<section class="profile-page" style="max-width: 480px;">
|
||||
<div class="profile-card well">
|
||||
<div class="profile-card-header">
|
||||
<img class="profile-avatar" src="https://www.gravatar.com/avatar/00000000000000000000000000000000?d=identicon&s=160&f=y" alt="" width="80" height="80" />
|
||||
<div class="profile-identity">
|
||||
<h1 class="type-title">jane-doe</h1>
|
||||
<p class="profile-handle">@jane-doe</p>
|
||||
<p class="profile-meta">Member since May 2026</p>
|
||||
<p class="profile-meta">2 shops</p>
|
||||
</div>
|
||||
</div>
|
||||
<details class="profile-email-reveal">
|
||||
<summary>Show email address</summary>
|
||||
<p class="profile-email"><a href="#">jane@example.com</a></p>
|
||||
<p class="profile-meta">Visible to you as an operator of Example Shop.</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="profile-shops well">
|
||||
<h2 class="type-title">Shops</h2>
|
||||
<ul class="profile-shop-list"><li><a href="#">Example Shop</a></li><li><a href="#">Side Project</a></li></ul>
|
||||
</div>
|
||||
</section>
|
||||
<div class="sg-code">.profile-page — grid, gap var(--space-4)
|
||||
.profile-card-header — auto 1fr; avatar + identity
|
||||
.profile-avatar — 80×80, var(--radius-md)
|
||||
.profile-handle / .profile-meta — var(--text-muted), var(--text-sm)
|
||||
.profile-email-reveal — <details>; email never shown by default (works no-JS)
|
||||
.profile-shop-list — grid list, var(--space-1) gap
|
||||
Email is server-gated: only sent when viewer is self, or a shop operator
|
||||
viewing in that shop's context with prior transaction history.</div>
|
||||
</div>
|
||||
|
||||
<div class="sg-subsection">
|
||||
<div class="sg-label">Action button grid (actions_view.j2)</div>
|
||||
<section class="action-columns well" style="max-width: 720px;">
|
||||
<div class="action-button-grid">
|
||||
<a href="#" class="mps-button product-edit-button">View Shop</a>
|
||||
<a href="#" class="mps-button product-edit-button">View Products</a>
|
||||
<a href="#" class="mps-button product-edit-button">🤝 Offers</a>
|
||||
<a href="#" class="mps-button product-edit-button">💰 Shop Sales</a>
|
||||
<a href="#" class="mps-button product-edit-button">💬 Shop Comments</a>
|
||||
<a href="#" class="mps-button product-edit-button">⚙ Shop Settings</a>
|
||||
</div>
|
||||
</section>
|
||||
<div class="sg-code">.action-columns — well wrapper, max-width 1100px, centered, padded
|
||||
.action-button-grid — grid, repeat(auto-fit, minmax(15rem, 1fr)), gap var(--space-3)
|
||||
One flat grid of .mps-button links — balances and wraps responsively.
|
||||
15rem track is wider than the widest label so nowrap buttons never
|
||||
overflow the well. Grid only — no flex, no <br> spacers.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6055,6 +6055,157 @@ class TestSettingsFormStyleguide(_AuthenticatedBase):
|
|||
self.assertNotIn("silently rejected", body)
|
||||
|
||||
|
||||
class TestUserProfile(_AuthenticatedBase):
|
||||
"""Public /profile/{handle} page + email-reveal gating."""
|
||||
|
||||
AJAX = {"X-Requested-With": "XMLHttpRequest"}
|
||||
|
||||
def _make_offer_on_user1_shop(self, list_price=10000, amount="70.00"):
|
||||
"""user2 makes a pending offer on a product in user1's shop.
|
||||
Returns (shop_id, product_id, offer_id). Leaves user2 logged in."""
|
||||
from ..models.product import Product
|
||||
shop = self._create_shop_helper(user_creds=self.user1_creds)
|
||||
shop.offer_enabled = True
|
||||
product = Product(title="Negotiable", description="...")
|
||||
product.shop = shop
|
||||
product.price_in_cents = list_price
|
||||
product.is_physical = False
|
||||
product.is_sellable = True
|
||||
product.pricing_mode = 3
|
||||
self.dbsession.add(product)
|
||||
self.dbsession.flush()
|
||||
product_id = product.uuid_str
|
||||
shop_id = shop.uuid_str
|
||||
transaction.commit()
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user2_creds)
|
||||
res = self.testapp.post(
|
||||
f"/p/{product_id}/offer", {"amount": amount},
|
||||
headers=self.AJAX, status=200,
|
||||
)
|
||||
return shop_id, product_id, res.json["offer_id"]
|
||||
|
||||
def test_profile_page_renders(self):
|
||||
handle = self.user2.name
|
||||
body = self.testapp.get(f"/profile/{handle}", status=200).body.decode()
|
||||
self.assertIn(handle, body)
|
||||
self.assertIn("profile-card-header", body)
|
||||
# No email exposed to an anonymous viewer.
|
||||
self.assertNotIn("profile-email-reveal", body)
|
||||
self.assertNotIn("test2@example.com", body)
|
||||
|
||||
def test_profile_page_404_unknown(self):
|
||||
self.testapp.get("/profile/no-such-user-xyz", status=404)
|
||||
|
||||
def test_profile_email_shown_to_self(self):
|
||||
self.log_in_user(self.user2_creds)
|
||||
body = self.testapp.get(
|
||||
f"/profile/{self.user2.name}", status=200
|
||||
).body.decode()
|
||||
self.assertIn("profile-email-reveal", body)
|
||||
self.assertIn("test2@example.com", body)
|
||||
|
||||
def test_profile_email_shown_to_shop_operator_with_history(self):
|
||||
handle2 = self.user2.name
|
||||
shop_id, _pid, _oid = self._make_offer_on_user1_shop()
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user1_creds)
|
||||
body = self.testapp.get(
|
||||
f"/profile/{handle2}?shop={shop_id}", status=200
|
||||
).body.decode()
|
||||
self.assertIn("test2@example.com", body)
|
||||
# ...but not without the shop context.
|
||||
body2 = self.testapp.get(
|
||||
f"/profile/{handle2}", status=200
|
||||
).body.decode()
|
||||
self.assertNotIn("test2@example.com", body2)
|
||||
|
||||
def test_profile_email_hidden_from_operator_without_history(self):
|
||||
handle2 = self.user2.name
|
||||
shop = self._create_shop_helper(user_creds=self.user1_creds)
|
||||
shop_id = shop.uuid_str
|
||||
transaction.commit()
|
||||
body = self.testapp.get(
|
||||
f"/profile/{handle2}?shop={shop_id}", status=200
|
||||
).body.decode()
|
||||
self.assertNotIn("test2@example.com", body)
|
||||
self.assertIn("hasn't transacted", body)
|
||||
|
||||
def test_offer_page_links_buyer_to_profile_not_email(self):
|
||||
handle2 = self.user2.name
|
||||
_shop_id, _pid, offer_id = self._make_offer_on_user1_shop()
|
||||
body = self.testapp.get(f"/o/{offer_id}", status=200).body.decode()
|
||||
self.assertIn(f"/profile/{handle2}", body)
|
||||
self.assertNotIn("test2@example.com", body)
|
||||
|
||||
|
||||
class TestShopOffersInbox(_AuthenticatedBase):
|
||||
"""MPS-21: /s/{shop_id}/offers operator inbox + actions-page button."""
|
||||
|
||||
AJAX = {"X-Requested-With": "XMLHttpRequest"}
|
||||
|
||||
def _shop_with_offer(self, make_offer=True):
|
||||
from ..models.product import Product
|
||||
shop = self._create_shop_helper(user_creds=self.user1_creds)
|
||||
shop.offer_enabled = True
|
||||
product = Product(title="Inbox Item", description="...")
|
||||
product.shop = shop
|
||||
product.price_in_cents = 10000
|
||||
product.is_physical = False
|
||||
product.is_sellable = True
|
||||
product.pricing_mode = 3
|
||||
self.dbsession.add(product)
|
||||
self.dbsession.flush()
|
||||
shop_id = shop.uuid_str
|
||||
product_id = product.uuid_str
|
||||
transaction.commit()
|
||||
offer_id = None
|
||||
if make_offer:
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user2_creds)
|
||||
res = self.testapp.post(
|
||||
f"/p/{product_id}/offer", {"amount": "70.00"},
|
||||
headers=self.AJAX, status=200,
|
||||
)
|
||||
offer_id = res.json["offer_id"]
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user1_creds)
|
||||
return shop_id, offer_id
|
||||
|
||||
def test_offers_inbox_empty(self):
|
||||
shop_id, _ = self._shop_with_offer(make_offer=False)
|
||||
body = self.testapp.get(f"/s/{shop_id}/offers", status=200).body.decode()
|
||||
self.assertIn("No one has made an offer", body)
|
||||
|
||||
def test_offers_inbox_lists_offer(self):
|
||||
handle2 = self.user2.name
|
||||
shop_id, offer_id = self._shop_with_offer()
|
||||
body = self.testapp.get(f"/s/{shop_id}/offers", status=200).body.decode()
|
||||
self.assertIn("Inbox Item", body)
|
||||
self.assertIn(handle2, body)
|
||||
self.assertIn(f"/o/{offer_id}", body)
|
||||
|
||||
def test_offers_inbox_requires_editor(self):
|
||||
shop_id, _ = self._shop_with_offer(make_offer=False)
|
||||
self.testapp.get("/log-out")
|
||||
self.log_in_user(self.user2_creds) # not an editor of user1's shop
|
||||
res = self.testapp.get(f"/s/{shop_id}/offers", expect_errors=True)
|
||||
self.assertIn(res.status_int, (302, 303, 401, 403, 404))
|
||||
|
||||
def test_actions_page_has_offers_button(self):
|
||||
shop_id, _ = self._shop_with_offer(make_offer=False)
|
||||
body = self.testapp.get("/actions/view", status=200).body.decode()
|
||||
self.assertIn("action-button-grid", body)
|
||||
self.assertIn(f"/s/{shop_id}/offers", body)
|
||||
|
||||
|
||||
class TestProfileStyleguide(_AuthenticatedBase):
|
||||
def test_styleguide_has_profile_card_and_action_grid(self):
|
||||
body = self.testapp.get("/styleguide", status=200).body.decode()
|
||||
self.assertIn("profile-card-header", body)
|
||||
self.assertIn("action-button-grid", body)
|
||||
|
||||
|
||||
class TestOfferNoJsFallback(_AuthenticatedBase):
|
||||
"""MPS-21 capability-driven presentation: every offer action works
|
||||
as a plain POST → 302 redirect with no JS / no X-Requested-With."""
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from ..lib.mail import (
|
|||
)
|
||||
from ..lib.currency import cents_to_dollars
|
||||
from ..models.offer import (
|
||||
MpsOffer,
|
||||
OFFER_PARTY_BUYER,
|
||||
OFFER_PARTY_SELLER,
|
||||
OFFER_STATE_DECLINED,
|
||||
|
|
@ -41,7 +42,7 @@ from ..models.offer import (
|
|||
get_offer_by_id,
|
||||
)
|
||||
from ..models.product import get_product_by_id
|
||||
from ..views import user_required
|
||||
from ..views import user_required, shop_editor_required
|
||||
|
||||
|
||||
def _is_ajax(request):
|
||||
|
|
@ -83,7 +84,8 @@ def _serialize_offer(offer):
|
|||
"product_title": offer.product.title,
|
||||
"shop_id": offer.shop.uuid_str,
|
||||
"buyer_id": offer.buyer.uuid_str,
|
||||
"buyer_email": offer.buyer.email,
|
||||
"buyer_name": offer.buyer.display_name,
|
||||
"buyer_handle": offer.buyer.name,
|
||||
"state": offer.state,
|
||||
"state_human": offer.state_human,
|
||||
"is_open": offer.is_open,
|
||||
|
|
@ -109,7 +111,9 @@ def _serialize_offer(offer):
|
|||
"event_human": OFFER_EVENT_INT_TO_HUMAN.get(
|
||||
e.event_type, "Unknown"
|
||||
),
|
||||
"actor_email": e.actor.email if e.actor else None,
|
||||
"actor_id": e.actor.uuid_str if e.actor else None,
|
||||
"actor_name": e.actor.display_name if e.actor else None,
|
||||
"actor_handle": e.actor.name if e.actor else None,
|
||||
"amount_in_cents": e.amount_in_cents,
|
||||
"amount": (
|
||||
cents_to_dollars(e.amount_in_cents)
|
||||
|
|
@ -364,3 +368,54 @@ def offer_checkout(request):
|
|||
request.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
|
||||
request.dbsession.flush()
|
||||
return HTTPFound("/cart")
|
||||
|
||||
|
||||
@view_config(route_name="shop_offers", renderer="shop_offers.j2")
|
||||
@shop_editor_required()
|
||||
def shop_offers(request):
|
||||
"""Operator inbox of all make-an-offer negotiations for this shop.
|
||||
|
||||
Open offers (pending / countered) sort to the top, most-recently
|
||||
active first; terminal offers follow. Each row links to /o/{id}.
|
||||
"""
|
||||
shop = request.shop
|
||||
offers = (
|
||||
request.dbsession.query(MpsOffer)
|
||||
.filter(MpsOffer.shop_id == shop.id)
|
||||
.all()
|
||||
)
|
||||
offers.sort(key=lambda o: (o.is_terminal, -(o.last_action_timestamp or 0)))
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def _human(ts):
|
||||
if not ts:
|
||||
return ""
|
||||
return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M UTC"
|
||||
)
|
||||
|
||||
rows = []
|
||||
for o in offers:
|
||||
rows.append({
|
||||
"id": o.uuid_str,
|
||||
"product_title": o.product.title if o.product else "(removed product)",
|
||||
"buyer_name": o.buyer.display_name if o.buyer else "(unknown)",
|
||||
"buyer_handle": o.buyer.name if o.buyer else None,
|
||||
"state": o.state,
|
||||
"state_human": o.state_human,
|
||||
"is_open": o.is_open,
|
||||
"is_terminal": o.is_terminal,
|
||||
"current_amount": o.current_amount,
|
||||
"round_count": o.round_count,
|
||||
"last_action_human": _human(o.last_action_timestamp),
|
||||
"waiting_on_seller": (
|
||||
o.is_open and o.current_party == OFFER_PARTY_SELLER
|
||||
),
|
||||
})
|
||||
return {
|
||||
"the_title": "Shop Offers",
|
||||
"shop": shop,
|
||||
"offers": rows,
|
||||
"open_count": sum(1 for r in rows if not r["is_terminal"]),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
from pyramid.view import view_config
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
||||
|
||||
from . import user_required, shop_is_ready_required
|
||||
|
||||
from ..models.user import is_user_name_available, is_user_name_valid
|
||||
from ..models.user import (
|
||||
is_user_name_available,
|
||||
is_user_name_valid,
|
||||
get_user_by_name,
|
||||
)
|
||||
|
||||
from ..models.invoice import Invoice
|
||||
from ..models.offer import MpsOffer
|
||||
from ..models.shop import get_shop_by_id
|
||||
|
||||
|
||||
@view_config(route_name="user_shops", renderer="shops.j2")
|
||||
|
|
@ -194,3 +200,100 @@ def user_address_activate(request):
|
|||
request.session.flash(msg)
|
||||
|
||||
return HTTPFound("/u/addresses")
|
||||
|
||||
|
||||
@view_config(route_name="user_profile", renderer="profile.j2")
|
||||
def user_profile(request):
|
||||
"""Public profile page for a user, addressed by @handle.
|
||||
|
||||
Shows display name, member-since, gravatar, and the public shops the
|
||||
user owns or edits. The email address is intentionally NOT public: it
|
||||
is revealed only when (a) you are looking at your own profile, or
|
||||
(b) you are an owner/editor of the shop passed in ?shop= and the
|
||||
profile user has actually transacted with that shop (an offer or an
|
||||
invoice). This keeps the offer-history "by <name>" link useful to a
|
||||
shop operator without leaking customer emails to the world.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
profile_user = get_user_by_name(
|
||||
request.dbsession, request.matchdict["user_name"]
|
||||
)
|
||||
if profile_user is None or profile_user.disabled:
|
||||
raise HTTPNotFound()
|
||||
|
||||
viewer = request.user
|
||||
is_self = viewer is not None and viewer == profile_user
|
||||
|
||||
shop = None
|
||||
shop_id = (request.params.get("shop") or "").strip()
|
||||
if shop_id:
|
||||
shop = get_shop_by_id(request.dbsession, shop_id)
|
||||
|
||||
viewer_is_shop_editor = bool(
|
||||
shop is not None and viewer is not None and viewer.can_edit_shop(shop)
|
||||
)
|
||||
|
||||
shop_offer_count = 0
|
||||
shop_invoice_count = 0
|
||||
if shop is not None:
|
||||
shop_offer_count = (
|
||||
request.dbsession.query(MpsOffer)
|
||||
.filter(
|
||||
MpsOffer.shop_id == shop.id,
|
||||
MpsOffer.buyer_user_id == profile_user.id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
shop_invoice_count = (
|
||||
request.dbsession.query(Invoice)
|
||||
.filter(
|
||||
Invoice.shop_id == shop.id,
|
||||
Invoice.user_id == profile_user.id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
profile_has_shop_history = bool(shop_offer_count or shop_invoice_count)
|
||||
|
||||
can_see_email = is_self or (
|
||||
viewer_is_shop_editor and profile_has_shop_history
|
||||
)
|
||||
|
||||
# Public shops this user owns or edits, hiding non-production shops.
|
||||
public_shops = sorted(
|
||||
{
|
||||
us.shop
|
||||
for us in profile_user.user_shops
|
||||
if us.role_id in (0, 1)
|
||||
and us.shop is not None
|
||||
and not us.shop.is_non_production
|
||||
},
|
||||
key=lambda s: (s.name or "").lower(),
|
||||
)
|
||||
|
||||
member_since_human = ""
|
||||
if profile_user.created_timestamp:
|
||||
member_since_human = datetime.fromtimestamp(
|
||||
profile_user.created_timestamp / 1000, tz=timezone.utc
|
||||
).strftime("%B %Y")
|
||||
|
||||
return {
|
||||
"the_title": profile_user.display_name,
|
||||
"profile_user": profile_user,
|
||||
"display_name": profile_user.display_name,
|
||||
"handle": profile_user.name,
|
||||
"member_since": profile_user.created_timestamp,
|
||||
"member_since_human": member_since_human,
|
||||
"gravatar_url": profile_user.gravatar_url(160),
|
||||
"public_shops": public_shops,
|
||||
"shop_count": len(public_shops),
|
||||
"can_see_email": can_see_email,
|
||||
"email": profile_user.email if can_see_email else None,
|
||||
"is_self": is_self,
|
||||
"shop_context": shop,
|
||||
"shop_context_id": shop.uuid_str if shop is not None else None,
|
||||
"viewer_is_shop_editor": viewer_is_shop_editor,
|
||||
"profile_has_shop_history": profile_has_shop_history,
|
||||
"shop_offer_count": shop_offer_count,
|
||||
"shop_invoice_count": shop_invoice_count,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue