Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
760d54c
feat(a11y): Add skeleton kit and async section primitives
OffCrazyFreak Jul 30, 2026
0f8845e
feat(a11y): Add colocated skeletons for products, lists and watchlist
OffCrazyFreak Jul 30, 2026
932344f
feat(a11y): Add per-route loading UI
OffCrazyFreak Jul 30, 2026
eb05594
refactor(a11y): Replace page-body spinners with skeletons
OffCrazyFreak Jul 30, 2026
c095806
refactor(api): Unify the data layer and derive loading from isPending
OffCrazyFreak Jul 30, 2026
b4de002
docs: Document the data fetching and loading UI layer
OffCrazyFreak Jul 30, 2026
a75b3e7
perf(api): Start authed queries on the session, not the profile
OffCrazyFreak Jul 30, 2026
831f01f
docs: Document session gating and the hydration TODO
OffCrazyFreak Jul 30, 2026
d7ef759
feat(ui): Add pending-state primitives for mutation buttons
OffCrazyFreak Jul 30, 2026
e171e61
feat(auth): Label pending auth buttons
OffCrazyFreak Jul 30, 2026
a4a7a36
feat(shopping-lists): Label pending list and item buttons
OffCrazyFreak Jul 30, 2026
855804a
feat(watchlist): Label pending watchlist buttons
OffCrazyFreak Jul 30, 2026
caf0d8d
feat(products): Label pending product modal buttons
OffCrazyFreak Jul 30, 2026
cefe221
feat(settings): Label pending settings, security and contact buttons
OffCrazyFreak Jul 30, 2026
7cce41c
feat(a11y): Add pending state to admin dashboard mutations
OffCrazyFreak Jul 30, 2026
d86808f
fix(ui): Match the loading spinner's optical weight to the icons it r…
OffCrazyFreak Jul 30, 2026
853249d
Merge remote-tracking branch 'origin/dev' into feat/loading-skeletons
OffCrazyFreak Jul 30, 2026
fc3aef2
Merge remote-tracking branch 'origin/dev' into feat/loading-skeletons
OffCrazyFreak Aug 2, 2026
ede54ae
Merge PR #144: app-wide skeleton loading system and data layer unific…
OffCrazyFreak Aug 2, 2026
adf7322
fix(products): Close three isLoading gaps missed in the sweep
OffCrazyFreak Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ Conventions:
- React Query hooks live next to their service in `lib/api/<domain>/`. 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/<domain>/` 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, `<component-name>-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.
Expand Down
503 changes: 503 additions & 0 deletions docs/DATA-FETCHING.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/PWA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ one part of the system.
- [PWA.md](PWA.md) - PWA setup, service worker, offline behaviour, install flow.
- [MOBILE-NAV.md](MOBILE-NAV.md) - mobile bottom nav bar, long-press gestures, tab scrubbing, the shared sheet shell.
- [STATE-PERSISTENCE.md](STATE-PERSISTENCE.md) - how inputs and forms remember state (URL, localStorage drafts, IndexedDB).
- [DATA-FETCHING.md](DATA-FETCHING.md) - data layer, query keys, cache times, loading states and the skeleton system.
- [LANDING.md](LANDING.md) - landing page composition, server-vs-client rendering, SEO, fonts.
- [BRAND.md](BRAND.md) - brand image system (logo, favicon, PWA icons, splash screens, social kit).
- [SUPPORT.md](SUPPORT.md) - Ko-fi support flow, GitHub funding links, and future recognition rules.
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ import type { IShoppingListItemUpdate } from "@/app/(user)/shopping-lists/[id]/t
interface IItemAmountControlsProps {
item: ShoppingListItemDto;
onUpdate: (updatedItem: IShoppingListItemUpdate) => void;
isUpdating: boolean;
}

