From caa492a2c0b0b72b208e3777c247ccee631fd84b Mon Sep 17 00:00:00 2001 From: bartstc Date: Sat, 18 Apr 2026 10:06:03 +0000 Subject: [PATCH 1/2] docs: add cross cutting feature slices --- docs/architecture.md | 2 ++ eslint.config.mjs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index ae8ee98..176f839 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 51f9b9e..fee4acc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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.", })); From bcf0a38ade0e2b15c32c770a1f210fc9e83a812d Mon Sep 17 00:00:00 2001 From: bartstc Date: Sat, 18 Apr 2026 10:19:23 +0000 Subject: [PATCH 2/2] refactor: move rating files from products to marketing feature slice --- public/locales/en-GB/translation.json | 8 +++-- .../use-rate-product-notifications.ts | 2 +- .../application/use-rate-product.ts | 2 +- .../components/ProductRating.stories.tsx | 25 ++++++++++++++++ .../marketing/components/ProductRating.tsx | 29 +++++++++++++++++++ .../components/StarRating.stories.tsx | 2 +- .../components/StarRating.tsx | 4 +-- .../models/marketing-product.ts | 0 .../providers/use-marketing-product-query.ts | 10 +++++++ .../providers/use-rate-product-mutation.ts | 0 .../components/ProductDetails.stories.tsx | 10 +++++-- .../products/components/ProductDetails.tsx | 17 +++-------- .../providers/use-marketing-product-query.ts | 5 ---- .../{product-id}/marketing-product-query.ts | 4 +++ src/pages/Product/index.tsx | 13 +++------ src/pages/Product/loader.ts | 7 ++++- 16 files changed, 100 insertions(+), 38 deletions(-) rename src/features/{products => marketing}/application/use-rate-product-notifications.ts (90%) rename src/features/{products => marketing}/application/use-rate-product.ts (89%) create mode 100644 src/features/marketing/components/ProductRating.stories.tsx create mode 100644 src/features/marketing/components/ProductRating.tsx rename src/features/{products => marketing}/components/StarRating.stories.tsx (90%) rename src/features/{products => marketing}/components/StarRating.tsx (93%) rename src/features/{products => marketing}/models/marketing-product.ts (100%) create mode 100644 src/features/marketing/providers/use-marketing-product-query.ts rename src/features/{products => marketing}/providers/use-rate-product-mutation.ts (100%) delete mode 100644 src/features/products/providers/use-marketing-product-query.ts diff --git a/public/locales/en-GB/translation.json b/public/locales/en-GB/translation.json index 9f9f187..505279c 100644 --- a/public/locales/en-GB/translation.json +++ b/public/locales/en-GB/translation.json @@ -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", @@ -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.", diff --git a/src/features/products/application/use-rate-product-notifications.ts b/src/features/marketing/application/use-rate-product-notifications.ts similarity index 90% rename from src/features/products/application/use-rate-product-notifications.ts rename to src/features/marketing/application/use-rate-product-notifications.ts index 3e1234e..2cf6f82 100644 --- a/src/features/products/application/use-rate-product-notifications.ts +++ b/src/features/marketing/application/use-rate-product-notifications.ts @@ -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 = () => diff --git a/src/features/products/application/use-rate-product.ts b/src/features/marketing/application/use-rate-product.ts similarity index 89% rename from src/features/products/application/use-rate-product.ts rename to src/features/marketing/application/use-rate-product.ts index 08f2eb3..7bf03fc 100644 --- a/src/features/products/application/use-rate-product.ts +++ b/src/features/marketing/application/use-rate-product.ts @@ -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"; diff --git a/src/features/marketing/components/ProductRating.stories.tsx b/src/features/marketing/components/ProductRating.stories.tsx new file mode 100644 index 0000000..fab52f7 --- /dev/null +++ b/src/features/marketing/components/ProductRating.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + productId: "1", + }, +}; diff --git a/src/features/marketing/components/ProductRating.tsx b/src/features/marketing/components/ProductRating.tsx new file mode 100644 index 0000000..341f883 --- /dev/null +++ b/src/features/marketing/components/ProductRating.tsx @@ -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 ( + + + + + ); +}; + +export { ProductRating }; diff --git a/src/features/products/components/StarRating.stories.tsx b/src/features/marketing/components/StarRating.stories.tsx similarity index 90% rename from src/features/products/components/StarRating.stories.tsx rename to src/features/marketing/components/StarRating.stories.tsx index db89ada..dae310a 100644 --- a/src/features/products/components/StarRating.stories.tsx +++ b/src/features/marketing/components/StarRating.stories.tsx @@ -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", diff --git a/src/features/products/components/StarRating.tsx b/src/features/marketing/components/StarRating.tsx similarity index 93% rename from src/features/products/components/StarRating.tsx rename to src/features/marketing/components/StarRating.tsx index f30f0c4..8fbe815 100644 --- a/src/features/products/components/StarRating.tsx +++ b/src/features/marketing/components/StarRating.tsx @@ -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"; @@ -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(null); diff --git a/src/features/products/models/marketing-product.ts b/src/features/marketing/models/marketing-product.ts similarity index 100% rename from src/features/products/models/marketing-product.ts rename to src/features/marketing/models/marketing-product.ts diff --git a/src/features/marketing/providers/use-marketing-product-query.ts b/src/features/marketing/providers/use-marketing-product-query.ts new file mode 100644 index 0000000..e33c3f4 --- /dev/null +++ b/src/features/marketing/providers/use-marketing-product-query.ts @@ -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)); diff --git a/src/features/products/providers/use-rate-product-mutation.ts b/src/features/marketing/providers/use-rate-product-mutation.ts similarity index 100% rename from src/features/products/providers/use-rate-product-mutation.ts rename to src/features/marketing/providers/use-rate-product-mutation.ts diff --git a/src/features/products/components/ProductDetails.stories.tsx b/src/features/products/components/ProductDetails.stories.tsx index a4b4f8c..0e82846 100644 --- a/src/features/products/components/ProductDetails.stories.tsx +++ b/src/features/products/components/ProductDetails.stories.tsx @@ -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"; @@ -26,7 +26,13 @@ type Story = StoryObj; export const Default: Story = { args: { product: ProductFixture.toStructure(), - marketingProduct: MarketingProductFixture.toStructure(), + children: ( + + + {"★★★★☆ (42 reviews)"} + + + ), onBack: action("back to products' list"), }, }; diff --git a/src/features/products/components/ProductDetails.tsx b/src/features/products/components/ProductDetails.tsx index 8f7a4b0..1b5c6d3 100644 --- a/src/features/products/components/ProductDetails.tsx +++ b/src/features/products/components/ProductDetails.tsx @@ -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"; @@ -24,7 +23,7 @@ import { useSecondaryTextColor } from "@/lib/theme/use-secondary-text-color"; interface IProps { product: Product; - marketingProduct: MarketingProduct; + children?: ReactNode; onBack: () => void; } @@ -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"); @@ -74,15 +73,7 @@ const ProductDetails = ({ product, marketingProduct, onBack }: IProps) => { {moneyVO.format(product.price.amount, product.price.currency)} - - + {children} - useQuery(marketingProductQuery(productId)); diff --git a/src/lib/api/marketing/{product-id}/marketing-product-query.ts b/src/lib/api/marketing/{product-id}/marketing-product-query.ts index 84854e4..4c812a4 100644 --- a/src/lib/api/marketing/{product-id}/marketing-product-query.ts +++ b/src/lib/api/marketing/{product-id}/marketing-product-query.ts @@ -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"; @@ -11,3 +12,6 @@ export const marketingProductQuery = (productId: string) => queryFn: (): Promise => httpService.get(`marketing/products/${productId}`), }); + +export const marketingProductLoader = (productId: string) => + queryClient.ensureQueryData(marketingProductQuery(productId)); diff --git a/src/pages/Product/index.tsx b/src/pages/Product/index.tsx index 20d2498..b0b9168 100644 --- a/src/pages/Product/index.tsx +++ b/src/pages/Product/index.tsx @@ -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"; @@ -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 ( @@ -26,11 +23,9 @@ const ProductPage = () => { {t("back-to-list")} - navigate("/products")} - /> + navigate("/products")}> + + ); }; diff --git a/src/pages/Product/loader.ts b/src/pages/Product/loader.ts index e4a99f0..9ee2d8d 100644 --- a/src/pages/Product/loader.ts +++ b/src/pages/Product/loader.ts @@ -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), + ]); };