feat(a11y): Add app-wide loading system, from pending buttons to skeletons - #143
feat(a11y): Add app-wide loading system, from pending buttons to skeletons#143OffCrazyFreak wants to merge 20 commits into
Conversation
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.
✅ Deploy Preview for disscount ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Important Review skippedToo many files! This PR contains 156 files, which is 56 over the limit of 100. To get a review, narrow the scope: 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (156)
You can disable this status message by setting the 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. Comment |
|
Heads-up: PR #145 must merge before this branch. There is no direct file conflict, but this PR changes shared After rebasing, please verify that the Ko-fi action still:
Please adjust this branch if its shared button conventions would otherwise change that behaviour. |
…ation Combined into #143 so both loading-related changes can be reviewed together.
There was a problem hiding this comment.
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
PendingStatuslive region plusaria-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.
| 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.
#144 merged inPer @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 Combined scope is roughly 160 files: this PR's pending-button work, plus the skeleton kit, The two sets overlapped in only 4 files, all merged cleanly and verified by hand rather than trusted:
Verification asks answeredFrom the #143 comment, re the Ko-fi action after rebasing onto #145:
From the #144 comment, re the donation modal:
Fixes applied while reviewingThree real gaps found in #144's own work, all the same bug it set out to fix, pushed as
Checks
Netlify's failed deploy preview is pre-existing and environmental: #145 failed the same way and was merged regardless. 🤖 Generated with Claude Code |
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.
Buttonrendersloading ? loadingText : children, and the two components behind almost every action button,modal-shell-footer.tsxandconfirm-dialog.tsx, passed noloadingText. 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.
loadingalso sets nativedisabled, which drops a button out of the accessibility tree, so pending state was silent for screen readers. There was noaria-busyanywhere infrontend/src.What changed
constants/loading-labels.ts, one source for the Croatian pending copy. Verbal noun plus ellipsis, matching the existingPrijava.../Slanje....PendingStatus, ansr-onlyrole="status"region. It stays mounted and only its text toggles, because a live region inserted together with its text is usually missed.ModalShellFootergainssubmitLoadingLabel,ConfirmDialoggainsconfirmLoadingLabel. Both default to the idle label, so any call site nobody updates keeps its label instead of blanking.aria-labeland the tooltip; the discounted-list button keeps its breakpoint swap, which is whyloadingTextwidened fromstringtoReactNode.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 aButtonthe size variant's[&_svg]:size-*overrides the width and height thesizeprop 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:
(0)in the heading before the real count landed.PersistQueryClientProviderparks every query atfetchStatus: "idle"while it restores the IndexedDB cache. TanStack Query v5 derivesisLoadingasisPending && isFetching, so during that windowisLoadingreads false withdatastillundefined. All 147isLoadingguards fell straight through to the next branch and rendered the error state, the empty state, or(0).Shopping list details had a second cause:
useGetShoppingListByIdwas 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
keys.tsper domain, replacing three coexisting key styles. TheJSON.stringify(params)cijene keys became explicit tuples, so cache seeding no longer depends on every caller passing identical fields.CACHE_TIMESreplaces 15 magic numbers, plus a global 60sstaleTimeso backend queries stop refetching on every mount.keys.ts/queries.ts/hooks.ts, with reads exposed asqueryOptions()descriptors rather thanuseGetXhooks.toUserMessageunifies the two error shapes (RFC 9457 from our backend,CijeneApiErrorfrom upstream);ErrorStatereplaces the bespoke error JSX.useAuthedQueryanduseDataPending;useProductsByEansreplaces four copies of the per-EANuseQueriesblock./api/users/meinstead of behind it.Loading UI
components/custom/skeleton/, andAsyncSectionwith a structurally fixedpending → error → empty → dataorder.*-skeleton.tsxfiles and five route-levelloading.tsx.BlockLoadingSpinnerretired from page bodies, kept for buttons and short inline actions.animate-pulse, which no existing rule covered.Docs: new
docs/DATA-FETCHING.md, plus a corrected cache-buster value inPWA.md.Where the two parts met
Only 4 files, all merged cleanly and then verified by hand rather than trusted:
admin-users-table.tsxTableSkeleton+useAuthedQueryalongsideConfirmDialog/AdminUserRowadmin-contact-table.tsxshopping-list-modal.tsxuseAuthedQuery+byIdQuery.pendingalongside theformStatedestructuring fixuse-shopping-list-item-mutations.tsSHOPPING_LIST_QUERY_KEYS.byId()alongsideupdatingItemId/onSettledblock-loading-spinner.tsxcorrectly 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.tsxbranched onisLoadingand flashed "Proizvod nije pronađen" on a cold cache. NowisPending, with a realProductInfoDisplaySkeleton.useAllLocationsreturnedisLoading, so with a location filter setlocationsReadycould read true with zero locations loaded, filtering every product out and painting a zero result. NowisPending.useInfiniteProductsnow takesisPendingat 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, thenloading.tsxfiles, are all purely additive and are the easy ones to skim first.refactor(api): Unify the data layeris the large one at 63 files, and it is genuinely atomic: removingproductByEanQueryKeyand theuseGetXhooks is a breaking API change that fans out to 17 call sites at once. Read it aslib/query/→lib/api/*/keys.ts→lib/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_BUSTERwent"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 incached-query-keys.tsstill matches.Follow-up: digital cards
(user)/digital-cardswas 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 intokeys.ts/queries.ts/hooks.ts, withdigitalCardQueriesasqueryOptions()descriptors.digital-cards-client.tsxontouseAuthedQuery+AsyncSection, replacing theBlockLoadingSpinnerand theenabled: isAuthenticatedgate, usingrequiresAuthfor the login gate.digital-card-item-skeleton.tsx,digital-cards-skeleton.tsxand(user)/digital-cards/loading.tsx.<Skeleton className="h-64 w-full" />indigital-card-modal.tsxreplaced with a real mirror.["digitalCards"]keys inuse-digital-card-modal.tsanddigital-card-item.tsxreplaced with the factory.submitLoadingLabel={isEdit ? LOADING_LABELS.saving : LOADING_LABELS.creating}.Also noted on #142 so it cannot get lost. Conventions are in
docs/DATA-FETCHING.mdand summarised inAGENTS.md.Notes for review
components/ui/button.tsx, whichAGENTS.mdmarks as shadcn output. That file already carries localloading/icon/effectextensions, so it is where loading behaviour lives.aria-busysits beside theisDisabledit mirrors, and theReactNodewidening has no workaround short of a wrapper component.item-amount-controlsgetsaria-busybut deliberately no spinner. That write is optimistic, so the new amount is already on screen and a loader would flicker on every tap.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.AsyncSectiondeliberately knows nothing about auth. A missing session replaces the whole page rather than one section, so callers returnLoginRequiredearly offrequiresAuth.pendingstill waits for the profile even though fetching starts on the session. The watchlist sorts onuser.pinnedStores, so painting rows before it lands would reorder them under the reader./map,/spending,/updatesand/suggestionsget no skeletons: the first two are Coming Soon placeholders, the last two read static local data with no query.Verification
tsc --noEmiteslint .devbaselinenext buildNetlify'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