From 760d54cf270c25ebdfaf50009e84df74c9681410 Mon Sep 17 00:00:00 2001
From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:46:18 +0200
Subject: [PATCH 01/17] feat(a11y): Add skeleton kit and async section
primitives
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Changes:
- Add components/custom/skeleton/ primitives: SkeletonRegion, RepeatSkeleton,
CountSkeleton, TextSkeleton, SectionHeaderSkeleton, TableSkeleton,
ChartSkeleton and PageShellSkeleton
- Add AsyncSection, which fixes the state order as pending, error, empty, data
- Add ErrorState and toUserMessage, one entry point for turning either error
shape into Croatian copy
- Add useDataPending, plus the remembered row-count store and its hooks
- Disable animate-pulse under prefers-reduced-motion
The building blocks for the loading sweep, added first so later batches only
compose them. AsyncSection makes the state order structural because several
pages checked error or emptiness before loading had finished, which is what
painted "Greška" and "(0)" on a cold cache.
Notes:
- animate-pulse was the one animation in the app that no reduced-motion rule
covered, so skeletons would have kept pulsing for readers who asked for less.
- CountSkeleton renders a span rather than the shared Skeleton div, because a
heading only permits phrasing content.
---
frontend/src/app/globals.css | 5 ++
.../custom/common/async-section.tsx | 47 +++++++++++++
.../components/custom/common/error-state.tsx | 56 ++++++++++++++++
.../custom/skeleton/chart-skeleton.tsx | 33 +++++++++
.../custom/skeleton/count-skeleton.tsx | 31 +++++++++
.../custom/skeleton/page-shell-skeleton.tsx | 22 ++++++
.../custom/skeleton/repeat-skeleton.tsx | 24 +++++++
.../skeleton/section-header-skeleton.tsx | 48 +++++++++++++
.../custom/skeleton/skeleton-region.tsx | 36 ++++++++++
.../custom/skeleton/table-skeleton.tsx | 34 ++++++++++
.../custom/skeleton/text-skeleton.tsx | 39 +++++++++++
.../src/hooks/use-remembered-row-count.ts | 38 +++++++++++
frontend/src/lib/api/error-message.ts | 34 ++++++++++
frontend/src/lib/query/use-data-pending.ts | 25 +++++++
frontend/src/lib/skeleton/row-count-store.ts | 67 +++++++++++++++++++
15 files changed, 539 insertions(+)
create mode 100644 frontend/src/components/custom/common/async-section.tsx
create mode 100644 frontend/src/components/custom/common/error-state.tsx
create mode 100644 frontend/src/components/custom/skeleton/chart-skeleton.tsx
create mode 100644 frontend/src/components/custom/skeleton/count-skeleton.tsx
create mode 100644 frontend/src/components/custom/skeleton/page-shell-skeleton.tsx
create mode 100644 frontend/src/components/custom/skeleton/repeat-skeleton.tsx
create mode 100644 frontend/src/components/custom/skeleton/section-header-skeleton.tsx
create mode 100644 frontend/src/components/custom/skeleton/skeleton-region.tsx
create mode 100644 frontend/src/components/custom/skeleton/table-skeleton.tsx
create mode 100644 frontend/src/components/custom/skeleton/text-skeleton.tsx
create mode 100644 frontend/src/hooks/use-remembered-row-count.ts
create mode 100644 frontend/src/lib/api/error-message.ts
create mode 100644 frontend/src/lib/query/use-data-pending.ts
create mode 100644 frontend/src/lib/skeleton/row-count-store.ts
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index aec7eb67..f2733f8e 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -568,6 +568,11 @@
}
@media (prefers-reduced-motion: reduce) {
+ /* Skeletons use Tailwind's animate-pulse, which no rule above covers. They
+ stay visible, just static, which is what reduced motion asks for. */
+ [data-slot="skeleton"] {
+ animation: none;
+ }
.spinner_9y7u {
animation: none;
}
diff --git a/frontend/src/components/custom/common/async-section.tsx b/frontend/src/components/custom/common/async-section.tsx
new file mode 100644
index 00000000..352f5b6a
--- /dev/null
+++ b/frontend/src/components/custom/common/async-section.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from "react";
+
+import ErrorState from "@/components/custom/common/error-state";
+
+interface IAsyncSectionProps {
+ /** From useAuthedQuery's `pending`, or useDataPending. Never a query's isLoading. */
+ pending: boolean;
+ error?: unknown;
+ isEmpty?: boolean;
+ /** Shown while pending. Should mirror `children` closely enough that nothing shifts. */
+ skeleton: ReactNode;
+ /** Defaults to the shared ErrorState. Override for a retry or a way back. */
+ errorState?: ReactNode;
+ /** Shown when the fetch succeeded with nothing in it. Falls through to children if unset. */
+ empty?: ReactNode;
+ children: ReactNode;
+}
+
+/**
+ * Picks which of the four states a data-backed section renders, in a fixed
+ * order: pending, then error, then empty, then the data.
+ *
+ * The order being structural is the point. Every page 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: a missing session replaces the whole page rather
+ * than one section, so callers return LoginRequired early off `requiresAuth`.
+ */
+export default function AsyncSection({
+ pending,
+ error,
+ isEmpty = false,
+ skeleton,
+ errorState,
+ empty,
+ children,
+}: IAsyncSectionProps) {
+ if (pending) return <>{skeleton}>;
+
+ if (error) return <>{errorState ?? }>;
+
+ if (isEmpty && empty) return <>{empty}>;
+
+ return <>{children}>;
+}
diff --git a/frontend/src/components/custom/common/error-state.tsx b/frontend/src/components/custom/common/error-state.tsx
new file mode 100644
index 00000000..a6099df4
--- /dev/null
+++ b/frontend/src/components/custom/common/error-state.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { ReactNode } from "react";
+import { TriangleAlert } from "lucide-react";
+
+import { toUserMessage } from "@/lib/api/error-message";
+import { cn } from "@/lib/utils";
+
+interface IErrorStateProps {
+ /** The thrown value. Turned into copy by toUserMessage, never shown raw. */
+ error?: unknown;
+ title?: string;
+ /** Shown when the error carries no message of its own. */
+ fallbackMessage?: string;
+ icon?: ReactNode;
+ /** Retry, or a way back. Rendered under the message. */
+ action?: ReactNode;
+ className?: string;
+}
+
+/**
+ * The failed branch of every async section, so a failure reads the same
+ * everywhere instead of each page inventing its own red text.
+ */
+export default function ErrorState({
+ error,
+ title = "Nešto je pošlo po zlu",
+ fallbackMessage = "Podatke trenutačno nije moguće učitati. Pokušaj ponovno.",
+ icon,
+ action,
+ className,
+}: IErrorStateProps) {
+ return (
+
+ {icon ?? (
+
+ )}
+
+
+ {title}
+
+
+
+ {toUserMessage(error, fallbackMessage)}
+
+
+ {action &&
{action}
}
+
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/chart-skeleton.tsx b/frontend/src/components/custom/skeleton/chart-skeleton.tsx
new file mode 100644
index 00000000..61860a36
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/chart-skeleton.tsx
@@ -0,0 +1,33 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+interface IChartSkeletonProps {
+ /** Match the real chart's height so the card does not resize under the reader. */
+ className?: string;
+}
+
+/**
+ * A price chart's footprint: y-axis labels, plot area, x-axis labels. Bars, not
+ * a fake plot, since inventing a shape would read as real data for a moment.
+ */
+export default function ChartSkeleton({ className }: IChartSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/count-skeleton.tsx b/frontend/src/components/custom/skeleton/count-skeleton.tsx
new file mode 100644
index 00000000..83d18322
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/count-skeleton.tsx
@@ -0,0 +1,31 @@
+import { cn } from "@/lib/utils";
+
+interface ICountSkeletonProps {
+ className?: string;
+}
+
+/**
+ * Stands in for a "(N)" inside a heading, so a count never paints as 0 first.
+ *
+ * Sized in em against the heading's own font and one line box tall, so the real
+ * number swaps in without nudging the words either side of it.
+ *
+ * A span, not the shared Skeleton component, because that renders a div and a
+ * heading only permits phrasing content. It carries the same `data-slot`, so the
+ * reduced-motion rule in globals.css still switches its animation off.
+ *
+ * Hidden from assistive tech on purpose: the heading reads correctly without a
+ * number, whereas announcing a placeholder count would announce something false.
+ */
+export default function CountSkeleton({ className }: ICountSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/page-shell-skeleton.tsx b/frontend/src/components/custom/skeleton/page-shell-skeleton.tsx
new file mode 100644
index 00000000..f712fd35
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/page-shell-skeleton.tsx
@@ -0,0 +1,22 @@
+import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton";
+import SkeletonRegion from "@/components/custom/skeleton/skeleton-region";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * A neutral page shape: a title, then a few blocks.
+ *
+ * The fallback for routes that have no skeleton of their own, and the last
+ * resort in app/loading.tsx. Prefer a route's own skeleton wherever one exists,
+ * since this one only avoids a collapse, it does not prevent a shift.
+ */
+export default function PageShellSkeleton() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/repeat-skeleton.tsx b/frontend/src/components/custom/skeleton/repeat-skeleton.tsx
new file mode 100644
index 00000000..2e5625ac
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/repeat-skeleton.tsx
@@ -0,0 +1,24 @@
+import { Fragment, type ReactNode } from "react";
+
+interface IRepeatSkeletonProps {
+ count: number;
+ /** The single placeholder row or card to repeat. */
+ children: ReactNode;
+ /** Goes on the wrapper, so callers keep their own spacing between rows. */
+ className?: string;
+}
+
+/** Saves every list skeleton from writing out its own Array.from(...).map. */
+export default function RepeatSkeleton({
+ count,
+ children,
+ className,
+}: IRepeatSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/section-header-skeleton.tsx b/frontend/src/components/custom/skeleton/section-header-skeleton.tsx
new file mode 100644
index 00000000..67e80d96
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/section-header-skeleton.tsx
@@ -0,0 +1,48 @@
+import { ChevronDown } from "lucide-react";
+
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface ISectionHeaderSkeletonProps {
+ /**
+ * Pass it when the heading is fixed copy: static text is not a placeholder and
+ * should just render. Omit it when the heading carries data, such as a count.
+ */
+ title?: string;
+ titleWidth?: string;
+}
+
+/**
+ * The collapsed header row of CollapsibleSection, and of the items section,
+ * which builds the same row inline. Geometry is copied from
+ * components/custom/common/collapsible-section.tsx: keep the two in step.
+ */
+export default function SectionHeaderSkeleton({
+ title,
+ titleWidth = "12rem",
+}: ISectionHeaderSkeletonProps) {
+ return (
+
+
+ {title ? (
+
{title}
+ ) : (
+
+
+
+ )}
+
+
+
+
+
Prikaži
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/skeleton-region.tsx b/frontend/src/components/custom/skeleton/skeleton-region.tsx
new file mode 100644
index 00000000..af07d6f0
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/skeleton-region.tsx
@@ -0,0 +1,36 @@
+import type { ReactNode } from "react";
+
+interface ISkeletonRegionProps {
+ /** What is loading, announced once. Croatian, second person. */
+ label?: string;
+ children: ReactNode;
+ className?: string;
+}
+
+/**
+ * The a11y contract for every skeleton, in one place so no individual bar has to
+ * repeat it. A screen reader hears the label once instead of walking a pile of
+ * empty boxes.
+ *
+ * The label sits in its own sr-only element rather than wrapping the bars, so
+ * the element carrying `className` is still the direct layout parent. Nesting a
+ * div here would break any caller whose children must stay direct grid or flex
+ * items, which is exactly the shift these skeletons exist to prevent.
+ */
+export default function SkeletonRegion({
+ label = "Učitavanje",
+ children,
+ className,
+}: ISkeletonRegionProps) {
+ return (
+ <>
+
+ {label}
+
+
+
+ {children}
+
+ >
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/table-skeleton.tsx b/frontend/src/components/custom/skeleton/table-skeleton.tsx
new file mode 100644
index 00000000..89e03b22
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/table-skeleton.tsx
@@ -0,0 +1,34 @@
+import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface ITableSkeletonProps {
+ rows?: number;
+ columns?: number;
+}
+
+/**
+ * A generic rows-and-columns placeholder, for the admin tables where the column
+ * set is uniform enough that a bespoke mirror would say nothing extra.
+ */
+export default function TableSkeleton({
+ rows = 6,
+ columns = 4,
+}: ITableSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/custom/skeleton/text-skeleton.tsx b/frontend/src/components/custom/skeleton/text-skeleton.tsx
new file mode 100644
index 00000000..8754303b
--- /dev/null
+++ b/frontend/src/components/custom/skeleton/text-skeleton.tsx
@@ -0,0 +1,39 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+interface ITextSkeletonProps {
+ lines?: number;
+ /** The last line normally stops short, the way wrapped prose does. */
+ lastLineWidth?: string;
+ className?: string;
+}
+
+/**
+ * Placeholder lines for a block of text.
+ *
+ * Each bar is `h-[1lh]`, one line box of whatever font the caller's wrapper
+ * sets, so the placeholder is exactly as tall as the text replacing it. Do not
+ * swap this for a fixed `h-4`: with `--spacing: 0.2rem` in this project that is
+ * 12.8px, and it will not match any of our text sizes.
+ */
+export default function TextSkeleton({
+ lines = 3,
+ lastLineWidth = "60%",
+ className,
+}: ITextSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/hooks/use-remembered-row-count.ts b/frontend/src/hooks/use-remembered-row-count.ts
new file mode 100644
index 00000000..b0111afd
--- /dev/null
+++ b/frontend/src/hooks/use-remembered-row-count.ts
@@ -0,0 +1,38 @@
+"use client";
+
+import { useEffect, useSyncExternalStore } from "react";
+
+import {
+ getRowCount,
+ setRowCount,
+ subscribeToRowCounts,
+} from "@/lib/skeleton/row-count-store";
+
+/**
+ * How many placeholder rows a list skeleton should draw, based on how long that
+ * list was last time.
+ *
+ * The persisted React Query cache is IndexedDB, which resolves asynchronously,
+ * so it cannot answer this on the first paint. A small synchronous localStorage
+ * note can, which is what makes the skeleton reserve close to the real height
+ * instead of a generic three rows.
+ *
+ * The server snapshot is the fallback, so `loading.tsx` and the first client
+ * render agree and hydration stays quiet.
+ */
+export function useRememberedRowCount(key: string, fallback: number): number {
+ return useSyncExternalStore(
+ subscribeToRowCounts,
+ () => getRowCount(key) ?? fallback,
+ () => fallback,
+ );
+}
+
+/** Records the real length once it is known, for the next cold load. */
+export function useRememberRowCount(key: string, count: number | undefined) {
+ useEffect(() => {
+ if (count === undefined || count <= 0) return;
+
+ setRowCount(key, count);
+ }, [key, count]);
+}
diff --git a/frontend/src/lib/api/error-message.ts b/frontend/src/lib/api/error-message.ts
new file mode 100644
index 00000000..547ced78
--- /dev/null
+++ b/frontend/src/lib/api/error-message.ts
@@ -0,0 +1,34 @@
+import { CijeneApiError } from "@/lib/cijene-api/errors";
+import { parseProblem } from "@/lib/api/problem-details";
+
+// The upstream price API speaks English and leaks implementation detail, so its
+// statuses are mapped rather than shown. Anything unmapped falls back to the
+// caller's own copy.
+const CIJENE_STATUS_MESSAGES: Record = {
+ 0: "Nema veze s internetom. Provjeri vezu i pokušaj ponovno.",
+ 404: "Podaci nisu pronađeni.",
+ 429: "Previše zahtjeva odjednom. Pričekaj trenutak i pokušaj ponovno.",
+ 500: "Izvor podataka trenutačno ne radi. Pokušaj kasnije.",
+ 502: "Izvor podataka trenutačno nije dostupan. Pokušaj kasnije.",
+ 503: "Izvor podataka trenutačno nije dostupan. Pokušaj kasnije.",
+ 504: "Izvoru podataka je isteklo vrijeme. Pokušaj kasnije.",
+};
+
+/**
+ * The one place an error becomes something a person reads.
+ *
+ * Two error shapes reach the UI: RFC 9457 Problem Details from our backend, and
+ * CijeneApiError from the upstream price API. Callers should not have to know
+ * which one they got, so both collapse here and everything else takes `fallback`.
+ */
+export function toUserMessage(error: unknown, fallback: string): string {
+ if (error instanceof CijeneApiError) {
+ return CIJENE_STATUS_MESSAGES[error.status] ?? fallback;
+ }
+
+ const problem = parseProblem(error);
+
+ // `title` is often the bare status phrase ("Bad Request"), so it is a last
+ // resort behind `detail`, which the backend writes for humans.
+ return problem?.detail || problem?.title || fallback;
+}
diff --git a/frontend/src/lib/query/use-data-pending.ts b/frontend/src/lib/query/use-data-pending.ts
new file mode 100644
index 00000000..1dfe87da
--- /dev/null
+++ b/frontend/src/lib/query/use-data-pending.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { useIsRestoring } from "@tanstack/react-query";
+
+/**
+ * True while there is still nothing real to render. Reach for this instead of a
+ * query's `isLoading`.
+ *
+ * `isLoading` is wrong under PersistQueryClientProvider. It parks every query at
+ * fetchStatus "idle" while restoring the IndexedDB cache, and v5 derives
+ * `isLoading` as `isPending && isFetching`, so throughout that window it reads
+ * false with `data` still undefined. A guard written on it falls straight
+ * through to the next branch, which is what painted "Greška" on the detail
+ * pages and "(0)" in the index headings for a frame on every reload.
+ *
+ * Pass each query's `isPending`, plus any non-query gate the render waits on
+ * (auth still resolving, a URL param not parsed yet). Note that a query with
+ * `enabled: false` reports `isPending` forever, so gate that flag on whatever
+ * disables it, or use useAuthedQuery, which already does.
+ */
+export function useDataPending(...flags: boolean[]): boolean {
+ const isRestoring = useIsRestoring();
+
+ return isRestoring || flags.some(Boolean);
+}
diff --git a/frontend/src/lib/skeleton/row-count-store.ts b/frontend/src/lib/skeleton/row-count-store.ts
new file mode 100644
index 00000000..cc719431
--- /dev/null
+++ b/frontend/src/lib/skeleton/row-count-store.ts
@@ -0,0 +1,67 @@
+const STORAGE_KEY = "disscount-skeleton-rows";
+
+// Enough rows to look like the real list, few enough that a long list does not
+// paint a wall of grey.
+const MIN_ROWS = 1;
+const MAX_ROWS = 8;
+
+type RowCounts = Record;
+
+const listeners = new Set<() => void>();
+
+// Reading localStorage on every useSyncExternalStore getSnapshot would be both
+// slow and unstable (a fresh object each call loops React), so the parsed map is
+// cached and only rebuilt on write.
+let cache: RowCounts | null = null;
+
+function read(): RowCounts {
+ if (cache) return cache;
+
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ cache = raw ? (JSON.parse(raw) as RowCounts) : {};
+ } catch {
+ // Private mode, quota, or a hand-edited value. A wrong row count is not
+ // worth throwing over.
+ cache = {};
+ }
+
+ return cache;
+}
+
+export function clampRowCount(count: number): number {
+ return Math.min(MAX_ROWS, Math.max(MIN_ROWS, Math.round(count)));
+}
+
+export function getRowCount(key: string): number | undefined {
+ if (typeof window === "undefined") return undefined;
+
+ const stored = read()[key];
+
+ return typeof stored === "number" ? clampRowCount(stored) : undefined;
+}
+
+export function setRowCount(key: string, count: number): void {
+ if (typeof window === "undefined") return;
+
+ const clamped = clampRowCount(count);
+ if (read()[key] === clamped) return;
+
+ cache = { ...read(), [key]: clamped };
+
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
+ } catch {
+ // Keep the in-memory value; it still helps for the rest of the session.
+ }
+
+ for (const listener of listeners) listener();
+}
+
+export function subscribeToRowCounts(listener: () => void): () => void {
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ };
+}
From 0f8845e6b31c0f875dddd0df8cdb5caf0c68f9fd Mon Sep 17 00:00:00 2001
From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:46:34 +0200
Subject: [PATCH 02/17] feat(a11y): Add colocated skeletons for products, lists
and watchlist
Changes:
- Add a sibling -skeleton.tsx for every content component on
the shopping list, product, watchlist and statistics surfaces
- Export PRODUCT_SUMMARY_ROW_CLASSES and PRODUCT_SUMMARY_IMAGE_CLASSES from
product-summary, and share them with its skeleton
- Drop the isLoading prop from ProductSummary and ProductCard, so the caller
picks the component instead of the component branching internally
Skeletons are server-renderable and take no hooks, so loading.tsx and a client
pending branch can share one file. Sharing the wrapper classes rather than
re-typing them is what keeps a row and its placeholder the same height.
Notes:
- ProductCardSkeleton still accepts trailing and actions, because a watchlist
row knows its controls before it knows its product and they stay live.
- Bars are h-[1lh] inside a wrapper carrying the real text's font classes. This
project sets --spacing to 0.2rem, so a fixed h-4 is 12.8px and matches nothing.
---
.../items/shopping-list-item-skeleton.tsx | 32 ++++++++
.../items/shopping-list-items-skeleton.tsx | 29 +++++++
.../shopping-list-detail-skeleton.tsx | 48 ++++++++++++
.../shopping-list-header-skeleton.tsx | 38 +++++++++
.../shopping-list-info-table-skeleton.tsx | 49 ++++++++++++
.../shopping-list-store-card-skeleton.tsx | 30 +++++++
.../shopping-list-stores-list-skeleton.tsx | 21 +++++
.../stores/shopping-list-stores-skeleton.tsx | 26 +++++++
.../shopping-list-item-skeleton.tsx | 41 ++++++++++
.../components/shopping-lists-skeleton.tsx | 29 +++++++
.../watchlist/components/watchlist-item.tsx | 74 ++++++++++--------
.../components/watchlist-skeleton.tsx | 29 +++++++
.../product-chains-list-skeleton.tsx | 26 +++++++
.../product-chains-section-skeleton.tsx | 18 +++++
.../components/product-detail-skeleton.tsx | 29 +++++++
.../store-item/store-item-skeleton.tsx | 30 +++++++
.../forms/product-actions-sheet.tsx | 26 ++++---
.../product-info-display-skeleton.tsx | 78 +++++++++++++++++++
.../products/components/products-skeleton.tsx | 31 ++++++++
.../components/store-item-skeleton.tsx | 36 +++++++++
.../components/header-actions-skeleton.tsx | 22 ++++++
.../components/notification-item-skeleton.tsx | 22 ++++++
.../custom/product/product-card-skeleton.tsx | 34 ++++++++
.../custom/product/product-card.tsx | 3 -
.../custom/product/product-info-skeleton.tsx | 27 +++++++
.../product/product-summary-skeleton.tsx | 52 +++++++++++++
.../custom/product/product-summary.tsx | 32 ++++----
27 files changed, 848 insertions(+), 64 deletions(-)
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-info-table-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-store-card-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/components/shopping-list-item-skeleton.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/components/shopping-lists-skeleton.tsx
create mode 100644 frontend/src/app/(user)/watchlist/components/watchlist-skeleton.tsx
create mode 100644 frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx
create mode 100644 frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx
create mode 100644 frontend/src/app/products/[id]/components/product-detail-skeleton.tsx
create mode 100644 frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx
create mode 100644 frontend/src/app/products/components/product-info-display-skeleton.tsx
create mode 100644 frontend/src/app/products/components/products-skeleton.tsx
create mode 100644 frontend/src/app/statistics/components/store-item-skeleton.tsx
create mode 100644 frontend/src/components/custom/header/components/header-actions-skeleton.tsx
create mode 100644 frontend/src/components/custom/notifications/components/notification-item-skeleton.tsx
create mode 100644 frontend/src/components/custom/product/product-card-skeleton.tsx
create mode 100644 frontend/src/components/custom/product/product-info-skeleton.tsx
create mode 100644 frontend/src/components/custom/product/product-summary-skeleton.tsx
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-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/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-list-skeleton.tsx b/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list-skeleton.tsx
new file mode 100644
index 00000000..652c66fd
--- /dev/null
+++ b/frontend/src/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-list-skeleton.tsx
@@ -0,0 +1,21 @@
+import SectionHeaderSkeleton from "@/components/custom/skeleton/section-header-skeleton";
+import ShoppingListStoresSkeleton from "@/app/(user)/shopping-lists/[id]/components/stores/shopping-list-stores-skeleton";
+
+interface IShoppingListStoresListSkeletonProps {
+ chains?: number;
+}
+
+/**
+ * Mirrors ShoppingListStoreSummary, open, since that is its stored default. The
+ * title is fixed copy so it renders for real.
+ */
+export default function ShoppingListStoresListSkeleton({
+ chains,
+}: IShoppingListStoresListSkeletonProps) {
+ 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/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-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)/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 = (
+
- }
+ trailing={trailing}
+ actions={actions}
/>
);
}
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/products/[id]/components/product-chains-list-skeleton.tsx b/frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx
new file mode 100644
index 00000000..fe168eb3
--- /dev/null
+++ b/frontend/src/app/products/[id]/components/product-chains-list-skeleton.tsx
@@ -0,0 +1,26 @@
+import RepeatSkeleton from "@/components/custom/skeleton/repeat-skeleton";
+import StoreItemSkeleton from "@/app/products/[id]/components/store-item/store-item-skeleton";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface IProductChainsListSkeletonProps {
+ chains?: number;
+}
+
+/**
+ * The body of ProductChainsSection, without its header. Kept separate because
+ * the live section is already inside a CollapsibleSection that draws the header
+ * itself, while the page skeleton has to draw both.
+ */
+export default function ProductChainsListSkeleton({
+ chains = 4,
+}: IProductChainsListSkeletonProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx b/frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx
new file mode 100644
index 00000000..04e1864a
--- /dev/null
+++ b/frontend/src/app/products/[id]/components/product-chains-section-skeleton.tsx
@@ -0,0 +1,18 @@
+import SectionHeaderSkeleton from "@/components/custom/skeleton/section-header-skeleton";
+import ProductChainsListSkeleton from "@/app/products/[id]/components/product-chains-list-skeleton";
+
+interface IProductChainsSectionSkeletonProps {
+ chains?: number;
+}
+
+/** Mirrors ProductChainsSection, open, since that is its stored default. */
+export default function ProductChainsSectionSkeleton({
+ chains,
+}: IProductChainsSectionSkeletonProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/products/[id]/components/product-detail-skeleton.tsx b/frontend/src/app/products/[id]/components/product-detail-skeleton.tsx
new file mode 100644
index 00000000..824061b3
--- /dev/null
+++ b/frontend/src/app/products/[id]/components/product-detail-skeleton.tsx
@@ -0,0 +1,29 @@
+import SkeletonRegion from "@/components/custom/skeleton/skeleton-region";
+import SectionHeaderSkeleton from "@/components/custom/skeleton/section-header-skeleton";
+import ProductInfoDisplaySkeleton from "@/app/products/components/product-info-display-skeleton";
+import ProductChainsSectionSkeleton from "@/app/products/[id]/components/product-chains-section-skeleton";
+
+/**
+ * The whole product detail page, in the same `space-y-4` rhythm as
+ * product-detail-client. Shared by loading.tsx and the client's pending branch,
+ * so it takes no hooks and no context.
+ */
+export default function ProductDetailSkeleton() {
+ return (
+
+
+
+
+
+ {/* Price history is stored closed by default, so its header is the whole
+ footprint until someone opens it. */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx b/frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx
new file mode 100644
index 00000000..084ddc80
--- /dev/null
+++ b/frontend/src/app/products/[id]/components/store-item/store-item-skeleton.tsx
@@ -0,0 +1,30 @@
+import { ChevronDown } from "lucide-react";
+
+import { Card, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/** Mirrors the collapsed header of StoreItem: chain logo, name, price row. */
+export default function StoreItemSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/products/components/forms/product-actions-sheet.tsx b/frontend/src/app/products/components/forms/product-actions-sheet.tsx
index 30b41349..31823fea 100644
--- a/frontend/src/app/products/components/forms/product-actions-sheet.tsx
+++ b/frontend/src/app/products/components/forms/product-actions-sheet.tsx
@@ -5,6 +5,7 @@ import { getMostFrequentCategory } from "@/app/products/utils/product-utils";
import ProductUnitPriceDetails from "@/app/products/components/product-item/product-price";
import ProductQuickActions from "@/components/custom/product/product-quick-actions";
import ProductSummary from "@/components/custom/product/product-summary";
+import ProductSummarySkeleton from "@/components/custom/product/product-summary-skeleton";
import { closeModalUrl } from "@/lib/modal/modal-navigation";
interface IProductActionsSheetProps {
@@ -30,16 +31,21 @@ export default function ProductActionsSheet({
product={product}
isLoading={isLoading}
summary={
- : undefined
- }
- className="px-0 @md:px-0"
- />
+ isLoading ? (
+
+ ) : (
+
+ ) : undefined
+ }
+ className="px-0 @md:px-0"
+ />
+ )
}
open={open}
onOpenChange={(next) => !next && closeModalUrl()}
diff --git a/frontend/src/app/products/components/product-info-display-skeleton.tsx b/frontend/src/app/products/components/product-info-display-skeleton.tsx
new file mode 100644
index 00000000..5e8d7864
--- /dev/null
+++ b/frontend/src/app/products/components/product-info-display-skeleton.tsx
@@ -0,0 +1,78 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+const TABLE_LABELS = [
+ ["Proizvođač:", "Bar kod:"],
+ ["Količina:", "Cijene:"],
+ ["Jedinična cijena:", "Kategorija:"],
+] as const;
+
+/**
+ * Mirrors ProductInfoDisplay: the name row plus the info table beneath it. The
+ * table's labels are fixed copy and render for real, so only the values move.
+ */
+export default function ProductInfoDisplaySkeleton() {
+ return (
+
+ );
+}
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/statistics/components/store-item-skeleton.tsx b/frontend/src/app/statistics/components/store-item-skeleton.tsx
new file mode 100644
index 00000000..b7425c82
--- /dev/null
+++ b/frontend/src/app/statistics/components/store-item-skeleton.tsx
@@ -0,0 +1,36 @@
+import { ChevronDown } from "lucide-react";
+
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface IStatisticsStoreItemSkeletonProps {
+ isLast?: boolean;
+}
+
+/** Mirrors the collapsed row of the statistics StoreItem. */
+export default function StatisticsStoreItemSkeleton({
+ isLast = false,
+}: IStatisticsStoreItemSkeletonProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isLast && }
+
+ );
+}
diff --git a/frontend/src/components/custom/header/components/header-actions-skeleton.tsx b/frontend/src/components/custom/header/components/header-actions-skeleton.tsx
new file mode 100644
index 00000000..ae4a13e7
--- /dev/null
+++ b/frontend/src/components/custom/header/components/header-actions-skeleton.tsx
@@ -0,0 +1,22 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+interface IHeaderActionsSkeletonProps {
+ isMobile: boolean;
+}
+
+/**
+ * Stands in for whichever of the two shapes HeaderActions settles on, the sign
+ * in button or the bell plus avatar, while the session initialises. Both are
+ * pill-shaped and about this wide, so the header does not reflow either way.
+ */
+export default function HeaderActionsSkeleton({
+ isMobile,
+}: IHeaderActionsSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/custom/notifications/components/notification-item-skeleton.tsx b/frontend/src/components/custom/notifications/components/notification-item-skeleton.tsx
new file mode 100644
index 00000000..058b26e6
--- /dev/null
+++ b/frontend/src/components/custom/notifications/components/notification-item-skeleton.tsx
@@ -0,0 +1,22 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+/** Mirrors NotificationItem: product name, brand, then its discounted stores. */
+export default function NotificationItemSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/custom/product/product-card-skeleton.tsx b/frontend/src/components/custom/product/product-card-skeleton.tsx
new file mode 100644
index 00000000..1a26833d
--- /dev/null
+++ b/frontend/src/components/custom/product/product-card-skeleton.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+
+import { Card } from "@/components/ui/card";
+import ProductSummarySkeleton from "@/components/custom/product/product-summary-skeleton";
+import { cn } from "@/lib/utils";
+
+interface IProductCardSkeletonProps {
+ withImage?: boolean;
+ /** Live controls, for a row that knows its actions before it knows its product. */
+ trailing?: ReactNode;
+ actions?: ReactNode;
+ className?: string;
+}
+
+/**
+ * The loading shape of ProductCard. Same Card wrapper, minus the overlay link,
+ * since there is nothing to navigate to yet.
+ */
+export default function ProductCardSkeleton({
+ withImage = false,
+ trailing,
+ actions,
+ className,
+}: IProductCardSkeletonProps) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/custom/product/product-card.tsx b/frontend/src/components/custom/product/product-card.tsx
index b1ed0083..992c3a46 100644
--- a/frontend/src/components/custom/product/product-card.tsx
+++ b/frontend/src/components/custom/product/product-card.tsx
@@ -16,7 +16,6 @@ interface IProductCardProps {
imageUrl?: string | null;
/** Returning false cancels navigation, for example after a long press */
onNavigate?: (viaKeyboard: boolean) => boolean | void;
- isLoading?: boolean;
trailing?: ReactNode;
actions?: ReactNode;
className?: string;
@@ -49,7 +48,6 @@ export default function ProductCard({
quantity,
imageUrl,
onNavigate,
- isLoading = false,
trailing,
actions,
className,
@@ -85,7 +83,6 @@ export default function ProductCard({
category={category}
quantity={quantity}
imageUrl={imageUrl}
- isLoading={isLoading}
trailing={trailing}
actions={actions}
actionProps={actionProps}
diff --git a/frontend/src/components/custom/product/product-info-skeleton.tsx b/frontend/src/components/custom/product/product-info-skeleton.tsx
new file mode 100644
index 00000000..9edc5dd4
--- /dev/null
+++ b/frontend/src/components/custom/product/product-info-skeleton.tsx
@@ -0,0 +1,27 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * Mirrors ProductInfo line for line.
+ *
+ * Each bar sits inside a wrapper carrying the same font classes as the text it
+ * stands in for, and is `h-[1lh]` tall, so it is exactly one line box of that
+ * text. Do not swap these for a fixed `h-4`: this project sets
+ * `--spacing: 0.2rem`, so `h-4` is 12.8px and matches none of our text sizes.
+ */
+export default function ProductInfoSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/custom/product/product-summary-skeleton.tsx b/frontend/src/components/custom/product/product-summary-skeleton.tsx
new file mode 100644
index 00000000..290583ed
--- /dev/null
+++ b/frontend/src/components/custom/product/product-summary-skeleton.tsx
@@ -0,0 +1,52 @@
+import type { ReactNode } from "react";
+
+import ProductInfoSkeleton from "@/components/custom/product/product-info-skeleton";
+import {
+ PRODUCT_SUMMARY_IMAGE_CLASSES,
+ PRODUCT_SUMMARY_ROW_CLASSES,
+} from "@/components/custom/product/product-summary";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+interface IProductSummarySkeletonProps {
+ withImage?: boolean;
+ /**
+ * Real nodes, not placeholders. A row whose product is still loading often
+ * already knows its controls, so they stay live instead of greying out.
+ */
+ trailing?: ReactNode;
+ actions?: ReactNode;
+ className?: string;
+}
+
+/** The loading shape of ProductSummary, sharing its wrapper classes verbatim. */
+export default function ProductSummarySkeleton({
+ withImage = false,
+ trailing,
+ actions,
+ className,
+}: IProductSummarySkeletonProps) {
+ return (
+
+
+
+ {withImage && (
+
+ )}
+
+
+
+
+ {(trailing || actions) && (
+
+ {trailing}
+ {actions}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/custom/product/product-summary.tsx b/frontend/src/components/custom/product/product-summary.tsx
index a212fe28..37318ece 100644
--- a/frontend/src/components/custom/product/product-summary.tsx
+++ b/frontend/src/components/custom/product/product-summary.tsx
@@ -3,17 +3,25 @@
import { type ComponentProps, type ReactNode } from "react";
import Image from "next/image";
-import { Skeleton } from "@/components/ui/skeleton";
import ProductInfo from "@/components/custom/product/product-info";
import { cn } from "@/lib/utils";
+/**
+ * Shared with product-summary-skeleton so the two cannot drift apart. A row and
+ * its placeholder having identical geometry is the whole reason nothing shifts.
+ */
+export const PRODUCT_SUMMARY_ROW_CLASSES =
+ "flex flex-col justify-between gap-3 px-3 py-2 @min-[300px]:flex-row @min-[300px]:items-center @md:gap-4 @md:px-6 @md:py-4";
+
+export const PRODUCT_SUMMARY_IMAGE_CLASSES =
+ "hidden @md:block size-16 @lg:size-20 shrink-0 rounded-lg object-contain";
+
interface IProductSummaryProps {
name: string | null;
brand?: string | null;
category: string | null;
quantity?: string | null;
imageUrl?: string | null;
- isLoading?: boolean;
/** Passive details, such as prices, shown opposite the product identity */
trailing?: ReactNode;
actions?: ReactNode;
@@ -36,7 +44,6 @@ export default function ProductSummary({
category,
quantity,
imageUrl,
- isLoading = false,
trailing,
actions,
actionProps,
@@ -46,12 +53,7 @@ export default function ProductSummary({
return (
-
+
{imageUrl && (
)}
- {isLoading ? (
-
-
-
-
-
- ) : (
-
- )}
+
{(trailing || actions) && (
From 932344fe277ad563883db3824d8d7728b7e82b57 Mon Sep 17 00:00:00 2001
From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:46:42 +0200
Subject: [PATCH 03/17] feat(a11y): Add per-route loading UI
Changes:
- Add loading.tsx to /shopping-lists, /shopping-lists/[id], /watchlist and
/products/[id], each rendering that route's page skeleton
- Swap the /products Suspense fallback from a spinner to ProductsSkeleton
These paint during the RSC navigation, before the client component mounts, which
is the window the single global spinner used to fill.
Notes:
- A page skeleton mirrors each collapsible section's stored default open state,
so the page height does not jump once the real component reads localStorage.
Price history is stored closed; items and stores are stored open.
- loading.tsx renders on the server, so it cannot read the remembered row count
and takes the fixed fallback. The client refines it on mount.
---
.../src/app/(user)/shopping-lists/[id]/loading.tsx | 10 ++++++++++
frontend/src/app/(user)/shopping-lists/loading.tsx | 5 +++++
frontend/src/app/(user)/watchlist/loading.tsx | 5 +++++
frontend/src/app/products/[id]/loading.tsx | 5 +++++
frontend/src/app/products/page.tsx | 14 ++------------
5 files changed, 27 insertions(+), 12 deletions(-)
create mode 100644 frontend/src/app/(user)/shopping-lists/[id]/loading.tsx
create mode 100644 frontend/src/app/(user)/shopping-lists/loading.tsx
create mode 100644 frontend/src/app/(user)/watchlist/loading.tsx
create mode 100644 frontend/src/app/products/[id]/loading.tsx
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/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/loading.tsx b/frontend/src/app/(user)/watchlist/loading.tsx
new file mode 100644
index 00000000..bf2a7ad4
--- /dev/null
+++ b/frontend/src/app/(user)/watchlist/loading.tsx
@@ -0,0 +1,5 @@
+import WatchlistSkeleton from "@/app/(user)/watchlist/components/watchlist-skeleton";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/src/app/products/[id]/loading.tsx b/frontend/src/app/products/[id]/loading.tsx
new file mode 100644
index 00000000..75d310bd
--- /dev/null
+++ b/frontend/src/app/products/[id]/loading.tsx
@@ -0,0 +1,5 @@
+import ProductDetailSkeleton from "@/app/products/[id]/components/product-detail-skeleton";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/src/app/products/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 (
-
-
-
-
-
-
- }
- >
+ }>
);
From eb055947ac4706640664b554f5eef89acce325e6 Mon Sep 17 00:00:00 2001
From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:46:52 +0200
Subject: [PATCH 04/17] refactor(a11y): Replace page-body spinners with
skeletons
Changes:
- Swap the price history chart spinners for ChartSkeleton
- Convert statistics, the dashboard guard, the header auth button and the
notifications list onto skeletons and AsyncSection
- Make app/loading.tsx a neutral page shell instead of a centred spinner
- Record the loading UI and data fetching conventions in AGENTS.md
BlockLoadingSpinner now only appears where a spinner is genuinely right: the
button loading state and short inline actions. Content loading gets a skeleton,
so a page keeps its height instead of collapsing and then shoving the viewport.
Notes:
- The dashboard guard also covers the moment after a denial while its redirect
runs, so a page shell is friendlier there than a bare spinner.
---
AGENTS.md | 11 ++++++
.../shopping-list-price-history.tsx | 6 ++--
.../dashboard/components/dashboard-guard.tsx | 9 ++---
frontend/src/app/loading.tsx | 16 ++++-----
.../price-history/price-history-panel.tsx | 8 ++---
.../statistics/components/health-status.tsx | 23 ++++++------
.../app/statistics/components/store-item.tsx | 9 ++---
.../app/statistics/components/stores-list.tsx | 35 +++++++++++-------
.../header/components/header-actions.tsx | 7 ++--
.../components/notifications-list.tsx | 36 +++++++++++--------
10 files changed, 87 insertions(+), 73 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 9de03517..c5ba9893 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/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/dashboard/components/dashboard-guard.tsx b/frontend/src/app/dashboard/components/dashboard-guard.tsx
index 3020352e..069eadb7 100644
--- a/frontend/src/app/dashboard/components/dashboard-guard.tsx
+++ b/frontend/src/app/dashboard/components/dashboard-guard.tsx
@@ -3,7 +3,7 @@
import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation";
-import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner";
+import PageShellSkeleton from "@/components/custom/skeleton/page-shell-skeleton";
import { useUser } from "@/context/user-context";
import { canAccessDashboard } from "@/lib/api/schemas/auth-user";
@@ -23,12 +23,9 @@ export default function DashboardGuard({ children }: IDashboardGuardProps) {
}
}, [isLoading, allowed, router]);
+ // Also covers the moment after a denial, while the redirect above runs.
if (isLoading || !allowed) {
- return (
-
-
-
- );
+ return ;
}
return <>{children}>;
diff --git a/frontend/src/app/loading.tsx b/frontend/src/app/loading.tsx
index 6382dcb9..af7a73aa 100644
--- a/frontend/src/app/loading.tsx
+++ b/frontend/src/app/loading.tsx
@@ -1,10 +1,10 @@
-import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner";
-import { JSX } from "react";
+import PageShellSkeleton from "@/components/custom/skeleton/page-shell-skeleton";
-export default function Loading(): JSX.Element {
- return (
-
-
-
- );
+/**
+ * The last-resort route fallback, for segments with no loading.tsx of their own.
+ * A neutral shape beats a centred spinner: the page keeps its height, so content
+ * does not shove the viewport when it arrives.
+ */
+export default function Loading() {
+ return ;
}
diff --git a/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx b/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx
index 0d4a94ec..08a32c28 100644
--- a/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx
+++ b/frontend/src/app/products/[id]/components/price-history/price-history-panel.tsx
@@ -1,4 +1,4 @@
-import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner";
+import ChartSkeleton from "@/components/custom/skeleton/chart-skeleton";
import PriceHistoryChart from "@/app/products/[id]/components/price-history/price-history-chart";
import { HistoryDataPoint } from "@/app/products/[id]/typings/history-data-point";
@@ -18,11 +18,7 @@ export default function PriceHistoryPanel({
selectedChains,
}: IPriceHistoryPanelProps) {
if (historyLoading) {
- return (
-
);
}
From c0958068a99f9e2983e8d9d3ae4af46ccd3f8c5c Mon Sep 17 00:00:00 2001
From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:47:11 +0200
Subject: [PATCH 05/17] refactor(api): Unify the data layer and derive loading
from isPending
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Changes:
- Add a keys.ts per domain, replacing three coexisting key styles, and turn the
stringified-params cijene keys into explicit tuples
- Add CACHE_TIMES and a global 60s staleTime, replacing 15 magic numbers
- Split every lib/api domain into keys.ts, queries.ts and hooks.ts, with reads
exposed as queryOptions() descriptors rather than useGetX hooks
- Add useAuthedQuery, which folds the session into enabled and returns pending
and requiresAuth
- Add useProductsByEans, replacing four copies of the per-EAN useQueries block
- Wire every client onto AsyncSection, its skeleton, and a count pill
- Gate the shopping list detail query on auth and show LoginRequired
- Bump the offline cache buster to 2
Detail pages rendered "Greška" and indexes rendered "(0)" for a frame on every
reload. PersistQueryClientProvider parks queries at fetchStatus idle while it
restores IndexedDB, and v5 derives isLoading as isPending && isFetching, so it
reads false with data still undefined and every guard fell through to the next
branch. Shopping list detail had a second cause: it was the only user-scoped
query with no auth gate, so it 401'd before the token existed and reported an
auth-timing failure as "list not found".
Notes:
- Reads are descriptors, not hooks, because useAuthedQuery reads useUser and
user-context imports the lib/api barrel, so a domain hook importing it would
close an import cycle.
- Query key shapes changed, hence the buster bump: existing users take one cold
load after this deploys. Top-level key roots are unchanged, so the offline
allowlist in cached-query-keys.ts still matches.
- lib/api/digital-cards is left alone as dead code pending its own removal.
---
.../shopping-list-detail-client.tsx | 142 +++++++------
.../stores/shopping-list-stores-list.tsx | 16 +-
.../[id]/hooks/use-shopping-list-data.ts | 32 +--
.../hooks/use-shopping-list-item-mutations.ts | 44 ++--
.../[id]/hooks/use-shopping-list-mutations.ts | 24 ++-
.../hooks/use-shopping-list-price-history.ts | 4 +-
.../[id]/hooks/use-store-chain-analysis.ts | 26 +--
.../components/forms/shopping-list-modal.tsx | 14 +-
.../components/shopping-lists-client.tsx | 188 ++++++++++--------
.../hooks/use-shopping-list-modal.ts | 5 +-
.../watchlist/components/watchlist-client.tsx | 19 +-
.../watchlist/components/watchlist-header.tsx | 8 +-
.../watchlist/components/watchlist-list.tsx | 12 +-
.../components/watchlist-suggestions.tsx | 23 ++-
.../watchlist/hooks/use-watchlist-data.ts | 43 ++--
.../hooks/use-watchlist-suggestions.ts | 27 +--
.../components/admin-contact-table.tsx | 18 +-
.../components/admin-users-stats.tsx | 9 +-
.../components/admin-users-table.tsx | 16 +-
.../components/product-chains-section.tsx | 37 ++--
.../[id]/components/product-detail-client.tsx | 74 ++++---
.../products/[id]/hooks/use-product-detail.ts | 8 +-
.../components/product-action-buttons.tsx | 8 +-
.../products/components/products-client.tsx | 181 ++++++++++-------
.../hooks/use-selected-shopping-list.ts | 13 +-
.../products/hooks/use-watchlist-item-form.ts | 6 +-
.../app/providers/react-query-provider.tsx | 5 +
.../bottom-nav/use-active-list-progress.ts | 7 +-
.../settings/hooks/use-settings-defaults.ts | 9 +-
.../context/use-watchlist-notifications.ts | 27 ++-
frontend/src/hooks/use-product-modals.ts | 7 +-
frontend/src/hooks/use-product-navigation.ts | 7 +-
frontend/src/lib/api/admin/hooks.ts | 47 +++++
frontend/src/lib/api/admin/index.ts | 78 +-------
frontend/src/lib/api/admin/keys.ts | 5 +
frontend/src/lib/api/admin/queries.ts | 24 +++
frontend/src/lib/api/contact/hooks.ts | 61 ++++++
frontend/src/lib/api/contact/index.ts | 138 +------------
frontend/src/lib/api/contact/keys.ts | 7 +
frontend/src/lib/api/contact/queries.ts | 59 ++++++
frontend/src/lib/api/preferences/hooks.ts | 56 ++++++
frontend/src/lib/api/preferences/index.ts | 110 +---------
frontend/src/lib/api/preferences/keys.ts | 8 +
frontend/src/lib/api/preferences/queries.ts | 41 ++++
frontend/src/lib/api/shopping-lists/hooks.ts | 72 ++++---
frontend/src/lib/api/shopping-lists/index.ts | 1 +
frontend/src/lib/api/shopping-lists/keys.ts | 12 ++
frontend/src/lib/api/users/hooks.ts | 11 +
frontend/src/lib/api/users/index.ts | 39 +---
frontend/src/lib/api/users/queries.ts | 23 +++
frontend/src/lib/api/watchlist/hooks.ts | 53 +++++
frontend/src/lib/api/watchlist/index.ts | 116 +----------
frontend/src/lib/api/watchlist/keys.ts | 10 +
frontend/src/lib/api/watchlist/queries.ts | 31 +++
frontend/src/lib/cijene-api/hooks.ts | 9 +-
frontend/src/lib/cijene-api/index.ts | 1 +
frontend/src/lib/cijene-api/keys.ts | 54 +++++
frontend/src/lib/cijene-api/query-hooks.ts | 44 ++--
.../lib/cijene-api/use-products-by-eans.ts | 65 ++++++
frontend/src/lib/offline/offline-mutations.ts | 23 ++-
frontend/src/lib/offline/persister.ts | 4 +-
frontend/src/lib/query/cache-times.ts | 25 +++
frontend/src/lib/query/use-authed-query.ts | 63 ++++++
63 files changed, 1358 insertions(+), 991 deletions(-)
create mode 100644 frontend/src/lib/api/admin/hooks.ts
create mode 100644 frontend/src/lib/api/admin/keys.ts
create mode 100644 frontend/src/lib/api/admin/queries.ts
create mode 100644 frontend/src/lib/api/contact/hooks.ts
create mode 100644 frontend/src/lib/api/contact/keys.ts
create mode 100644 frontend/src/lib/api/contact/queries.ts
create mode 100644 frontend/src/lib/api/preferences/hooks.ts
create mode 100644 frontend/src/lib/api/preferences/keys.ts
create mode 100644 frontend/src/lib/api/preferences/queries.ts
create mode 100644 frontend/src/lib/api/shopping-lists/keys.ts
create mode 100644 frontend/src/lib/api/users/hooks.ts
create mode 100644 frontend/src/lib/api/users/queries.ts
create mode 100644 frontend/src/lib/api/watchlist/hooks.ts
create mode 100644 frontend/src/lib/api/watchlist/keys.ts
create mode 100644 frontend/src/lib/api/watchlist/queries.ts
create mode 100644 frontend/src/lib/cijene-api/keys.ts
create mode 100644 frontend/src/lib/cijene-api/use-products-by-eans.ts
create mode 100644 frontend/src/lib/query/cache-times.ts
create mode 100644 frontend/src/lib/query/use-authed-query.ts
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.
Ovaj popis još ne sadrži proizvode. Probaj pretražiti proizvode pa
ih dodaj na ovaj popis.
@@ -118,7 +118,7 @@ export default function ShoppingListStoreSummary({
))}
)}
- >
+
);
}
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/components/forms/shopping-list-modal.tsx b/frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx
index 4db83ea7..4c94a415 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-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 (
-