export default function ItemAmountControls({
item,
onUpdate,
isUpdating,
}: IItemAmountControlsProps) {
// No spinner here on purpose: the write is optimistic, so the new amount is
// already rendered and swapping in a loader would flicker on every tap.
return (
<div className="pointer-events-none relative z-20 flex items-center gap-2 [&_button]:pointer-events-auto">
<div
className="pointer-events-none relative z-20 flex items-center gap-2 [&_button]:pointer-events-auto"
aria-busy={isUpdating}
>
<Button
size="icon"
aria-label="Smanji količinu za 1"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import BlockLoadingSpinner from "@/components/custom/common/block-loading-spinner";
import { LOADING_LABELS } from "@/constants/loading-labels";
import {
Tooltip,
TooltipContent,
Expand All @@ -20,12 +21,15 @@ export default function RemoveItemButton({
onDelete,
isDeleting,
}: IRemoveItemButtonProps) {
// Icon-only, so the spinner is the whole visual and the name carries the copy.
const label = isDeleting ? LOADING_LABELS.deleting : "Makni proizvod";

return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
aria-label="Makni proizvod"
aria-label={label}
className={cn(
"shrink-0 bg-red-600 hover:bg-red-700",
visibilityClassName,
Expand All @@ -42,7 +46,7 @@ export default function RemoveItemButton({
</TooltipTrigger>

<TooltipContent variant="destructive" className="px-2 py-1 text-xs">
Makni proizvod
{label}
</TooltipContent>
</Tooltip>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="relative flex flex-wrap items-center justify-between gap-6 py-1 sm:flex-nowrap">
<div className="flex items-center gap-4 w-full sm:w-auto">
<Skeleton aria-hidden="true" className="size-4 shrink-0 rounded-sm" />

<div className="flex-1 text-sm sm:text-md">
<Skeleton aria-hidden="true" className="h-[1lh] w-40 max-w-full" />
</div>
</div>

<div className="flex w-full items-center justify-between gap-4 sm:w-auto sm:justify-end">
<Skeleton aria-hidden="true" className="h-9 w-24 rounded-md" />
<Skeleton aria-hidden="true" className="h-9 w-32 rounded-md" />
</div>
</div>

{showSeparator && <Separator className="my-1" />}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface IShoppingListItemProps {
onUpdate: (updatedItem: IShoppingListItemUpdate) => void;
onDelete: () => void;
isDeleting: boolean;
isUpdating: boolean;
cheapestStore?: string;
averagePrice?: number;
storePrices: Record<string, number>;
Expand All @@ -29,6 +30,7 @@ export default function ShoppingListItem({
onUpdate,
onDelete,
isDeleting,
isUpdating,
cheapestStore,
averagePrice,
storePrices,
Expand Down Expand Up @@ -96,7 +98,11 @@ export default function ShoppingListItem({
<div className="flex items-center justify-between gap-6">
<ItemPriceDisplay item={item} averagePrice={averagePrice} />

<ItemAmountControls item={item} onUpdate={onUpdate} />
<ItemAmountControls
item={item}
onUpdate={onUpdate}
isUpdating={isUpdating}
/>
</div>

{/* Store Chain Select */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<SectionHeaderSkeleton titleWidth="10rem" />

<Card className="p-4">
<RepeatSkeleton className="space-y-1" count={rows}>
<ShoppingListItemSkeleton />
</RepeatSkeleton>
</Card>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function ShoppingListItems({
averagePrices,
storePrices,
}: IShoppingListItemsProps) {
const { handleUpdateItem, handleDeleteItem, deletingItemId } =
const { handleUpdateItem, handleDeleteItem, deletingItemId, updatingItemId } =
useShoppingListItemMutations(shoppingList.id, averagePrices, storePrices);

const [isItemsOpen, setIsItemsOpen] = useState(() =>
Expand Down Expand Up @@ -108,6 +108,7 @@ export default function ShoppingListItems({
}
onDelete={() => handleDeleteItem(item.id)}
isDeleting={deletingItemId === item.id}
isUpdating={updatingItemId === item.id}
cheapestStore={cheapestStores[item.id]}
averagePrice={averagePrices[item.id]}
storePrices={storePrices[item.id] || {}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ShoppingListDto as ShoppingList } from "@/lib/api/types";
import { ConfirmDialog } from "@/components/custom/modal/confirm-dialog";
import { LOADING_LABELS } from "@/constants/loading-labels";
import { useShoppingListActions } from "@/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-actions";
import ShoppingListDesktopActions from "@/app/(user)/shopping-lists/[id]/components/shopping-list-desktop-actions";
import ShoppingListMobileActions from "@/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions";
Expand Down Expand Up @@ -57,6 +58,7 @@ export default function ShoppingListActionButtons({
title="Obriši popis za kupnju"
description={`Sigurno želiš obrisati popis "${shoppingList.title}"? Ova akcija se ne može poništiti.`}
confirmLabel="Obriši"
confirmLoadingLabel={LOADING_LABELS.deleting}
variant="destructive"
onConfirm={handleConfirmDelete}
isLoading={isDeleting}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { LOADING_LABELS } from "@/constants/loading-labels";
import { cn } from "@/lib/utils";
import type { IShoppingListActionGroupProps } from "@/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-actions";

Expand All @@ -29,6 +30,11 @@ export default function ShoppingListDesktopActions({
visibleOnMobile = false,
className,
}: IShoppingListDesktopActionsProps) {
// Icon-only, so the spinner is the whole visual and the names carry the copy.
const shareLabel = isSharing ? LOADING_LABELS.sharing : "Podijeli popis";
const copyLabel = isCopying ? LOADING_LABELS.copying : "Kopiraj popis";
const deleteLabel = isDeleting ? LOADING_LABELS.deleting : "Obriši popis";

return (
<div
className={cn(
Expand All @@ -42,7 +48,7 @@ export default function ShoppingListDesktopActions({
<TooltipTrigger asChild>
<Button
size="icon"
aria-label="Podijeli popis"
aria-label={shareLabel}
className="shrink-0"
onClick={onShare}
disabled={isSharing}
Expand All @@ -56,7 +62,7 @@ export default function ShoppingListDesktopActions({
</TooltipTrigger>

<TooltipContent className="px-2 py-1 text-xs">
Podijeli popis
{shareLabel}
</TooltipContent>
</Tooltip>
)}
Expand All @@ -66,7 +72,7 @@ export default function ShoppingListDesktopActions({
<TooltipTrigger asChild>
<Button
size="icon"
aria-label="Kopiraj popis"
aria-label={copyLabel}
className="shrink-0"
onClick={() => {
onCopy();
Expand All @@ -82,7 +88,7 @@ export default function ShoppingListDesktopActions({
</TooltipTrigger>

<TooltipContent className="px-2 py-1 text-xs">
Kopiraj popis
{copyLabel}
</TooltipContent>
</Tooltip>
)}
Expand Down Expand Up @@ -111,7 +117,7 @@ export default function ShoppingListDesktopActions({
<TooltipTrigger asChild>
<Button
size="icon"
aria-label="Obriši popis"
aria-label={deleteLabel}
className="shrink-0 bg-red-600 hover:bg-red-700"
onClick={() => {
onDeleteClick();
Expand All @@ -127,7 +133,7 @@ export default function ShoppingListDesktopActions({
</TooltipTrigger>

<TooltipContent variant="destructive" className="px-2 py-1 text-xs">
Obriši popis
{deleteLabel}
</TooltipContent>
</Tooltip>
)}
Expand Down
Loading
Loading