diff --git a/.agents/skills/better-auth-best-practices b/.agents/skills/better-auth-best-practices new file mode 120000 index 00000000..b521122b --- /dev/null +++ b/.agents/skills/better-auth-best-practices @@ -0,0 +1 @@ +../../frontend/.agents/skills/better-auth-best-practices \ No newline at end of file diff --git a/.agents/skills/create-auth-skill b/.agents/skills/create-auth-skill new file mode 120000 index 00000000..8f7f6a2a --- /dev/null +++ b/.agents/skills/create-auth-skill @@ -0,0 +1 @@ +../../frontend/.agents/skills/create-auth-skill \ No newline at end of file diff --git a/.agents/skills/document-subsystem b/.agents/skills/document-subsystem new file mode 120000 index 00000000..b11f3e3d --- /dev/null +++ b/.agents/skills/document-subsystem @@ -0,0 +1 @@ +../../.claude/skills/document-subsystem \ No newline at end of file diff --git a/.agents/skills/email-and-password-best-practices b/.agents/skills/email-and-password-best-practices new file mode 120000 index 00000000..9c6c68b1 --- /dev/null +++ b/.agents/skills/email-and-password-best-practices @@ -0,0 +1 @@ +../../frontend/.agents/skills/email-and-password-best-practices \ No newline at end of file diff --git a/.agents/skills/email-best-practices b/.agents/skills/email-best-practices new file mode 120000 index 00000000..0efc93fa --- /dev/null +++ b/.agents/skills/email-best-practices @@ -0,0 +1 @@ +../../frontend/.agents/skills/email-best-practices \ No newline at end of file diff --git a/.agents/skills/frontend-design b/.agents/skills/frontend-design new file mode 120000 index 00000000..191f22ff --- /dev/null +++ b/.agents/skills/frontend-design @@ -0,0 +1 @@ +../../frontend/.github/skills/frontend-design \ No newline at end of file diff --git a/.agents/skills/multi-tool-code-review b/.agents/skills/multi-tool-code-review new file mode 120000 index 00000000..2ce6e9df --- /dev/null +++ b/.agents/skills/multi-tool-code-review @@ -0,0 +1 @@ +../../.claude/skills/multi-tool-code-review \ No newline at end of file diff --git a/.agents/skills/react-email b/.agents/skills/react-email new file mode 120000 index 00000000..bcd0cee2 --- /dev/null +++ b/.agents/skills/react-email @@ -0,0 +1 @@ +../../frontend/.agents/skills/react-email \ No newline at end of file diff --git a/.agents/skills/resend b/.agents/skills/resend new file mode 120000 index 00000000..23134afe --- /dev/null +++ b/.agents/skills/resend @@ -0,0 +1 @@ +../../frontend/.agents/skills/resend \ No newline at end of file diff --git a/.agents/skills/sentry-nextjs-sdk b/.agents/skills/sentry-nextjs-sdk new file mode 120000 index 00000000..80daaa6b --- /dev/null +++ b/.agents/skills/sentry-nextjs-sdk @@ -0,0 +1 @@ +../../frontend/.agents/skills/sentry-nextjs-sdk \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index d163da4c..4d6f62ef 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,8 +1,9 @@ # Adds a "Sponsor" button to the repository. -# Ko-fi (0% on one-time tips, best for a general-audience donate button). +# Ko-fi can charge 0% on one-time tips when Contributor mode is disabled. +# Payment processor fees still apply. ko_fi: disscount -# GitHub Sponsors (0% fees, reaches developers). Needs bank/Stripe + approval. -# Uncomment once the account is set up. +# Uncomment after the GitHub Sponsors profile is approved and publicly available. +# Personal-account sponsors have no GitHub fee. Organization sponsors can incur fees. # github: OffCrazyFreak 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/README.md b/README.md index b2bcba9f..df66ea03 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,9 @@ Big thanks to _[Cijene API](https://github.com/senko/cijene-api/)_ for providing ## Support -If Disscount saves you money or you would like to support its development, you can buy me a coffee. Every bit helps keep the project going and hosted. +If Disscount saves you money, you can support its hosting and further development on Ko-fi. Disscount stays free whether or not you choose to contribute. -[![Support me on Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/disscount) +[![Podrži Disscount na Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/disscount) ## License [![BUSL 1.1][busl-shield]][busl] 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 16ef980a..a88f39e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,5 +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/docs/SUPPORT.md b/docs/SUPPORT.md new file mode 100644 index 00000000..3590063f --- /dev/null +++ b/docs/SUPPORT.md @@ -0,0 +1,148 @@ +# Support and recognition + +Disscount is free to use. The support flow gives people a voluntary way to help cover hosting and continued development without creating an account, collecting payment data, or making any feature conditional on payment. + +Ko-fi is the only live payment destination today. GitHub Sponsors and public recognition are intentionally future work, because there is not yet an approved Sponsors profile or anyone to list. + +## Table of contents + +1. [Quick reference](#1-quick-reference) +2. [How the support flow works](#2-how-the-support-flow-works) +3. [Entry points](#3-entry-points) +4. [Automatic and manual work](#4-automatic-and-manual-work) +5. [Key files](#5-key-files) +6. [Payment platform and fees](#6-payment-platform-and-fees) +7. [GitHub repository funding](#7-github-repository-funding) +8. [Accessibility and external-link safety](#8-accessibility-and-external-link-safety) +9. [Verification checklist](#9-verification-checklist) +10. [Gotchas](#10-gotchas) +11. [Future improvements and TODOs](#11-future-improvements-and-todos) + +## 1. Quick reference + +| Thing | Current value | +| --------------------- | -------------------------------------------------- | +| Live payment platform | [Ko-fi](https://ko-fi.com/disscount) | +| Modal URL | `?modal=donate` | +| Public access | Everyone, including signed-out visitors | +| Sidebar location | `Pomoć i podrška`, after `Kontakt` | +| Footer location | Icon-only support control with an accessible label | +| Data collection | None in Disscount | +| Backend work | None | +| Environment variables | None | + +## 2. How the support flow works + +The sidebar and footer do not open a local piece of state. They link to `?modal=donate`, matching the rest of Disscount's URL-driven modal system. `ModalRouter` reads the URL, resolves `donate` as a public target, and mounts one `DonationModal` at the root of the app. + +The modal explains what a voluntary contribution supports, then opens Ko-fi in a separate tab. Disscount never handles a payment, stores payment information, or calls its backend during this flow. + +```mermaid +flowchart LR + Entry[Sidebar or footer control] --> Url[?modal=donate] + Url --> Router[ModalRouter] + Router --> Modal[DonationModal] + Modal --> External[Ko-fi checkout in a new tab] + Modal --> Close[Close, Escape, overlay, Back, or Ne sada] + Close --> Page[Original app page and focus trigger] +``` + +The modal is public by design. `PUBLIC_MODAL_NAMES` prevents the authentication gate from replacing it with a login prompt for a visitor who is not signed in. + +## 3. Entry points + +### Sidebar + +`supportNavItems` drives the `Pomoć i podrška` group in the app sidebar. The `donate` item comes after `Kontakt`, so it is discoverable without competing with shopping and account navigation. It deliberately has no PWA shortcut metadata because voluntary support is not a core app task. + +### Footer + +`FooterSupportIcons` maps the same `supportNavItems` data. When it sees a live item, it renders an icon-only button link with the item's label as its accessible name. That makes `Podrži Disscount` compact visually while remaining understandable to screen-reader and keyboard users. + +### Deep links + +`?modal=donate` can be opened on any route. The existing modal URL helper preserves unrelated query parameters and the hash when it adds or removes the modal parameter. A person can also dismiss the modal with their browser's Back button. + +## 4. Automatic and manual work + +| Task | Automatic | Manual | +| --------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------- | +| Open the support modal from sidebar or footer | Yes | No | +| Keep the modal public | Yes, through `PUBLIC_MODAL_NAMES` | No | +| Open the payment destination | Yes, in a separate tab | No | +| Receive and process a payment | No | Ko-fi, then its connected Stripe or PayPal account | +| Keep Ko-fi one-time-tip fees at 0% | No | Turn off Ko-fi Contributor mode and accept processor fees | +| Show a GitHub repository Sponsor button | Partly, through `FUNDING.yml` | Confirm the repository setting in GitHub after release | +| Enable GitHub Sponsors | No | Set up and approve the profile before uncommenting its funding entry | +| List supporters or contributors on the landing page | No | Obtain consent and curate the names or logos first | + +## 5. Key files + +| File | Role | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `frontend/src/constants/donation.ts` | Holds the live Ko-fi URL and the GitHub Sponsors follow-up TODO. | +| `frontend/src/constants/navigation.ts` | Declares the `donate` support-navigation item and the landing recognition TODO. | +| `frontend/src/lib/modal/modal-registry.ts` | Defines, parses, and publicly exposes the `donate` modal target. | +| `frontend/src/components/custom/donation/donation-modal.tsx` | Renders the support copy, Ko-fi link, dismiss action, and focus restoration. | +| `frontend/src/components/custom/modal-router/modal-router.tsx` | Mounts the modal once for the whole app. | +| `frontend/src/components/custom/sidebar/sidebar-support-nav.tsx` | Renders the sidebar support group from shared navigation data. | +| `frontend/src/components/custom/common/footer-support-icons.tsx` | Renders compact footer controls from the same navigation data. | +| `.github/FUNDING.yml` | Configures the repository funding destination shown by GitHub. | +| `README.md` | Gives repository visitors the public Ko-fi support link. | + +## 6. Payment platform and fees + +Ko-fi is the only linked payment option. The platform can charge 0% service fees on one-time tips when its optional Contributor mode is disabled. Ko-fi starts new creators with Contributor mode enabled, which applies a 5% fee to one-time tips, so check that setting before describing the support flow as zero-fee. Stripe or PayPal processing fees still apply in either mode. [Ko-fi fee details](https://help.ko-fi.com/hc/en-us/articles/360002506494-Does-Ko-fi-take-a-fee) + +Buy Me a Coffee is not linked because it charges a 5% platform fee per transaction, in addition to payment processing. Maintaining one live payment choice is also clearer for the people using Disscount. [Buy Me a Coffee fees](https://help.buymeacoffee.com/en/articles/8105744-how-to-calculate-charges-on-your-payment) + +## 7. GitHub repository funding + +The repository already has `.github/FUNDING.yml` with `ko_fi: disscount`. GitHub reads that file from the default branch to provide a Sponsor button and funding destination on the repository. + +The commented `github: OffCrazyFreak` line stays disabled until the GitHub Sponsors profile is approved and public. Before enabling it, confirm that the project meets GitHub's current eligibility requirements, complete the profile and payout setup, then verify the repository setting under Settings, General, Features, Sponsorships. GitHub lists Croatia as a supported payout region. Personal-account sponsorships have no GitHub fee, while organization sponsorships can incur a fee. [GitHub Sponsors overview](https://docs.github.com/en/sponsors/getting-started-with-github-sponsors/about-github-sponsors), [Sponsor button setup](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository) + +## 8. Accessibility and external-link safety + +The dialog uses the shared Radix-based `ModalShell`, which traps focus while it is open and offers an Escape key and close control. `DonationModal` captures the focused sidebar or footer trigger before opening and restores it after normal dismissal when that trigger is still in the document. A direct deep link has no prior trigger, so it closes without attempting to focus a stale element. + +The Ko-fi action is a real anchor with `target="_blank"` and `rel="noopener noreferrer"`. The first opens Ko-fi without replacing the current Disscount page. The second protects the original page from the new tab. + +The modal's support icon is decorative and hidden from the accessibility tree. The footer action is visually icon-only but receives the clear accessible label `Podrži Disscount` from the shared navigation data. + +## 9. Verification checklist + +- [ ] Open `?modal=donate` while signed out and confirm no login prompt appears. +- [ ] Open the sidebar and footer controls and confirm they show the same modal. +- [ ] Close the modal with `Ne sada`, the close control, Escape, the overlay, and browser Back. +- [ ] Confirm closing keeps unrelated query parameters and the URL hash. +- [ ] Use the keyboard to open and close the modal, then confirm focus returns to the original sidebar or footer control. +- [ ] Confirm the Ko-fi control opens `https://ko-fi.com/disscount` in a separate tab. +- [ ] Confirm the footer icon announces itself as `Podrži Disscount`. +- [ ] In GitHub, confirm the repository's Sponsor control leads to Ko-fi after the default branch is updated. + +## 10. Gotchas + +### The support item must remain public + +Do not remove `donate` from `PUBLIC_MODAL_NAMES`. A donation option that first asks a visitor to create an account defeats the purpose of a voluntary, low-friction contribution. + +### Do not make the footer control a raw external link + +The footer should open the same modal as the sidebar. It gives people a short explanation before sending them to a third-party payment service, while keeping all support copy in one place. + +### Do not report a universal 0% fee + +Ko-fi's service fee depends on Contributor mode and payment processors charge their own fees. The repository funding-file comments deliberately state these conditions rather than promising a universal 0% rate. + +### Do not list people without consent + +GitHub sponsorships can be private and Ko-fi supporter data is not a substitute for permission to publish a name or logo. Recognition should use an explicit opt-in and a curated list, never automatic scraping. + +## 11. Future improvements and TODOs + +- Set up GitHub Sponsors, verify eligibility and payout details, then uncomment the `github: OffCrazyFreak` funding entry and add its live destination to the app. +- Add the `Zajedno gradimo Disscount` landing section only after there are people to recognise. +- Keep that future section in two columns: `Doprinos razvoju` for code contributions and `Podrška projektu` for opted-in financial support. +- Decide on a consent and curation workflow before storing or displaying supporter names, logos, or contribution levels. +- Consider adding voluntary support analytics only after defining a privacy-preserving measurement goal. This first version deliberately sends no tracking event. diff --git a/frontend/src/app/(root)/page.tsx b/frontend/src/app/(root)/page.tsx index ce6348b4..5da9cad7 100644 --- a/frontend/src/app/(root)/page.tsx +++ b/frontend/src/app/(root)/page.tsx @@ -19,7 +19,7 @@ export const metadata: Metadata = { export default function Home() { return ( -
+
@@ -30,6 +30,7 @@ export default function Home() { + {/* TODO: Add the "Zajedno gradimo Disscount" contributor and opted-in supporter section here. */}
diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item-skeleton.tsx new file mode 100644 index 00000000..e21225b3 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item-skeleton.tsx @@ -0,0 +1,32 @@ +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface IShoppingListItemSkeletonProps { + showSeparator?: boolean; +} + +/** Mirrors ShoppingListItem: checkbox, name, then the amount and price cluster. */ +export default function ShoppingListItemSkeleton({ + showSeparator = true, +}: IShoppingListItemSkeletonProps) { + return ( + <> +
+
+
+ +
+
+
+ + {showSeparator && } + + ); +} diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items-skeleton.tsx new file mode 100644 index 00000000..a6eb8e36 --- /dev/null +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items-skeleton.tsx @@ -0,0 +1,29 @@ +import { Card } from "@/components/ui/card"; +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; +import SectionHeaderSkeleton from "@/components/custom/skeleton/section-header-skeleton"; +import ShoppingListItemSkeleton from "@/app/(user)/shopping-lists/[id]/components/items/shopping-list-item-skeleton"; + +interface IShoppingListItemsSkeletonProps { + rows?: number; +} + +/** + * Mirrors ShoppingListItems, open, since that is its default state. The heading + * carries a count, so it stays a placeholder rather than rendering "Proizvodi" + * and then reflowing when the number arrives. + */ +export default function ShoppingListItemsSkeleton({ + rows = 4, +}: IShoppingListItemsSkeletonProps) { + return ( +
+ + + + + + + +
+ ); +} diff --git a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx index d30c039f..551fbd62 100644 --- a/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx +++ b/frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx @@ -1,16 +1,23 @@ "use client"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, ListChecks } from "lucide-react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -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 LoginRequired from "@/components/custom/common/login-required"; import ShoppingListStoreSummary from "@/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list"; import ShoppingListHeader from "@/app/(user)/shopping-lists/[id]/components/shopping-list-header"; import ShoppingListItems from "@/app/(user)/shopping-lists/[id]/components/items/shopping-list-items"; import ShoppingListPriceHistory from "@/app/(user)/shopping-lists/[id]/components/shopping-list-price-history"; import ShoppingListInfoTable from "@/app/(user)/shopping-lists/[id]/components/shopping-list-info-table"; +import ShoppingListDetailSkeleton from "@/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton"; import LastSyncedLabel from "@/components/custom/offline/last-synced-label"; import { useShoppingListData } from "@/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data"; +import { + useRememberedRowCount, + useRememberRowCount, +} from "@/hooks/use-remembered-row-count"; interface IShoppingListDetailClientProps { listId: string; @@ -19,11 +26,11 @@ interface IShoppingListDetailClientProps { export default function ShoppingListDetailClient({ listId, }: IShoppingListDetailClientProps) { - // Use custom hooks for data and mutations const { shoppingList, isLoading, error, + requiresAuth, listUpdatedAt, cheapestStores, averagePrices, @@ -31,77 +38,84 @@ export default function ShoppingListDetailClient({ isPricesLoading, } = useShoppingListData(listId); - if (isLoading) { - return ( -
- -
- ); - } + // Reserves close to the real height on a cold load, instead of a generic four + // rows that then jumps once the list arrives. + const rowCountKey = `shoppingList:${listId}`; + const itemRows = useRememberedRowCount(rowCountKey, 4); + useRememberRowCount(rowCountKey, shoppingList?.items?.length); - if (error || !shoppingList) { + if (requiresAuth) { return ( -
-
-
-

