Files
Matthew Meszaros 0d92f726f9 fix(api+web): root cause of blank campaigns + infinite-loading contacts; new filters sheet
Backend root cause:
The frontend client omits ?limit= when it would equal the default
(DEFAULT_PAGINATION_LIMIT = 50). validate.Limit("") treated empty as
invalid and returned errx.ErrLimit → 400 on /contacts/search and
/campaigns. ContactsTable derived isLoading from `!contacts`, which
stays true forever when the query errors, so the page hung in the
skeleton state instead of surfacing the error.

Fix:
- validate.Limit now accepts "" and returns LimitDefault = 50, in
  sync with the frontend constant. The frontend's omission semantics
  ("don't send the param when it's the default") was already correct;
  it was the validator that was wrong.

Frontend:
- ContactsTable: use isPending/isError/refetch directly from react-query
  instead of deriving from `contacts`. New explicit error block renders
  inside the body with: red alert tile, server error message, Try-again
  button (with spinner during refetch), and Reload-page fallback.
- Campaigns page: same error UI promoted from the old EmptyBlock CTA
  to a prominent block — alert tile + message + retry + reload.

New ContactFilters sheet (was the legacy 800px poppins drawer):
- 420px right-side panel matching the rest of the theme.
- Sticky 48px header with "Filters · N active" eyebrow + close.
- Sticky 48px footer with Reset / Cancel / Apply (slate-900 primary).
- Hairline-divided SectionBars between groups: Search, Custom field
  filters, Sort, Subscription, Campaign membership, Dates.
- Custom field rows pair TextInput + FILTER_TYPES popover + value
  input + remove button — all 28px tall.
- Sort: SelectButton popover + asc/desc toggle.
- Subscription: 3-state pill toggle (Any / Subscribed / Unsubscribed).
- Min/max campaign rows: checkbox toggle + number input + suffix.
- Date rows: checkbox toggle + native date input.
- Draft state mirrors parent until Apply, so editing filters doesn't
  trigger refetches mid-build.
2026-05-23 05:03:48 +00:00

29 lines
808 B
Go

package validate
import (
"strconv"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
)
// LimitDefault matches the frontend's DEFAULT_PAGINATION_LIMIT. The
// client omits ?limit= when it'd be sending the default, so the empty
// string here means "use the default", not "invalid". Treating it as
// invalid is what 400'd the campaigns and contacts listings — they
// stayed in loading state forever because the page didn't handle the
// error and the client kept retrying.
const LimitDefault int32 = 50
func Limit(limit string) (int32, *errx.Error) {
if limit == "" {
return LimitDefault, nil
}
i, err := strconv.ParseInt(limit, 10, 32)
if err != nil || i > config.LimitMax || i < config.LimitMin {
return 0, errx.ErrLimit
}
return int32(i), nil
}