Skip to content

feat(a11y): Add app-wide loading system, from pending buttons to skeletons - #143

Open
OffCrazyFreak wants to merge 20 commits into
devfrom
feat/button-loading-labels
Open

feat(a11y): Add app-wide loading system, from pending buttons to skeletons#143
OffCrazyFreak wants to merge 20 commits into
devfrom
feat/button-loading-labels

Conversation

@OffCrazyFreak

@OffCrazyFreak OffCrazyFreak commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Everything the app does while it is waiting, in one place: what a button says mid-mutation, what a page shows before its data lands, and the data layer underneath that decides when "waiting" is even true.

Combines two PRs so they review together. #144 was repointed at this branch and merged, since the two overlapped in 4 files and splitting the review would have meant reading the same files twice.

156 files, +3700 / -1222.


Part 1: pending state on mutation buttons

A button mid-mutation said nothing. Button renders loading ? loadingText : children, and the two components behind almost every action button, modal-shell-footer.tsx and confirm-dialog.tsx, passed no loadingText. So "Spremi", "Stvori", "Dodaj", "Pošalji poruku", "Obriši" and "Odjavi" all vanished the moment you pressed them, leaving a bare spinner.

A second group was worse: the four admin inbox actions, Poveži/Odspoji and the admin account-type select fired real mutations with no feedback at all, which reads as a dead button and invites a second click.

loading also sets native disabled, which drops a button out of the accessibility tree, so pending state was silent for screen readers. There was no aria-busy anywhere in frontend/src.

What changed

  • constants/loading-labels.ts, one source for the Croatian pending copy. Verbal noun plus ellipsis, matching the existing Prijava... / Slanje....
  • PendingStatus, an sr-only role="status" region. It stays mounted and only its text toggles, because a live region inserted together with its text is usually missed.
  • ModalShellFooter gains submitLoadingLabel, ConfirmDialog gains confirmLoadingLabel. Both default to the idle label, so any call site nobody updates keeps its label instead of blanking.
  • Button shape is preserved. Text buttons get the word; icon-only buttons show spinner blocks and move the busy wording into aria-label and the tooltip; the discounted-list button keeps its breakpoint swap, which is why loadingText widened from string to ReactNode.
  • BlockLoadingSpinner's blocks travelled x/y 1 to 23, so on a plain 24 viewBox they painted edge to edge while the Lucide icon they replace sits inset about an eighth a side. Fixing it per call site does not work, because inside a Button the size variant's [&_svg]:size-* overrides the width and height the size prop sets. The correction is now intrinsic to the artwork via a padded viewBox, so it holds at any rendered size.

Part 2: skeletons, and the data layer under them

Three reported symptoms shared one root cause:

  1. Detail pages flashed "Greška" / "Proizvod nije pronađen" on hard reload.
  2. Indexes flashed (0) in the heading before the real count landed.
  3. Everything else collapsed to a centred spinner, so the page jumped when content arrived.

PersistQueryClientProvider parks every query at fetchStatus: "idle" while it restores the IndexedDB cache. TanStack Query v5 derives isLoading as isPending && isFetching, so during that window isLoading reads false with data still undefined. All 147 isLoading guards fell straight through to the next branch and rendered the error state, the empty state, or (0).

Shopping list details had a second cause: useGetShoppingListById was the only user-scoped query with no auth gate, so it fired before the better-auth token existed, got a 401, and reported an auth-timing failure as "list not found".

Data layer

  • One keys.ts per domain, replacing three coexisting key styles. The JSON.stringify(params) cijene keys became explicit tuples, so cache seeding no longer depends on every caller passing identical fields.
  • CACHE_TIMES replaces 15 magic numbers, plus a global 60s staleTime so backend queries stop refetching on every mount.
  • Every domain split into keys.ts / queries.ts / hooks.ts, with reads exposed as queryOptions() descriptors rather than useGetX hooks.
  • toUserMessage unifies the two error shapes (RFC 9457 from our backend, CijeneApiError from upstream); ErrorState replaces the bespoke error JSX.
  • useAuthedQuery and useDataPending; useProductsByEans replaces four copies of the per-EAN useQueries block.
  • Authed queries start on the session rather than the loaded profile, so the data request runs in parallel with /api/users/me instead of behind it.

Loading UI

  • Skeleton kit in components/custom/skeleton/, and AsyncSection with a structurally fixed pending → error → empty → data order.
  • ~27 colocated *-skeleton.tsx files and five route-level loading.tsx.
  • BlockLoadingSpinner retired from page bodies, kept for buttons and short inline actions.
  • Reduced-motion rule for animate-pulse, which no existing rule covered.
  • Remembered row counts, so list skeletons reserve close to the real height.

Docs: new docs/DATA-FETCHING.md, plus a corrected cache-buster value in PWA.md.


Where the two parts met

Only 4 files, all merged cleanly and then verified by hand rather than trusted:

File Both changes present
admin-users-table.tsx TableSkeleton + useAuthedQuery alongside ConfirmDialog / AdminUserRow
admin-contact-table.tsx same shape
shopping-list-modal.tsx useAuthedQuery + byIdQuery.pending alongside the formState destructuring fix
use-shopping-list-item-mutations.ts SHOPPING_LIST_QUERY_KEYS.byId() alongside updatingItemId / onSettled

block-loading-spinner.tsx correctly kept Part 1's padded-viewBox fix; Part 2 only removed call sites and never touched the artwork.

Three bugs found while reviewing the combined branch, all the same bug Part 2 set out to fix:

  • watchlist-item-modal.tsx branched on isLoading and flashed "Proizvod nije pronađen" on a cold cache. Now isPending, with a real ProductInfoDisplaySkeleton.
  • useAllLocations returned isLoading, so with a location filter set locationsReady could read true with zero locations loaded, filtering every product out and painting a zero result. Now isPending.
  • useInfiniteProducts now takes isPending at source.

Review order

The 17 commits are ordered so each one builds standalone, verified with git rebase --exec 'tsc --noEmit'. Skeleton primitives, then the skeletons themselves, then loading.tsx files, are all purely additive and are the easy ones to skim first.

