diff --git a/AGENTS.md b/AGENTS.md index 814ffbbb..7f5480d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,17 @@ Conventions: - React Query hooks live next to their service in `lib/api//`. Feature composition hooks go in the feature's `hooks/`. - Before generating or redesigning UI, read `frontend/.github/skills/frontend-design/SKILL.md` and follow it. +Data fetching and loading UI, in full in `docs/DATA-FETCHING.md`: + +- Each `lib/api//` splits into `keys.ts`, `queries.ts` (fetchers) and `hooks.ts` (`queryOptions()` descriptors plus mutation hooks). Reads are descriptors, not hooks, so the React layer picks `useQuery`, `useAuthedQuery`, `useQueries` or a prefetch. +- Never branch on a query's `isLoading`. Under `PersistQueryClientProvider` it reads false with no data while the IndexedDB cache restores, so guards fall through to the error or empty branch. Use `useAuthedQuery`'s `pending`, or `useDataPending(...)`. +- Auth-gated reads go through `useAuthedQuery`, which folds the session into `enabled` and returns `requiresAuth` for the `LoginRequired` gate. +- `staleTime` comes from `CACHE_TIMES` in `lib/query/cache-times.ts`, never a hand-written number. +- Sections render through `AsyncSection`, which fixes the order as pending, error, empty, data. +- A skeleton is a colocated sibling, `-skeleton.tsx`, server-renderable, with no hooks, so `loading.tsx` and the client pending branch can share it. It must not re-type the real component's wrapper classes: import them, or share a shell. +- Bars are `h-[1lh]` inside a wrapper carrying the same font classes as the text they replace. `--spacing` is `0.2rem` here, so `h-4` is 12.8px and matches no text size we use. +- `BlockLoadingSpinner` is for buttons and short inline actions only. Content loading gets a skeleton. + Accessibility is where I have had to go back and fix things most often, so check these before you hand UI work over: - Every icon-only control has an accessible name, and it does not contradict a visible label sitting next to it. diff --git a/docs/DATA-FETCHING.md b/docs/DATA-FETCHING.md new file mode 100644 index 00000000..177a162e --- /dev/null +++ b/docs/DATA-FETCHING.md @@ -0,0 +1,503 @@ +# Disscount: Data Fetching & Loading UI Guide + +A complete reference for how the frontend asks the server for data and what the screen shows while it waits. Written to be understandable even if you're new to this. Keep it up to date as the layer changes. + +_Last verified end-to-end on 2026-07-30: every data-driven route converted, `tsc --noEmit` and `next build` clean._ + +> **Mental model in one sentence:** the **data layer** (`lib/api`, `lib/cijene-api`) only describes requests, the **query layer** (`lib/query`) turns a description into state a page can branch on, and the **view layer** (`AsyncSection` plus a colocated skeleton) turns that state into pixels that never move once the data lands. + +--- + +## Table of contents + +1. [Quick reference](#1-quick-reference) +2. [Architecture](#2-architecture) +3. [Layer 1: the data layer](#3-layer-1-the-data-layer) +4. [Layer 2: the query layer](#4-layer-2-the-query-layer) +5. [Layer 3: the view layer](#5-layer-3-the-view-layer) +6. [The skeleton kit](#6-the-skeleton-kit) +7. [Route-level loading.tsx](#7-route-level-loadingtsx) +8. [Remembered row counts](#8-remembered-row-counts) +9. [Offline persister interaction](#9-offline-persister-interaction) +10. [What's automatic vs manual](#10-whats-automatic-vs-manual) +11. [Key files](#11-key-files) +12. [Config, flags, and libraries](#12-config-flags-and-libraries) +13. [Gotchas & lessons learned](#13-gotchas--lessons-learned) +14. [Future improvements & TODOs](#14-future-improvements--todos) + +--- + +## 1. Quick reference + +| Thing | Value | +| ---------------------- | -------------------------------------------------------------------------------- | +| Query client | `frontend/src/app/providers/react-query-provider.tsx` | +| Global `staleTime` | `CACHE_TIMES.default` (60s) | +| Global `gcTime` | 7 days, matched to the persister `maxAge` | +| Reads are described as | `queryOptions()` in `lib/api//hooks.ts` | +| Authed reads use | `useAuthedQuery` | +| Loading flag | `pending` from `useAuthedQuery`, or `useDataPending(...)`. **Never `isLoading`** | +| Branching | `AsyncSection`, fixed order: pending, error, empty, data | +| Skeletons | `-skeleton.tsx`, colocated next to the component | +| Error copy | `toUserMessage(error, fallback)` | + +**Adding a read to a page, in four steps:** add a `queryOptions()` entry to the domain's `hooks.ts`, call it through `useAuthedQuery` (or `useQuery` for public data), write a sibling skeleton mirroring the component, wire both into `AsyncSection`. + +--- + +## 2. Architecture + +Three layers, each ignorant of the one above it. That is what stops the fetch layer needing React context, and stops pages hand-rolling their own loading logic. + +```mermaid +flowchart TD + subgraph Data["Data layer: lib/api, lib/cijene-api"] + K[keys.ts
query key factories] + Q[queries.ts
axios fetchers] + H[hooks.ts
queryOptions + mutations] + K --> H + Q --> H + end + + subgraph Query["Query layer: lib/query"] + AQ[useAuthedQuery
folds session into enabled] + DP[useDataPending
restore-aware pending] + CT[CACHE_TIMES] + end + + subgraph View["View layer: components/custom"] + AS[AsyncSection
pending / error / empty / data] + SK[*-skeleton.tsx] + ES[ErrorState] + end + + H --> AQ + H --> DP + CT --> H + AQ --> AS + DP --> AS + SK --> AS + ES --> AS + AS --> Page[*-client.tsx] + SK --> Loading[loading.tsx] +``` + +**Why reads are descriptors, not hooks.** A `queryOptions()` object can be handed to `useQuery`, `useAuthedQuery`, `useQueries`, a prefetch, or `getQueryData`, so one definition serves every consumer. It also keeps `lib/api` free of React context: `useAuthedQuery` reads `useUser()`, and `user-context.tsx` imports the `lib/api` barrel, so a domain hook importing `useAuthedQuery` would close an import cycle. + +--- + +## 3. Layer 1: the data layer + +Every domain under `frontend/src/lib/api/` has the same three files plus a thin `index.ts` barrel. + +| File | Owns | Never does | +| ------------ | ---------------------------------------------------------- | -------------------------- | +| `keys.ts` | query key factories | anything else | +| `queries.ts` | axios fetchers | touch React Query | +| `hooks.ts` | `queryOptions()` descriptors, mutation hooks, invalidation | render, expose `isLoading` | + +### Query keys + +Keys are factories, never inline literals. Every domain exports a `*_QUERY_KEYS` object: + +```ts +export const SHOPPING_LIST_QUERY_KEYS = { + all: ["shoppingLists"] as const, + me: ["shoppingLists", "me"] as const, + byId: (id: string) => ["shoppingLists", id] as const, + itemsAll: ["shoppingListItems"] as const, + myItems: ["shoppingListItems", "me"] as const, +}; +``` + +The external price API (`lib/cijene-api/keys.ts`) takes params, and each key ends in an **explicit object of the optional filters**: + +```ts +productByEan: ({ ean, date, chains }: GetProductParams) => + [ROOT, "product", "ean", ean, { date, chains }] as const, +``` + +This replaced a `JSON.stringify(params)` form that keyed on argument _shape_. React Query hashes object keys in sorted order and skips `undefined` members, so a product card seeding the cache with `{ ean }` now lands on the same key a reader passing `{ ean, date: undefined }` looks up. Under the old form that only worked because every caller happened to pass the same fields. + +### Cache times + +`staleTime` is never a hand-written number. It comes from `frontend/src/lib/query/cache-times.ts`: + +| Constant | Value | Why | +| ---------------------- | ------ | ---------------------------------------------------------------------------------- | +| `default` | 1 min | Global default, so moving between pages does not refetch a list you just looked at | +| `products` | 6 h | The upstream price feed publishes once a day | +| `chains` | 1 h | Chains change when a retailer enters or leaves the market | +| `stores` | 30 min | Store lists change when a location opens or closes | +| `priceHistoryEdge` | 1 min | The newest archived day can still be revised upstream | +| `priceHistoryArchived` | 6 h | Older archived days never change again | +| `health` | 30 s | A health probe that is cached is not a health probe | + +`staleTime` is how long data counts as fresh. Retention is `gcTime`, set once in the provider to match the persister's `maxAge`. + +--- + +## 4. Layer 2: the query layer + +### `useAuthedQuery` + +For any read that only makes sense for a signed-in user. It folds the session into `enabled` and returns two extra fields on top of the normal query result: + +| Field | Meaning | +| -------------- | --------------------------------------------------------------------------------------- | +| `pending` | Show the skeleton. Covers auth resolution and cache restore, not just the network fetch | +| `requiresAuth` | Auth resolved to no session. Show `LoginRequired`, not an error | + +```tsx +const { data = [], pending, error, requiresAuth } = useAuthedQuery( + shoppingListQueries.me(), +); + +if (requiresAuth) return ; +``` + +Before this, every authed page hand-wrote the same three things: fold `isAuthenticated` into `enabled`, merge `userLoading || isLoading` into one flag, and separately decide when to show the login gate. That merge was also load-bearing by accident, since a disabled query reports `isLoading: false`, so the gate only held while `userLoading` happened to still be true. + +#### It gates on the session, not the profile + +`enabled` keys on `hasSession` (`!!session.user`), not `isAuthenticated` (`!!user`). The distinction matters because those resolve a round trip apart: + +```mermaid +sequenceDiagram + participant B as Browser + participant A as better-auth + participant API as Backend + + B->>A: get-session + A-->>B: session + Note over B: hasSession true here + B->>API: /api/auth/token + par now parallel + B->>API: /api/users/me + and + B->>API: /api/shopping-lists/me + end + Note over B: isAuthenticated true once /users/me lands +``` + +An authed request only needs a session to be authorised. Gating on the loaded profile made every authed page queue behind a request it did not depend on, so total time was `profile + data` instead of `max(profile, data)`. + +**`pending` still waits for the profile, deliberately.** Several surfaces derive from `user.pinnedStores` (the watchlist sorts on it), so painting rows before it lands would reorder them under the reader. Waiting costs nothing now that the fetch already started in parallel. + +`requiresAuth` is keyed on the session too, so a profile fetch that fails surfaces as an error rather than telling a signed-in reader to sign in. + +### `useDataPending` + +For public reads and for combining several flags: + +```ts +const pending = useDataPending(productPending, waitingForLocations); +``` + +It ORs the flags with `useIsRestoring()`. See [gotcha 13.1](#131-isloading-is-false-while-the-cache-restores) for why that matters. + +Note that a query with `enabled: false` reports `isPending` forever, so gate that flag on whatever disables it, or use `useAuthedQuery`, which already does. + +### `useProductsByEans` + +Prices for a set of products, one request per EAN so each row resolves on its own rather than the slowest one holding up the page. It replaced four near-identical `useQueries` blocks in the shopping list detail, its store analysis, the watchlist, and the notification bell. + +Returns `{ results, products, productsByEan, pending, isError, updatedAt }`. `results` is the raw per-EAN array for callers that render a row while its own product loads; `updatedAt` is the newest successful fetch, for a "last synced" label. + +--- + +## 5. Layer 3: the view layer + +### `AsyncSection` + +Every data-backed section renders through it, and the state order is **fixed**: pending, then error, then empty, then the data. + +```tsx + + + + } + empty={} +> + {items.map((item) => ( + + ))} + +``` + +The order being structural is the whole point. Roughly a dozen clients used to hand-write this ternary chain, and several checked error or emptiness _before_ loading had finished, so a cold cache painted "Greška" or "(0)" for a frame. There is no way to express that mistake through this component. + +Auth is deliberately absent from it: a missing session replaces the whole page rather than one section, so callers return `LoginRequired` early off `requiresAuth`. + +### `ErrorState` and `toUserMessage` + +Two error shapes reach the UI, and callers should not have to know which one they got: + +| Source | Shape | Parsed by | +| ------------------ | ------------------------ | ---------------------------- | +| Our Spring backend | RFC 9457 Problem Details | `lib/api/problem-details.ts` | +| Upstream price API | `CijeneApiError` | `lib/cijene-api/errors.ts` | + +`toUserMessage(error, fallback)` in `lib/api/error-message.ts` is the single consumer-facing entry point. It maps `CijeneApiError` statuses onto Croatian copy (the upstream messages are English and leak implementation detail), prefers Problem Details `detail` over `title`, and falls back to the caller's own copy for anything else. `ErrorState` is the shared block that renders it, with an optional retry or way back. + +--- + +## 6. The skeleton kit + +### The convention + +- File: `-skeleton.tsx`, **colocated next to** `.tsx`. Default export `Skeleton`. +- Skeletons are **server-renderable**: no `"use client"`, no hooks, plain scalar props. That is what lets `loading.tsx` and the client's pending branch share one file. +- A skeleton **never re-types** the real component's wrapper classes. It imports them (for example `PRODUCT_SUMMARY_ROW_CLASSES`, exported from `product-summary.tsx`) or both render a shared shell. + +This beats a central `skeletons/` mirror, because the pair stays adjacent in the file tree and drift is visible in review. It also beats one big barrel file, which blows past the 50 to 100 line target immediately. + +### Shared primitives + +Under `frontend/src/components/custom/skeleton/`: + +| Component | Role | +| ----------------------- | ----------------------------------------------------------------------------------------------- | +| `SkeletonRegion` | The a11y contract. One `role="status"` label, everything inside `aria-hidden` | +| `RepeatSkeleton` | Renders `count` keyed copies of a row, so no list skeleton writes its own `Array.from(...).map` | +| `CountSkeleton` | The inline pill that stands in for a `(N)` in a heading | +| `TextSkeleton` | `lines` bars, last line shorter, the way wrapped prose ends | +| `SectionHeaderSkeleton` | The collapsed header row of `CollapsibleSection` | +| `TableSkeleton` | Generic rows and columns, for the admin tables | +| `ChartSkeleton` | A price chart's footprint: axis labels plus plot area | +| `PageShellSkeleton` | Neutral page shape, the last-resort fallback | + +`SkeletonRegion` puts its label in a separate `sr-only` element rather than wrapping the bars, so the element carrying `className` is still the direct layout parent. Nesting a div there would break any caller whose children must stay direct grid or flex items, which is exactly the shift these skeletons exist to prevent. + +### Sizing bars correctly + +Bars are `h-[1lh]` inside a wrapper carrying **the same font classes** as the text they stand in for. One line box of that text, exactly: + +```tsx +
+ +
+``` + +Do not reach for a fixed `h-4`. See [gotcha 13.2](#132---spacing-is-02rem-so-h-4-is-128px). + +--- + +## 7. Route-level `loading.tsx` + +Each data-driven route has its own `loading.tsx` rendering that route's page skeleton. It paints during the RSC navigation, before the client component mounts, which is the window the old global spinner used to fill. + +| Route | Skeleton | +| ---------------------- | ------------------------------------------------------ | +| `/shopping-lists` | `ShoppingListsSkeleton` | +| `/shopping-lists/[id]` | `ShoppingListDetailSkeleton` | +| `/products` | `ProductsSkeleton` (also the page's Suspense fallback) | +| `/products/[id]` | `ProductDetailSkeleton` | +| `/watchlist` | `WatchlistSkeleton` | +| everything else | `app/loading.tsx` renders `PageShellSkeleton` | + +A page skeleton must mirror the **stored default open state** of its collapsible sections, or the page height jumps as soon as the real component reads localStorage: + +| Section | Default | Skeleton shows | +| --------------------------- | ------- | -------------------- | +| Shopping list items | open | header + rows | +| Shopping list price history | closed | header only | +| Shopping list stores | open | header + store cards | +| Product price history | closed | header only | +| Product chains | open | header + store cards | + +Routes with no skeleton, and why: `/map` and `/spending` are Coming Soon placeholders, `/updates` and `/suggestions` read static local data with no query, and the static legal pages fetch nothing. + +--- + +## 8. Remembered row counts + +A list skeleton drawing a generic three rows still jumps when the real list has nine. The persisted React Query cache cannot help on the first paint, because IndexedDB resolves asynchronously. + +So a small synchronous `localStorage` note carries the last known length: + +```mermaid +sequenceDiagram + participant P as Page + participant S as row-count-store (localStorage) + participant Q as React Query (IndexedDB) + + P->>S: useRememberedRowCount("watchlist:me", 3) + S-->>P: 7 (from last visit) + P->>P: render 7 placeholder rows + Q-->>P: restored data, 7 items + P->>S: useRememberRowCount writes 7 +``` + +Counts are clamped to 1 to 8, so a long list does not paint a wall of grey. `useRememberedRowCount` uses `useSyncExternalStore` with `fallback` as the server snapshot, so `loading.tsx` and the first client render agree and hydration stays quiet. + +--- + +## 9. Offline persister interaction + +The persisted cache is documented in full in [PWA.md](PWA.md#5-offline-reads-caching-and-persistence). Two things bind it to this layer: + +1. **Key roots are load-bearing.** `lib/offline/cached-query-keys.ts` allowlists dehydration by the _top-level_ key string (`cijene`, `shoppingLists`, `watchlist`, and so on), and `lib/offline/purge.ts` treats `cijene` as the public root that survives logout. The key factories deliberately keep those roots spelled exactly as before, and each `keys.ts` says so in a comment. +2. **Changing key shapes needs a buster bump.** Entries written under the old shape would never be read again, so `CACHE_BUSTER` in `lib/offline/persister.ts` went from `"1"` to `"2"`. Every existing user takes one cold load after that deploys, then it is back to normal. + +--- + +## 10. What's automatic vs manual + +| Thing | Automatic? | Notes | +| ----------------------------------------------- | ------------ | ----------------------------------------------------------- | +| Skeleton on a cold cache | ✅ automatic | `AsyncSection` picks it off `pending` | +| Instant render on a warm cache | ✅ automatic | Data present means `pending` is false, so no skeleton flash | +| Background refresh of stale data | ✅ automatic | `staleTime` expiry plus refetch on focus | +| Reduced motion | ✅ automatic | `[data-slot="skeleton"]` rule in `globals.css` | +| Row count memory | ✅ automatic | Written whenever a list renders with data | +| **Writing the skeleton** | ❌ manual | One per component, mirroring its geometry | +| **Adding a `loading.tsx`** | ❌ manual | New data-driven route needs one | +| **Adding a query key to the offline allowlist** | ❌ manual | `cached-query-keys.ts` | +| **Bumping the cache buster** | ❌ manual | After any breaking key or data-shape change | +| **Picking a `CACHE_TIMES` value** | ❌ manual | Add a named constant rather than a number | + +--- + +## 11. Key files + +| Path | Role | +| --------------------------------------------------------- | --------------------------------------------------------- | +| `frontend/src/lib/query/cache-times.ts` | Named `staleTime` windows | +| `frontend/src/lib/query/use-authed-query.ts` | Session-gated query, returns `pending` and `requiresAuth` | +| `frontend/src/lib/query/use-data-pending.ts` | Restore-aware pending flag | +| `frontend/src/lib/api//keys.ts` | Query key factory per domain | +| `frontend/src/lib/api//queries.ts` | axios fetchers | +| `frontend/src/lib/api//hooks.ts` | `queryOptions()` descriptors + mutation hooks | +| `frontend/src/lib/api/error-message.ts` | `toUserMessage`, the one place an error becomes copy | +| `frontend/src/lib/cijene-api/keys.ts` | Keys for the external price API | +| `frontend/src/lib/cijene-api/use-products-by-eans.ts` | Batched per-EAN product fetch | +| `frontend/src/lib/skeleton/row-count-store.ts` | Clamped localStorage map of last list lengths | +| `frontend/src/hooks/use-remembered-row-count.ts` | Read and write hooks for the above | +| `frontend/src/components/custom/common/async-section.tsx` | The four-state branch | +| `frontend/src/components/custom/common/error-state.tsx` | Shared error block | +| `frontend/src/components/custom/skeleton/` | Shared skeleton primitives | +| `frontend/src/app/providers/react-query-provider.tsx` | `QueryClient` defaults + persistence | +| `frontend/src/lib/offline/cached-query-keys.ts` | Which key roots persist to disk | +| `frontend/src/lib/offline/persister.ts` | IndexedDB persister, `maxAge`, `buster` | + +--- + +## 12. Config, flags, and libraries + +No environment variables are specific to this layer. One existing flag is relevant: + +| Variable | Effect | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_ENABLE_REACT_QUERY_DEVTOOLS` | `true` mounts the React Query devtools panel. Opt-in because its floating button sits over the mobile bottom nav | + +Libraries, versions from `frontend/package.json`: + +| Library | Version | Role | +| ----------------------------------------- | ---------- | ----------------------------------------------------------- | +| `@tanstack/react-query` | `^5.90.12` | Query cache, `queryOptions`, `useQueries`, `useIsRestoring` | +| `@tanstack/react-query-persist-client` | `^5.101.1` | `PersistQueryClientProvider` | +| `@tanstack/query-async-storage-persister` | `^5.101.1` | Async persister used with IndexedDB | +| `idb-keyval` | `^6.2.5` | IndexedDB storage adapter | +| `axios` | `^1.13.2` | HTTP client, auth interceptors in `lib/api/api-base.ts` | +| `zod` | `^4.1.13` | Response validation, Problem Details schema | +| `tailwindcss` | `^4` | `animate-pulse`, `h-[1lh]`, the `--spacing` scale | + +--- + +## 13. Gotchas & lessons learned + +### 13.1 `isLoading` is false while the cache restores + +**The trap.** `PersistQueryClientProvider` parks every query at `fetchStatus: "idle"` while it restores the IndexedDB cache. TanStack Query v5 derives `isLoading` as `isPending && isFetching`, so throughout that window **`isLoading` reads false with `data` still `undefined`**. + +**What it looked like.** Every `if (isLoading)` guard fell straight through to the next branch. Detail pages rendered "Greška" and "Proizvod nije pronađen" for a frame on every hard reload, and index headings rendered `(0)` before the real count landed. It looked like a data bug; it was a flag bug, in 147 places. + +**The fix.** Never branch on a query's `isLoading`. Use `pending` from `useAuthedQuery`, or `useDataPending(...)`, both of which OR in `useIsRestoring()`. + +### 13.2 `--spacing` is `0.2rem`, so `h-4` is 12.8px + +**The trap.** This project overrides Tailwind's spacing unit from `0.25rem` to `0.2rem` in `globals.css`. Every `h-*`, `w-*`, `gap-*` and `p-*` is therefore 0.8x what the Tailwind docs say. `h-4` is 12.8px, not 16px, and matches none of our text sizes. + +**The fix.** Size text bars with `h-[1lh]` inside a wrapper carrying the same font classes as the real text. That is one line box of that exact text, whatever the spacing scale does. + +### 13.3 `animate-pulse` was not covered by reduced motion + +**The trap.** `globals.css` disables every custom animation by class name under `prefers-reduced-motion: reduce`. Skeletons use Tailwind's built-in `animate-pulse`, which no rule named, so skeletons would have kept pulsing for readers who asked for less motion. + +**The fix.** A `[data-slot="skeleton"] { animation: none; }` rule in that block. Anything hand-rolling a skeleton must carry that `data-slot` to inherit it, which is why `CountSkeleton` sets it explicitly. + +### 13.4 A heading only permits phrasing content + +**The trap.** The shared `Skeleton` renders a `div`. Dropping it inside an `