Greška

-

Popis za kupnju nije pronađen ili se dogodila greška.

-
- - - - -
-
+ } + /> ); } 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-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..0ffa13c6 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( @@ -23,10 +24,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; @@ -35,14 +35,15 @@ export function useShoppingListItemMutations( if (updatedItem.amount < 1) return; // 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,7 +97,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 ažuriranju stavke. Pokušaj ponovno.", @@ -110,14 +114,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 +138,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.", 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..5f690bf5 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,15 @@ 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 { 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 +44,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"; 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/watchlist-client.tsx b/frontend/src/app/(user)/watchlist/components/watchlist-client.tsx index 2db8e3c8..73a520d4 100644 --- a/frontend/src/app/(user)/watchlist/components/watchlist-client.tsx +++ b/frontend/src/app/(user)/watchlist/components/watchlist-client.tsx @@ -10,6 +10,12 @@ import WatchlistHeader from "@/app/(user)/watchlist/components/watchlist-header" import WatchlistList from "@/app/(user)/watchlist/components/watchlist-list"; import WatchlistSuggestions from "@/app/(user)/watchlist/components/watchlist-suggestions"; import { useWatchlistData } from "@/app/(user)/watchlist/hooks/use-watchlist-data"; +import { + useRememberedRowCount, + useRememberRowCount, +} from "@/hooks/use-remembered-row-count"; + +const ROW_COUNT_KEY = "watchlist:me"; interface IWatchlistClientProps { query: string; @@ -19,7 +25,7 @@ export default function WatchlistClient({ query }: IWatchlistClientProps) { const pathname = usePathname(); const { - isAuthenticated, + requiresAuth, userLoading, watchlistLoading, hasWatchedProducts, @@ -32,7 +38,13 @@ export default function WatchlistClient({ query }: IWatchlistClientProps) { filteredSuggestionItems, } = useWatchlistData(query); - if (!userLoading && !isAuthenticated) { + const listLoading = userLoading || watchlistLoading; + + // Above the auth gate: hooks cannot sit after an early return. + const rows = useRememberedRowCount(ROW_COUNT_KEY, 3); + useRememberRowCount(ROW_COUNT_KEY, filteredItems.length); + + if (requiresAuth) { return ( }> @@ -71,6 +81,7 @@ export default function WatchlistClient({ query }: IWatchlistClientProps) { isLoading={listLoading} query={query} hasPinnedStores={hasPinnedStores} + skeletonRows={rows} /> {!listLoading && diff --git a/frontend/src/app/(user)/watchlist/components/watchlist-header.tsx b/frontend/src/app/(user)/watchlist/components/watchlist-header.tsx index 04e042a5..a91105f1 100644 --- a/frontend/src/app/(user)/watchlist/components/watchlist-header.tsx +++ b/frontend/src/app/(user)/watchlist/components/watchlist-header.tsx @@ -1,10 +1,12 @@ import LastSyncedLabel from "@/components/custom/offline/last-synced-label"; +import CountSkeleton from "@/components/custom/skeleton/count-skeleton"; import CreateDiscountedListButton from "@/app/(user)/watchlist/components/create-discounted-list-button"; import { IWatchlistItemWithProduct } from "@/app/(user)/watchlist/utils/watchlist-utils"; interface IWatchlistHeaderProps { query: string; itemCount: number; + /** False while the list is still loading: the count shows a pill, never 0. */ showCount: boolean; pricesUpdatedAt: number; discountedItems: IWatchlistItemWithProduct[]; @@ -24,8 +26,10 @@ export default function WatchlistHeader({

{query.length > 0 - ? `Rezultati pretrage za "${query}" (${itemCount})` - : `Praćeni proizvodi${showCount ? ` (${itemCount})` : ""}`} + ? `Rezultati pretrage za "${query}" ` + : "Praćeni proizvodi "} + + {showCount ? `(${itemCount})` : }

{pricesUpdatedAt > 0 && ( diff --git a/frontend/src/app/(user)/watchlist/components/watchlist-item.tsx b/frontend/src/app/(user)/watchlist/components/watchlist-item.tsx index b18583ac..edc12913 100644 --- a/frontend/src/app/(user)/watchlist/components/watchlist-item.tsx +++ b/frontend/src/app/(user)/watchlist/components/watchlist-item.tsx @@ -6,6 +6,7 @@ import WatchlistItemDiscountInfo from "@/app/(user)/watchlist/components/watchli import WatchlistActionButton from "@/app/(user)/watchlist/components/watchlist-action-button"; import WatchlistThresholdBadges from "@/app/(user)/watchlist/components/watchlist-threshold-badges"; import ProductCard from "@/components/custom/product/product-card"; +import ProductCardSkeleton from "@/components/custom/product/product-card-skeleton"; import { usePrimeProductNavigation } from "@/hooks/use-product-navigation"; interface IWatchlistItemProps { @@ -46,6 +47,45 @@ export default function WatchlistItem({ const primeProductNavigation = usePrimeProductNavigation(); + // The row knows its controls before it knows its product, so they stay live + // either side of the swap and only the identity block is a placeholder. + const trailing = ( + + ); + + const actions = ( +
+ + + {showThresholdBadges && ( + + )} +
+ ); + + if (isLoading) { + return ; + } + return ( primeProductNavigation(productApiId, product)} - trailing={ - - } - actions={ -
- - - {showThresholdBadges && ( - - )} -
- } + trailing={trailing} + actions={actions} /> ); } diff --git a/frontend/src/app/(user)/watchlist/components/watchlist-list.tsx b/frontend/src/app/(user)/watchlist/components/watchlist-list.tsx index 9c2be091..896f8ef0 100644 --- a/frontend/src/app/(user)/watchlist/components/watchlist-list.tsx +++ b/frontend/src/app/(user)/watchlist/components/watchlist-list.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { Search, Eye } from "lucide-react"; import { Button } from "@/components/ui/button"; import NoResults from "@/components/custom/common/no-results"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton"; +import ProductCardSkeleton from "@/components/custom/product/product-card-skeleton"; import WatchlistItem from "@/app/(user)/watchlist/components/watchlist-item"; import { sortWatchlistItemsByDiscount } from "@/app/(user)/watchlist/utils/watchlist-utils"; import { IWatchlistSearchItem } from "@/app/(user)/watchlist/typings/watchlist-types"; @@ -12,6 +13,8 @@ interface IWatchlistListProps { isLoading: boolean; query: string; hasPinnedStores: boolean; + /** Placeholder rows to draw, remembered from the previous visit. */ + skeletonRows?: number; } export default function WatchlistList({ @@ -19,12 +22,13 @@ export default function WatchlistList({ isLoading, query, hasPinnedStores, + skeletonRows = 3, }: IWatchlistListProps) { if (isLoading) { return ( -
- -
+ + + ); } diff --git a/frontend/src/app/(user)/watchlist/components/watchlist-skeleton.tsx b/frontend/src/app/(user)/watchlist/components/watchlist-skeleton.tsx new file mode 100644 index 00000000..477a8b2d --- /dev/null +++ b/frontend/src/app/(user)/watchlist/components/watchlist-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 ProductCardSkeleton from "@/components/custom/product/product-card-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface IWatchlistSkeletonProps { + rows?: number; +} + +/** The watchlist page: search bar, heading row, then the watched product cards. */ +export default function WatchlistSkeleton({ + rows = 3, +}: IWatchlistSkeletonProps) { + return ( + + + +
+ + +
+ + + + +
+ ); +} diff --git a/frontend/src/app/(user)/watchlist/components/watchlist-suggestions.tsx b/frontend/src/app/(user)/watchlist/components/watchlist-suggestions.tsx index 3e6878bb..8af2e9d6 100644 --- a/frontend/src/app/(user)/watchlist/components/watchlist-suggestions.tsx +++ b/frontend/src/app/(user)/watchlist/components/watchlist-suggestions.tsx @@ -1,6 +1,9 @@ import { useState } from "react"; import { ChevronDown } from "lucide-react"; -import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner"; +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 ProductCardSkeleton from "@/components/custom/product/product-card-skeleton"; import WatchlistItem from "@/app/(user)/watchlist/components/watchlist-item"; import { cn } from "@/lib/utils"; import { @@ -28,7 +31,8 @@ export default function WatchlistSuggestions({ +
+ + ) : 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-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 + +