Imported from zulalnb/stokmate (
web/AGENTS.md). Install upstream withnpx skills add zulalnb/stokmate --skill web. Copyright stays with the author.
Skill Loading
Before editing files for a substantial task:
- Run
pnpm dlx @tanstack/intent@latest listfrom the workspace root to see available local skills. - If a listed skill matches the task, run
pnpm dlx @tanstack/intent@latest load <package>#<skill>before changing files. - Use the loaded
SKILL.mdguidance while making the change. - Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
Project
StokMate is an internal inventory management panel developed for the head office employees of a small retail chain.
The application:
- Uses Vite + React + TypeScript.
- Runs entirely client-side; there is no SSR or SEO requirement.
- Runs behind authentication.
- Server state is managed by TanStack Query.
- Routing is handled by TanStack Router.
- HTTP requests are made with Axios.
- The UI is built with shadcn/ui (
base-vegastyle, Base UI-based) + Tailwind. - Tables are built with
@tanstack/react-table.
The technology stack is fixed.
Working Rules
Shared working rules are defined in the repository-level AGENTS.md.
Documentation Priority
Before writing code, review the documentation relevant to the task. Priority order:
api/API.md- Rules in this file
INSTALLATION.md(only for installation and tool configuration)
Do not make assumptions about API behavior.
Decision rationales are documented in docs/DECISIONS.md. When making a new architectural or library decision, add a short heading there: selected approach, rejected alternative, rationale, and trade-off.
Architecture
Component
↓
Feature Hook (queryOptions factory + useQuery / useMutation)
↓
API Service (HTTP calls only)
↓
Axios Client + Interceptors
↓
API
The import direction is strictly one-way. If a component imports products.service.ts, the architecture is broken; the missing hook or queryOptions factory must be added to the relevant feature.
Component
Components:
- Render UI and handle user interactions.
- Use feature hooks.
- Do not call API services directly.
- Do not call
useQuery/useMutationdirectly; server-state operations are performed through feature hooks. - Do not know about Axios or HTTP details.
Wrong:
productsService.getProducts(filters)
Correct:
useProducts(filters)
Folder structure
src/
├── api/ axios-client, interceptors, errors, services/
├── components/ app-wide — sidebar, header, route fallbacks
│ └── ui/ shadcn components (generated by CLI, not manually edited)
├── features/
│ ├── auth/
│ │ ├── components/
│ │ └── hooks/use-auth.ts
│ └── products/
│ ├── components/ columns.tsx, table-specific pieces
│ └── hooks/use-products.ts
├── hooks/ feature-independent reusable hooks
├── lib/ auth-storage, constants, enums, money, types, utils
└── routes/
A component belonging to a feature must not be placed under src/components/; src/components/ is only for components used by multiple features or belonging to the application as a whole.
src/hooks/ is only for domain-independent reusable hooks (use-debounce.ts, use-media-query.ts). Server-state hooks do not belong here.
Query definitions — queryOptions factory is mandatory
Query keys and queryFns must not be written directly inside a hook or route file. The factory is defined and exported from the relevant feature's hooks/ file; the hook wraps it:
// src/features/products/hooks/use-products.ts
export const productsQuery = (filters: ProductFilters) =>
queryOptions({
queryKey: ['products', filters],
queryFn: () => productsService.getProducts(filters),
})
export function useProducts(filters: ProductFilters) {
return useQuery(productsQuery(filters))
}
Reason: route beforeLoad and loader functions cannot call React hooks. They use the same factory when calling ensureQueryData(...), keeping the key and configuration in one place:
loader: ({ context, deps }) => context.queryClient.ensureQueryData(productsQuery(deps)),
The key or queryFn for the same query must never be written a second time elsewhere; otherwise the configurations can drift over time and produce duplicate requests for the same data.
Scope — current hook and factory list
The web panel covers listing, detail, creation, updating, and deletion:
meQuery() (factory — used by guard)
useLogin()
useLogout()
productsQuery(filters) useProducts(filters)
productQuery(id) useProduct(id)
useCreateProduct()
useUpdateProduct()
useUpdateProductStock()
useDeleteProduct()
categoriesQuery() useCategories()
brandsQuery() useBrands()
statsQuery() useStats()
This list is the scope, not an example. Ask the user before adding a hook or factory that is not on this list.
TanStack Query
Server state is stored only in TanStack Query. Do not create a Redux, Zustand, or Context-based server-state cache.
Query keys:
['me']
['products', filters]
['product', id]
['categories']
['brands']
['stats']
Do not change the query key structure.
List filters are not stored in component state. The following filters are stored in URL search parameters and included in the relevant query key:
q categoryId brandId status page sort dir
Invalidate the relevant cache after mutations. After product mutations:
queryClient.invalidateQueries({ queryKey: ['products'] })
Invalidation is defined in the relevant hook's onSuccess, not inside the component.
When a query or mutation fails, the ApiError produced by the interceptor is passed to the UI layer. The UI layer displays notifications for mutation errors.
API Layer
src/api/
├── axios-client.ts
├── interceptors.ts
├── errors.ts
└── services/
├── auth.service.ts
├── products.service.ts
├── categories.service.ts
├── brands.service.ts
└── stats.service.ts
Axios client
src/api/axios-client.ts contains the application's single Axios instance.
Only exception: the /auth/refresh request is made through a separate instance without interceptors. Otherwise, a 401 from the refresh request itself would trigger the response interceptor again and create an infinite loop. Do not create a new instance outside this exception.
Interceptors
src/api/interceptors.ts:
- Request: adds
Authorization: Bearer <accessToken>to requests other than/auth/loginand/auth/refresh. The token is read throughlib/auth-storage. - Response: manages single-flight refresh on 401 responses and normalizes HTTP errors into
ApiError.
401 behavior:
- If multiple 401 responses occur at the same time, only one
/auth/refreshrequest is sent; the other requests wait for the same Promise. - If refresh succeeds, waiting requests are retried once. The retried request is marked (
_retry) and is never retried a second time. - If refresh fails, the session is cleared and the requests fail with
ApiError.
Do not reimplement token refresh behavior in features, components, or routes.
Errors
src/api/errors.ts contains the standard ApiError model:
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
API error bodies are returned as text/plain, in English, with no structured error code — only the HTTP status code is machine-readable. The raw body is never shown to the user.
ApiError.message is generated from the HTTP status code via getErrorMessage(status) in src/api/errors.ts, which maps each status to a fixed, user-facing Turkish message. /auth/login's 401 is special-cased in the interceptor to "E-posta veya şifre hatalı." instead of the generic session-expired message, since the same status code means something different there. Do not read or display error.response.data.
Distinguish network errors from HTTP errors based on the presence of error.response; network errors use status: 0 and should display an appropriate fallback message.
Do not make the rest of the application depend on Axios-specific details such as AxiosError, error.response, or error.config.
Services
The service layer only defines API operations. Services:
- Do not know about React or use hooks.
- Do not use TanStack Query.
- Do not hold UI state or display toasts.
- Do not use
try/catch— error transformation is handled by the interceptor. - Do not perform kuruş conversion; pass through whatever the API returns.
When a new endpoint needs to be used, follow this order: service function first, then queryOptions factory + hook, and finally the component. Do not write all three files at once; stop at each step.
Authentication
- Access tokens are valid for 15 minutes.
- The refresh token changes on every refresh; the previous refresh token becomes invalid.
- Token storage, reading, and clearing are performed only through
lib/auth-storage. - 401 refresh logic exists only in the response interceptor layer.
Session protection — _authenticated pathless layout route
Protected screens are grouped under src/routes/_authenticated/. There is a single guard: beforeLoad in src/routes/_authenticated.tsx.
beforeLoad: async ({ context }) => {
if (!hasSession()) {
throw redirect({ to: '/login' })
}
const isSessionValid = await context.queryClient.ensureQueryData(meQuery()).then(
() => true,
() => false,
)
if (!isSessionValid) {
throw redirect({ to: '/login' })
}
},
The two-stage check exists for a reason: hasSession() is synchronous and requires no network request, so GET /auth/me is not unnecessarily called when there is no token. When a token exists, its validity is verified with meQuery(); if the token is stale, the interceptor attempts a refresh. If that also fails, the query is rejected and the user is redirected to /login.
Rules:
- Add new protected screens under
src/routes/_authenticated/. Do not add another guard. - Do not create a component such as
ProtectedRoute; protection belongs to the route layer. - Do not write redirects such as
if (!isAuthenticated) navigate('/login')inside components. /loginis outside this tree (src/routes/login.tsx) and is not protected.meQuery()exists only for session validation; if user information needs to be displayed, use the same factory withuseQueryrather than defining a second query.
Login
When useLogin() succeeds, write the returned user data to the meQuery() cache using meQuery().queryKey. This allows the _authenticated guard triggered by the redirect to /products to find fresh data in the cache within the staleTime period and avoid another GET /auth/me request.
Do not manually repeat the cache key (['auth', 'me']); use meQuery().queryKey. Defining the key in two places creates a risk of the definitions drifting over time (see § Query definitions).
Logout
useLogout():
- Calls
POST /auth/logout(the flow continues even if the request fails). - Clears
lib/auth-storage. - Calls
queryClient.clear(). - Redirects to
/login.
queryClient.clear() must not be skipped: if ['me'] remains in the cache, ensureQueryData(meQuery()) may succeed without making a network request and allow the next user through the guard.
Routing
Route tree:
src/routes/
├── __root.tsx createRootRouteWithContext
├── login.tsx unprotected
├── _authenticated.tsx guard + panel layout
└── _authenticated/
├── index.tsx → redirects to /products
└── products/
├── index.tsx → /products
├── new.tsx → /products/new
└── $id.tsx → /products/$id
- Every protected screen belongs under
_authenticated/; do not add protected screens elsewhere. _authenticateddoes not appear in the URL (pathless layout route). Screen URLs are/products,/products/42, etc.; do not use a prefix such as/dashboard.src/routeTree.gen.tsis generated automatically: do not edit it manually, do not add it to.gitignore, and it must be committed.- List filters are defined in the route's
validateSearchschema. beforeLoadandloaderfunctions usequeryOptionsfactories (see § Architecture).- Redirect using
throw redirect({ ... }); do not callnavigateinsidebeforeLoad.
Layout
_authenticated.tsx contains both the guard and the panel layout: SidebarProvider + AppSidebar + SiteHeader + Outlet. Protected screens do not render their own sidebar or header; they render their content directly.
Sidebar navigation items are defined in lib/constants.ts; nav-main.tsx reads them. Before adding a new menu item, verify that the corresponding screen is within scope.
Shared route components
route-pending.tsx, route-error-fallback.tsx, and route-not-found.tsx are connected once in router.ts as defaultPendingComponent, defaultErrorComponent, and defaultNotFoundComponent.
- Use the shared default unless a route needs error-type-specific recovery copy/actions the default can't express (e.g. 404 vs. a generic error, or a client-caused vs. server-caused error) —
products/$id.tsx(ProductDetailError) andproducts/index.tsx(ProductsListError) are the two current exceptions. In that case the component lives in the relevant feature'scomponents/folder (product-detail-error.tsx,products-list-error.tsx), never defined inline inside the route file. - Define
pendingComponentonly when a screen needs a skeleton that mirrors its specific content structure (see § Loading / Error / Empty). The skeleton belongs in the relevant feature'scomponents/folder, not in the route file.
Navigation with search params
As filters are added, the search object grows. Links that change the page must preserve existing filters:
<Link to="." search={(prev) => ({ ...prev, page: prev.page + 1 })} />
Do not use the object form (search={{ page: 2 }}), as it removes the other filters.
Tables
When a feature's list is displayed as a table, it must follow a fixed file structure; the product table is the reference implementation (src/features/products/components/):
data-table-features.tsx tableFeatures({...}) configuration — columnMeta type, rowSortingFeature, rowPaginationFeature; exports `features` and `DataTableFeatures` types
columns.tsx `columns` array built with createColumnHelper<DataTableFeatures, T>()
data-table-column-header.tsx sortable column header — column.getIsSorted() / column.getToggleSortingHandler()
data-table.tsx useTable() call + <Table> render tree; also renders `ProductFilterBar`, `DataTableSortDropdown`, and `DataTablePagination`; receives columns/data/sorting/onSortingChange/pagination/rowCount/filter props/actions as props
data-table-sort-dropdown.tsx sort control — same column.getToggleSortingHandler()/getIsSorted() API as the column header, derives its field list from table.getAllLeafColumns().filter(c => c.getCanSort()) instead of a separate hardcoded list
data-table-pagination.tsx pagination footer — receives the `table` instance (not raw numbers); page/pageCount/canNext/canPrev are all read from it
The filter bar is a separate file in the same components/ folder (product-filter-bar.tsx for products). Like pagination, it is not route-rendered: data-table.tsx renders both product-filter-bar.tsx and data-table-sort-dropdown.tsx itself — the sort dropdown needs the table instance it already built (it drives the same column.getToggleSortingHandler() API as the column headers), so page/pageCount/navigation-capability and sort control all come from one source instead of a sibling component recomputing or duplicating them. The route still only manages URL/search state and passes props (including pagination/rowCount and the filter values/handlers) to DataTable; it must not call useTable() itself. DataTable also takes an actions slot (ReactNode) for page-level buttons like "Ürün ekle" that don't need the table instance — the route still owns what they do (e.g. the <Link> navigation), DataTable only positions them next to the sort dropdown.
The project uses the library's new API: tableFeatures, createColumnHelper, useTable, <FlexRender />. Most examples on the internet and the shadcn data-table documentation use the old API (useReactTable, flexRender, getCoreRowModel) and do not apply here. Before working on table, router, or query code, follow § Skill Loading above — load the matching @tanstack/table-core / @tanstack/react-table / @tanstack/router-core skill (e.g. #core, #sorting, #pagination, #client-vs-server, #with-tanstack-query, #migrate-v8-to-v9) instead of guessing from a v8-shaped example or reading node_modules types cold.
Pagination and sorting are server-side (filtering is handled entirely outside the table, via product-filter-bar.tsx and URL search params — it is not a registered table feature):
- Do not add
getPaginationRowModel()/getSortedRowModel()(meaning do not passpaginatedRowModel/sortedRowModelslots totableFeatures({...})); otherwise the API's returned page will be paginated a second time and sorting will work only within that page. rowSortingFeatureis registered withmanualSorting: true. Sorting is driven through the table's own column API —data-table-column-header.tsx's click handler iscolumn.getToggleSortingHandler()— sostate: { sorting }andonSortingChangepassed touseTableare required and connected to the route's URL-updating handler. Leaving outonSortingChange(or settingenableSorting: falseat the table level) makes every header's click silently do nothing — this happened once; seedocs/DECISIONS.mdif a similar report comes up again.rowPaginationFeatureis registered too, fed withstate: { pagination }androwCount(the server'stotal) — but with noonPaginationChange. Every page change goes through<Link search={...}>(see below), never the table's own API, so the table only needs read access (table.getPageCount(),table.getRowCount(),table.getCanPreviousPage(),table.getCanNextPage()) to stay the single source of truth for the pagination footer. Do not addonPaginationChangeor calltable.nextPage()/setPageIndex()— since pagination state is externally controlled from the URL, the next render would immediately overwrite whatever that call did, so the interaction would silently do nothing (same failure shape as the sorting note above).pageCountcomes fromtable.getPageCount()(resolved from therowCountoption, i.e. the server'stotal) — never compute it manually withMath.ceilagain once a component already has thetableinstance.- Page and sorting state is not stored inside the component; it is read from the
validateSearchschema and updated with<Link search={...}>/navigate({ search: ... }). Do not useuseStatefor pagination or sorting state. - Sortable columns are limited to those accepted by the API:
name|price|stock|updatedAt.enableSortingdefaults totruefor any accessor column (column.columnDef.enableSorting ?? true, per@tanstack/table-core'scolumn_getCanSort— it is opt-out, not opt-in), so settingenableSorting: trueonly on the four sortable columns is not enough by itself: every other accessor column (categoryName,brandName,status) must explicitly setenableSorting: false, orcolumn.getCanSort()silently returnstruefor it too. This stayed invisible as long as the only sortable-aware consumer wasdata-table-column-header.tsx(those columns use a plain stringheader, never renderingDataTableColumnHeader) — it surfaced oncedata-table-sort-dropdown.tsxstarted deriving its field list directly fromtable.getAllLeafColumns().filter((c) => c.getCanSort()). Display columns (columnHelper.display(...), noaccessorFn) are unaffected —getCanSort()requires!!column.accessorFntoo. - Column definitions belong in
columns.tsx, not the route file. - Cells must not display raw values: prices go through
formatKurus, whileunitandstatusgo through the maps inlib/enums.ts. - Column widths should be specified through
meta.classNamein the column definition only when necessary (for example, limiting an overflowing column withmax-w-*or fixing a narrow numeric column withw-*). The table is nottable-fixed;meta.classNameshould be applied selectively rather than to every column. Numeric columns usetabular-nums.
Money
price and costPrice are integer kuruş values: 3950 → 39,50 ₺
Do not use price / 100 or price * 100 anywhere in the codebase. Money conversions must only be performed through formatKurus and parseKurus in lib/money.ts.
Product update
PUT /products/{id} rewrites the entire record and requires the following fields:
costPrice supplierId description
The edit form must obtain these values from the detail endpoint and include them in the update request even if the user does not change them. Otherwise, they will be silently cleared when the record is saved.
Do not change the request model based on assumptions about backend behavior.
Product image
imageUrl can be null — an image is not guaranteed for every product. It is typed as imageUrl: string | null in lib/types.ts.
Check for null before passing imageUrl directly to an <img src>. When it is null, show a placeholder (see routes/_authenticated/products/$id.tsx). Do not fall back to an empty string or a default image URL.
Enums
unit and status are returned as numbers by the API. Do not display raw numbers in the UI; obtain their labels from the as const maps in lib/enums.ts.
These fields are typed as narrow unions in lib/types.ts (status: 1 | 2 | 3), so map access does not require assertions such as as keyof typeof.
Pagination
The GET /products response does not contain totalPages. Calculate it with Math.ceil(total / pageSize) when needed. The API's maximum pageSize is 100.
TypeScript
- Do not use
anyoras any. - Avoid unnecessary type assertions. If an assertion seems necessary, the type in
lib/types.tsis usually too broad; narrow the type instead. - Domain types belong in
lib/types.ts. - Do not create a second parallel model for the same type.
UI
UI text:
- Must use sentence case and avoid unnecessary filler.
- Buttons must clearly name the action they perform.
- Error messages must explain the problem and the user's next step.
- Do not use apologetic language.
shadcn base-vega style
The shadcn style in this project is Base UI-based. Components such as Button use the render prop and nativeButton={false} when rendered as another element:
<Button nativeButton={false} render={<Link to="/products" />}>Ürünler</Button>
Do not use the asChild pattern. Files under src/components/ui/ are generated by the CLI; do not edit them manually. If necessary, regenerate them with add.
Icons
Lucide is used. When passing an icon as a prop, the type is LucideIcon and the value is the component itself (icon={CircleCheck}, not icon={<CircleCheck />}). Use Tailwind classes such as size-4 for sizing.
Loading / Error / Empty
Every data screen must handle three states.
Loading — do not use a centered spinner; use the shadcn Skeleton to mirror the structure of the actual content. The table skeleton must match the real column count and row height; the number of rows must correspond to pageSize, not the number of columns.
Error — display ApiError.message and provide a "Tekrar dene" button that calls router.invalidate() to retry. For queries that fail in a loader, useQueryErrorResetBoundary().reset() must also be called; otherwise the button may appear to do nothing.
Empty — if filters are active, indicate that no results were found and provide an option to clear the filters; if there are no filters, indicate that there is no data yet.
On screens using useSuspenseQuery, isPending is never true; loading is handled by the route's pendingComponent. Do not combine useSuspenseQuery with an isPending check on the same screen.
Forms
Forms are built with react-hook-form + zod. Do not invent validation rules independently of backend behavior; if the API contract is unknown, inspect api/API.md.
Notifications
Notifications are handled in the UI layer. The shadcn toast component (@/components/ui/toast) may be used at the component or feature level; do not use it in API services, Axios interceptors, or the route guard.
Code Rules
- File names use kebab-case:
app-sidebar.tsx,route-pending.tsx,use-products.ts,columns.tsx. Exported component names remain PascalCase. (The shadcn CLI generates kebab-case; everything was standardized to this style across the project.) - Do not write comments; use descriptive naming instead.
- Do not import raw
axios; all requests go throughsrc/api/axios-client.ts.
Scope Control
For a component-only task, do not change the architecture.
If a problem cannot be solved within the current task's scope, report it to the user instead of expanding the scope yourself.
Validation
After every change:
npx tsc -b
Do not use npx tsc --noEmit. Because the root tsconfig.json is solution-style, it checks 0 files and can produce a falsely green result.
When necessary, existing scripts:
pnpm run lint
pnpm run build
If the task affects only a specific file, do not make broad changes.
Documentation
- Installation and tool configuration:
INSTALLATION.md - API contract:
api/API.md - Decision rationales:
docs/DECISIONS.md - Architecture rules: this file (§ Architecture)
ROADMAP.md
ROADMAP.md is the source of truth for the web project's implementation progress.
Before starting any web task:
- Read
ROADMAP.md. - Check whether the requested task already exists.
- If it is already marked as completed, verify the implementation before making changes.
- If the task exists and is incomplete, implement it and mark it as completed after verification.
- If the task does not exist, add it to the appropriate section only if it is explicitly requested.
- Never mark a task as completed based only on the user's request. Verify the actual implementation first.
- Do not add speculative or future work to the roadmap.
- Keep
ROADMAP.mdupdated when completing requested work.