refactor(api): Unify the data layer is the large one at 63 files, and it is genuinely atomic: removing productByEanQueryKey and the useGetX hooks is a breaking API change that fans out to 17 call sites at once. Read it as lib/query/lib/api/*/keys.tslib/api/*/hooks.ts → the clients.

Merge order

Merge this before #142 (feat/digital-cards-rework). #145 is already in.

Deploy note

Query key shapes changed, so CACHE_BUSTER went "1""2". Every existing user takes one cold load after this deploys, then it is back to normal. Top-level key roots are unchanged, so the offline allowlist in cached-query-keys.ts still matches.

Follow-up: digital cards

(user)/digital-cards was deliberately skipped, because #142 rebuilds the feature and converting it here would only have created conflicts. It is now the one domain left on the old conventions. Once #142 merges it needs:

  • lib/api/digital-cards/ split into keys.ts / queries.ts / hooks.ts, with digitalCardQueries as queryOptions() descriptors.
  • digital-cards-client.tsx onto useAuthedQuery + AsyncSection, replacing the BlockLoadingSpinner and the enabled: isAuthenticated gate, using requiresAuth for the login gate.
  • digital-card-item-skeleton.tsx, digital-cards-skeleton.tsx and (user)/digital-cards/loading.tsx.
  • The bare <Skeleton className="h-64 w-full" /> in digital-card-modal.tsx replaced with a real mirror.
  • The inline ["digitalCards"] keys in use-digital-card-modal.ts and digital-card-item.tsx replaced with the factory.
  • One line for the rebuilt modal footer: submitLoadingLabel={isEdit ? LOADING_LABELS.saving : LOADING_LABELS.creating}.

Also noted on #142 so it cannot get lost. Conventions are in docs/DATA-FETCHING.md and summarised in AGENTS.md.

Notes for review

  • Two lines in components/ui/button.tsx, which AGENTS.md marks as shadcn output. That file already carries local loading / icon / effect extensions, so it is where loading behaviour lives. aria-busy sits beside the isDisabled it mirrors, and the ReactNode widening has no workaround short of a wrapper component.
  • item-amount-controls gets aria-busy but deliberately no spinner. That write is optimistic, so the new amount is already on screen and a loader would flicker on every tap.
  • Optimistic close is untouched. Several modals call closeModalUrl() before the mutation resolves, so their copy flashes briefly on a fast connection. That is intentional: React Query pauses mutations offline, and blocking there would trap an offline user in a modal that can never resolve. The contact modal awaits properly and is the clearest place to see the new copy.
  • AsyncSection deliberately knows nothing about auth. A missing session replaces the whole page rather than one section, so callers return LoginRequired early off requiresAuth.
  • pending still waits for the profile even though fetching starts on the session. The watchlist sorts on user.pinnedStores, so painting rows before it lands would reorder them under the reader.
  • /map, /spending, /updates and /suggestions get no skeletons: the first two are Coming Soon placeholders, the last two read static local data with no query.

Verification

Check Result
tsc --noEmit passes, 0 errors, on every commit individually
eslint . 0 errors, 27 warnings, identical to dev baseline
next build passes, 37 routes
Browser not done

⚠️ Not browser-verified: cold-cache reload per route, warm-cache renavigation, logged out, offline replay, reduced motion, CLS. Worth doing before merge, since the whole PR is about what things look like while waiting.

Netlify's failed deploy preview is pre-existing and environmental, not caused by this branch: #145 failed the same way and was merged regardless.

🤖 Generated with Claude Code

Changes:
- Add components/custom/skeleton/ primitives: SkeletonRegion, RepeatSkeleton,
  CountSkeleton, TextSkeleton, SectionHeaderSkeleton, TableSkeleton,
  ChartSkeleton and PageShellSkeleton
- Add AsyncSection, which fixes the state order as pending, error, empty, data
- Add ErrorState and toUserMessage, one entry point for turning either error
  shape into Croatian copy
- Add useDataPending, plus the remembered row-count store and its hooks
- Disable animate-pulse under prefers-reduced-motion

The building blocks for the loading sweep, added first so later batches only
compose them. AsyncSection makes the state order structural because several
pages checked error or emptiness before loading had finished, which is what
painted "Greška" and "(0)" on a cold cache.

Notes:
- animate-pulse was the one animation in the app that no reduced-motion rule
  covered, so skeletons would have kept pulsing for readers who asked for less.
- CountSkeleton renders a span rather than the shared Skeleton div, because a
  heading only permits phrasing content.
Changes:
- Add a sibling <component-name>-skeleton.tsx for every content component on
  the shopping list, product, watchlist and statistics surfaces
- Export PRODUCT_SUMMARY_ROW_CLASSES and PRODUCT_SUMMARY_IMAGE_CLASSES from
  product-summary, and share them with its skeleton
- Drop the isLoading prop from ProductSummary and ProductCard, so the caller
  picks the component instead of the component branching internally

Skeletons are server-renderable and take no hooks, so loading.tsx and a client
pending branch can share one file. Sharing the wrapper classes rather than
re-typing them is what keeps a row and its placeholder the same height.

Notes:
- ProductCardSkeleton still accepts trailing and actions, because a watchlist
  row knows its controls before it knows its product and they stay live.
- Bars are h-[1lh] inside a wrapper carrying the real text's font classes. This
  project sets --spacing to 0.2rem, so a fixed h-4 is 12.8px and matches nothing.
Changes:
- Add loading.tsx to /shopping-lists, /shopping-lists/[id], /watchlist and
  /products/[id], each rendering that route's page skeleton
- Swap the /products Suspense fallback from a spinner to ProductsSkeleton

These paint during the RSC navigation, before the client component mounts, which
is the window the single global spinner used to fill.

Notes:
- A page skeleton mirrors each collapsible section's stored default open state,
  so the page height does not jump once the real component reads localStorage.
  Price history is stored closed; items and stores are stored open.
- loading.tsx renders on the server, so it cannot read the remembered row count
  and takes the fixed fallback. The client refines it on mount.