` to stand in for a count is invalid HTML. + +**The fix.** `CountSkeleton` renders a `span` with the same classes and `data-slot`, rather than reusing the shared component. + +### 13.5 Hooks cannot sit after the auth gate + +**The trap.** `requiresAuth` invites an early `return ` near the top of a client, and it is natural to put the row-count hooks just below it. That makes them conditional, which breaks the rules of hooks. + +**The fix.** All hooks go above the gate. Every converted client does this, with a comment where it is not obvious. + +### 13.6 Changing a query key shape invalidates persisted caches + +**The trap.** Moving from `JSON.stringify(params)` keys to explicit tuples means every persisted entry is written under a key nothing will ever read again. Left alone, users carry dead weight in IndexedDB and get a cold load anyway, just silently. + +**The fix.** Bump `CACHE_BUSTER` in `persister.ts` so the old snapshot is discarded cleanly. Keep top-level key roots identical, or `cached-query-keys.ts` silently stops persisting the data. + +### 13.7 `pnpm` inside a git worktree purges the main tree's `node_modules` + +Not specific to this layer, but it bit during the work. The worktree's `node_modules` is a symlink to the main tree's, and `pnpm lint` triggers a dependency check that tries to remove and reinstall through it. Call the binaries directly instead: `./node_modules/.bin/eslint .`, `./node_modules/.bin/tsc --noEmit`. + +--- + +## 14. Future improvements & TODOs + +- **Prefetch on hover or viewport entry.** `useProductNavigation` already seeds the product cache before pushing a route. The same trick would suit shopping list cards, so opening one is a cache hit. + +### TODO: server-side prefetch with `HydrationBoundary` + +The largest remaining win, and the one that would retire most skeletons rather than just make them well behaved. + +Nothing prefetches on the server today, so a cold visit always pays the full client waterfall before anything renders. Even with the session gating above, a first paint still costs `get-session` → `/api/auth/token` → the data request. A server prefetch collapses that: the RSC renders with data already in the cache, and the skeleton never appears at all. + +The shape: + +```tsx +// page.tsx (server component) +const queryClient = new QueryClient(); +await queryClient.prefetchQuery(cijeneQueries.productByEan({ ean })); + +return ( + + + +); +``` + +The `queryOptions()` descriptors already make this trivial, which was part of why reads became descriptors rather than hooks: the same object feeds `prefetchQuery` on the server and `useQuery` on the client. + +Do it in this order: + +| Step | Route | Why first | +| ---- | ---------------- | ---------------------------------------------------------------------------------------------------------------------- | +| 1 | `/products/[id]` | Public data, no auth, no cookie forwarding. Pure win, lowest risk | +| 2 | `/products` | Public, but the query depends on searchParams, so key parity matters | +| 3 | Authed routes | Needs the session cookie forwarded to the fetcher, so `apiClient`'s browser-only token logic has to grow a server path | + +Things that will bite: + +- **`lib/api/api-base.ts` is browser-only.** `getToken()` returns `null` when `typeof window === "undefined"`, so authed prefetch needs a server-side token path before step 3 is possible at all. +- **Key parity is absolute.** A server prefetch under a key the client does not read is wasted bytes and a silent double fetch. The factories in `keys.ts` are what make this safe, so prefetch through them, never with a literal. +- **Interaction with the persister.** Hydrated data lands in the same cache the persister dehydrates. Confirm a hydrated entry does not overwrite fresher restored data on a repeat visit. +- **Do not delete the skeletons.** They still cover client navigation, refetch after invalidation, and the offline case. Prefetch removes the skeleton from the first paint, not from the app. +- **Extend the offline allowlist.** `cached-query-keys.ts` carries `TODO(offline)` markers for `/spending`, `/updates` and `/map` keys, to add when those features ship. +- **Skeletons for the Coming Soon routes.** `/map` and `/spending` need them once they hold real data. +- **A visual regression check on CLS.** The zero-shift claim is currently verified by hand. A Lighthouse or Playwright assertion per route would keep it honest. +- **Retry affordance in `ErrorState`.** The `action` slot exists but only the shopping list detail passes one. A standard "Pokušaj ponovno" that calls `refetch()` would suit most sections. +- **Reconsider `digital-cards`.** The domain still uses inline query keys and the old file layout because the feature is dead code pending removal on `refactor/remove-digital-cards`. diff --git a/docs/PWA.md b/docs/PWA.md index 3b96ab8c..ef472be6 100644 --- a/docs/PWA.md +++ b/docs/PWA.md @@ -240,7 +240,7 @@ flowchart LR ``` - `react-query-provider.tsx` uses `PersistQueryClientProvider`. The `QueryClient` sets a default `gcTime` equal to the persister `maxAge` (7 days), so entries are not garbage-collected out of memory before they can be restored from disk. -- `persister.ts` builds a `createAsyncStoragePersister` backed by `idb-keyval` (IndexedDB, larger and safer than localStorage). `maxAge` 7 days, `buster` `"1"` (bump to invalidate all persisted caches after a breaking data-shape change). +- `persister.ts` builds a `createAsyncStoragePersister` backed by `idb-keyval` (IndexedDB, larger and safer than localStorage). `maxAge` 7 days, `buster` `"2"` (bump to invalidate all persisted caches after a breaking data-shape change; it went to `"2"` when query keys moved from stringified params to explicit tuples). - `cached-query-keys.ts` is the **whitelist**: only queries whose top-level key is in `cijene`, `shoppingLists`, `shoppingListItems`, `watchlist`, `digitalCards`, `pinnedStores`, `pinnedPlaces`, or `users` are persisted, and only when successful. Everything else (for example admin data) is never written to disk. Coming-soon features carry `TODO(offline)` markers here to be added when they ship. - `purge.ts` (`purgeOfflineCache`) removes the user-specific queries from both the in-memory and IndexedDB caches, guarded so a failed IndexedDB clear never blocks logout. The public `cijene` cache is deliberately kept so public pages stay fast across a logout, and the in-flight cancellation is scoped by the same predicate, so logging out mid-request cannot abort a public price fetch. `user-context.tsx` calls it on **any transition to unauthenticated** (explicit logout, session expiry, revoked cookie, or sign-out in another tab), not just explicit logout, so a previous user's data and queued writes never linger on a shared device. diff --git a/docs/README.md b/docs/README.md index 03eb36bf..a88f39e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ one part of the system. - [PWA.md](PWA.md) - PWA setup, service worker, offline behaviour, install flow. - [MOBILE-NAV.md](MOBILE-NAV.md) - mobile bottom nav bar, long-press gestures, tab scrubbing, the shared sheet shell. - [STATE-PERSISTENCE.md](STATE-PERSISTENCE.md) - how inputs and forms remember state (URL, localStorage drafts, IndexedDB). +- [DATA-FETCHING.md](DATA-FETCHING.md) - data layer, query keys, cache times, loading states and the skeleton system. - [LANDING.md](LANDING.md) - landing page composition, server-vs-client rendering, SEO, fonts. - [BRAND.md](BRAND.md) - brand image system (logo, favicon, PWA icons, splash screens, social kit). - [SUPPORT.md](SUPPORT.md) - Ko-fi support flow, GitHub funding links, and future recognition rules. diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx index b89976cc..46126915 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx @@ -6,14 +6,21 @@ import type { IShoppingListItemUpdate } from "@/app/(user)/shopping-lists/[id]/t interface IItemAmountControlsProps { item: ShoppingListItemDto; onUpdate: (updatedItem: IShoppingListItemUpdate) => void; + isUpdating: boolean; } export default function ItemAmountControls({ item, onUpdate, + isUpdating, }: IItemAmountControlsProps) { + // No spinner here on purpose: the write is optimistic, so the new amount is + // already rendered and swapping in a loader would flicker on every tap. return ( -
+
- -
-
+ } + /> ); } return ( -
- {/* Header Section */} -
- + + +
- {/* Info Display Section */} -
- -
+
+ +
- {/* Shopping List Items Section */} -
- -
+
+ +
- {/* Price History Section */} -
- -
+
+ +
- {/* Store Summary Section */} -
- -
-
+
+ +
+ + )} + ); } diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton.tsx new file mode 100644 index 00000000..845d5041 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton.tsx @@ -0,0 +1,48 @@ +import SkeletonRegion from "@/components/custom/skeleton/skeleton-region"; +import SectionHeaderSkeleton from "@/components/custom/skeleton/section-header-skeleton"; +import ShoppingListHeaderSkeleton from "@/app/(user)/shopping-lists/[id]/components/shopping-list-header-skeleton"; +import ShoppingListInfoTableSkeleton from "@/app/(user)/shopping-lists/[id]/components/shopping-list-info-table-skeleton"; +import ShoppingListItemsSkeleton from "@/app/(user)/shopping-lists/[id]/components/items/shopping-list-items-skeleton"; +import ShoppingListStoresListSkeleton from "@/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list-skeleton"; + +interface IShoppingListDetailSkeletonProps { + /** Remembered from the last visit by the client; loading.tsx takes the default. */ + itemRows?: number; +} + +/** + * The whole shopping list detail page, section for section, in the same + * `space-y-8` rhythm as shopping-list-detail-client. + * + * Shared by loading.tsx and the client's pending branch, which is why it takes + * no hooks and no context. + */ +export default function ShoppingListDetailSkeleton({ + itemRows, +}: IShoppingListDetailSkeletonProps) { + return ( + +
+ +
+ +
+ +
+ +
+ +
+ + {/* Price history is stored closed by default, so a header is its whole + footprint until someone opens it. Stores is stored open. */} +
+ +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header-skeleton.tsx new file mode 100644 index 00000000..da1cc245 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header-skeleton.tsx @@ -0,0 +1,38 @@ +import { ChevronLeft } from "lucide-react"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Mirrors ShoppingListHeader. The back link is real, since it works without the + * list having loaded and is the way out if this page never resolves. + */ +export default function ShoppingListHeaderSkeleton() { + return ( +
+
+
+ + +
+
+
+ + {/* Actions need the list to act on, so they are placeholders here. */} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-info-table-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-info-table-skeleton.tsx new file mode 100644 index 00000000..7453f040 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-info-table-skeleton.tsx @@ -0,0 +1,49 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +const ROWS = [ + ["Stvoreno:", "Ažurirano:"], + ["Ukupno:", "Preostalo:"], + ["Potrošeno:", "Ušteđeno:"], +] as const; + +/** + * Mirrors ShoppingListInfoTable. The labels are fixed copy, so they render for + * real and only the values are placeholders, which keeps the table exactly as + * tall as it will be once the numbers land. + */ +export default function ShoppingListInfoTableSkeleton() { + return ( +
+ + + {ROWS.map(([left, right], index) => ( + + + + + + ))} + +
+ {left} + + {right} +
+
+ ); +} diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx index 7e20ce57..c464a28f 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx @@ -7,6 +7,7 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import { DropdownMenu, DropdownMenuContent, @@ -56,7 +57,9 @@ export default function ShoppingListMobileActions({ ) : ( )} - Podijeli popis + + {isSharing ? LOADING_LABELS.sharing : "Podijeli popis"} + )} @@ -73,7 +76,9 @@ export default function ShoppingListMobileActions({ ) : ( )} - Kopiraj popis + + {isCopying ? LOADING_LABELS.copying : "Kopiraj popis"} + )} @@ -100,7 +105,9 @@ export default function ShoppingListMobileActions({ ) : ( )} - Obriši popis + + {isDeleting ? LOADING_LABELS.deleting : "Obriši popis"} + )} diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsx index d281b953..fe557394 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsx +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-price-history.tsx @@ -7,7 +7,7 @@ import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import StoreChainMultiSelect from "@/components/custom/store-chain/store-chain-multi-select"; import PriceHistoryPeriodSelect from "@/components/custom/price/price-history-period-select"; import PriceChangeDisplay from "@/components/custom/price/price-change-display"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import ChartSkeleton from "@/components/custom/skeleton/chart-skeleton"; import { ShoppingListDto } from "@/lib/api/types"; import { PeriodOption } from "@/typings/history-period-options"; import { DISABLED_PERIODS, getEnabledPeriod } from "@/constants/price-history"; @@ -95,9 +95,7 @@ export default function ShoppingListPriceHistory({ {isLoading ? ( -
- -
+ ) : chartData.length === 0 || hasError ? (

diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card-skeleton.tsx new file mode 100644 index 00000000..5d034031 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card-skeleton.tsx @@ -0,0 +1,30 @@ +import { Card, CardHeader } from "@/components/ui/card"; +import { ChevronDown } from "lucide-react"; + +import { Skeleton } from "@/components/ui/skeleton"; + +/** Mirrors the collapsed header of ShoppingListStoreItem: logo, name, prices. */ +export default function ShoppingListStoreCardSkeleton() { + return ( + + +

+
+ +
)} - + ); } diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-skeleton.tsx new file mode 100644 index 00000000..16e21304 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-skeleton.tsx @@ -0,0 +1,26 @@ +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; +import ShoppingListStoreCardSkeleton from "@/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface IShoppingListStoresSkeletonProps { + chains?: number; +} + +/** + * The body of the stores section, without its header. Kept separate because the + * live section sits inside a CollapsibleSection that draws the header itself, + * while the page skeleton has to draw both. + */ +export default function ShoppingListStoresSkeleton({ + chains = 3, +}: IShoppingListStoresSkeletonProps) { + return ( +
+
+ ); +} diff --git a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts index 88fe688c..46bbd74f 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts +++ b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts @@ -1,8 +1,7 @@ import { useMemo } from "react"; -import { useQueries } from "@tanstack/react-query"; -import { shoppingListService } from "@/lib/api"; -import cijeneService, { productByEanQueryKey } from "@/lib/cijene-api"; -import type { ProductResponse } from "@/lib/cijene-api/schemas"; +import { shoppingListQueries } from "@/lib/api/shopping-lists/hooks"; +import { useProductsByEans } from "@/lib/cijene-api/use-products-by-eans"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; import { useUser } from "@/context/user-context"; import { findCheapestStoreFromProduct, @@ -15,10 +14,11 @@ export function useShoppingListData(listId: string) { const { data: shoppingList, - isLoading, + pending: isLoading, error, + requiresAuth, dataUpdatedAt: listUpdatedAt, - } = shoppingListService.useGetShoppingListById(listId); + } = useAuthedQuery(shoppingListQueries.byId(listId)); const eans = useMemo( () => [ @@ -29,24 +29,9 @@ export function useShoppingListData(listId: string) { [shoppingList?.items], ); - const { productsData, isPricesLoading } = useQueries({ - queries: eans.map((ean) => ({ - queryKey: productByEanQueryKey(ean), - queryFn: () => cijeneService.getProductByEan({ ean }), - staleTime: 6 * 60 * 60 * 1000, - })), - combine: (results) => ({ - productsData: results - .map((result) => result.data) - .filter((data): data is ProductResponse => data !== undefined), - isPricesLoading: results.some((result) => result.isLoading), - }), - }); + const { productsByEan, pending: isPricesLoading } = useProductsByEans(eans); const { cheapestStores, averagePrices, storePrices } = useMemo(() => { - const productsByEan = new Map( - productsData.map((product) => [product.ean, product]), - ); const nextCheapestStores: Record = {}; const nextAveragePrices: Record = {}; const nextStorePrices: Record> = {}; @@ -80,7 +65,7 @@ export function useShoppingListData(listId: string) { averagePrices: nextAveragePrices, storePrices: nextStorePrices, }; - }, [productsData, shoppingList?.items, user?.pinnedStores]); + }, [productsByEan, shoppingList?.items, user?.pinnedStores]); // Calculate total savings from checked items const { totalSavings, totalPotentialCost } = shoppingList?.items @@ -106,6 +91,7 @@ export function useShoppingListData(listId: string) { shoppingList, isLoading, error, + requiresAuth, listUpdatedAt, cheapestStores, averagePrices, diff --git a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts index d0fafe87..d871a439 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts +++ b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts @@ -2,6 +2,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { shoppingListService } from "@/lib/api"; +import { SHOPPING_LIST_QUERY_KEYS } from "@/lib/api/shopping-lists/keys"; import type { ShoppingListDto as ShoppingList } from "@/lib/api/types"; export function useShoppingListItemMutations( @@ -11,6 +12,7 @@ export function useShoppingListItemMutations( ) { const queryClient = useQueryClient(); const [deletingItemId, setDeletingItemId] = useState(null); + const [updatingItemId, setUpdatingItemId] = useState(null); const updateItemMutation = shoppingListService.useUpdateShoppingListItem(); const deleteItemMutation = shoppingListService.useDeleteShoppingListItem(); @@ -23,10 +25,9 @@ export function useShoppingListItemMutations( chainCode: string | null; }, ) => { - const shoppingList = queryClient.getQueryData([ - "shoppingLists", - listId, - ]); + const shoppingList = queryClient.getQueryData( + SHOPPING_LIST_QUERY_KEYS.byId(listId), + ); const item = shoppingList?.items?.find((i) => i.id === itemId); if (!item) return; @@ -34,15 +35,18 @@ export function useShoppingListItemMutations( // Validate amount if (updatedItem.amount < 1) return; + setUpdatingItemId(itemId); + // Optimistic update - await queryClient.cancelQueries({ queryKey: ["shoppingLists", listId] }); - const previousData = queryClient.getQueryData([ - "shoppingLists", - listId, - ]); + await queryClient.cancelQueries({ + queryKey: SHOPPING_LIST_QUERY_KEYS.byId(listId), + }); + const previousData = queryClient.getQueryData( + SHOPPING_LIST_QUERY_KEYS.byId(listId), + ); queryClient.setQueryData( - ["shoppingLists", listId], + SHOPPING_LIST_QUERY_KEYS.byId(listId), (old) => { if (!old) return old; return { @@ -96,12 +100,17 @@ export function useShoppingListItemMutations( { onError: (error: Error) => { if (previousData) { - queryClient.setQueryData(["shoppingLists", listId], previousData); + queryClient.setQueryData( + SHOPPING_LIST_QUERY_KEYS.byId(listId), + previousData, + ); } toast.error( error.message || "Greška pri ažuriranju stavke. Pokušaj ponovno.", ); }, + // Clears the busy flag only; dev deliberately dropped the invalidation here. + onSettled: () => setUpdatingItemId(null), }, ); }; @@ -110,14 +119,15 @@ export function useShoppingListItemMutations( setDeletingItemId(itemId); // Optimistic update - await queryClient.cancelQueries({ queryKey: ["shoppingLists", listId] }); - const previousData = queryClient.getQueryData([ - "shoppingLists", - listId, - ]); + await queryClient.cancelQueries({ + queryKey: SHOPPING_LIST_QUERY_KEYS.byId(listId), + }); + const previousData = queryClient.getQueryData( + SHOPPING_LIST_QUERY_KEYS.byId(listId), + ); queryClient.setQueryData( - ["shoppingLists", listId], + SHOPPING_LIST_QUERY_KEYS.byId(listId), (old) => { if (!old) return old; return { @@ -133,7 +143,10 @@ export function useShoppingListItemMutations( { onError: (error: Error) => { if (previousData) { - queryClient.setQueryData(["shoppingLists", listId], previousData); + queryClient.setQueryData( + SHOPPING_LIST_QUERY_KEYS.byId(listId), + previousData, + ); } toast.error( error.message || "Greška pri brisanju stavke. Pokušaj ponovno.", @@ -153,5 +166,6 @@ export function useShoppingListItemMutations( handleUpdateItem, handleDeleteItem, deletingItemId, + updatingItemId, }; } diff --git a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts index d23aabba..d3f3f2b2 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts +++ b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { shoppingListService } from "@/lib/api"; +import { SHOPPING_LIST_QUERY_KEYS } from "@/lib/api/shopping-lists/keys"; import type { ShoppingListDto as ShoppingList, ShoppingListRequest, @@ -22,13 +23,12 @@ export function useShoppingListMutations( const confirmDelete = async () => { // Prepare optimistic update: remove item from cache immediately - await queryClient.cancelQueries({ queryKey: ["shoppingLists", "me"] }); - const previous = queryClient.getQueryData([ - "shoppingLists", - "me", - ]); + await queryClient.cancelQueries({ queryKey: SHOPPING_LIST_QUERY_KEYS.me }); + const previous = queryClient.getQueryData( + SHOPPING_LIST_QUERY_KEYS.me, + ); queryClient.setQueryData( - ["shoppingLists", "me"], + SHOPPING_LIST_QUERY_KEYS.me, (old: ShoppingList[] | undefined) => old ? old.filter((l) => l.id !== listId) : [], ); @@ -38,7 +38,7 @@ export function useShoppingListMutations( onError: (error: Error) => { // Rollback cache so UI reflects server state if (previous) { - queryClient.setQueryData(["shoppingLists", "me"], previous); + queryClient.setQueryData(SHOPPING_LIST_QUERY_KEYS.me, previous); } toast.error( error.message || @@ -47,11 +47,15 @@ export function useShoppingListMutations( }, onSuccess: () => { toast.success("Popis za kupnju je uspješno obrisan!"); - queryClient.invalidateQueries({ queryKey: ["shoppingLists", "me"] }); + queryClient.invalidateQueries({ + queryKey: SHOPPING_LIST_QUERY_KEYS.me, + }); router.push("/shopping-lists"); }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["shoppingLists", "me"] }); + queryClient.invalidateQueries({ + queryKey: SHOPPING_LIST_QUERY_KEYS.me, + }); }, }); }; @@ -97,7 +101,7 @@ export function useShoppingListMutations( // Invalidate queries to refresh data await queryClient.invalidateQueries({ - queryKey: ["shoppingLists"], + queryKey: SHOPPING_LIST_QUERY_KEYS.all, }); // Show success toast diff --git a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-price-history.ts b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-price-history.ts index dbcb202f..54ab107f 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-price-history.ts +++ b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-price-history.ts @@ -3,7 +3,7 @@ import { useQueries } from "@tanstack/react-query"; import { ShoppingListDto } from "@/lib/api/types"; import { PeriodOption } from "@/typings/history-period-options"; import { periodOptions } from "@/constants/price-history"; -import cijeneService from "@/lib/cijene-api"; +import cijeneService, { CIJENE_QUERY_KEYS } from "@/lib/cijene-api"; import { useUser } from "@/context/user-context"; import { usePriceHistoryChains } from "@/app/(user)/shopping-lists/[id]/hooks/use-price-history-chains"; import { @@ -37,7 +37,7 @@ export function useShoppingListPriceHistory( const queries = useQueries({ queries: eans.flatMap((ean) => dates.map((date, index) => ({ - queryKey: ["cijene", "product", "history", ean, date], + queryKey: CIJENE_QUERY_KEYS.productHistory(ean, date), queryFn: () => cijeneService.getProductByEan({ ean, date }), enabled: !!ean, staleTime: diff --git a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-store-chain-analysis.ts b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-store-chain-analysis.ts index 4478f728..4b410902 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-store-chain-analysis.ts +++ b/frontend/src/app/(user)/shopping-lists/[id]/hooks/use-store-chain-analysis.ts @@ -1,11 +1,9 @@ "use client"; import { useMemo } from "react"; -import { useQueries } from "@tanstack/react-query"; -import cijeneService, { productByEanQueryKey } from "@/lib/cijene-api"; +import { useProductsByEans } from "@/lib/cijene-api/use-products-by-eans"; import { ShoppingListDto } from "@/lib/api/types"; import { PinnedStoreDto } from "@/lib/api/schemas/preferences"; -import { ProductResponse } from "@/lib/cijene-api/schemas"; import { compareStoreChains, type StoreOptimizeMode, @@ -40,22 +38,12 @@ export function useStoreChainAnalysis({ ); }, [shoppingList.items]); - // combine is memoised by TanStack, so productsData keeps a stable identity between renders. - const { productsData, productsLoading, productsError } = useQueries({ - queries: eans.map((ean) => ({ - queryKey: productByEanQueryKey(ean), - queryFn: () => cijeneService.getProductByEan({ ean }), - enabled: Boolean(ean), - staleTime: 6 * 60 * 60 * 1000, // 6 hours - })), - combine: (results) => ({ - productsData: results - .map((result) => result.data) - .filter((data): data is ProductResponse => data !== undefined), - productsLoading: results.some((result) => result.isLoading), - productsError: results.some((result) => result.error), - }), - }); + // combine is memoised by TanStack, so products keeps a stable identity between renders. + const { + products: productsData, + pending: productsLoading, + isError: productsError, + } = useProductsByEans(eans); const allChains = useMemo( () => buildChainAggregates(productsData, activeItems), diff --git a/frontend/src/app/(user)/shopping-lists/[id]/loading.tsx b/frontend/src/app/(user)/shopping-lists/[id]/loading.tsx new file mode 100644 index 00000000..3eadd015 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/loading.tsx @@ -0,0 +1,10 @@ +import ShoppingListDetailSkeleton from "@/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton"; + +/** + * Paints during the RSC navigation, before the client component mounts. It + * cannot read the remembered row count (that is localStorage, and this renders + * on the server), so it takes the default and the client refines it. + */ +export default function Loading() { + return ; +} diff --git a/frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx b/frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx index b0931ee8..c356807d 100644 --- a/frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx +++ b/frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx @@ -19,13 +19,16 @@ import { } from "@/components/ui/form"; import type { ShoppingListDto, ShoppingListRequest } from "@/lib/api/types"; import { shoppingListRequestSchema } from "@/lib/api/types"; -import { shoppingListService } from "@/lib/api"; +import { SHOPPING_LIST_QUERY_KEYS } from "@/lib/api/shopping-lists/keys"; import { applyProblemToForm } from "@/lib/api/problem-details"; import { closeModalUrl } from "@/lib/modal/modal-navigation"; import { takeModalError } from "@/lib/modal/modal-error-bus"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import { useFormDraft } from "@/hooks/use-form-draft"; import { getFormDraft } from "@/utils/browser/local-storage"; import { useShoppingListModal } from "@/app/(user)/shopping-lists/hooks/use-shopping-list-modal"; +import { shoppingListQueries } from "@/lib/api/shopping-lists/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; interface IShoppingListModalProps { open: boolean; @@ -42,14 +45,14 @@ export default function ShoppingListModal({ const isEdit = action === "edit" && !!id; // Only seeds an instant value while the reactive by-id query settles; by-id wins - // once loaded, since edits invalidate ["shoppingLists"] and refetch it. + // once loaded, since edits invalidate the shopping list root and refetch it. const cachedList = queryClient - .getQueryData(["shoppingLists", "me"]) + .getQueryData(SHOPPING_LIST_QUERY_KEYS.me) ?.find((list) => list.id === id); - const byIdQuery = shoppingListService.useGetShoppingListById( - isEdit ? (id as string) : "", + const byIdQuery = useAuthedQuery( + shoppingListQueries.byId(isEdit ? (id as string) : ""), ); - const seededList = byIdQuery.isLoading ? cachedList : undefined; + const seededList = byIdQuery.pending ? cachedList : undefined; const shoppingList = isEdit ? (byIdQuery.data ?? seededList ?? null) : null; const draftKey = isEdit ? `shopping-list.edit.${id}` : "shopping-list.new"; @@ -127,6 +130,9 @@ export default function ShoppingListModal({ dirty={isDirty} formId="shopping-list-form" submitLabel={isEdit ? "Spremi" : "Stvori"} + submitLoadingLabel={ + isEdit ? LOADING_LABELS.saving : LOADING_LABELS.creating + } submitIcon={Save} submitLoading={isLoading} submitDisabled={!isDirty || !isValid || notFound || loadError} diff --git a/frontend/src/app/(user)/shopping-lists/components/shopping-list-item-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/components/shopping-list-item-skeleton.tsx new file mode 100644 index 00000000..cae86445 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/components/shopping-list-item-skeleton.tsx @@ -0,0 +1,41 @@ +import { Calendar, ListChecks } from "lucide-react"; + +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Mirrors ShoppingListListItem. The metadata icons are fixed, so they render for + * real and only their values are placeholders. + */ +export default function ShoppingListItemSkeleton() { + return ( + +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/app/(user)/shopping-lists/components/shopping-lists-client.tsx b/frontend/src/app/(user)/shopping-lists/components/shopping-lists-client.tsx index 54b52c16..e91ab5e8 100644 --- a/frontend/src/app/(user)/shopping-lists/components/shopping-lists-client.tsx +++ b/frontend/src/app/(user)/shopping-lists/components/shopping-lists-client.tsx @@ -8,14 +8,23 @@ import { Button } from "@/components/ui/button"; import SearchBar from "@/components/custom/search/search-bar"; import SearchBarSkeleton from "@/components/custom/search/search-bar-skeleton"; import ShoppingListItem from "@/app/(user)/shopping-lists/components/shopping-list-item"; +import ShoppingListItemSkeleton from "@/app/(user)/shopping-lists/components/shopping-list-item-skeleton"; import CreateShoppingListButton from "@/app/(user)/shopping-lists/components/create-shopping-list-button"; +import AsyncSection from "@/components/custom/common/async-section"; +import CountSkeleton from "@/components/custom/skeleton/count-skeleton"; +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; import NoResults from "@/components/custom/common/no-results"; import LoginRequired from "@/components/custom/common/login-required"; import { filterByFields } from "@/utils/generic"; -import { shoppingListService } from "@/lib/api"; -import { useUser } from "@/context/user-context"; +import { shoppingListQueries } from "@/lib/api/shopping-lists/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; +import { + useRememberedRowCount, + useRememberRowCount, +} from "@/hooks/use-remembered-row-count"; import { openModalUrl } from "@/lib/modal/modal-navigation"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; + +const ROW_COUNT_KEY = "shoppingLists:me"; interface IShoppingListsClientProps { query: string; @@ -26,17 +35,19 @@ export default function ShoppingListsClient({ }: IShoppingListsClientProps) { const pathname = usePathname(); - const { isAuthenticated, isLoading: userLoading } = useUser(); - const { data: shoppingLists = [], isLoading } = - shoppingListService.useGetCurrentUserShoppingLists({ - enabled: isAuthenticated, - }); - - const isUserLoading = userLoading || isLoading; + const { + data: shoppingLists = [], + pending: isUserLoading, + error, + requiresAuth, + } = useAuthedQuery(shoppingListQueries.me()); const matchingShoppingLists = filterByFields(shoppingLists, query, ["title"]); - if (!userLoading && !isAuthenticated) { + const rows = useRememberedRowCount(ROW_COUNT_KEY, 3); + useRememberRowCount(ROW_COUNT_KEY, shoppingLists.length); + + if (requiresAuth) { return ( -
- }> - - +
+ }> + + -
-

