/billing/current-subscription answers in two shapes. A subscriber gets the
Subscription document, which always carries a status because the schema
requires one. A user with no subscription gets a synthesised
{ plan, isActive, usage } with no status, no amount and no dates.
The page read that second shape as a subscription with missing fields, so
every free user was told their subscription status was "Unknown" and shown
two "N/A" billing dates. Absence of a status is not an unknown status.
lib/billing.ts interprets the payload instead of guessing at it. A paid plan
arriving without a status is still genuinely unknown and is still reported
that way, so the earlier fix for fabricated "Active" is preserved.
The redesign splits one flat card into plan identity and usage. The old
inner panels were bg-card with shadow-sm sitting on a bg-card parent, so the
nesting was invisible; they are bordered muted panels now. Free accounts
drop the status pill, the always-N/A dates and the portal link they have
nothing to manage with.
CTAs are Buttons rather than hand-rolled Links with background utilities,
and the upgrade target comes from position on the plan ladder, so Scale
subscribers are no longer sold a tier above Scale and bespoke plans are not
pushed onto the self-serve ladder at all. Added a "Compare all plans" link
to textbee.dev/pricing, matching the label the onboarding plan picker
already uses for the same destination.
Also corrected the Pro fixture from 1900 to 999 cents, a price we do not
charge, and gave the portal link noopener noreferrer.
Verified: build clean, 0 lint errors (21 warnings, unchanged), 153 unit
tests (from 121), 78 e2e (from 75). The three free-account guards and the
pricing link guard were each confirmed to fail against the pre-change
component before being trusted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plan step hardcoded Free and Pro inline, so Scale never appeared no
matter what the pricing page offered. Tiers now come from lib/plans,
which mirrors the marketing pricing section, with a test pinning the
values so the two cannot drift silently.
Kept static rather than fetched from /billing/plans. That endpoint
returns Plan documents (limits and cents), not customer-facing copy, and
an environment with no plans rows left the step showing "plans could not
be loaded" with nothing to choose.
Reopening a completed step showed an empty row. Two things caused it:
the body was gated on the step not being done, and an effect cleared the
selection whenever the selected step was done, so clicking a finished
step deselected it on the next render. That effect exists to advance you
when the step you are sitting on completes underneath you, so it now
fires only on that transition. Both paths are covered: reopening the API
key step offers "Generate another API key", and a step completing while
selected still moves the selection on.
Skip is hidden on an already-finished step, where it would mean nothing.
Also corrects the Plan type, which declared amount, currency and
recurringInterval. The plans endpoint has never sent those; they belong
to Subscription. Anything reading plan.amount saw undefined and rendered
a paid plan as free. The fixture encoded the same wrong shape, so tests
would have passed while production broke.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Day headers in message history could cover message rows. Two distinct causes,
both reproduced before fixing:
- On mobile the header was pinned to top-14, the same sticky band the mobile
search bar already occupies, so it detached and landed on top of rows.
Being bg-muted/70 with a backdrop blur, row text bled through it, which is
the distortion that was reported.
- On desktop it was pinned to top-0, behind the app header, and it covered
rows scrolled beneath it and swallowed their clicks. Playwright surfaced
this second one: a row click failed with "h3 intercepts pointer events".
Day headers are no longer sticky. A page holds 20 messages, so groups are
short and pinning bought little in exchange for those failures.
Footer links are left-aligned on mobile. Centred links in a single column had
no common edge to scan down.
Modal animation is now a quick fade. Dialogs slid in from the left and top
while zooming, which is the movement that read badly; the slide and zoom are
gone and the duration drops from 200ms to 150ms, applied to both the dialog
and alert-dialog primitives so every modal matches. Sheets still slide, since
that is what a drawer should do.
Guards: the overlap check runs at both mobile and desktop widths and also
asserts a row is still clickable after scrolling. Verified it fails against
the old markup rather than assuming a green test means coverage.
Fixture dates now anchor to local midnight instead of "N hours ago". A
2-hour-old message falls on the previous day when the suite runs shortly
after midnight, which made the Today/Yesterday assertions depend on the wall
clock. It failed exactly that way during this session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The screen opened with a tall filter card, so messages started below the fold,
and each message was a bordered card capped at max-w-sm, fitting three or four
per mobile screen.
List:
- Messages are now rows grouped under day headings (Today, Yesterday, then the
date). Roughly triples what fits on a phone, and scanning by time is how
people actually look for a message.
- Rows are buttons, so the list is keyboard navigable, with relative times
that reveal the exact timestamp on hover.
- Direction now comes from the `type` the API returns. It was inferred from
whether `sender` was present, which is only a proxy; the old check survives
as a fallback for rows written before `type` existed.
- Pagination renders only when there is more than one page.
- A search that matches nothing is a different state from a device with no
messages, and offers a way back.
Filter bar collapsed into one compact row: search, device, refresh, and auto
refresh moved into a dropdown instead of four inline buttons occupying a whole
row for a rarely-changed setting.
Details dialog reordered around why it is opened: the message body first, then
metadata, then errors, then actions. The exact timestamp is text here because
the list tooltip is hover-only and unreachable on touch.
Composer dialog gains real field labels (it used placeholders as labels, which
vanish on focus and are not reliably announced) and the same segment counter
bulk send has.
Two fixes found along the way:
- Reply passed message.device?._id with no fallback. The endpoint does
populate device, but if it were ever absent the composer would open with no
device and could not send. It now falls back to the device being viewed.
- The device-defaulting effect became derived state, so the list no longer
renders once with no device selected before correcting itself.
Segment helpers moved to lib/sms.ts now that the send page, bulk send and the
composer all use them. Fixtures now carry the populated `device` and relative
dates, so the mocked path matches production and exercises day grouping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Establish the testing safety net for the web dashboard before refactoring.
- Vitest + React Testing Library + jsdom for unit/component tests
- MSW node server mocks the API; unhandled requests throw so tests can
never reach a real backend
- Playwright e2e boots the real Next app but intercepts every /api/v1 call
with shared fixtures, and mints a NextAuth session cookie to run authed
- Shared fixtures in test/fixtures.ts back both layers
- Seed tests: Button unit tests, MSW sanity checks, and a dashboard e2e
happy path plus an auth-guard redirect
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>