Changes:
- Swap the price history chart spinners for ChartSkeleton
- Convert statistics, the dashboard guard, the header auth button and the
  notifications list onto skeletons and AsyncSection
- Make app/loading.tsx a neutral page shell instead of a centred spinner
- Record the loading UI and data fetching conventions in AGENTS.md

BlockLoadingSpinner now only appears where a spinner is genuinely right: the
button loading state and short inline actions. Content loading gets a skeleton,
so a page keeps its height instead of collapsing and then shoving the viewport.

Notes:
- The dashboard guard also covers the moment after a denial while its redirect
  runs, so a page shell is friendlier there than a bare spinner.
Changes:
- Add a keys.ts per domain, replacing three coexisting key styles, and turn the
  stringified-params cijene keys into explicit tuples
- Add CACHE_TIMES and a global 60s staleTime, replacing 15 magic numbers
- Split every lib/api domain into keys.ts, queries.ts and hooks.ts, with reads
  exposed as queryOptions() descriptors rather than useGetX hooks
- Add useAuthedQuery, which folds the session into enabled and returns pending
  and requiresAuth
- Add useProductsByEans, replacing four copies of the per-EAN useQueries block
- Wire every client onto AsyncSection, its skeleton, and a count pill
- Gate the shopping list detail query on auth and show LoginRequired
- Bump the offline cache buster to 2

Detail pages rendered "Greška" and indexes rendered "(0)" for a frame on every
reload. PersistQueryClientProvider parks queries at fetchStatus idle while it
restores IndexedDB, and v5 derives isLoading as isPending && isFetching, so it
reads false with data still undefined and every guard fell through to the next
branch. Shopping list detail had a second cause: it was the only user-scoped
query with no auth gate, so it 401'd before the token existed and reported an
auth-timing failure as "list not found".

Notes:
- Reads are descriptors, not hooks, because useAuthedQuery reads useUser and
  user-context imports the lib/api barrel, so a domain hook importing it would
  close an import cycle.
- Query key shapes changed, hence the buster bump: existing users take one cold
  load after this deploys. Top-level key roots are unchanged, so the offline
  allowlist in cached-query-keys.ts still matches.
- lib/api/digital-cards is left alone as dead code pending its own removal.
Changes:
- Add docs/DATA-FETCHING.md covering the three layers, query keys, cache times,
  useAuthedQuery, AsyncSection, the skeleton convention and the gotchas
- Correct the cache buster value in PWA.md, now "2"
- Index the new doc in docs/README.md
Changes:
- Expose hasSession from UserProvider, true as soon as better-auth resolves
- Gate useAuthedQuery's enabled and requiresAuth on hasSession
- Switch the remaining useProductsByEans gates from isAuthenticated to hasSession

Authed pages queued behind a request they did not depend on. isAuthenticated is
!!user, so it only turns true once /api/users/me returns, which put every data
query behind the profile fetch: get-session, then token, then users/me, and only
then the list. A request only needs a session to be authorised, so the profile
and the data now go out together and total time is the slower of the two rather
than their sum.

Notes:
- pending still waits for the profile on purpose. The watchlist sorts on
  user.pinnedStores, so painting rows before it lands would reorder them under
  the reader. That costs nothing now the fetch has already started.
- requiresAuth keys on the session too, so a failed profile fetch surfaces as an
  error instead of telling a signed-in reader to sign in.
Changes:
- Explain why useAuthedQuery gates on the session rather than the loaded
  profile, with a sequence diagram of the parallelised requests
- Expand the HydrationBoundary prefetch note into a staged TODO, with the
  route order and the traps: the browser-only token path, key parity, the
  persister interaction, and keeping the skeletons for client navigation
Changes:
- Add constants/loading-labels.ts as the single source of Croatian pending copy
- Add PendingStatus, an sr-only role="status" region that stays mounted so the
  announcement is not lost when the text appears
- Set aria-busy on Button and widen loadingText to ReactNode
- Pass loadingText through ModalShellFooter and ConfirmDialog, defaulting to the
  idle label so a pending button is never left unlabelled
- Give RemoveIconButton a loadingLabel for its aria-label and tooltip
- Size in-button spinners to ~80% of the icon box and drop the wrapper's px-1

Button renders `loading ? loadingText : children`, and the two shells that
render most action buttons passed no loadingText, so the Croatian label vanished
exactly when the user most needed it. The spinner also painted edge to edge
where a Lucide icon sits inset, and its wrapper padding widened buttons on swap.
Changes:
- Point the existing login, signup and forgot-password loadingText at
  LOADING_LABELS so the wording has one source
- Give the social sign-in button "Prijava..." and move its spinner to the left,
  where the provider icon was
- Give the reset-password modal submit "Postavljanje..."

The social button and the reset-password submit previously blanked their label
while pending, leaving a bare spinner with no accessible name.
Changes:
- Give the list modal submit "Spremanje..." or "Stvaranje..." by mode
- Give the delete confirm "Brisanje..."
- Swap the aria-label and tooltip to the busy wording on the icon-only share,
  copy and delete actions, and mirror it in the mobile dropdown
- Track updatingItemId in the item mutations hook and set aria-busy on the
  amount controls

The icon-only actions have no room for text, so their accessible name now
matches the spinner. The amount controls deliberately get no spinner: the write
is optimistic, so a loader would fight the already-rendered value.
Changes:
- Give the discounted-list button a breakpoint-aware pending label, so mobile
  shows "Stvaranje..." instead of a string longer than the "Stvori popis" it
  replaced
- Swap the icon-only track/remove button's aria-label and tooltip to "Brisanje..."

The button swaps its wording by breakpoint, so its pending wording has to do the
same or it grows wider than the label it replaced.
Changes:
- Give the add-to-list submit "Dodavanje..."
- Give the watchlist item submit "Spremanje..." or "Dodavanje..." by mode

Both submits previously blanked their label while the mutation ran.
Changes:
- Give both settings footers and the onboarding finish button "Spremanje..."
- Give the revoke-sessions and delete-account confirms "Odjava..." and
  "Brisanje..."
