Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Each feature follows feature slice architecture patterns with four layers:
| `providers/` | `models/`, `lib/api/`, `lib/*` |
| `models/` | `lib/api/`, `lib/*` |

**Cross-slice primitives:** `features/auth/` and `features/authv2/` are cross-cutting concerns (identity, permissions, auth state). Any feature slice may import from them.

## API Library

`src/lib/api/` is the global home for all HTTP logic: `queryOptions` factories, loaders, mutation hooks, query keys, domain errors, and DTOs, organised by resource. Query files expose `queryOptions` factories (no `useQuery` hooks — hook composition belongs in `providers/`). Feature `providers/` compose hooks on top of those factories and re-export them for feature slice. New API logic always goes in `src/lib/api/` first, then gets exposed through the relevant feature's `providers/`.
Expand Down
4 changes: 3 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ const allFeatureSlices = [
"products",
];

// AIDEV-NOTE: `auth` and `authv2` are cross-slice primitives (identity, permissions,
// auth state). Any feature may import from them. See docs/architecture.md
const featureToFeatureZones = featureSlices.map((feature) => ({
target: `./src/features/${feature}`,
from: "./src/features",
except: [`./${feature}`, "./auth"],
except: [`./${feature}`, "./auth", "./authv2"],
message: "Avoid importing from other features.",
}));