- {query.length > 0 - ? `Rezultati pretrage za "${query}" (${matchingShoppingLists.length})` - : `Moji popisi za kupnju${ - isUserLoading ? "" : ` (${matchingShoppingLists.length})` - }`} -

+
+ {/* The count is a placeholder pill until it is known, so the heading + never paints a 0 it then has to correct. */} +

+ {query.length > 0 + ? `Rezultati pretrage za "${query}" ` + : "Moji popisi za kupnju "} - - openModalUrl({ name: "shopping-list", action: "new" }) - } - /> -

+ {isUserLoading ? ( + + ) : ( + `(${matchingShoppingLists.length})` + )} +

- {isUserLoading ? ( -
- -
- ) : matchingShoppingLists.length > 0 ? ( - <> - {matchingShoppingLists.map((shoppingList) => ( - - ))} - - ) : query ? ( - } - /> - ) : ( -
- -

- Nema popisa za kupnju -

-

- Stvori popis za kupnju ili pretraži proizvode i dodaj ih na novi - popis. -

-
- + + openModalUrl({ name: "shopping-list", action: "new" }) + } + /> +
+ + + + + } + empty={ + query ? ( + } + /> + ) : ( +
+ +

+ Nema popisa za kupnju +

+

+ Stvori popis za kupnju ili pretraži proizvode i dodaj ih na novi + popis. +

+
+ - + +
-
- )} - - + ) + } + > + {matchingShoppingLists.map((shoppingList) => ( + + ))} + + ); } diff --git a/frontend/src/app/(user)/shopping-lists/components/shopping-lists-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/components/shopping-lists-skeleton.tsx new file mode 100644 index 00000000..01708687 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/components/shopping-lists-skeleton.tsx @@ -0,0 +1,29 @@ +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; +import SkeletonRegion from "@/components/custom/skeleton/skeleton-region"; +import SearchBarSkeleton from "@/components/custom/search/search-bar-skeleton"; +import ShoppingListItemSkeleton from "@/app/(user)/shopping-lists/components/shopping-list-item-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface IShoppingListsSkeletonProps { + rows?: number; +} + +/** The shopping lists index: search bar, heading row, then the list cards. */ +export default function ShoppingListsSkeleton({ + rows = 3, +}: IShoppingListsSkeletonProps) { + return ( + + + +
+ + +
+ + + + +
+ ); +} diff --git a/frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts b/frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts index 07cc4320..ffd3e61e 100644 --- a/frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts +++ b/frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts @@ -2,6 +2,7 @@ import { onlineManager, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { shoppingListService } from "@/lib/api"; +import { SHOPPING_LIST_QUERY_KEYS } from "@/lib/api/shopping-lists/keys"; import type { ShoppingListDto, ShoppingListRequest } from "@/lib/api/types"; import { stashModalError } from "@/lib/modal/modal-error-bus"; import { closeModalUrl, openModalUrl } from "@/lib/modal/modal-navigation"; @@ -42,7 +43,9 @@ export function useShoppingListModal({ } removeFormDraft(draftKey); - await queryClient.invalidateQueries({ queryKey: ["shoppingLists"] }); + await queryClient.invalidateQueries({ + queryKey: SHOPPING_LIST_QUERY_KEYS.all, + }); } catch (error) { stashModalError(draftKey, error); openModalUrl( diff --git a/frontend/src/app/(user)/shopping-lists/loading.tsx b/frontend/src/app/(user)/shopping-lists/loading.tsx new file mode 100644 index 00000000..ee1e797b --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/loading.tsx @@ -0,0 +1,5 @@ +import ShoppingListsSkeleton from "@/app/(user)/shopping-lists/components/shopping-lists-skeleton"; + +export default function Loading() { + return ; +} diff --git a/frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx b/frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx index 9c1940aa..1763b5d0 100644 --- a/frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx +++ b/frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import ResponsiveLabel from "@/components/custom/common/responsive-label"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import { shoppingListService } from "@/lib/api"; import { IWatchlistItemWithProduct } from "@/app/(user)/watchlist/utils/watchlist-utils"; import { formatDate } from "@/utils/strings"; @@ -104,7 +105,14 @@ export default function CreateDiscountedListButton({ iconPlacement="left" disabled={isDisabled} loading={isCreating} - loadingText="Stvaranje popisa..." + // Mirrors the label's own breakpoint swap, so the pending wording is + // never wider than the idle one it replaces. + loadingText={ + + } > diff --git a/frontend/src/app/(user)/watchlist/components/watchlist-action-button.tsx b/frontend/src/app/(user)/watchlist/components/watchlist-action-button.tsx index dc7a01fa..9793a2d8 100644 --- a/frontend/src/app/(user)/watchlist/components/watchlist-action-button.tsx +++ b/frontend/src/app/(user)/watchlist/components/watchlist-action-button.tsx @@ -6,6 +6,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import { cn } from "@/lib/utils"; interface IWatchlistActionButtonProps { @@ -26,13 +27,15 @@ export default function WatchlistActionButton({ onRemove, }: IWatchlistActionButtonProps) { const label = isAddMode ? "Prati proizvod" : "Makni proizvod"; + // Icon-only, so the spinner is the whole visual and the name carries the copy. + const currentLabel = isRemoving ? LOADING_LABELS.deleting : label; return ( {email && ( @@ -100,21 +124,31 @@ export default function AdminContactRow({ type="button" variant="ghost" size="icon" - aria-label="Vrati poruku" + aria-label={restoreLabel} onClick={() => onRestore(message)} + disabled={isRestoring} > - + {isRestoring ? ( + + ) : ( + + )} ) : ( )} diff --git a/frontend/src/app/dashboard/components/admin-contact-table.tsx b/frontend/src/app/dashboard/components/admin-contact-table.tsx index d3e08420..cc399c77 100644 --- a/frontend/src/app/dashboard/components/admin-contact-table.tsx +++ b/frontend/src/app/dashboard/components/admin-contact-table.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import TableSkeleton from "@/components/custom/skeleton/table-skeleton"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { @@ -22,9 +22,10 @@ import { import AdminContactRow from "@/app/dashboard/components/admin-contact-row"; import AdminContactDetail from "@/app/dashboard/components/admin-contact-detail"; import { useContactInbox } from "@/app/dashboard/hooks/use-contact-inbox"; -import { contactService } from "@/lib/api"; import { ContactMessageDto } from "@/lib/api/types"; import { filterByFields } from "@/utils/generic"; +import { contactQueries } from "@/lib/api/contact/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; type InboxView = "all" | "unread"; @@ -44,8 +45,11 @@ export default function AdminContactTable() { const [showDeleted, setShowDeleted] = useState(false); const [detail, setDetail] = useState(null); - const { data, isLoading, isError } = - contactService.useGetContactMessages(showDeleted); + const { + data, + pending: isLoading, + isError, + } = useAuthedQuery(contactQueries.list(showDeleted)); const inbox = useContactInbox(); const messages = useMemo(() => { @@ -59,11 +63,7 @@ export default function AdminContactTable() { }, [data, view, search]); if (isLoading) { - return ( -
- -
- ); + return ; } if (isError) { @@ -121,6 +121,9 @@ export default function AdminContactTable() { onToggleRead={inbox.toggleRead} onDelete={inbox.remove} onRestore={inbox.restore} + isTogglingRead={inbox.readPendingId === message.id} + isDeleting={inbox.deletePendingId === message.id} + isRestoring={inbox.restorePendingId === message.id} /> ))} diff --git a/frontend/src/app/dashboard/components/admin-user-row.tsx b/frontend/src/app/dashboard/components/admin-user-row.tsx index 177ae710..6ff0bae0 100644 --- a/frontend/src/app/dashboard/components/admin-user-row.tsx +++ b/frontend/src/app/dashboard/components/admin-user-row.tsx @@ -13,6 +13,9 @@ import { } from "@/components/ui/select"; import { TableCell, TableRow } from "@/components/ui/table"; import RelativeTime from "@/components/custom/common/relative-time"; +import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import PendingStatus from "@/components/custom/common/pending-status"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import { AccountType, ACCOUNT_TYPE_LABELS, @@ -56,24 +59,31 @@ export default function AdminUserRow({ - + {/* A select has nowhere to put pending copy, so the spinner sits beside + it and the live region carries the wording. */} +
+ + + {isUpdating && } + +
diff --git a/frontend/src/app/dashboard/components/admin-users-stats.tsx b/frontend/src/app/dashboard/components/admin-users-stats.tsx index 93fa4568..60ace199 100644 --- a/frontend/src/app/dashboard/components/admin-users-stats.tsx +++ b/frontend/src/app/dashboard/components/admin-users-stats.tsx @@ -5,11 +5,16 @@ import { MONTHLY_WINDOW_DAYS, WEEKLY_WINDOW_DAYS, } from "@/app/dashboard/utils/user-activity"; -import { adminService } from "@/lib/api"; +import { adminQueries } from "@/lib/api/admin/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; /** Active-user counters above the user list; shares its cached query, so no extra request. */ export default function AdminUsersStats() { - const { data: users, isLoading, isError } = adminService.useGetAllUsers(); + const { + data: users, + pending: isLoading, + isError, + } = useAuthedQuery(adminQueries.users()); return (
diff --git a/frontend/src/app/dashboard/components/admin-users-table.tsx b/frontend/src/app/dashboard/components/admin-users-table.tsx index 024db60b..05a29faa 100644 --- a/frontend/src/app/dashboard/components/admin-users-table.tsx +++ b/frontend/src/app/dashboard/components/admin-users-table.tsx @@ -3,8 +3,9 @@ import { useState } from "react"; import { toast } from "sonner"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import TableSkeleton from "@/components/custom/skeleton/table-skeleton"; import { ConfirmDialog } from "@/components/custom/modal/confirm-dialog"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import { Table, TableBody, @@ -16,10 +17,16 @@ import AdminUserRow from "@/app/dashboard/components/admin-user-row"; import { adminService } from "@/lib/api"; import { AccountType, UserDto } from "@/lib/api/schemas/auth-user"; import { useUser } from "@/context/user-context"; +import { adminQueries } from "@/lib/api/admin/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; export default function AdminUsersTable() { const { user: currentUser } = useUser(); - const { data: users, isLoading, isError } = adminService.useGetAllUsers(); + const { + data: users, + pending: isLoading, + isError, + } = useAuthedQuery(adminQueries.users()); const updateAccountType = adminService.useUpdateUserAccountType(); const deleteUser = adminService.useDeleteUser(); @@ -52,11 +59,7 @@ export default function AdminUsersTable() { } if (isLoading) { - return ( -
- -
- ); + return ; } if (isError) { @@ -104,6 +107,7 @@ export default function AdminUsersTable() { deleteTarget?.username || deleteTarget?.email || "" }? Ova akcija se ne može poništiti.`} confirmLabel="Obriši račun" + confirmLoadingLabel={LOADING_LABELS.deleting} variant="destructive" onConfirm={handleDelete} isLoading={deleteUser.isPending} diff --git a/frontend/src/app/dashboard/components/dashboard-guard.tsx b/frontend/src/app/dashboard/components/dashboard-guard.tsx index 3020352e..069eadb7 100644 --- a/frontend/src/app/dashboard/components/dashboard-guard.tsx +++ b/frontend/src/app/dashboard/components/dashboard-guard.tsx @@ -3,7 +3,7 @@ import { useEffect, ReactNode } from "react"; import { useRouter } from "next/navigation"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import PageShellSkeleton from "@/components/custom/skeleton/page-shell-skeleton"; import { useUser } from "@/context/user-context"; import { canAccessDashboard } from "@/lib/api/schemas/auth-user"; @@ -23,12 +23,9 @@ export default function DashboardGuard({ children }: IDashboardGuardProps) { } }, [isLoading, allowed, router]); + // Also covers the moment after a denial, while the redirect above runs. if (isLoading || !allowed) { - return ( -
- -
- ); + return ; } return <>{children}; diff --git a/frontend/src/app/dashboard/hooks/use-contact-inbox.ts b/frontend/src/app/dashboard/hooks/use-contact-inbox.ts index b281d23e..75c39130 100644 --- a/frontend/src/app/dashboard/hooks/use-contact-inbox.ts +++ b/frontend/src/app/dashboard/hooks/use-contact-inbox.ts @@ -22,7 +22,16 @@ export function useContactInbox() { }); } + // Which row is busy, and doing what, so a table of rows can show it per row. + // `variables` is the id, since each of these mutations takes only that. + function pendingIdFor(...mutations: ContactMutation[]) { + return mutations.find((m) => m.isPending)?.variables ?? null; + } + return { + readPendingId: pendingIdFor(markRead, markUnread), + deletePendingId: pendingIdFor(softDelete), + restorePendingId: pendingIdFor(restore), toggleRead: (m: ContactMessageDto) => m.readAt ? run(markUnread, m.id, "Označeno kao nepročitano.") diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index aec7eb67..f2733f8e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -568,6 +568,11 @@ } @media (prefers-reduced-motion: reduce) { + /* Skeletons use Tailwind's animate-pulse, which no rule above covers. They + stay visible, just static, which is what reduced motion asks for. */ + [data-slot="skeleton"] { + animation: none; + } .spinner_9y7u { animation: none; } diff --git a/frontend/src/app/loading.tsx b/frontend/src/app/loading.tsx index 6382dcb9..af7a73aa 100644 --- a/frontend/src/app/loading.tsx +++ b/frontend/src/app/loading.tsx @@ -1,10 +1,10 @@ -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; -import { JSX } from "react"; +import PageShellSkeleton from "@/components/custom/skeleton/page-shell-skeleton"; -export default function Loading(): JSX.Element { - return ( -
- -
- ); +/** + * The last-resort route fallback, for segments with no loading.tsx of their own. + * A neutral shape beats a centred spinner: the page keeps its height, so content + * does not shove the viewport when it arrives. + */ +export default function Loading() { + return ; } diff --git a/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx b/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx index 0d4a94ec..08a32c28 100644 --- a/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx +++ b/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx @@ -1,4 +1,4 @@ -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import ChartSkeleton from "@/components/custom/skeleton/chart-skeleton"; import PriceHistoryChart from "@/app/products/[id]/components/price-history/price-history-chart"; import { HistoryDataPoint } from "@/app/products/[id]/typings/history-data-point"; @@ -18,11 +18,7 @@ export default function PriceHistoryPanel({ selectedChains, }: IPriceHistoryPanelProps) { if (historyLoading) { - return ( -
- -
- ); + return ; } if (priceHistoryData.length === 0 || historyError) { diff --git a/frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx b/frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx new file mode 100644 index 00000000..fe168eb3 --- /dev/null +++ b/frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx @@ -0,0 +1,26 @@ +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; +import StoreItemSkeleton from "@/app/products/[id]/components/store-item/store-item-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface IProductChainsListSkeletonProps { + chains?: number; +} + +/** + * The body of ProductChainsSection, without its header. Kept separate because + * the live section is already inside a CollapsibleSection that draws the header + * itself, while the page skeleton has to draw both. + */ +export default function ProductChainsListSkeleton({ + chains = 4, +}: IProductChainsListSkeletonProps) { + return ( +
+
+ ); +} diff --git a/frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx b/frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx new file mode 100644 index 00000000..04e1864a --- /dev/null +++ b/frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx @@ -0,0 +1,18 @@ +import SectionHeaderSkeleton from "@/components/custom/skeleton/section-header-skeleton"; +import ProductChainsListSkeleton from "@/app/products/[id]/components/product-chains-list-skeleton"; + +interface IProductChainsSectionSkeletonProps { + chains?: number; +} + +/** Mirrors ProductChainsSection, open, since that is its stored default. */ +export default function ProductChainsSectionSkeleton({ + chains, +}: IProductChainsSectionSkeletonProps) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/app/products/[id]/components/product-chains-section.tsx b/frontend/src/app/products/[id]/components/product-chains-section.tsx index bb79ba91..ce21a9ad 100644 --- a/frontend/src/app/products/[id]/components/product-chains-section.tsx +++ b/frontend/src/app/products/[id]/components/product-chains-section.tsx @@ -1,8 +1,10 @@ "use client"; import { useCallback, useState } from "react"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import { useDataPending } from "@/lib/query/use-data-pending"; +import AsyncSection from "@/components/custom/common/async-section"; import CollapsibleSection from "@/components/custom/common/collapsible-section"; +import ProductChainsListSkeleton from "@/app/products/[id]/components/product-chains-list-skeleton"; import LastSyncedLabel from "@/components/custom/offline/last-synced-label"; import StoreItem from "@/app/products/[id]/components/store-item/store-item"; import ProductChainSortSelect from "@/app/products/[id]/components/product-chain-sort-select"; @@ -28,6 +30,8 @@ export default function ProductChainsSection({ const [isOpen, setIsOpen] = useState(() => getProductStoresOpen(ean)); const [expandedChain, setExpandedChain] = useState(null); + const pricesPending = useDataPending(detail.pricesPending); + const toggleChain = useCallback((chainCode: string) => { setExpandedChain((previous) => (previous === chainCode ? null : chainCode)); }, []); @@ -51,19 +55,22 @@ export default function ProductChainsSection({ /> )} - {detail.pricesLoading ? ( -
- -
- ) : detail.pricesError ? ( -

- Greška pri učitavanju cijena. Pokušaj ponovno. -

- ) : detail.sortedChains.length === 0 ? ( -

- Nema dostupnih cijena za ovaj proizvod. -

- ) : ( + + Greška pri učitavanju cijena. Pokušaj ponovno. +

+ } + isEmpty={detail.sortedChains.length === 0} + empty={ +

+ Nema dostupnih cijena za ovaj proizvod. +

+ } + skeleton={} + >
))}
- )} +
); } diff --git a/frontend/src/app/products/[id]/components/product-detail-client.tsx b/frontend/src/app/products/[id]/components/product-detail-client.tsx index 3e60470c..525d4eeb 100644 --- a/frontend/src/app/products/[id]/components/product-detail-client.tsx +++ b/frontend/src/app/products/[id]/components/product-detail-client.tsx @@ -3,7 +3,10 @@ import ProductInfoDisplay from "@/app/products/components/product-info-display"; import PriceHistory from "@/app/products/[id]/components/price-history/price-history-base"; import ProductChainsSection from "@/app/products/[id]/components/product-chains-section"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import ProductDetailSkeleton from "@/app/products/[id]/components/product-detail-skeleton"; +import AsyncSection from "@/components/custom/common/async-section"; +import ErrorState from "@/components/custom/common/error-state"; +import { useDataPending } from "@/lib/query/use-data-pending"; import { useProductDetail } from "@/app/products/[id]/hooks/use-product-detail"; interface IProductDetailClientProps { @@ -14,45 +17,40 @@ export default function ProductDetailClient({ ean, }: IProductDetailClientProps) { const detail = useProductDetail(ean); - const { product, productLoading, productError } = detail; + const { product, productPending, productError } = detail; - if (productLoading) { - return ( -
- -
- ); - } - - if (productError || !product) { - return ( -
-
-

- Proizvod nije pronađen -

- -

- Nije moguće učitati podatke za ovaj proizvod. -

-
-
- ); - } + const pending = useDataPending(productPending); return ( -
-
- -
- -
- -
- -
- -
-
+ + } + skeleton={} + > + {product && ( +
+
+ +
+ +
+ +
+ +
+ +
+
+ )} +
); } diff --git a/frontend/src/app/products/[id]/components/product-detail-skeleton.tsx b/frontend/src/app/products/[id]/components/product-detail-skeleton.tsx new file mode 100644 index 00000000..824061b3 --- /dev/null +++ b/frontend/src/app/products/[id]/components/product-detail-skeleton.tsx @@ -0,0 +1,29 @@ +import SkeletonRegion from "@/components/custom/skeleton/skeleton-region"; +import SectionHeaderSkeleton from "@/components/custom/skeleton/section-header-skeleton"; +import ProductInfoDisplaySkeleton from "@/app/products/components/product-info-display-skeleton"; +import ProductChainsSectionSkeleton from "@/app/products/[id]/components/product-chains-section-skeleton"; + +/** + * The whole product detail page, in the same `space-y-4` rhythm as + * product-detail-client. Shared by loading.tsx and the client's pending branch, + * so it takes no hooks and no context. + */ +export default function ProductDetailSkeleton() { + return ( + +
+ +
+ + {/* Price history is stored closed by default, so its header is the whole + footprint until someone opens it. */} +
+ +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx b/frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx new file mode 100644 index 00000000..084ddc80 --- /dev/null +++ b/frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx @@ -0,0 +1,30 @@ +import { ChevronDown } from "lucide-react"; + +import { Card, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** Mirrors the collapsed header of StoreItem: chain logo, name, price row. */ +export default function StoreItemSkeleton() { + return ( + + +
+
+ +
+
+ ); +} diff --git a/frontend/src/app/products/[id]/hooks/use-product-detail.ts b/frontend/src/app/products/[id]/hooks/use-product-detail.ts index ad26f577..a957a006 100644 --- a/frontend/src/app/products/[id]/hooks/use-product-detail.ts +++ b/frontend/src/app/products/[id]/hooks/use-product-detail.ts @@ -16,13 +16,13 @@ export function useProductDetail(ean: string) { const { data: product, - isLoading: productLoading, + isPending: productPending, error: productError, } = cijeneService.useGetProductByEan({ ean }); const { data: pricesData, - isLoading: pricesLoading, + isPending: pricesPending, error: pricesError, dataUpdatedAt: pricesUpdatedAt, } = cijeneService.useGetPrices({ eans: ean }); @@ -53,10 +53,10 @@ export function useProductDetail(ean: string) { return { product, - productLoading, + productPending, productError, pricesByChain, - pricesLoading, + pricesPending, pricesError, pricesUpdatedAt, sortedChains, diff --git a/frontend/src/app/products/[id]/loading.tsx b/frontend/src/app/products/[id]/loading.tsx new file mode 100644 index 00000000..75d310bd --- /dev/null +++ b/frontend/src/app/products/[id]/loading.tsx @@ -0,0 +1,5 @@ +import ProductDetailSkeleton from "@/app/products/[id]/components/product-detail-skeleton"; + +export default function Loading() { + return ; +} diff --git a/frontend/src/app/products/components/forms/add-to-shopping-list-form.tsx b/frontend/src/app/products/components/forms/add-to-shopping-list-form.tsx index 844e3c69..51dfb081 100644 --- a/frontend/src/app/products/components/forms/add-to-shopping-list-form.tsx +++ b/frontend/src/app/products/components/forms/add-to-shopping-list-form.tsx @@ -14,6 +14,7 @@ import QuantityInput from "@/app/products/components/forms/quantity-input"; import MarkAsCheckedCheckbox from "@/app/products/components/forms/mark-as-checked-checkbox"; import StoreChainField from "@/app/products/components/forms/store-chain-field"; import { closeModalUrl } from "@/lib/modal/modal-navigation"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import { useAddToListForm } from "@/app/products/hooks/use-add-to-list-form"; interface IAddToShoppingListFormProps { @@ -60,6 +61,7 @@ export default function AddToShoppingListForm({ submitLabel="Dodaj" submitIcon={ListPlus} submitLoading={isSubmitting} + submitLoadingLabel={LOADING_LABELS.adding} submitDisabled={ !product || !form.formState.isValid || diff --git a/frontend/src/app/products/components/forms/product-actions-sheet.tsx b/frontend/src/app/products/components/forms/product-actions-sheet.tsx index 30b41349..31823fea 100644 --- a/frontend/src/app/products/components/forms/product-actions-sheet.tsx +++ b/frontend/src/app/products/components/forms/product-actions-sheet.tsx @@ -5,6 +5,7 @@ import { getMostFrequentCategory } from "@/app/products/utils/product-utils"; import ProductUnitPriceDetails from "@/app/products/components/product-item/product-price"; import ProductQuickActions from "@/components/custom/product/product-quick-actions"; import ProductSummary from "@/components/custom/product/product-summary"; +import ProductSummarySkeleton from "@/components/custom/product/product-summary-skeleton"; import { closeModalUrl } from "@/lib/modal/modal-navigation"; interface IProductActionsSheetProps { @@ -30,16 +31,21 @@ export default function ProductActionsSheet({ product={product} isLoading={isLoading} summary={ - : undefined - } - className="px-0 @md:px-0" - /> + isLoading ? ( + + ) : ( + + ) : undefined + } + className="px-0 @md:px-0" + /> + ) } open={open} onOpenChange={(next) => !next && closeModalUrl()} diff --git a/frontend/src/app/products/components/forms/watchlist-item-modal.tsx b/frontend/src/app/products/components/forms/watchlist-item-modal.tsx index 5cd6d37e..690f6c4a 100644 --- a/frontend/src/app/products/components/forms/watchlist-item-modal.tsx +++ b/frontend/src/app/products/components/forms/watchlist-item-modal.tsx @@ -5,13 +5,14 @@ import { Eye, Save, TriangleAlert } from "lucide-react"; import { ModalShell } from "@/components/custom/modal/modal-shell"; import { Form } from "@/components/ui/form"; -import { Skeleton } from "@/components/ui/skeleton"; import RemoveIconButton from "@/components/custom/common/remove-icon-button"; import { WatchType } from "@/lib/api"; import cijeneService from "@/lib/cijene-api"; import { closeModalUrl } from "@/lib/modal/modal-navigation"; +import { LOADING_LABELS } from "@/constants/loading-labels"; import type { WatchTypeParam } from "@/lib/modal/modal-registry"; import ProductInfoDisplay from "@/app/products/components/product-info-display"; +import ProductInfoDisplaySkeleton from "@/app/products/components/product-info-display-skeleton"; import { Banner } from "@/components/custom/common/banner"; import { getAveragePrice, @@ -108,6 +109,9 @@ export default function WatchlistItemModal({ submitLabel={existingItemForType ? "Spremi" : "Prati"} submitIcon={existingItemForType ? Save : Eye} submitLoading={isSaving} + submitLoadingLabel={ + existingItemForType ? LOADING_LABELS.saving : LOADING_LABELS.adding + } submitDisabled={ isCheckingWatchlist || !product || !form.formState.isValid } @@ -119,8 +123,8 @@ export default function WatchlistItemModal({ form.reset(); }} > - {productQuery.isLoading ? ( - + {productQuery.isPending ? ( + ) : !product ? (

Proizvod nije pronađen.

) : ( diff --git a/frontend/src/app/products/components/product-action-buttons.tsx b/frontend/src/app/products/components/product-action-buttons.tsx index fa692cdf..225fe09e 100644 --- a/frontend/src/app/products/components/product-action-buttons.tsx +++ b/frontend/src/app/products/components/product-action-buttons.tsx @@ -14,7 +14,8 @@ import { openExternal } from "@/utils/browser/open-external"; import WatchlistActionButton from "@/app/products/components/watchlist-action-button"; import useProductModals from "@/hooks/use-product-modals"; import useProductShare from "@/hooks/use-product-share"; -import { watchlistService } from "@/lib/api"; +import { watchlistQueries } from "@/lib/api/watchlist/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; interface IProductActionButtonsProps { product: ProductResponse; @@ -35,8 +36,9 @@ export default function ProductActionButtons({ grouped = false, className, }: IProductActionButtonsProps) { - const { data: currentUserWatchlist = [] } = - watchlistService.useGetCurrentUserWatchlist(); + const { data: currentUserWatchlist = [] } = useAuthedQuery( + watchlistQueries.me(), + ); const { openAddToList } = useProductModals(product); const share = useProductShare(product); diff --git a/frontend/src/app/products/components/product-info-display-skeleton.tsx b/frontend/src/app/products/components/product-info-display-skeleton.tsx new file mode 100644 index 00000000..5e8d7864 --- /dev/null +++ b/frontend/src/app/products/components/product-info-display-skeleton.tsx @@ -0,0 +1,78 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +const TABLE_LABELS = [ + ["Proizvođač:", "Bar kod:"], + ["Količina:", "Cijene:"], + ["Jedinična cijena:", "Kategorija:"], +] as const; + +/** + * Mirrors ProductInfoDisplay: the name row plus the info table beneath it. The + * table's labels are fixed copy and render for real, so only the values move. + */ +export default function ProductInfoDisplaySkeleton() { + return ( +
+
+
+
+ +
+
+
+ +
+ + + {TABLE_LABELS.map(([left, right], index) => ( + + + + + + ))} + +
+ {left} + + {right} +
+
+
+ ); +} diff --git a/frontend/src/app/products/components/products-client.tsx b/frontend/src/app/products/components/products-client.tsx index 431d125b..32ee65a3 100644 --- a/frontend/src/app/products/components/products-client.tsx +++ b/frontend/src/app/products/components/products-client.tsx @@ -13,7 +13,18 @@ import { Suspense } from "react"; import SearchBar from "@/components/custom/search/search-bar"; import SearchBarSkeleton from "@/components/custom/search/search-bar-skeleton"; import { useIsMobile } from "@/hooks/use-mobile"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import AsyncSection from "@/components/custom/common/async-section"; +import ErrorState from "@/components/custom/common/error-state"; +import CountSkeleton from "@/components/custom/skeleton/count-skeleton"; +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; +import ProductCardSkeleton from "@/components/custom/product/product-card-skeleton"; +import { useDataPending } from "@/lib/query/use-data-pending"; +import { + useRememberedRowCount, + useRememberRowCount, +} from "@/hooks/use-remembered-row-count"; + +const ROW_COUNT_KEY = "products:results"; interface IProductsClientProps { query: string; @@ -44,6 +55,18 @@ export default function ProductsClient({ query }: IProductsClientProps) { // A location filter is set but the city -> chains mapping is still loading const waitingForLocations = Boolean(query) && !locationsReady; + // Results only exist once a query is typed, so an empty search box is not a + // pending state, it is the prompt below. + const pending = useDataPending( + Boolean(query) && (isLoading || waitingForLocations), + ); + + const rows = useRememberedRowCount(ROW_COUNT_KEY, 6); + useRememberRowCount( + ROW_COUNT_KEY, + query ? visibleProducts.length : undefined, + ); + return (
}> @@ -59,90 +82,100 @@ export default function ProductsClient({ query }: IProductsClientProps) {

- {query.length > 0 && - `Rezultati pretrage za "${query}"${ - isLoading || waitingForLocations - ? "" - : ` (${total}${isTruncated ? "+" : ""})` - }`} + {query.length > 0 && ( + <> + {`Rezultati pretrage za "${query}" `} + + {/* A pill rather than a number, so the heading never shows 0 + results for a search that is about to return some. */} + {pending ? ( + + ) : ( + `(${total}${isTruncated ? "+" : ""})` + )} + + )}

{/* TODO: re-enable with personalisable list views (see view-switcher.tsx) */} {/* */}
- {isLoading || waitingForLocations ? ( -
- -
- ) : error ? ( -
- -

- Greška pri pretraživanju -

-

- Došlo je do greške pri dohvaćanju podataka. Pokušaj ponovo. -

-
- ) : query && total === 0 && activeFilterCount > 0 ? ( -
+ } + title="Greška pri pretraživanju" + fallbackMessage="Došlo je do greške pri dohvaćanju podataka. Pokušaj ponovo." + /> + } + skeleton={ + + + + } + > + {query && total === 0 && activeFilterCount > 0 ? ( +
+ } + description="Nema rezultata za odabrane filtere" + /> +
+ +
+
+ ) : query && total === 0 ? ( } - description="Nema rezultata za odabrane filtere" /> -
- + ) : query ? ( + <> +
+ {visibleProducts.map((product) => ( +
+ +
+ ))} +
+ + ) : activeFilterCount > 0 ? ( +
+ +

+ Unesi pojam za pretragu +

+

+ Filteri su postavljeni, rezultati će se prikazati nakon pretrage +

-
- ) : query && total === 0 ? ( - } - /> - ) : query ? ( - <> -
- {visibleProducts.map((product) => ( -
- -
- ))} + ) : ( +
+ +

+ Pretraži proizvode +

+

+ Unesi naziv proizvoda koji tražiš +

- - ) : activeFilterCount > 0 ? ( -
- -

- Unesi pojam za pretragu -

-

- Filteri su postavljeni, rezultati će se prikazati nakon pretrage -

-
- ) : ( -
- -

- Pretraži proizvode -

-

- Unesi naziv proizvoda koji tražiš -

-
- )} + )} +
); } diff --git a/frontend/src/app/products/components/products-skeleton.tsx b/frontend/src/app/products/components/products-skeleton.tsx new file mode 100644 index 00000000..4a7d1953 --- /dev/null +++ b/frontend/src/app/products/components/products-skeleton.tsx @@ -0,0 +1,31 @@ +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; +import SkeletonRegion from "@/components/custom/skeleton/skeleton-region"; +import SearchBarSkeleton from "@/components/custom/search/search-bar-skeleton"; +import ProductCardSkeleton from "@/components/custom/product/product-card-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface IProductsSkeletonProps { + rows?: number; +} + +/** + * The products index: search bar, filters bar, heading row, then result cards. + * Shared by page.tsx's Suspense fallback and the client's pending branch. + */ +export default function ProductsSkeleton({ rows = 6 }: IProductsSkeletonProps) { + return ( + + + + + +
+ +
+ + + + +
+ ); +} diff --git a/frontend/src/app/products/hooks/use-infinite-products.ts b/frontend/src/app/products/hooks/use-infinite-products.ts index 0a2090b3..640cd712 100644 --- a/frontend/src/app/products/hooks/use-infinite-products.ts +++ b/frontend/src/app/products/hooks/use-infinite-products.ts @@ -42,7 +42,11 @@ export default function useInfiniteProducts( Number.isInteger(batchSize) && batchSize > 0 ? batchSize : 50; // One unfiltered request, filtered client-side, so facet counts match results. - const { data, isLoading, error } = useGetProductByName({ + const { + data, + isPending: isLoading, + error, + } = useGetProductByName({ q, limit: PRODUCT_SEARCH_LIMIT, // Raising this needs paging upstream: >100 is a 422. }); diff --git a/frontend/src/app/products/hooks/use-selected-shopping-list.ts b/frontend/src/app/products/hooks/use-selected-shopping-list.ts index 6a39443a..f2056188 100644 --- a/frontend/src/app/products/hooks/use-selected-shopping-list.ts +++ b/frontend/src/app/products/hooks/use-selected-shopping-list.ts @@ -5,6 +5,8 @@ import type { UseFormReturn } from "react-hook-form"; import { toast } from "sonner"; import { shoppingListService } from "@/lib/api"; +import { shoppingListQueries } from "@/lib/api/shopping-lists/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; import type { AddToListFormData } from "@/app/products/typings/add-to-list"; export function useSelectedShoppingList( @@ -13,8 +15,10 @@ export function useSelectedShoppingList( enabled: boolean, restoredListId: string | null, ) { - const { data: shoppingLists = [], isLoading: isLoadingLists } = - shoppingListService.useGetCurrentUserShoppingLists({ enabled }); + const { data: shoppingLists = [], pending: isLoadingLists } = useAuthedQuery({ + ...shoppingListQueries.me(), + enabled, + }); const removeItemMutation = shoppingListService.useDeleteShoppingListItem(); const sortedShoppingLists = shoppingLists.slice().sort((a, b) => { @@ -30,8 +34,9 @@ export function useSelectedShoppingList( }); const selectedListId = form.watch("shoppingListId"); - const { data: selectedShoppingList } = - shoppingListService.useGetShoppingListById(selectedListId); + const { data: selectedShoppingList } = useAuthedQuery( + shoppingListQueries.byId(selectedListId), + ); const duplicateItem = selectedShoppingList?.items?.find( (item) => item.ean === ean, diff --git a/frontend/src/app/products/hooks/use-watchlist-item-form.ts b/frontend/src/app/products/hooks/use-watchlist-item-form.ts index aba2d9a6..430a6a47 100644 --- a/frontend/src/app/products/hooks/use-watchlist-item-form.ts +++ b/frontend/src/app/products/hooks/use-watchlist-item-form.ts @@ -14,6 +14,8 @@ import { WatchlistFormData, watchlistFormSchema, } from "@/app/products/typings/watchlist-form"; +import { watchlistQueries } from "@/lib/api/watchlist/hooks"; +import { useAuthedQuery } from "@/lib/query/use-authed-query"; // A restored pair looks like a type switch, so match it to keep the value. function matchesDraft(draftKey: string, values: WatchlistFormData): boolean { @@ -37,8 +39,8 @@ export function useWatchlistItemForm( const addMutation = watchlistService.useAddToWatchlist(); const removeMutation = watchlistService.useRemoveFromWatchlist(); - const { data: existingItems = [], isLoading: isCheckingWatchlist } = - watchlistService.useGetWatchlistItemsByProductApiId(ean); + const { data: existingItems = [], pending: isCheckingWatchlist } = + useAuthedQuery(watchlistQueries.byProduct(ean)); const form = useForm({ resolver: zodResolver(watchlistFormSchema), diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx index ebecb195..a2a0d6e2 100644 --- a/frontend/src/app/products/page.tsx +++ b/frontend/src/app/products/page.tsx @@ -1,8 +1,7 @@ import { Metadata } from "next"; import { Suspense } from "react"; import ProductsClient from "@/app/products/components/products-client"; -import SearchBarSkeleton from "@/components/custom/search/search-bar-skeleton"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import ProductsSkeleton from "@/app/products/components/products-skeleton"; import { readSearchParam } from "@/utils/generic"; export const metadata: Metadata = { @@ -15,16 +14,7 @@ export default async function ProductsPage(props: PageProps<"/products">) { // ProductsClient's useSearchParams needs a Suspense boundary when prerendering. return ( - - -
- -
-
- } - > + }> ); diff --git a/frontend/src/app/providers/react-query-provider.tsx b/frontend/src/app/providers/react-query-provider.tsx index e989a32f..3f9ec236 100644 --- a/frontend/src/app/providers/react-query-provider.tsx +++ b/frontend/src/app/providers/react-query-provider.tsx @@ -10,6 +10,7 @@ import { OFFLINE_CACHE_MAX_AGE_MS, } from "@/lib/offline/persister"; import { registerOfflineMutationDefaults } from "@/lib/offline/offline-mutations"; +import { CACHE_TIMES } from "@/lib/query/cache-times"; interface IReactQueryProviderWrapperProps { children: ReactNode; @@ -25,6 +26,10 @@ export default function ReactQueryProviderWrapper({ queries: { // Must be >= the persister's maxAge, or entries evict before restoring. gcTime: OFFLINE_CACHE_MAX_AGE_MS, + // Without this every backend query refetches on each mount, so moving + // between pages refetches a list the user just looked at. The longer + // per-query windows in lib/api and lib/cijene-api still win. + staleTime: CACHE_TIMES.default, // One retry: a blip gets a second chance, a real failure surfaces fast. retry: 1, }, diff --git a/frontend/src/app/statistics/components/health-status.tsx b/frontend/src/app/statistics/components/health-status.tsx index b72a7be2..f8a677ed 100644 --- a/frontend/src/app/statistics/components/health-status.tsx +++ b/frontend/src/app/statistics/components/health-status.tsx @@ -1,21 +1,22 @@ "use client"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; import cijeneService from "@/lib/cijene-api"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useDataPending } from "@/lib/query/use-data-pending"; export default function HealthStatus() { - const { - data: health, - isLoading: healthLoading, - error, - } = cijeneService.useHealthCheck(); + const { data: health, isPending, error } = cijeneService.useHealthCheck(); - if (healthLoading) { + const pending = useDataPending(isPending); + + if (pending) { return ( -
- - Provjera stanja... -
+ <> + + Provjera stanja + +