- Give Poveži and Odspoji a real pending state, replacing a bare disabled
- Give the contact modal submit "Slanje..."

Poveži and Odspoji only greyed out, so a slow request looked like a dead button.
The contact modal awaits its mutation before closing, so it is the clearest
place the new copy is visible.
Changes:
- Expose per-row pending ids from useContactInbox, derived from each mutation's
  isPending and its variables, which hold the message id
- Swap icon for spinner and aria-label for the busy wording on the inbox
  read, restore and delete actions
- Give the delete-account confirm "Brisanje..."
- Put a spinner and a live region beside the account-type select, which has
  nowhere to host pending copy of its own

The four inbox actions fired real mutations with no feedback at all, so a slow
request read as a dead button and invited a second click.
…eplaces

Changes:
- Pad the spinner's viewBox so the blocks sit inset like a Lucide stroke
- Size the in-button spinner to the icon box it stands in for, now that the
  inset rather than the number carries the correction

The blocks travel x/y 1 to 23, so on a plain 24 viewBox they painted edge to
edge while the icon they replace is inset by about an eighth a side, and the
spinner read as the heavier of the two. Correcting this per call site does not
work: inside a Button the size variant's `[&_svg]:size-*` overrides the width
and height attributes the size prop sets, so the fix has to be intrinsic to
the artwork.
@netlify

netlify Bot commented Jul 30, 2026

Copy link
Copy Markdown

Deploy Preview for disscount ready!

Name Link
🔨 Latest commit adf7322
🔍 Latest deploy log https://app.netlify.com/projects/disscount/deploys/6a6ec4d495ceae0008a7e386
😎 Deploy Preview https://deploy-preview-143--disscount.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 156 files, which is 56 over the limit of 100.

To get a review, narrow the scope:
• coderabbit review --committed # exclude uncommitted changes
• coderabbit review --dir # limit to a subdirectory
• coderabbit review --base # compare against a closer base

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 63a4cba4-4120-46c4-b40c-47ba32315383

📥 Commits

Reviewing files that changed from the base of the PR and between f49d4c7 and adf7322.

