feat(frontend): share-link button on /trader/:address (mobile Web Share + copy) #665

Closed
opened 2026-08-26 04:16:11 +00:00 by PlasticDigits · 17 comments
PlasticDigits commented 2026-08-26 04:16:11 +00:00 (Migrated from gitlab.com)

Summary

Add a Share control on the public trader profile (/trader/:address) so a user can send the canonical profile URL from a phone without opening the browser chrome, selecting a long terra1… path, or copying the wallet address by mistake.

The report named /trader/{pair}. There is no /trader/:pair route. The shareable public lookup is /trader/:address (wallet bech32). Pair deep links are /trade/:pairAddr and /charts/:pairAddr. This issue ships trader-profile share as the required surface. A reusable helper may also be mounted on those pair pages in the same change if it stays one control — do not invent a second share widget.

/portfolio is wallet-home and is not a public URL. A Share action there, if added, must emit /trader/{connectedAddr}, never /portfolio.

Current codebase

Routes and chrome

Route Param What exists today
/trader none Lookup form + empty prompt. No share. Nothing canonical to send.
/trader/:address wallet bech32 Profile via getTrader + TraderSummaryStats + positions + trade history. No share-link control.
/portfolio connected wallet only Link View on Trader page → /trader/{wallet}. No share.
/trade/:pairAddr pair contract Shareable URL already (invalid-pair cleanup so the bar stays clean — #175 / #176 / #358). No Share button.
/charts/:pairAddr pair contract Same: URL is shareable; no Share button.

Wired in App.tsx: TraderRouteShell for /trader and /trader/:address with resetKeys on the address segment (#126). Nav label Trader is under More (navItems.ts).

TraderPage.tsx header is title + one-line lead + search / My Profile / My Portfolio. After a successful profile load, TraderSummaryStats.tsx shows AddressRow (copyAriaLabel="Copy trader address") plus explorer. That copies the bech32, not https://…/trader/{addr}.

There is no navigator.share usage anywhere in frontend-dapp/src. Clipboard is centralized:

AddressRow must not be overloaded to copy a URL (explorer + address copy stay as they are).

Why mobile is the gap

On a phone, the address bar is often hidden (in-app browsers, PWA, Keplr webview — #632). Selecting a 44+ character terra1… path is error-prone. Recipients who receive a raw address cannot open the profile without knowing to paste it into /trader. The OS share sheet (navigator.share) is the expected path; clipboard is the fallback when Web Share is missing or the user cancels.

Open Graph (do not “fix” by adding per-route meta)

Crawlers do not run React. Every SPA path, including /trader/:address, returns the same static index.html tags (#578, OG-5 / OG-6). Share must not add react-helmet, prerender, or og:url built from window.location / wallet / pair. Recipients see the product card. That is accepted. This issue is an in-app control, not a crawler change.

Tests today

TraderPage.test.tsx covers outage vs 404 only. e2e/trader-page.spec.ts asserts heading + positions. No share assertions.

Why this is needed

Traders and community members want to send a profile link (leaderboard row, Discord, Telegram, SMS). Today the only one-tap action on the profile header copies the wallet, not the dApp URL. On mobile that is worse: no visible URL, no share sheet, easy to send the wrong string.

A small control next to the profile header (or next to AddressRow) that shares or copies the canonical same-origin /trader/{validated-address} URL closes that gap without a new indexer route or per-wallet OG.

Constraints / guardrails

ID Rule
TS-1 Required surface: /trader/:address when isValidTerraAddress(param) is true. Hide Share on /trader with no address, and when the path segment fails isValidTerraAddress. Do not create /trader/:pair.
TS-2 Shared payload is the canonical URL: origin + '/trader/' + address (or URL API). Strip ? / # / wallet-connect / theme junk. Do not share window.location.href if it still has search/hash. Address in the path must pass isValidTerraAddress after trim; prefer the route param, not a raw location.pathname splice.
TS-3 Mobile first: on a capable secure context, prefer navigator.share({ url, title, text }) (user gesture only). AbortError / user dismiss is not a failure toast. If share is missing, throws a non-abort error, or canShare is false, fall back to copyToClipboard (do not add a second navigator.clipboard.writeText path).
TS-4 Copy vs share: AddressRow stays Copy trader address. Share is a distinct control (aria-label e.g. Share trader profile link). Do not replace address copy.
TS-5 Copy (#489): visible label Share (≤ ~5 words). Success: reuse Copied to clipboard when falling back, or a short Link copied if you add one string to copyButtonCopy.ts. No “how to share”, no Telegram/Discord lecture, no VITE_* / host:port in errors.
TS-6 One chrome layer (C653): icon/text button in the existing profile header row (TraderSummaryStats or the page H1 row). No extra shell-panel* / card-glass wrap. No metric-tile nest. python3 scripts/check_chrome_nesting.py stays green.
TS-7 A11y: real <button type="button">, explicit aria-label, :focus-visible ring (#144). Clipboard fallback uses the existing sr-only + aria-live="polite" pattern from CopyButton. 44px-class hit target on phone (375). sounds.playButtonPress on activate.
TS-8 OG-5 / OG-6: no per-route helmet, no og:* from request host / wallet / pair. Share text/title are static product copy (e.g. CL8Y DEX trader) plus optional shortened address via shortenAddress — not P&L, volume, or indexer fields (stale + spoof).
TS-9 Origin: use the page’s window.location.origin so LocalTerra / QA / prod share the deployment the user is on. Do not hard-code https://dex.cl8y.com in the helper (that origin is for baked OG only — viteOg.ts). Do not read VITE_PUBLIC_ORIGIN in the React bundle for this.
TS-10 Show when the URL is valid, including profile 404 (“Trader not found”) and indexer outage: the link is still the lookup URL. Hide only when the segment is missing or not a terra address.
TS-11 Optional same PR: mount the same control on /trade/:pairAddr and /charts/:pairAddr (canonical /trade/{pair} / /charts/{pair} after existing pair-param validation) and on /portfolio as Share public profile → /trader/{wallet} when connected. Do not share /portfolio. Do not add Share to Swap/Pool/Limits in this issue.
TS-12 No new indexer/API, wallet signature, QR library, or third-party share SDK. Web Share + existing clipboard only.
TS-13 In-app browsers (Keplr / WalletConnect webview): if share is absent, clipboard fallback must still work; if clipboard is denied, use COPY_BUTTON_FAILURE_MESSAGE (permission-safe, no DOMException text).

Relevant files

File Role
frontend-dapp/src/pages/TraderPage.tsx When to enable Share; empty /trader has none
frontend-dapp/src/components/trader/TraderSummaryStats.tsx Likely header placement next to AddressRow / You badge
frontend-dapp/src/components/ui/AddressRow.tsx Keep address copy; do not overload
frontend-dapp/src/components/ui/CopyButton.tsx Pattern + clipboard fallback
frontend-dapp/src/utils/copyToClipboard.ts Only clipboard write path
frontend-dapp/src/utils/copyButtonCopy.ts Retail success/failure strings
frontend-dapp/src/utils/constants.ts isValidTerraAddress
frontend-dapp/src/App.tsx Routes only — no share in the shell header
frontend-dapp/src/pages/TraderPage.test.tsx Unit coverage
frontend-dapp/e2e/trader-page.spec.ts Smoke: control visible on profile
docs/frontend.md § Trader profile Document Share vs address copy
skills/AGENTS_FRONTEND_COPY_BUTTON.md Note share fallback uses the same helper
frontend-dapp/src/pages/PortfolioPage.tsx Optional: share public /trader/{addr}
frontend-dapp/src/pages/TradePage.tsx / ChartsPage.tsx Optional pair-URL mount

Likely new: frontend-dapp/src/utils/sharePageLink.ts (canonical URL + share vs copy) and frontend-dapp/src/components/ui/ShareLinkButton.tsx (+ Vitest). Do not fork a second clipboard helper.

  1. Pure helper: given { origin, path } (or kind: 'trader' \| 'trade' \| 'charts' + validated id), return an absolute URL with no search/hash. Reject invalid terra addresses (return null).
  2. ShareLinkButton: on click, if navigator.share exists, await navigator.share({ title, text, url }); on abort, stop; else copyToClipboard(url) and announce via the CopyButton live-region pattern. Inject share / clipboard in tests.
  3. Place the button on TraderSummaryStats header (flex wrap next to AddressRow) or on the Trader page H1 row so it remains visible while the profile is loading/404. Prefer the header row that is on screen at 375px without horizontal scroll.
  4. data-testid="trader-share-link" (and share-link-button on the primitive).
  5. Document in docs/frontend.md that Share sends the profile URL; AddressRow still copies the wallet.

Acceptance criteria

  • On /trader/{valid terra1…}, a Share control is visible at 375px and 1280px, light and dark.
  • Activate with Web Share available → navigator.share is called with the canonical origin/trader/{address} URL (no query/hash). User cancel does not show an error.
  • Activate without Web Share (or after non-abort failure) → clipboard receives the same URL; live region announces success or the existing permission-safe failure string.
  • AddressRow Copy trader address still copies the bech32 only.
  • /trader (no address) and /trader/not-a-wallet do not show Share (or Share is disabled and does not write garbage).
  • Share remains available on profile 404 and indexer outage when the path address is valid.
  • No new shell-panel / nested card-glass; chrome-nesting check stays green.
  • No per-route Open Graph / helmet. No hard-coded production origin in the helper.
  • Docs: docs/frontend.md § trader profile mentions Share vs address copy.
  • Optional: same control on /trade/:pair and /charts/:pair (validated pair) and /portfolio → /trader/{wallet} only.

Test plan — all paths

Path Setup Expect
Profile + share API /trader/{valid}, mock navigator.share resolves Share called with canonical URL; no clipboard write
User cancels share share rejects AbortError No error live text; no clipboard
Share throws share rejects TypeError / NotAllowedError Clipboard fallback; success or failure announce
No navigator.share delete share Clipboard gets canonical URL
Clipboard denied copy helper returns { ok: false } COPY_BUTTON_FAILURE_MESSAGE; no raw exception
Canonical strip page has ?utm= or #foo Shared URL has neither
Invalid segment /trader/evil / empty /trader No Share, or no write
Address copy unchanged click AddressRow copy Clipboard is bech32, not URL
Own profile connected wallet matches param Share still public /trader/{addr}; You badge unchanged
404 profile getTrader 404 Share still present; URL still /trader/{addr}
Outage getTrader 502 Banner + Share; no env/host in share text
Portfolio (if in scope) connected Share payload is /trader/{wallet}, not /portfolio
Pair pages (if in scope) /trade/{pair} / /charts/{pair} Canonical pair path only; invalid pair hidden (existing #175/#176 cleanup)
Mobile 375 /trader/:addr Control visible, tappable, not under bottom nav
Desktop 1280 same Control in header row; no nested chrome
Theme light + dark --ink / btn-muted or icon; focus-visible ring
Keyboard Tab to Share, Enter/Space Same as click
E2E smoke connected /trader/{dev} trader-share-link visible; no new console errors beyond allowlist
CopyButton regression existing CopyButton tests Still green

Vitest: helper unit tests (URL build, reject invalid, strip search/hash) + ShareLinkButton with injected share / copyToClipboard + TraderPage / TraderSummaryStats presence tests. Playwright: extend e2e/trader-page.spec.ts (visibility; optional clipboard grant). Smoke workers stay 5.

Test plan — attack, hack, and abuse

Share is client-only and must not widen write surface or leak secrets.

Vector Attack Expect
Open redirect Path /trader/https://evil or //evil.com isValidTerraAddress fails; no Share, or Share never builds https://evil…. share.url / clipboard is same-origin /trader/… only.
XSS in address <script> / javascript: in param or indexer address React text + isValidTerraAddress. No dangerouslySetInnerHTML. Share text/title are static + shortenAddress, not raw HTML.
javascript: URL Forged share payload Helper only concatenates origin + allowed path prefix + validated id. Never new URL(userString) as the sole href.
Query / hash leak ?mnemonic= / WC URI / #token left on location.href TS-2: strip search and hash. Do not pass location.href through.
Clipboard hijack Page copies WC pairing URI or mnemonic Share writes only the canonical profile (or pair) URL. Never wallet-connect URI, seed, or JWT.
Origin spoof VITE_PUBLIC_ORIGIN / host header / X-Forwarded-Host Helper uses window.location.origin of the loaded SPA. Do not bake request Host into the URL. Do not use unallowlisted OG origin logic here.
P&L / volume spoof Share text includes indexer stats Forbidden (TS-8). Attacker-controlled indexer cannot put fake PnL in the OS share sheet.
Clickjack Overlay steal of share gesture Existing frame protections unchanged. Do not weaken CSP for Web Share (it is a browser API, not a new connect-src).
Web Share target phishing text contains “approve tx” / wallet CTA Static product title only. No “connect wallet” in share body.
Prototype pollution Malformed JSON on profile Share does not read indexer body to build the URL. Route param only.
IDOR / privacy Share someone else’s /trader/{addr} Public indexed stats already. Do not attach connected-wallet address when viewing another profile.
CSRF / write Share click No POST, no wallet signature, no indexer write.
Clipboard permission prompt spam Rapid clicks Same 2s feedback window as CopyButton; do not loop share without a click.
In-app browser share undefined, clipboard blocked Failure string is permission-safe; page does not crash.
Gem / hidden market Share /trade/{gem-pair} (if pair mount) URL share is OK (deep links already exist — P562-7). Do not put gems back in pickers.
Tabnab / target=_blank Share implemented as <a href> to mailto: / third-party Use navigator.share or clipboard, not a random outbound share host.
OG / crawler bypass Try to set og:url from wallet OG-5/6 — out of scope; do not add.

Verification criteria

Done when all of the following are true:

  1. Manual phone-width (375) and desktop (1280): /trader/{valid} shows Share; activate copies or opens the OS share sheet with …/trader/{addr} only.
  2. Manual: AddressRow copy still copies the wallet; Share does not.
  3. Manual: /trader empty — no Share. Invalid segment — no Share.
  4. make test-frontend (or scoped Vitest) covers helper + button + trader presence, including abort vs fallback.
  5. e2e/trader-page.spec.ts (or added spec) asserts trader-share-link on the connected profile path.
  6. python3 scripts/check_chrome_nesting.py and make verify-issue-653 stay green.
  7. docs/frontend.md states Share vs address copy. No indexer diff required.

Out of scope

  • Per-route Open Graph / Twitter cards / react-helmet (#578)
  • New /trader/:pair route or pair-filtered trader API
  • QR codes, native apps, or third-party share widgets
  • Changing leaderboard (#657) or blockie/PFP (#656)
  • Sharing /portfolio as a public URL
  • Swap / Pool / Limits / Protocol share buttons (unless the optional pair mounts above)

Dependencies

  • Blocked by: none (CopyButton / copyToClipboard already shipped: #183)
  • Related: #188 AddressRow, #578 OG (do not regress), #489 copy, #653 chrome, #126 / #177 / #215 trader route states, #656 / #657 trader chrome (place Share so it still fits)

Labels / owner / priority

frontend UX enhancement missing-implementation docs testing e2e product priority::medium

Owner: frontend
Priority: P2

## Summary Add a **Share** control on the public trader profile (`/trader/:address`) so a user can send the canonical profile URL from a phone without opening the browser chrome, selecting a long `terra1…` path, or copying the **wallet address** by mistake. The report named `/trader/{pair}`. **There is no `/trader/:pair` route.** The shareable public lookup is **`/trader/:address`** (wallet bech32). Pair deep links are `/trade/:pairAddr` and `/charts/:pairAddr`. This issue ships trader-profile share as the required surface. A reusable helper may also be mounted on those pair pages in the **same** change if it stays one control — do not invent a second share widget. `/portfolio` is wallet-home and is **not** a public URL. A Share action there, if added, must emit `/trader/{connectedAddr}`, never `/portfolio`. ## Current codebase ### Routes and chrome | Route | Param | What exists today | |-------|--------|-------------------| | `/trader` | none | Lookup form + empty prompt. **No share.** Nothing canonical to send. | | `/trader/:address` | wallet bech32 | Profile via `getTrader` + `TraderSummaryStats` + positions + trade history. **No share-link control.** | | `/portfolio` | connected wallet only | Link **View on Trader page** → `/trader/{wallet}`. **No share.** | | `/trade/:pairAddr` | pair contract | Shareable URL already (invalid-pair cleanup so the bar stays clean — [#175](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/175) / [#176](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/176) / [#358](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/358)). **No Share button.** | | `/charts/:pairAddr` | pair contract | Same: URL is shareable; **no Share button.** | Wired in [`App.tsx`](frontend-dapp/src/App.tsx): `TraderRouteShell` for `/trader` and `/trader/:address` with `resetKeys` on the address segment ([#126](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/126)). Nav label **Trader** is under More ([`navItems.ts`](frontend-dapp/src/components/common/navItems.ts)). [`TraderPage.tsx`](frontend-dapp/src/pages/TraderPage.tsx) header is title + one-line lead + search / My Profile / My Portfolio. After a successful profile load, [`TraderSummaryStats.tsx`](frontend-dapp/src/components/trader/TraderSummaryStats.tsx) shows [`AddressRow`](frontend-dapp/src/components/ui/AddressRow.tsx) (`copyAriaLabel="Copy trader address"`) plus explorer. That copies the **bech32**, not `https://…/trader/{addr}`. There is **no** `navigator.share` usage anywhere in `frontend-dapp/src`. Clipboard is centralized: - [`copyToClipboard`](frontend-dapp/src/utils/copyToClipboard.ts) + [`CopyButton`](frontend-dapp/src/components/ui/CopyButton.tsx) ([#183](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/183)) - Retail strings in [`copyButtonCopy.ts`](frontend-dapp/src/utils/copyButtonCopy.ts) - Docs: [docs/frontend.md § Copy to clipboard](docs/frontend.md#copy-button-primitive) `AddressRow` must **not** be overloaded to copy a URL (explorer + address copy stay as they are). ### Why mobile is the gap On a phone, the address bar is often hidden (in-app browsers, PWA, Keplr webview — [#632](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/632)). Selecting a 44+ character `terra1…` path is error-prone. Recipients who receive a raw address cannot open the profile without knowing to paste it into `/trader`. The OS share sheet (`navigator.share`) is the expected path; clipboard is the fallback when Web Share is missing or the user cancels. ### Open Graph (do not “fix” by adding per-route meta) Crawlers do not run React. Every SPA path, including `/trader/:address`, returns the same static [`index.html`](frontend-dapp/index.html) tags ([#578](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/578), **OG-5 / OG-6**). Share **must not** add `react-helmet`, prerender, or `og:url` built from `window.location` / wallet / pair. Recipients see the product card. That is accepted. This issue is an in-app control, not a crawler change. ### Tests today [`TraderPage.test.tsx`](frontend-dapp/src/pages/TraderPage.test.tsx) covers outage vs 404 only. [`e2e/trader-page.spec.ts`](frontend-dapp/e2e/trader-page.spec.ts) asserts heading + positions. No share assertions. ## Why this is needed Traders and community members want to send a **profile link** (leaderboard row, Discord, Telegram, SMS). Today the only one-tap action on the profile header copies the **wallet**, not the dApp URL. On mobile that is worse: no visible URL, no share sheet, easy to send the wrong string. A small control next to the profile header (or next to `AddressRow`) that shares or copies the **canonical same-origin** `/trader/{validated-address}` URL closes that gap without a new indexer route or per-wallet OG. ## Constraints / guardrails | ID | Rule | |----|------| | **TS-1** | Required surface: **`/trader/:address`** when `isValidTerraAddress(param)` is true. Hide Share on `/trader` with no address, and when the path segment fails `isValidTerraAddress`. Do **not** create `/trader/:pair`. | | **TS-2** | Shared payload is the **canonical URL**: `origin + '/trader/' + address` (or `URL` API). Strip `?` / `#` / wallet-connect / theme junk. Do not share `window.location.href` if it still has search/hash. Address in the path must pass `isValidTerraAddress` after trim; prefer the route param, not a raw `location.pathname` splice. | | **TS-3** | **Mobile first:** on a capable secure context, prefer `navigator.share({ url, title, text })` (user gesture only). `AbortError` / user dismiss is **not** a failure toast. If `share` is missing, throws a non-abort error, or `canShare` is false, fall back to [`copyToClipboard`](frontend-dapp/src/utils/copyToClipboard.ts) (do not add a second `navigator.clipboard.writeText` path). | | **TS-4** | **Copy vs share:** AddressRow stays **Copy trader address**. Share is a distinct control (`aria-label` e.g. **Share trader profile link**). Do not replace address copy. | | **TS-5** | **Copy (#489):** visible label **Share** (≤ ~5 words). Success: reuse **Copied to clipboard** when falling back, or a short **Link copied** if you add one string to `copyButtonCopy.ts`. No “how to share”, no Telegram/Discord lecture, no `VITE_*` / host:port in errors. | | **TS-6** | **One chrome layer (C653):** icon/text button in the existing profile header row (`TraderSummaryStats` or the page H1 row). No extra `shell-panel*` / `card-glass` wrap. No metric-tile nest. `python3 scripts/check_chrome_nesting.py` stays green. | | **TS-7** | **A11y:** real `<button type="button">`, explicit `aria-label`, `:focus-visible` ring ([#144](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/144)). Clipboard fallback uses the existing `sr-only` + `aria-live="polite"` pattern from `CopyButton`. 44px-class hit target on phone (375). `sounds.playButtonPress` on activate. | | **TS-8** | **OG-5 / OG-6:** no per-route helmet, no `og:*` from request host / wallet / pair. Share `text`/`title` are static product copy (e.g. **CL8Y DEX trader**) plus optional shortened address via `shortenAddress` — **not** P&L, volume, or indexer fields (stale + spoof). | | **TS-9** | **Origin:** use the page’s `window.location.origin` so LocalTerra / QA / prod share the deployment the user is on. Do **not** hard-code `https://dex.cl8y.com` in the helper (that origin is for baked OG only — `viteOg.ts`). Do not read `VITE_PUBLIC_ORIGIN` in the React bundle for this. | | **TS-10** | **Show when the URL is valid**, including profile **404** (“Trader not found”) and indexer outage: the link is still the lookup URL. Hide only when the segment is missing or not a terra address. | | **TS-11** | **Optional same PR:** mount the same control on `/trade/:pairAddr` and `/charts/:pairAddr` (canonical `/trade/{pair}` / `/charts/{pair}` after existing pair-param validation) and on `/portfolio` as **Share public profile** → `/trader/{wallet}` when connected. Do not share `/portfolio`. Do not add Share to Swap/Pool/Limits in this issue. | | **TS-12** | **No** new indexer/API, wallet signature, QR library, or third-party share SDK. Web Share + existing clipboard only. | | **TS-13** | In-app browsers (Keplr / WalletConnect webview): if `share` is absent, clipboard fallback must still work; if clipboard is denied, use `COPY_BUTTON_FAILURE_MESSAGE` (permission-safe, no `DOMException` text). | ## Relevant files | File | Role | |------|------| | [`frontend-dapp/src/pages/TraderPage.tsx`](frontend-dapp/src/pages/TraderPage.tsx) | When to enable Share; empty `/trader` has none | | [`frontend-dapp/src/components/trader/TraderSummaryStats.tsx`](frontend-dapp/src/components/trader/TraderSummaryStats.tsx) | Likely header placement next to AddressRow / You badge | | [`frontend-dapp/src/components/ui/AddressRow.tsx`](frontend-dapp/src/components/ui/AddressRow.tsx) | Keep address copy; do not overload | | [`frontend-dapp/src/components/ui/CopyButton.tsx`](frontend-dapp/src/components/ui/CopyButton.tsx) | Pattern + clipboard fallback | | [`frontend-dapp/src/utils/copyToClipboard.ts`](frontend-dapp/src/utils/copyToClipboard.ts) | Only clipboard write path | | [`frontend-dapp/src/utils/copyButtonCopy.ts`](frontend-dapp/src/utils/copyButtonCopy.ts) | Retail success/failure strings | | [`frontend-dapp/src/utils/constants.ts`](frontend-dapp/src/utils/constants.ts) | `isValidTerraAddress` | | [`frontend-dapp/src/App.tsx`](frontend-dapp/src/App.tsx) | Routes only — no share in the shell header | | [`frontend-dapp/src/pages/TraderPage.test.tsx`](frontend-dapp/src/pages/TraderPage.test.tsx) | Unit coverage | | [`frontend-dapp/e2e/trader-page.spec.ts`](frontend-dapp/e2e/trader-page.spec.ts) | Smoke: control visible on profile | | [`docs/frontend.md`](docs/frontend.md) § Trader profile | Document Share vs address copy | | [`skills/AGENTS_FRONTEND_COPY_BUTTON.md`](skills/AGENTS_FRONTEND_COPY_BUTTON.md) | Note share fallback uses the same helper | | [`frontend-dapp/src/pages/PortfolioPage.tsx`](frontend-dapp/src/pages/PortfolioPage.tsx) | Optional: share public `/trader/{addr}` | | [`frontend-dapp/src/pages/TradePage.tsx`](frontend-dapp/src/pages/TradePage.tsx) / [`ChartsPage.tsx`](frontend-dapp/src/pages/ChartsPage.tsx) | Optional pair-URL mount | **Likely new:** `frontend-dapp/src/utils/sharePageLink.ts` (canonical URL + `share` vs copy) and `frontend-dapp/src/components/ui/ShareLinkButton.tsx` (+ Vitest). Do not fork a second clipboard helper. ## Recommended direction 1. Pure helper: given `{ origin, path }` (or `kind: 'trader' \| 'trade' \| 'charts'` + validated id), return an absolute URL with no search/hash. Reject invalid terra addresses (return `null`). 2. `ShareLinkButton`: on click, if `navigator.share` exists, `await navigator.share({ title, text, url })`; on abort, stop; else `copyToClipboard(url)` and announce via the CopyButton live-region pattern. Inject `share` / clipboard in tests. 3. Place the button on `TraderSummaryStats` header (flex wrap next to AddressRow) **or** on the Trader page H1 row so it remains visible while the profile is loading/404. Prefer the header row that is on screen at 375px without horizontal scroll. 4. `data-testid="trader-share-link"` (and `share-link-button` on the primitive). 5. Document in `docs/frontend.md` that Share sends the profile URL; AddressRow still copies the wallet. ## Acceptance criteria - [ ] On `/trader/{valid terra1…}`, a **Share** control is visible at 375px and 1280px, light and dark. - [ ] Activate with Web Share available → `navigator.share` is called with the canonical `origin/trader/{address}` URL (no query/hash). User cancel does not show an error. - [ ] Activate without Web Share (or after non-abort failure) → clipboard receives the same URL; live region announces success or the existing permission-safe failure string. - [ ] AddressRow **Copy trader address** still copies the bech32 only. - [ ] `/trader` (no address) and `/trader/not-a-wallet` do not show Share (or Share is disabled and does not write garbage). - [ ] Share remains available on profile 404 and indexer outage when the path address is valid. - [ ] No new `shell-panel` / nested `card-glass`; chrome-nesting check stays green. - [ ] No per-route Open Graph / helmet. No hard-coded production origin in the helper. - [ ] Docs: `docs/frontend.md` § trader profile mentions Share vs address copy. - [ ] Optional: same control on `/trade/:pair` and `/charts/:pair` (validated pair) and `/portfolio` → `/trader/{wallet}` only. ## Test plan — all paths | Path | Setup | Expect | |------|--------|--------| | Profile + share API | `/trader/{valid}`, mock `navigator.share` resolves | Share called with canonical URL; no clipboard write | | User cancels share | `share` rejects `AbortError` | No error live text; no clipboard | | Share throws | `share` rejects `TypeError` / `NotAllowedError` | Clipboard fallback; success or failure announce | | No `navigator.share` | delete `share` | Clipboard gets canonical URL | | Clipboard denied | copy helper returns `{ ok: false }` | `COPY_BUTTON_FAILURE_MESSAGE`; no raw exception | | Canonical strip | page has `?utm=` or `#foo` | Shared URL has neither | | Invalid segment | `/trader/evil` / empty `/trader` | No Share, or no write | | Address copy unchanged | click AddressRow copy | Clipboard is bech32, not URL | | Own profile | connected wallet matches param | Share still public `/trader/{addr}`; You badge unchanged | | 404 profile | `getTrader` 404 | Share still present; URL still `/trader/{addr}` | | Outage | `getTrader` 502 | Banner + Share; no env/host in share text | | Portfolio (if in scope) | connected | Share payload is `/trader/{wallet}`, not `/portfolio` | | Pair pages (if in scope) | `/trade/{pair}` / `/charts/{pair}` | Canonical pair path only; invalid pair hidden (existing #175/#176 cleanup) | | Mobile 375 | `/trader/:addr` | Control visible, tappable, not under bottom nav | | Desktop 1280 | same | Control in header row; no nested chrome | | Theme | light + dark | `--ink` / `btn-muted` or icon; focus-visible ring | | Keyboard | Tab to Share, Enter/Space | Same as click | | E2E smoke | connected `/trader/{dev}` | `trader-share-link` visible; no new console errors beyond allowlist | | CopyButton regression | existing CopyButton tests | Still green | Vitest: helper unit tests (URL build, reject invalid, strip search/hash) + `ShareLinkButton` with injected `share` / `copyToClipboard` + `TraderPage` / `TraderSummaryStats` presence tests. Playwright: extend `e2e/trader-page.spec.ts` (visibility; optional clipboard grant). Smoke workers stay **5**. ## Test plan — attack, hack, and abuse Share is **client-only** and must not widen write surface or leak secrets. | Vector | Attack | Expect | |--------|--------|--------| | Open redirect | Path `/trader/https://evil` or `//evil.com` | `isValidTerraAddress` fails; no Share, or Share never builds `https://evil…`. `share.url` / clipboard is same-origin `/trader/…` only. | | XSS in address | `<script>` / `javascript:` in param or indexer `address` | React text + `isValidTerraAddress`. No `dangerouslySetInnerHTML`. Share `text`/`title` are static + `shortenAddress`, not raw HTML. | | javascript: URL | Forged share payload | Helper only concatenates `origin` + allowed path prefix + validated id. Never `new URL(userString)` as the sole href. | | Query / hash leak | `?mnemonic=` / WC URI / `#token` left on `location.href` | **TS-2:** strip search and hash. Do not pass `location.href` through. | | Clipboard hijack | Page copies WC pairing URI or mnemonic | Share writes **only** the canonical profile (or pair) URL. Never wallet-connect URI, seed, or JWT. | | Origin spoof | `VITE_PUBLIC_ORIGIN` / host header / `X-Forwarded-Host` | Helper uses `window.location.origin` of the loaded SPA. Do not bake request Host into the URL. Do not use unallowlisted OG origin logic here. | | P&L / volume spoof | Share text includes indexer stats | Forbidden (**TS-8**). Attacker-controlled indexer cannot put fake PnL in the OS share sheet. | | Clickjack | Overlay steal of share gesture | Existing frame protections unchanged. Do not weaken CSP for Web Share (it is a browser API, not a new connect-src). | | Web Share target phishing | `text` contains “approve tx” / wallet CTA | Static product title only. No “connect wallet” in share body. | | Prototype pollution | Malformed JSON on profile | Share does not read indexer body to build the URL. Route param only. | | IDOR / privacy | Share someone else’s `/trader/{addr}` | Public indexed stats already. Do not attach connected-wallet address when viewing another profile. | | CSRF / write | Share click | No POST, no wallet signature, no indexer write. | | Clipboard permission prompt spam | Rapid clicks | Same 2s feedback window as CopyButton; do not loop `share` without a click. | | In-app browser | `share` undefined, clipboard blocked | Failure string is permission-safe; page does not crash. | | Gem / hidden market | Share `/trade/{gem-pair}` (if pair mount) | URL share is OK (deep links already exist — **P562-7**). Do not put gems back in pickers. | | Tabnab / `target=_blank` | Share implemented as `<a href>` to `mailto:` / third-party | Use `navigator.share` or clipboard, not a random outbound share host. | | OG / crawler bypass | Try to set `og:url` from wallet | **OG-5/6** — out of scope; do not add. | ## Verification criteria Done when all of the following are true: 1. Manual phone-width (375) and desktop (1280): `/trader/{valid}` shows **Share**; activate copies or opens the OS share sheet with `…/trader/{addr}` only. 2. Manual: AddressRow copy still copies the wallet; Share does not. 3. Manual: `/trader` empty — no Share. Invalid segment — no Share. 4. `make test-frontend` (or scoped Vitest) covers helper + button + trader presence, including abort vs fallback. 5. `e2e/trader-page.spec.ts` (or added spec) asserts `trader-share-link` on the connected profile path. 6. `python3 scripts/check_chrome_nesting.py` and `make verify-issue-653` stay green. 7. `docs/frontend.md` states Share vs address copy. No indexer diff required. ## Out of scope - Per-route Open Graph / Twitter cards / `react-helmet` (**#578**) - New `/trader/:pair` route or pair-filtered trader API - QR codes, native apps, or third-party share widgets - Changing leaderboard (#657) or blockie/PFP (#656) - Sharing `/portfolio` as a public URL - Swap / Pool / Limits / Protocol share buttons (unless the optional pair mounts above) ## Dependencies - Blocked by: none (`CopyButton` / `copyToClipboard` already shipped: [#183](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/183)) - Related: [#188](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/188) AddressRow, [#578](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/578) OG (do not regress), [#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489) copy, [#653](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/653) chrome, [#126](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/126) / [#177](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/177) / [#215](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/215) trader route states, [#656](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/656) / [#657](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/657) trader chrome (place Share so it still fits) ## Labels / owner / priority `frontend` `UX` `enhancement` `missing-implementation` `docs` `testing` `e2e` `product` `priority::medium` **Owner:** frontend **Priority:** P2
PlasticDigits commented 2026-08-26 04:16:13 +00:00 (Migrated from gitlab.com)

marked as related to #183

marked as related to #183
PlasticDigits commented 2026-08-26 04:16:13 +00:00 (Migrated from gitlab.com)

marked as related to #188

marked as related to #188
PlasticDigits commented 2026-08-26 04:16:14 +00:00 (Migrated from gitlab.com)

marked as related to #578

marked as related to #578
PlasticDigits commented 2026-08-26 04:16:15 +00:00 (Migrated from gitlab.com)

marked as related to #656

marked as related to #656
PlasticDigits commented 2026-08-26 04:16:16 +00:00 (Migrated from gitlab.com)

marked as related to #657

marked as related to #657
PlasticDigits commented 2026-08-26 07:16:23 +00:00 (Migrated from gitlab.com)

mentioned in commit 8069dfc31f

mentioned in commit 8069dfc31fa5e83d13221f3d370935097af9bc1b
PlasticDigits commented 2026-08-26 07:18:46 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1171

mentioned in merge request !1171
PlasticDigits commented 2026-08-26 07:19:47 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1172

mentioned in merge request !1172
PlasticDigits commented 2026-08-26 08:51:55 +00:00 (Migrated from gitlab.com)

mentioned in commit 9a82f40580

mentioned in commit 9a82f40580ec38509feefce19e1b1fd63fda61b7
PlasticDigits commented 2026-08-26 08:52:04 +00:00 (Migrated from gitlab.com)

mentioned in commit 676df7e520

mentioned in commit 676df7e520632daf237cbebb6e2f11b695425d53
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-26 08:52:05 +00:00
PlasticDigits commented 2026-08-26 09:31:05 +00:00 (Migrated from gitlab.com)

Merged to main via !1172. Share lives on /trader/:address next to the #657 leaderboard. Optional /trade / /charts Share mounts were not in this MR (playbook: pair mounts optional; do not add Share to Swap/Pool/Limits here).

Leftover: live /trader/:address Share (Web Share + clipboard). make verify-issue-665. Follow-up only if we want Share on /trade or /charts.

Merged to `main` via !1172. Share lives on `/trader/:address` next to the #657 leaderboard. Optional `/trade` / `/charts` Share mounts were not in this MR (playbook: pair mounts optional; do not add Share to Swap/Pool/Limits here). Leftover: live `/trader/:address` Share (Web Share + clipboard). `make verify-issue-665`. Follow-up only if we want Share on `/trade` or `/charts`.
PlasticDigits commented 2026-08-26 09:31:36 +00:00 (Migrated from gitlab.com)

mentioned in issue #673

mentioned in issue #673
PlasticDigits commented 2026-08-27 00:20:53 +00:00 (Migrated from gitlab.com)

mentioned in issue #680

mentioned in issue #680
PlasticDigits commented 2026-08-31 05:36:04 +00:00 (Migrated from gitlab.com)

mentioned in issue #713

mentioned in issue #713
PlasticDigits commented 2026-08-31 16:08:49 +00:00 (Migrated from gitlab.com)

mentioned in issue #715

mentioned in issue #715
PlasticDigits commented 2026-08-31 16:08:52 +00:00 (Migrated from gitlab.com)

marked as related to #715

marked as related to #715
PlasticDigits commented 2026-09-01 07:48:09 +00:00 (Migrated from gitlab.com)

mentioned in issue #716

mentioned in issue #716
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#665
No description provided.