Expand Down
8 changes: 5 additions & 3 deletions public/locales/en-GB/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,19 @@
}
},
"features": {
"products": {
"marketing": {
"rating": {
"tooltip": "Average customer rating: {rating}",
"see-reviews": "See all {{number}} reviews",
"notifications": {
"title": "Product rating",
"not-authenticated": "You have to log in to rate the product",
"success": "Thank you! Your rating has been submitted.",
"error": "Something went wrong with submitting your rating. Please try again or contact us."
}
},
}
},
"products": {
"categories": {
"clothing": "Clothing",
"jewelery": "Jewelery",
Expand All @@ -77,7 +80,6 @@
},
"details": {
"collection": "A part of our {{category}} collection.",
"see-reviews": "See all {{number}} reviews",
"back-to-list": "Back to products' list",
"features": "Features",
"features-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useToast } from "@/lib/components/Toast/use-toast";
import { useTranslations } from "@/lib/i18n/use-transations";

export const useRateProductNotifications = () => {
const t = useTranslations("features.products.rating.notifications");
const t = useTranslations("features.marketing.rating.notifications");
const toast = useToast();

const notifyNotAuthenticated = () =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from "react";

import { useAuthStore } from "@/features/auth/application/auth-store";
import { useRateProductMutation } from "@/features/products/providers/use-rate-product-mutation";
import { useRateProductMutation } from "@/features/marketing/providers/use-rate-product-mutation";

import { useRateProductNotifications } from "./use-rate-product-notifications";

Expand Down
25 changes: 25 additions & 0 deletions src/features/marketing/components/ProductRating.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import { getMarketingProductHandler } from "@/test-lib/handlers/get-marketing-product-handler";

import { ProductRating } from "./ProductRating";

const meta = {
title: "modules/Marketing/ProductRating",
component: ProductRating,
parameters: {
layout: "centered",
msw: {
handlers: [getMarketingProductHandler()],
},
},
} satisfies Meta<typeof ProductRating>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
productId: "1",
},
};
29 changes: 29 additions & 0 deletions src/features/marketing/components/ProductRating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Button, HStack } from "@chakra-ui/react";

import { useMarketingProductQuery } from "@/features/marketing/providers/use-marketing-product-query";
import { useTranslations } from "@/lib/i18n/use-transations";

import { StarRating } from "./StarRating";

interface IProps {
productId: string;
}

const ProductRating = ({ productId }: IProps) => {
const { data: marketingProduct } = useMarketingProductQuery(productId);
const t = useTranslations("features.marketing.rating");

return (
<HStack gap={4}>
<StarRating
rating={marketingProduct?.rating.rate ?? 0}
productId={productId}
/>
<Button variant="plain" colorPalette="orange">
{t("see-reviews", { number: marketingProduct?.rating.count ?? 0 })}
</Button>
</HStack>
);
};

export { ProductRating };
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { StarRating } from "./StarRating";

const meta = {
title: "modules/Products/StarRating",
title: "modules/Marketing/StarRating",
component: StarRating,
parameters: {
layout: "centered",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HStack, Icon, Portal, Tooltip } from "@chakra-ui/react";
import { Star } from "lucide-react";
import { useState } from "react";

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

Expand All @@ -14,7 +14,7 @@ interface IProps {
const StarRating = ({ rating, productId }: IProps) => {
const idleStar = useColorModeValue("gray.300", "gray.600");
const activeStar = useColorModeValue("gray.700", "gray.300");
const t = useTranslations("features.products.rating");
const t = useTranslations("features.marketing.rating");
const { rate, isPending, hasRated } = useRateProduct();
const isDisabled = isPending || hasRated;
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
Expand Down
10 changes: 10 additions & 0 deletions src/features/marketing/providers/use-marketing-product-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {
marketingProductLoader,
marketingProductQuery,
} from "@/lib/api/marketing/{product-id}/marketing-product-query";
import { useQuery } from "@/lib/query";

export { marketingProductLoader };

export const useMarketingProductQuery = (productId: string) =>
useQuery(marketingProductQuery(productId));
10 changes: 8 additions & 2 deletions src/features/products/components/ProductDetails.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { HStack, Text } from "@chakra-ui/react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { action } from "storybook/actions";
import { withRouter } from "storybook-addon-remix-react-router";

import { MarketingProductFixture } from "@/test-lib/fixtures/marketing-product-fixture";
import { ProductFixture } from "@/test-lib/fixtures/product-fixture";
import { getAddToCartHandler } from "@/test-lib/handlers/get-add-to-cart-handler";

Expand All @@ -26,7 +26,13 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
product: ProductFixture.toStructure(),
marketingProduct: MarketingProductFixture.toStructure(),
children: (
<HStack gap={4}>
<Text fontSize="sm" color="orange.400">
{"★★★★☆ (42 reviews)"}
</Text>
</HStack>
),
onBack: action("back to products' list"),
},
};
17 changes: 4 additions & 13 deletions src/features/products/components/ProductDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ import {
Text,
VStack,
} from "@chakra-ui/react";
import type { ReactNode } from "react";

import { AddToCartButton } from "@/features/carts/components/AddToCartButton/AddToCartButton";
import { ProductAddedDialog } from "@/features/carts/components/AddToCartButton/ProductAddedDialog";
import { StarRating } from "@/features/products/components/StarRating";
import { useCategoryLabel } from "@/features/products/components/use-category-label";
import type { MarketingProduct } from "@/features/products/models/marketing-product";
import type { Product } from "@/features/products/models/product";
import { PageHeader } from "@/lib/components/Layout/PageHeader";
import { moneyVO } from "@/lib/format/money";
Expand All @@ -24,7 +23,7 @@ import { useSecondaryTextColor } from "@/lib/theme/use-secondary-text-color";

interface IProps {
product: Product;
marketingProduct: MarketingProduct;
children?: ReactNode;
onBack: () => void;
}

Expand All @@ -35,7 +34,7 @@ const accordionItems = [
{ value: "returns", labelKey: "returns", contentKey: "returns-content" },
] as const;

const ProductDetails = ({ product, marketingProduct, onBack }: IProps) => {
const ProductDetails = ({ product, children, onBack }: IProps) => {
const categoryLabel = useCategoryLabel(product.category);
const secondaryColor = useSecondaryTextColor();
const t = useTranslations("features.products.details");
Expand Down Expand Up @@ -74,15 +73,7 @@ const ProductDetails = ({ product, marketingProduct, onBack }: IProps) => {
{moneyVO.format(product.price.amount, product.price.currency)}
</Text>
<Separator orientation="vertical" />
<StarRating
rating={marketingProduct?.rating.rate ?? 0}
productId={product.id}
/>
<Button variant="plain" colorPalette="orange">
{t("see-reviews", {
number: marketingProduct?.rating.count ?? 0,
})}
</Button>
{children}
</HStack>
<Text
color={secondaryColor}
Expand Down

This file was deleted.

4 changes: 4 additions & 0 deletions src/lib/api/marketing/{product-id}/marketing-product-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { queryOptions } from "@tanstack/react-query";

import { marketingQueryKeys } from "@/lib/api/marketing/marketing-query-keys";
import { httpService } from "@/lib/http";
import { queryClient } from "@/lib/query";

import type { MarketingProductDto } from "./marketing-product-dto";

Expand All @@ -11,3 +12,6 @@ export const marketingProductQuery = (productId: string) =>
queryFn: (): Promise<MarketingProductDto> =>
httpService.get<MarketingProductDto>(`marketing/products/${productId}`),
});

export const marketingProductLoader = (productId: string) =>
queryClient.ensureQueryData(marketingProductQuery(productId));
13 changes: 4 additions & 9 deletions src/pages/Product/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Button } from "@chakra-ui/react";
import { ArrowLeft } from "lucide-react";

import { ProductRating } from "@/features/marketing/components/ProductRating";
import { ProductDetails } from "@/features/products/components/ProductDetails";
import { ProductNotFoundResult } from "@/features/products/components/ProductNotFoundResult";
import { useProductQuery } from "@/features/products/providers/product-query";
import { useMarketingProductQuery } from "@/features/products/providers/use-marketing-product-query";
import { Page } from "@/lib/components/Layout/Page";
import { InternalErrorResult } from "@/lib/components/Result/InternalErrorResult";
import { ResourceNotFoundException } from "@/lib/http/exceptions/resource-not-found-exception";
Expand All @@ -15,9 +15,6 @@ const ProductPage = () => {
const params = useParams<{ productId: string }>();
const navigate = useNavigate();
const { data } = useProductQuery(params.productId!);
const { data: marketingProduct } = useMarketingProductQuery(
params.productId!
);
const t = useTranslations("pages.product");

return (
Expand All @@ -26,11 +23,9 @@ const ProductPage = () => {
<ArrowLeft />
{t("back-to-list")}
</Button>
<ProductDetails
product={data}
marketingProduct={marketingProduct}
onBack={() => navigate("/products")}
/>
<ProductDetails product={data} onBack={() => navigate("/products")}>
<ProductRating productId={params.productId!} />
</ProductDetails>
</Page>
);
};
Expand Down
7 changes: 6 additions & 1 deletion src/pages/Product/loader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { marketingProductLoader } from "@/features/marketing/providers/use-marketing-product-query";
import { productLoader } from "@/features/products/providers/product-query";
import type { LoaderFunctionArgs } from "@/lib/router";

export const productPageLoader = ({ params }: LoaderFunctionArgs) => {
return productLoader((params as { productId: string }).productId);
const productId = (params as { productId: string }).productId;
return Promise.all([
productLoader(productId),
marketingProductLoader(productId).catch(() => null),
]);
};
Loading