📒 Files selected for processing (156)
  • AGENTS.md
  • docs/DATA-FETCHING.md
  • docs/PWA.md
  • docs/README.md
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/remove-item-button.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-action-buttons.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-desktop-actions.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-info-table-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-price-history.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-store-chain-analysis.ts
  • frontend/src/app/(user)/shopping-lists/[id]/loading.tsx
  • frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx
  • frontend/src/app/(user)/shopping-lists/components/shopping-list-item-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/components/shopping-lists-client.tsx
  • frontend/src/app/(user)/shopping-lists/components/shopping-lists-skeleton.tsx
  • frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts
  • frontend/src/app/(user)/shopping-lists/loading.tsx
  • frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx
  • frontend/src/app/(user)/watchlist/components/watchlist-action-button.tsx
  • frontend/src/app/(user)/watchlist/components/watchlist-client.tsx
  • frontend/src/app/(user)/watchlist/components/watchlist-header.tsx
  • frontend/src/app/(user)/watchlist/components/watchlist-item.tsx
  • frontend/src/app/(user)/watchlist/components/watchlist-list.tsx
  • frontend/src/app/(user)/watchlist/components/watchlist-skeleton.tsx
  • frontend/src/app/(user)/watchlist/components/watchlist-suggestions.tsx
  • frontend/src/app/(user)/watchlist/hooks/use-watchlist-data.ts
  • frontend/src/app/(user)/watchlist/hooks/use-watchlist-suggestions.ts
  • frontend/src/app/(user)/watchlist/loading.tsx
  • frontend/src/app/dashboard/components/admin-contact-row.tsx
  • frontend/src/app/dashboard/components/admin-contact-table.tsx
  • frontend/src/app/dashboard/components/admin-user-row.tsx
  • frontend/src/app/dashboard/components/admin-users-stats.tsx
  • frontend/src/app/dashboard/components/admin-users-table.tsx
  • frontend/src/app/dashboard/components/dashboard-guard.tsx
  • frontend/src/app/dashboard/hooks/use-contact-inbox.ts
  • frontend/src/app/globals.css
  • frontend/src/app/loading.tsx
  • frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx
  • frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx
  • frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx
  • frontend/src/app/products/[id]/components/product-chains-section.tsx
  • frontend/src/app/products/[id]/components/product-detail-client.tsx
  • frontend/src/app/products/[id]/components/product-detail-skeleton.tsx
  • frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx
  • frontend/src/app/products/[id]/hooks/use-product-detail.ts
  • frontend/src/app/products/[id]/loading.tsx
  • frontend/src/app/products/components/forms/add-to-shopping-list-form.tsx
  • frontend/src/app/products/components/forms/product-actions-sheet.tsx
  • frontend/src/app/products/components/forms/watchlist-item-modal.tsx
  • frontend/src/app/products/components/product-action-buttons.tsx
  • frontend/src/app/products/components/product-info-display-skeleton.tsx
  • frontend/src/app/products/components/products-client.tsx
  • frontend/src/app/products/components/products-skeleton.tsx
  • frontend/src/app/products/hooks/use-infinite-products.ts
  • frontend/src/app/products/hooks/use-selected-shopping-list.ts
  • frontend/src/app/products/hooks/use-watchlist-item-form.ts
  • frontend/src/app/products/page.tsx
  • frontend/src/app/providers/react-query-provider.tsx
  • frontend/src/app/statistics/components/health-status.tsx
  • frontend/src/app/statistics/components/store-item-skeleton.tsx
  • frontend/src/app/statistics/components/store-item.tsx
  • frontend/src/app/statistics/components/stores-list.tsx
  • frontend/src/components/custom/auth/components/forms/forgot-password-form.tsx
  • frontend/src/components/custom/auth/components/forms/login-form.tsx
  • frontend/src/components/custom/auth/components/forms/signup-form.tsx
  • frontend/src/components/custom/auth/components/social/auth-social-button.tsx
  • frontend/src/components/custom/auth/reset-password-modal.tsx
  • frontend/src/components/custom/bottom-nav/use-active-list-progress.ts
  • frontend/src/components/custom/common/async-section.tsx
  • frontend/src/components/custom/common/block-loading-spinner.tsx
  • frontend/src/components/custom/common/error-state.tsx
  • frontend/src/components/custom/common/pending-status.tsx
  • frontend/src/components/custom/common/remove-icon-button.tsx
  • frontend/src/components/custom/contact/contact-modal.tsx
  • frontend/src/components/custom/header/components/header-actions-skeleton.tsx
  • frontend/src/components/custom/header/components/header-actions.tsx
  • frontend/src/components/custom/modal/confirm-dialog.tsx
  • frontend/src/components/custom/modal/modal-shell-footer.tsx
  • frontend/src/components/custom/notifications/components/notification-item-skeleton.tsx
  • frontend/src/components/custom/notifications/components/notifications-list.tsx
  • frontend/src/components/custom/product/product-card-skeleton.tsx
  • frontend/src/components/custom/product/product-card.tsx
  • frontend/src/components/custom/product/product-info-skeleton.tsx
  • frontend/src/components/custom/product/product-summary-skeleton.tsx
  • frontend/src/components/custom/product/product-summary.tsx
  • frontend/src/components/custom/settings/hooks/use-settings-defaults.ts
  • frontend/src/components/custom/settings/onboarding/onboarding-wizard.tsx
  • frontend/src/components/custom/settings/security/components/account-actions.tsx
  • frontend/src/components/custom/settings/security/components/linked-accounts.tsx
  • frontend/src/components/custom/settings/settings-modal.tsx
  • frontend/src/components/custom/skeleton/chart-skeleton.tsx
  • frontend/src/components/custom/skeleton/count-skeleton.tsx
  • frontend/src/components/custom/skeleton/page-shell-skeleton.tsx
  • frontend/src/components/custom/skeleton/repeat-skeleton.tsx
  • frontend/src/components/custom/skeleton/section-header-skeleton.tsx
  • frontend/src/components/custom/skeleton/skeleton-region.tsx
  • frontend/src/components/custom/skeleton/table-skeleton.tsx
  • frontend/src/components/custom/skeleton/text-skeleton.tsx
  • frontend/src/components/ui/button.tsx
  • frontend/src/constants/loading-labels.ts
  • frontend/src/context/use-watchlist-notifications.ts
  • frontend/src/context/user-context.tsx
  • frontend/src/hooks/use-product-modals.ts
  • frontend/src/hooks/use-product-navigation.ts
  • frontend/src/hooks/use-remembered-row-count.ts
  • frontend/src/lib/api/admin/hooks.ts
  • frontend/src/lib/api/admin/index.ts
  • frontend/src/lib/api/admin/keys.ts
  • frontend/src/lib/api/admin/queries.ts
  • frontend/src/lib/api/contact/hooks.ts
  • frontend/src/lib/api/contact/index.ts
  • frontend/src/lib/api/contact/keys.ts
  • frontend/src/lib/api/contact/queries.ts
  • frontend/src/lib/api/error-message.ts
  • frontend/src/lib/api/preferences/hooks.ts
  • frontend/src/lib/api/preferences/index.ts
  • frontend/src/lib/api/preferences/keys.ts
  • frontend/src/lib/api/preferences/queries.ts
  • frontend/src/lib/api/shopping-lists/hooks.ts
  • frontend/src/lib/api/shopping-lists/index.ts
  • frontend/src/lib/api/shopping-lists/keys.ts
  • frontend/src/lib/api/users/hooks.ts
  • frontend/src/lib/api/users/index.ts
  • frontend/src/lib/api/users/queries.ts
  • frontend/src/lib/api/watchlist/hooks.ts
  • frontend/src/lib/api/watchlist/index.ts
  • frontend/src/lib/api/watchlist/keys.ts
  • frontend/src/lib/api/watchlist/queries.ts
  • frontend/src/lib/cijene-api/hooks.ts
  • frontend/src/lib/cijene-api/index.ts
  • frontend/src/lib/cijene-api/keys.ts
  • frontend/src/lib/cijene-api/query-hooks.ts
  • frontend/src/lib/cijene-api/use-products-by-eans.ts
  • frontend/src/lib/offline/offline-mutations.ts
  • frontend/src/lib/offline/persister.ts
  • frontend/src/lib/query/cache-times.ts
  • frontend/src/lib/query/use-authed-query.ts
  • frontend/src/lib/query/use-data-pending.ts
  • frontend/src/lib/skeleton/row-count-store.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@OffCrazyFreak

Copy link
Copy Markdown
Owner Author

Heads-up: PR #145 must merge before this branch. There is no direct file conflict, but this PR changes shared Button and modal-footer behaviour used by the new public donation modal.

After rebasing, please verify that the Ko-fi action still:

  • visibly and accessibly says Podrži na Ko-fi;
  • remains an external anchor with target="_blank" and rel="noopener noreferrer";
  • has no mutation loading state or loading copy.

Please adjust this branch if its shared button conventions would otherwise change that behaviour.

Copilot AI review requested due to automatic review settings August 2, 2026 04:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves mutation and loading-state accessibility across the Next.js frontend by introducing consistent, contextual pending copy for buttons (including screen-reader announcements), and by standardising data-loading UI around skeletons and a safer “pending” signal under React Query persistence.

Changes:

  • Add shared Croatian loading labels, wire them through common modal/button shells, and announce pending state via a persistent PendingStatus live region plus aria-busy.
  • Introduce AsyncSection + a skeleton component set to standardise pending/error/empty/data rendering, and replace many spinners with skeletons.
  • Refactor React Query keying/caching (explicit tuple keys, shared CACHE_TIMES, cache buster bump) and add remembered skeleton row counts via localStorage.

Reviewed changes

