Skip to content

Commit cada1a2

Browse files
authored
Merge pull request #56 from bartstc/feat/rate-product
feat: add interactive product star rating
2 parents d0eaf70 + 14af589 commit cada1a2

10 files changed

Lines changed: 318 additions & 7 deletions

File tree

public/locales/en-GB/translation.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@
5757
"features": {
5858
"products": {
5959
"rating": {
60-
"tooltip": "Average customer rating: {rating}"
60+
"tooltip": "Average customer rating: {rating}",
61+
"notifications": {
62+
"title": "Product rating",
63+
"not-authenticated": "You have to log in to rate the product",
64+
"success": "Thank you! Your rating has been submitted.",
65+
"error": "Something went wrong with submitting your rating. Please try again or contact us."
66+
}
6167
},
6268
"categories": {
6369
"clothing": "Clothing",

specs/004-product-rating/spec.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Product Rating
2+
3+
## Meta
4+
5+
| Field | Value |
6+
| -------------- | --------------------------- |
7+
| Status | `approved` |
8+
| Author | bartstc |
9+
| Created | 2026-04-17 |
10+
| Last updated | 2026-04-17 |
11+
| Spec directory | `specs/004-product-rating/` |
12+
13+
---
14+
15+
## 1. Goal & Context
16+
17+
The `StarRating` component currently renders read-only stars derived from the marketing product's aggregate rating. Users have no way to contribute their own rating. This feature makes the stars interactive: clicking a star submits a 1–5 integer rating to `PATCH /api/marketing/products/:id/rate`, updates the displayed rating optimistically, and disables further rating in that session. Unauthenticated users see a warning toast instead.
18+
19+
## 2. Requirements
20+
21+
- **R1**: WHEN a logged-in user clicks a star (1–5), THE SYSTEM SHALL immediately update the displayed rating optimistically and call `PATCH /api/marketing/products/:id/rate` with the selected integer.
22+
- **R2**: WHEN the API call fails, THE SYSTEM SHALL revert the displayed rating to its pre-click value and show an error toast.
23+
- **R3**: WHEN an unauthenticated user clicks a star, THE SYSTEM SHALL show a warning toast "You have to log in to rate the product" and make no API call.
24+
- **R4**: WHEN a user has submitted a rating in the current session, THE SYSTEM SHALL disable the star controls so no further rating is possible.
25+
- **R5**: WHEN the rating mutation is pending, THE SYSTEM SHALL visually disable the star controls to prevent double submission.
26+
27+
## 3. Non-Goals / Out of Scope
28+
29+
- Persisting "already rated" state across sessions (no localStorage / server-side deduplication).
30+
- Half-star or decimal rating input.
31+
- Showing the user's own previously submitted rating vs. the aggregate.
32+
- Any changes to the "see reviews" button behaviour.
33+
- Rate-limiting or throttling beyond disabling after one submission.
34+
35+
## 4. Building Blocks Diff
36+
37+
### Added
38+
39+
- `useRateProductMutation` (mutation-hook) — `src/lib/api/marketing/{product-id}/use-rate-product-mutation.ts` — calls `PATCH /api/marketing/products/:id/rate`, applies optimistic update to cached `MarketingProductDto`, rolls back on error
40+
- `useRateProductMutation` (mutation-hook, providers re-export) — `src/features/products/providers/use-rate-product-mutation.ts`
41+
- `useRateProductNotifications` (notification-hook) — `src/features/products/application/use-rate-product-notifications.ts` — warning toast for unauthenticated, error toast on failure
42+
- `useRateProduct` (use-case-hook) — `src/features/products/application/use-rate-product.ts` — checks auth, calls mutation, dispatches notifications; returns `{ rate, isPending, hasRated }`
43+
44+
### Modified
45+
46+
- `StarRating` (component) — `src/features/products/components/StarRating.tsx` — add `productId` prop, wire `useRateProduct`, make stars clickable, disable after rating or while pending
47+
- `ProductDetails` (component) — `src/features/products/components/ProductDetails.tsx` — pass `product.id` as `productId` to `StarRating`
48+
- `public/locales/en/translation.json` — add toast keys under `features.products.rating`
49+
50+
## 5. Design Decisions
51+
52+
- **Optimistic update in the mutation, not local state**: The cache already holds `MarketingProductDto`; mutating it directly keeps one source of truth. A separate `useState` would duplicate state and diverge on rollback.
53+
- **Auth check in `useRateProduct`**: Consistent with `useAddToCart` — auth guard lives in the use-case hook, not in the component or mutation. Component stays presentational.
54+
- **`hasRated` as local state in `useRateProduct`**: Session-only disable with no persistence, per requirements. Clean `useState<boolean>` inside the hook keeps the mutation layer unaware.
55+
56+
## 6. Boundaries
57+
58+
### ✅ Always
59+
60+
- Create/modify files under `src/features/products/`
61+
- Create files under `src/lib/api/marketing/`
62+
- Add translation keys to `public/locales/en/translation.json`
63+
64+
### ⚠️ Ask First
65+
66+
- Any change to `MarketingProductDto` in `src/lib/api/marketing/{product-id}/marketing-product-dto.ts`
67+
68+
### 🚫 Never
69+
70+
- Modify `server/` source files
71+
- Remove or skip existing tests
72+
- Change the `rateMarketingProductHandler` API contract
73+
74+
## 7. Task Breakdown
75+
76+
1. [S] Add `useRateProductMutation` with optimistic update and rollback — `src/lib/api/marketing/{product-id}/use-rate-product-mutation.ts` (R1, R2)
77+
2. [S] Re-export `useRateProductMutation` from providers — `src/features/products/providers/use-rate-product-mutation.ts` (R1, R2)
78+
3. [P] Add `useRateProductNotifications``src/features/products/application/use-rate-product-notifications.ts` (R2, R3)
79+
4. [P] Add translation keys for rating toasts — `public/locales/en/translation.json` (R2, R3)
80+
5. [S] Add `useRateProduct` use-case hook — `src/features/products/application/use-rate-product.ts` (R1, R2, R3, R4, R5)
81+
6. [S] Modify `StarRating` — add `productId` prop, wire `useRateProduct`, clickable + disabled states — `src/features/products/components/StarRating.tsx` (R1, R4, R5)
82+
7. [S] Pass `productId` from `ProductDetails` to `StarRating``src/features/products/components/ProductDetails.tsx` (R1)
83+
84+
## 8. Error & Edge Cases
85+
86+
- GIVEN the API returns a non-2xx response, WHEN the mutation settles, THEN revert the optimistic rating to the pre-click cached value and show an error toast.
87+
- GIVEN the user is unauthenticated, WHEN a star is clicked, THEN show the warning toast and do not call the mutation; stars remain enabled so the user can log in and retry.
88+
- GIVEN the mutation is in-flight, WHEN the user attempts to click another star, THEN controls are disabled and no second request is made.
89+
- GIVEN the user has successfully rated, WHEN the component re-renders due to a query refetch, THEN `hasRated` persists in hook state and stars remain disabled.
90+
- GIVEN the cached `MarketingProductDto` is absent, WHEN a star is clicked, THEN the optimistic update is skipped gracefully and the mutation still fires; rollback is a no-op.
91+
92+
## 10. Open Questions
93+
94+
_(none)_
95+
96+
## 11. References
97+
98+
- `server/src/modules/marketing/marketing.handlers.ts``rateMarketingProductHandler` implementation
99+
- `server/src/modules/marketing/marketing.routes.ts``PATCH /api/marketing/products/:id/rate` route
100+
- `src/features/carts/application/use-add-to-cart.ts` — auth-check pattern to follow

specs/lessons.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,41 @@ Patterns captured after corrections. Review at session start.
3939
**How to apply:** Before using any type from `src/lib/api/` in a component or page, check whether a domain alias exists in the feature's `models/`. If not, create one following the pattern in `product.ts`: `export type { XxxDto as Xxx } from "@/lib/api/..."`.
4040

4141
**Source:** Correction 2026-04-12 — `MarketingProductDto` leaked into `ProductDetails.tsx` component props.
42+
43+
---
44+
45+
## L003 — Mutation building blocks in specs must be named as hooks (use\* prefix)
46+
47+
**Rule:** Mutation hooks in `lib/api/` and `providers/` are React hooks and must follow the `use` prefix convention. In specs, name them `useXxxMutation`, never `xxxMutation`.
48+
49+
**Why it failed:** `rateMarketingProductMutation` was listed in the Building Blocks Diff without the `use` prefix, despite being a hook that calls `useMutation` internally.
50+
51+
**How to apply:** In any spec Building Blocks Diff, check every mutation-hook entry — if it wraps `useMutation`, prefix it with `use`. File name uses kebab-case: `use-rate-product-mutation.ts`.
52+
53+
Also, we might have inconsistency, as queries from lib/api are exposed by query factories, not query hooks, which might be confusing. Maybe we should always export useQuery (default version) as well, then we would have consistent exports there: useSmthMutation (use-smth-mutation) and useSmthQuery (use-smth-query.ts).
54+
55+
**Source:** Correction 2026-04-17 — spec 004-product-rating Building Blocks Diff.
56+
57+
---
58+
59+
## L004 — Spec boundaries must only list items within the feature's scope
60+
61+
**Rule:** The ⚠️ Ask First boundary tier must only include items that are plausible within the current feature's scope. Generic project-wide concerns (e.g. "adding npm dependencies") that have no connection to the feature being specced must be omitted.
62+
63+
**Why it failed:** "Adding new npm dependencies" was added to ⚠️ Ask First in a spec that adds no dependencies — it was a boilerplate copy-paste rather than a scope-specific boundary.
64+
65+
**How to apply:** Before finalising the Boundaries section, ask: "Is this item actually reachable during implementation of this feature?" If no, remove it.
66+
67+
**Source:** Correction 2026-04-17 — spec 004-product-rating Boundaries section.
68+
69+
---
70+
71+
## L005 — Never explore the codebase for patterns during implementation — read spec and building-blocks rules instead
72+
73+
**Rule:** Before implementing any building block, read the spec's Building Blocks Diff and the corresponding rule file in `.agents/skills/building-blocks/rules/`. Do not open existing feature files to reverse-engineer patterns. If a rule file is missing or ambiguous, raise that gap — do not substitute with file exploration.
74+
75+
**Why it failed:** An agent issued broad `read`/`list` calls across multiple feature folders (`providers/`, `application/`, `models/`, `lib/api/`) to infer patterns from live code. The spec already listed every building block to create, and the building-blocks skill already documents the canonical pattern for each one. The exploration was pure redundancy — and risks drifting toward the existing code's quirks rather than the authoritative pattern.
76+
77+
**How to apply:** At the start of every implementation task: (1) open the spec, identify every block in the Building Blocks Diff, (2) for each block, load the matching rule file from `.agents/skills/building-blocks/rules/<block>.md`, (3) implement against the rule, not against existing files. Only read an existing file when the spec explicitly says "follow the pattern in `path/to/file.ts`" or when you need to find the exact symbol to import (e.g. a query key or provider name).
78+
79+
**Source:** Correction 2026-04-17 — spec 004-product-rating implementation; agent explored 9+ existing files instead of reading building-blocks rules.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useToast } from "@/lib/components/Toast/use-toast";
2+
import { useTranslations } from "@/lib/i18n/use-transations";
3+
4+
export const useRateProductNotifications = () => {
5+
const t = useTranslations("features.products.rating.notifications");
6+
const toast = useToast();
7+
8+
const notifyNotAuthenticated = () =>
9+
toast({
10+
status: "warning",
11+
title: t("title"),
12+
description: t("not-authenticated"),
13+
});
14+
15+
const notifySuccess = () =>
16+
toast({
17+
status: "success",
18+
title: t("title"),
19+
description: t("success"),
20+
});
21+
22+
const notifyFailure = () =>
23+
toast({
24+
status: "error",
25+
title: t("title"),
26+
description: t("error"),
27+
});
28+
29+
return { notifyNotAuthenticated, notifySuccess, notifyFailure } as const;
30+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useState } from "react";
2+
3+
import { useAuthStore } from "@/features/auth/application/auth-store";
4+
import { useRateProductMutation } from "@/features/products/providers/use-rate-product-mutation";
5+
6+
import { useRateProductNotifications } from "./use-rate-product-notifications";
7+
8+
export const useRateProduct = () => {
9+
const isAuthenticated = useAuthStore((store) => store.isAuthenticated);
10+
const [mutateAsync, isPending] = useRateProductMutation();
11+
const { notifyNotAuthenticated, notifySuccess, notifyFailure } =
12+
useRateProductNotifications();
13+
const [hasRated, setHasRated] = useState(false);
14+
15+
const rate = async (productId: string, rating: number) => {
16+
if (!isAuthenticated) {
17+
notifyNotAuthenticated();
18+
return;
19+
}
20+
21+
try {
22+
await mutateAsync(productId, rating);
23+
setHasRated(true);
24+
notifySuccess();
25+
} catch {
26+
notifyFailure();
27+
}
28+
};
29+
30+
return { rate, isPending, hasRated };
31+
};

src/features/products/components/ProductDetails.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ const ProductDetails = ({ product, marketingProduct, onBack }: IProps) => {
7474
{moneyVO.format(product.price.amount, product.price.currency)}
7575
</Text>
7676
<Separator orientation="vertical" />
77-
<StarRating rating={marketingProduct?.rating.rate ?? 0} />
77+
<StarRating
78+
rating={marketingProduct?.rating.rate ?? 0}
79+
productId={product.id}
80+
/>
7881
<Button variant="plain" colorPalette="orange">
7982
{t("see-reviews", {
8083
number: marketingProduct?.rating.count ?? 0,

src/features/products/components/StarRating.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ type Story = StoryObj<typeof meta>;
1616
export const Default: Story = {
1717
args: {
1818
rating: 3.7,
19+
productId: "1",
1920
},
2021
};

src/features/products/components/StarRating.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,55 @@
11
import { HStack, Icon, Portal, Tooltip } from "@chakra-ui/react";
22
import { Star } from "lucide-react";
3+
import { useState } from "react";
34

5+
import { useRateProduct } from "@/features/products/application/use-rate-product";
46
import { useTranslations } from "@/lib/i18n/use-transations";
57
import { useColorModeValue } from "@/lib/theme/use-color-mode";
68

79
interface IProps {
810
rating: number;
11+
productId: string;
912
}
1013

11-
const StarRating = ({ rating }: IProps) => {
12-
const idleStar = useColorModeValue("gray.400", "gray.600");
14+
const StarRating = ({ rating, productId }: IProps) => {
15+
const idleStar = useColorModeValue("gray.300", "gray.600");
1316
const activeStar = useColorModeValue("gray.700", "gray.300");
1417
const t = useTranslations("features.products.rating");
18+
const { rate, isPending, hasRated } = useRateProduct();
19+
const isDisabled = isPending || hasRated;
20+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
1521

16-
const countColor = (index: number) => {
22+
const starColor = (index: number) => {
23+
if (!isDisabled && hoveredIndex !== null) {
24+
return index <= hoveredIndex ? "yellow.400" : idleStar;
25+
}
1726
return Math.round(rating) >= index ? activeStar : idleStar;
1827
};
1928

2029
return (
2130
<Tooltip.Root>
2231
<Tooltip.Trigger asChild>
23-
<HStack gap={1} display="flex" alignItems="center" mt={2}>
32+
<HStack
33+
gap={1}
34+
display="flex"
35+
alignItems="center"
36+
mt={2}
37+
opacity={isDisabled ? 0.5 : 1}
38+
cursor={isDisabled ? "not-allowed" : "pointer"}
39+
onMouseLeave={() => setHoveredIndex(null)}
40+
>
2441
{Array.from([1, 2, 3, 4, 5]).map((number) => (
25-
<Icon key={number} color={countColor(number)} boxSize={3}>
42+
<Icon
43+
key={number}
44+
color={starColor(number)}
45+
boxSize={3}
46+
style={{ transition: "color 0.15s ease" }}
47+
onMouseEnter={
48+
isDisabled ? undefined : () => setHoveredIndex(number)
49+
}
50+
onClick={isDisabled ? undefined : () => rate(productId, number)}
51+
cursor={isDisabled ? "not-allowed" : "pointer"}
52+
>
2653
<Star fill="currentColor" />
2754
</Icon>
2855
))}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useRateProductMutation } from "@/lib/api/marketing/{product-id}/use-rate-product-mutation";
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
3+
import { marketingQueryKeys } from "@/lib/api/marketing/marketing-query-keys";
4+
import { httpService } from "@/lib/http";
5+
import { Logger } from "@/lib/logger";
6+
import { UnknownError } from "@/lib/types/unknown-error";
7+
8+
import type { MarketingProductDto } from "./marketing-product-dto";
9+
10+
interface RateProductDto {
11+
productId: string;
12+
rating: number;
13+
}
14+
15+
interface MutationContext {
16+
previous: MarketingProductDto | undefined;
17+
productId: string;
18+
}
19+
20+
export const useRateProductMutation = () => {
21+
const queryClient = useQueryClient();
22+
23+
const { mutateAsync, isPending } = useMutation<
24+
MarketingProductDto,
25+
unknown,
26+
RateProductDto,
27+
MutationContext
28+
>({
29+
mutationFn: ({ productId, rating }) =>
30+
httpService.patch<MarketingProductDto, { rating: number }>(
31+
`marketing/products/${productId}/rate`,
32+
{ rating }
33+
),
34+
onMutate: async ({ productId, rating }) => {
35+
const queryKey = marketingQueryKeys.product(productId);
36+
await queryClient.cancelQueries({ queryKey });
37+
38+
const previous = queryClient.getQueryData<MarketingProductDto>(queryKey);
39+
40+
if (previous) {
41+
const { rate: oldRate, count: oldCount } = previous.rating;
42+
const newCount = oldCount + 1;
43+
const newRate =
44+
Math.round(((oldRate * oldCount + rating) / newCount) * 100) / 100;
45+
46+
queryClient.setQueryData<MarketingProductDto>(queryKey, {
47+
...previous,
48+
rating: { rate: newRate, count: newCount },
49+
});
50+
}
51+
52+
return { previous, productId };
53+
},
54+
onError: (_err, _vars, context) => {
55+
if (context?.previous) {
56+
queryClient.setQueryData(
57+
marketingQueryKeys.product(context.productId),
58+
context.previous
59+
);
60+
}
61+
},
62+
});
63+
64+
const handler = async (productId: string, rating: number) => {
65+
try {
66+
return await mutateAsync({ productId, rating });
67+
} catch (e) {
68+
Logger.error("An error occurred during rating the product", e as Error);
69+
throw new UnknownError();
70+
}
71+
};
72+
73+
return [handler, isPending] as const;
74+
};

0 commit comments

Comments
 (0)