Copilot reviewed 155 out of 155 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
frontend/src/lib/skeleton/row-count-store.ts localStorage-backed store for remembered skeleton row counts
frontend/src/lib/query/use-data-pending.ts helper hook to avoid isLoading under persister restore
frontend/src/lib/query/use-authed-query.ts auth-gated query wrapper returning pending + requiresAuth
frontend/src/lib/query/cache-times.ts centralised staleTime constants
frontend/src/lib/offline/persister.ts bump persisted cache buster for key-shape changes
frontend/src/lib/offline/offline-mutations.ts update invalidation keys to shared key constants
frontend/src/lib/cijene-api/use-products-by-eans.ts shared multi-EAN product querying hook
frontend/src/lib/cijene-api/query-hooks.ts migrate to shared keys and CACHE_TIMES
frontend/src/lib/cijene-api/keys.ts new explicit tuple query-key factory for cijene API
frontend/src/lib/cijene-api/index.ts export CIJENE_QUERY_KEYS from cijene barrel
frontend/src/lib/cijene-api/hooks.ts migrate history queries to shared keys + cache windows
frontend/src/lib/api/watchlist/queries.ts split watchlist fetchers into queries.ts
frontend/src/lib/api/watchlist/keys.ts new shared watchlist query keys
frontend/src/lib/api/watchlist/index.ts restructure watchlist barrel exports/service
frontend/src/lib/api/watchlist/hooks.ts add watchlist mutations + queryOptions descriptors
frontend/src/lib/api/users/queries.ts split users fetchers into queries.ts
frontend/src/lib/api/users/index.ts restructure users barrel exports/service
frontend/src/lib/api/users/hooks.ts add user mutation hook module
frontend/src/lib/api/shopping-lists/keys.ts new shared shopping list query keys
frontend/src/lib/api/shopping-lists/index.ts export shopping list keys from barrel
frontend/src/lib/api/shopping-lists/hooks.ts convert list reads to queryOptions descriptors, update invalidations
frontend/src/lib/api/preferences/queries.ts split preferences fetchers into queries.ts
frontend/src/lib/api/preferences/keys.ts new shared preferences query keys
frontend/src/lib/api/preferences/index.ts restructure preferences barrel exports/service
frontend/src/lib/api/preferences/hooks.ts add preferences queryOptions + mutation hooks
frontend/src/lib/api/error-message.ts centralise error-to-user-message mapping
frontend/src/lib/api/contact/queries.ts split contact fetchers/mutations into queries.ts
frontend/src/lib/api/contact/keys.ts new shared contact query keys
frontend/src/lib/api/contact/index.ts restructure contact barrel exports/service
frontend/src/lib/api/contact/hooks.ts add contact queryOptions + mutation hooks
frontend/src/lib/api/admin/queries.ts split admin fetchers into queries.ts
frontend/src/lib/api/admin/keys.ts new shared admin query keys
frontend/src/lib/api/admin/index.ts restructure admin barrel exports/service
frontend/src/lib/api/admin/hooks.ts add admin queryOptions + mutation hooks
frontend/src/hooks/use-remembered-row-count.ts client hook to read/write remembered skeleton row counts
frontend/src/hooks/use-product-navigation.ts seed product cache using new key factory
frontend/src/hooks/use-product-modals.ts seed product cache using new key factory
frontend/src/context/user-context.tsx add hasSession to support earlier auth-gated fetching
frontend/src/context/use-watchlist-notifications.ts refactor to useAuthedQuery + useProductsByEans
frontend/src/constants/loading-labels.ts shared Croatian loading-copy constants
frontend/src/components/ui/button.tsx widen loadingText type + set aria-busy + tweak spinner size
frontend/src/components/custom/skeleton/text-skeleton.tsx reusable text skeleton component
frontend/src/components/custom/skeleton/table-skeleton.tsx reusable table skeleton for admin/statistics
frontend/src/components/custom/skeleton/skeleton-region.tsx a11y wrapper for skeleton regions (live label + aria-hidden bars)
frontend/src/components/custom/skeleton/section-header-skeleton.tsx skeleton for collapsible section headers
frontend/src/components/custom/skeleton/repeat-skeleton.tsx helper to repeat skeleton rows/cards
frontend/src/components/custom/skeleton/page-shell-skeleton.tsx generic page skeleton fallback
frontend/src/components/custom/skeleton/count-skeleton.tsx heading count “pill” skeleton
frontend/src/components/custom/skeleton/chart-skeleton.tsx chart footprint skeleton
frontend/src/components/custom/settings/settings-modal.tsx supply contextual loading labels for modal submit
frontend/src/components/custom/settings/security/components/linked-accounts.tsx add loading labels and proper loading state for link/unlink
frontend/src/components/custom/settings/security/components/account-actions.tsx add confirm-dialog loading labels
frontend/src/components/custom/settings/onboarding/onboarding-wizard.tsx add button loading label for save/finish
frontend/src/components/custom/settings/hooks/use-settings-defaults.ts migrate to useAuthedQuery + preferences query descriptors
frontend/src/components/custom/product/product-summary.tsx remove inline skeleton mode, extract shared layout classes
frontend/src/components/custom/product/product-summary-skeleton.tsx new skeleton mirroring product summary layout
frontend/src/components/custom/product/product-info-skeleton.tsx new skeleton mirroring ProductInfo lines
frontend/src/components/custom/product/product-card.tsx remove isLoading prop passthrough
frontend/src/components/custom/product/product-card-skeleton.tsx new skeleton card without overlay navigation
frontend/src/components/custom/notifications/components/notifications-list.tsx switch loading UI to AsyncSection + skeleton rows
frontend/src/components/custom/notifications/components/notification-item-skeleton.tsx add notification row skeleton
frontend/src/components/custom/modal/modal-shell-footer.tsx add submitLoadingLabel and PendingStatus announcements
frontend/src/components/custom/modal/confirm-dialog.tsx add confirmLoadingLabel and PendingStatus announcements
frontend/src/components/custom/header/components/header-actions.tsx use dedicated header actions skeleton
frontend/src/components/custom/header/components/header-actions-skeleton.tsx new header skeleton component
frontend/src/components/custom/contact/contact-modal.tsx add contextual submit loading label
frontend/src/components/custom/common/remove-icon-button.tsx make aria-label/tooltip reflect pending action label
frontend/src/components/custom/common/pending-status.tsx new SR-only live region for pending actions
frontend/src/components/custom/common/error-state.tsx shared error UI using toUserMessage
frontend/src/components/custom/common/block-loading-spinner.tsx adjust spinner artwork viewBox padding
frontend/src/components/custom/common/async-section.tsx standardise pending/error/empty/data rendering order
frontend/src/components/custom/bottom-nav/use-active-list-progress.ts migrate to useAuthedQuery + query descriptor
frontend/src/components/custom/auth/reset-password-modal.tsx add contextual submit loading label
frontend/src/components/custom/auth/components/social/auth-social-button.tsx add contextual loading label for social sign-in
frontend/src/components/custom/auth/components/forms/signup-form.tsx use shared loading label constant
frontend/src/components/custom/auth/components/forms/login-form.tsx use shared loading label constant
frontend/src/components/custom/auth/components/forms/forgot-password-form.tsx use shared loading label constant
frontend/src/app/statistics/components/stores-list.tsx switch to AsyncSection + skeletons + useDataPending
frontend/src/app/statistics/components/store-item.tsx replace spinner with table skeleton for store lists
frontend/src/app/statistics/components/store-item-skeleton.tsx add statistics store row skeleton
frontend/src/app/statistics/components/health-status.tsx replace spinner with skeleton + SR status
frontend/src/app/providers/react-query-provider.tsx set default query staleTime from CACHE_TIMES
frontend/src/app/products/page.tsx replace Suspense fallback with dedicated products skeleton
frontend/src/app/products/hooks/use-watchlist-item-form.ts migrate watchlist read to useAuthedQuery descriptor
frontend/src/app/products/hooks/use-selected-shopping-list.ts migrate shopping list reads to useAuthedQuery descriptors
frontend/src/app/products/components/products-skeleton.tsx add products index skeleton
frontend/src/app/products/components/products-client.tsx use AsyncSection, remembered row counts, count skeleton
frontend/src/app/products/components/product-info-display-skeleton.tsx add product detail info skeleton
frontend/src/app/products/components/product-action-buttons.tsx migrate watchlist read to useAuthedQuery descriptor
frontend/src/app/products/components/forms/watchlist-item-modal.tsx add contextual submit loading label
frontend/src/app/products/components/forms/product-actions-sheet.tsx swap in summary skeleton instead of inline loading
frontend/src/app/products/components/forms/add-to-shopping-list-form.tsx add contextual submit loading label
frontend/src/app/products/[id]/loading.tsx add route-level product detail skeleton loading
frontend/src/app/products/[id]/hooks/use-product-detail.ts switch to isPending naming for query state
frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx add product detail store row skeleton
frontend/src/app/products/[id]/components/product-detail-skeleton.tsx add full product detail page skeleton
frontend/src/app/products/[id]/components/product-detail-client.tsx use AsyncSection for pending/error branches
frontend/src/app/products/[id]/components/product-chains-section.tsx use AsyncSection + skeleton for prices section
frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx add chains section skeleton
frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx add chains list skeleton
frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx replace spinner with chart skeleton
frontend/src/app/loading.tsx replace global spinner with page-shell skeleton
frontend/src/app/globals.css disable skeleton pulse animation under reduced motion
frontend/src/app/dashboard/hooks/use-contact-inbox.ts expose per-row pending ids for inbox actions
frontend/src/app/dashboard/components/dashboard-guard.tsx replace spinner with skeleton while guarding/redirecting
frontend/src/app/dashboard/components/admin-users-table.tsx migrate admin users read to useAuthedQuery + skeleton, add loading label
frontend/src/app/dashboard/components/admin-users-stats.tsx share authed users query for stats
frontend/src/app/dashboard/components/admin-user-row.tsx add per-row pending UI for account-type mutation
frontend/src/app/dashboard/components/admin-contact-table.tsx migrate contact reads to useAuthedQuery + skeleton, pass per-row pending flags
frontend/src/app/dashboard/components/admin-contact-row.tsx add icon-button pending spinners + contextual aria-labels
frontend/src/app/(user)/watchlist/loading.tsx add route-level watchlist skeleton loading
frontend/src/app/(user)/watchlist/hooks/use-watchlist-suggestions.ts migrate to authed descriptors + useProductsByEans
frontend/src/app/(user)/watchlist/hooks/use-watchlist-data.ts migrate to useAuthedQuery + useProductsByEans, return requiresAuth
frontend/src/app/(user)/watchlist/components/watchlist-suggestions.tsx switch to AsyncSection + skeletons + count skeleton
frontend/src/app/(user)/watchlist/components/watchlist-skeleton.tsx add watchlist page skeleton
frontend/src/app/(user)/watchlist/components/watchlist-list.tsx replace loading spinner with skeleton rows
frontend/src/app/(user)/watchlist/components/watchlist-item.tsx render skeleton card while product detail per-row is pending
frontend/src/app/(user)/watchlist/components/watchlist-header.tsx avoid transient “(0)” by using count skeleton
frontend/src/app/(user)/watchlist/components/watchlist-client.tsx remember row counts, gate with requiresAuth
frontend/src/app/(user)/watchlist/components/watchlist-action-button.tsx pending-aware aria-label/tooltip text for icon-only action
frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx responsive loading label via ReactNode loadingText
frontend/src/app/(user)/shopping-lists/loading.tsx add route-level shopping-lists skeleton loading
frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts update invalidation to shared keys
frontend/src/app/(user)/shopping-lists/components/shopping-lists-skeleton.tsx add shopping lists index skeleton
frontend/src/app/(user)/shopping-lists/components/shopping-lists-client.tsx use AsyncSection, count skeleton, remembered row counts, authed query
frontend/src/app/(user)/shopping-lists/components/shopping-list-item-skeleton.tsx add shopping list card skeleton
frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx seed cache via shared keys, migrate by-id read to useAuthedQuery, add loading label
frontend/src/app/(user)/shopping-lists/[id]/loading.tsx add route-level shopping list detail skeleton loading
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-store-chain-analysis.ts migrate product batch fetch to useProductsByEans
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-price-history.ts migrate history keys to CIJENE_QUERY_KEYS
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts update invalidations to shared keys
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts add per-item updating state + migrate keys
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts migrate list read to useAuthedQuery + products to useProductsByEans
frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-skeleton.tsx add stores section body skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list.tsx wrap stores section in AsyncSection + skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list-skeleton.tsx add stores section skeleton including header
frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card-skeleton.tsx add store card skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsx replace spinner with chart skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx contextual pending labels for dropdown actions
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-info-table-skeleton.tsx add info table skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header-skeleton.tsx add header skeleton with real back link
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton.tsx add full detail page skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx use AsyncSection, login gate, remembered item rows
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-desktop-actions.tsx pending-aware aria-label/tooltip for icon-only actions
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-action-buttons.tsx add confirm-dialog loading label
frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items.tsx plumb per-item updating state into rows
frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items-skeleton.tsx add items section skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsx plumb isUpdating into amount controls
frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item-skeleton.tsx add item row skeleton
frontend/src/app/(user)/shopping-lists/[id]/components/items/remove-item-button.tsx pending-aware aria-label/tooltip for delete icon
frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx add aria-busy without spinner for optimistic updates
docs/README.md link new data-fetching documentation
docs/PWA.md update persister buster documentation
AGENTS.md document new data-fetching/loading conventions

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +52 to +65
export function useProductsByEans(
eans: string[],
{ enabled = true }: { enabled?: boolean } = {},
): IProductsByEans {
return useQueries({
queries: eans.map((ean) => ({
queryKey: CIJENE_QUERY_KEYS.productByEan({ ean }),
queryFn: () => getProductByEan({ ean }),
enabled: enabled && Boolean(ean),
staleTime: CACHE_TIMES.products,
})),
combine: combineProductQueries,
});
}
Changes:
- watchlist-item-modal branches on isPending and shows a real
  ProductInfoDisplaySkeleton instead of a bare h-24 block
- useAllLocations reports isPending, so a location filter cannot read as ready
  with no locations loaded
- useInfiniteProducts takes isPending from the search query

Found reviewing the combined branch. All three are the same bug the sweep set
out to fix: PersistQueryClientProvider parks queries at an idle fetchStatus
while restoring, and isLoading is isPending && isFetching, so it reads false
with no data. The watchlist modal flashed "Proizvod nije pronađen", and
locationsReady went true with an empty location set, which filters every
product out and paints a zero result.

Notes:
- useInfiniteProducts was already covered in practice, since products-client
  runs the flag through useDataPending, but the source is now correct too.
@OffCrazyFreak

Copy link
Copy Markdown
Owner Author

#144 merged in

Per @OffCrazyFreak's request, PR #144 (app-wide skeleton loading system + data layer unification) was repointed at this branch and merged, so both loading-related changes review as one. This branch now also carries the current dev, including #145.

Combined scope is roughly 160 files: this PR's pending-button work, plus the skeleton kit, AsyncSection, per-route loading.tsx, the lib/api domain split into keys.ts / queries.ts / hooks.ts with queryOptions() descriptors, useAuthedQuery / useDataPending, and docs/DATA-FETCHING.md.

The two sets overlapped in only 4 files, all merged cleanly and verified by hand rather than trusted:

File Both changes present
admin-users-table.tsx TableSkeleton + useAuthedQuery alongside ConfirmDialog / AdminUserRow
admin-contact-table.tsx same shape
shopping-list-modal.tsx useAuthedQuery + byIdQuery.pending alongside the formState destructuring fix
use-shopping-list-item-mutations.ts SHOPPING_LIST_QUERY_KEYS.byId() alongside updatingItemId / onSettled

block-loading-spinner.tsx correctly kept this PR's padded-viewBox fix; #144 only removed call sites and never touched the artwork.

Verification asks answered

From the #143 comment, re the Ko-fi action after rebasing onto #145:

  • Visible and accessible label Podrži na Ko-fi ✅ unchanged.
  • External anchor with target="_blank" rel="noopener noreferrer" ✅ unchanged.
  • No mutation loading state or loading copy ✅. The button passes no loading prop, so the new aria-busy={loading || undefined} renders no attribute at all, and loadingText is never supplied.
  • This PR's ModalShellFooter changes cannot reach the donation modal: it passes a raw footer prop with its own markup rather than using ModalShellFooter.

From the #144 comment, re the donation modal:

  • docs/README.md lists both SUPPORT.md and the new DATA-FETCHING.md ✅.
  • ?modal=donate stays independent of the loading shells ✅, and its focus restoration (remember trigger, refocus on close) is untouched. footer-support-icons.tsx and sidebar-support-nav.tsx are not modified by either PR.

Fixes applied while reviewing

Three real gaps found in #144's own work, all the same bug it set out to fix, pushed as fix(products): Close three isLoading gaps missed in the sweep:

  • watchlist-item-modal.tsx branched on isLoading and flashed "Proizvod nije pronađen" on a cold cache. Now isPending, with a real ProductInfoDisplaySkeleton instead of a bare h-24 block.
  • useAllLocations returned isLoading, so with a location filter set locationsReady could read true with zero locations loaded, filtering every product out and painting a zero result. Now isPending.
  • useInfiniteProducts now takes isPending at the source. It was already covered in practice by useDataPending, but the source is correct now too.

Checks

tsc --noEmit clean, eslint . 0 errors / 27 warnings (identical to dev's baseline), next build passes 37 routes.

Netlify's failed deploy preview is pre-existing and environmental: #145 failed the same way and was merged regardless.

⚠️ Still not browser-verified: cold-cache reload per route, warm renavigation, logged out, offline replay, reduced motion, CLS.

🤖 Generated with Claude Code

@OffCrazyFreak OffCrazyFreak changed the title feat(a11y): Add contextual loading copy to every mutation button feat(a11y): Add app-wide loading system, from pending buttons to skeletons Aug 2, 2026
@OffCrazyFreak OffCrazyFreak reopened this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants