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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ This is an Nx monorepo for an e-commerce platform built with Medusa.js. The proj
- `medusa-be`: Medusa.js v2 backend
- `medusa-demo`: Next.js demo frontend (reference implementation)
- `medusa-fe`: Next.js frontend (reference implementation)
- `herbatika`: Next.js 16 storefront app
- **libs/**: Shared libraries
- `ui`: Component library built with Zag.js and Tailwind CSS
- `storefront-data`: TanStack Query + Medusa storefront data layer

### Subtree Guidance
- When touching `apps/herbatika`, read `apps/herbatika/AGENTS.md` before editing.
It overrides the root frontend-demo server assumption with Herbatika's
`http://localhost:3001` runtime and routes work through the `libs/ui/skills`
and `libs/storefront-data/skills` workflows.

### Key Technologies
- **Monorepo**: Nx
Expand Down Expand Up @@ -83,6 +91,7 @@ This project includes MCP (Model Context Protocol) servers configuration to enha
### Important: Development Server Assumptions
**NEVER ask to run `pnpm dev` or check if the dev server is running!**
- Always assume the development server is already running on http://localhost:3000 (for frontend-demo)
- Exception: `apps/herbatika` uses `http://localhost:3001`; follow `apps/herbatika/AGENTS.md` for Herbatika work.
- This saves time and avoids unnecessary communication
- If you need to interact with the running application, use MCP tools (especially puppeteer-mcp)

Expand Down
36 changes: 30 additions & 6 deletions apps/herbatika/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,55 @@
# Repository Guidelines
# Herbatika App Guidelines

## Scope & Precedence
- This file applies to `apps/herbatika`.
- In this monorepo, the closest `AGENTS.md` wins (app/lib-level rules override broader ones).
- If work touches `libs/ui` or `libs/storefront-data`, follow their local `AGENTS.md` as source of truth.
- Root `AGENTS.md` still applies unless this file overrides it.
- If work starts from the repo root and touches Herbatika, read this file before editing `apps/herbatika`.
- If work touches `libs/ui` or `libs/storefront-data`, follow their local `AGENTS.md` as the source of truth for library code.
- Keep app-specific behavior in this app; promote behavior into a library only when the shared package has a real API/platform gap.

## Project Context
- Stack: Next.js 16 e-shop (App Router), React 19, Tailwind v4 tokenized styling.
- Main app code: `src/app`, `src/components`, `src/lib`, `src/styles/tokens`, `public`.
- Shared packages consumed by the app: `@techsio/ui-kit` and `@techsio/storefront-data`.
- Design references: `local/screens/figma-2/`.
- You may read `local/` reference material, but do not edit ignored local workspace files unless the user explicitly asks.
- Generated outputs must not be edited directly: `.next/`, `dist/`, `storybook-static/`.

## Skill Routing
- For app UI work that consumes `@techsio/ui-kit`, read `libs/ui/skills/ui-kit-workflow-orchestrator/SKILL.md` first.
- App usage normally routes to `component-usage-ux`, the exact component `*-usage` skill, `framework-consumer-integration`, `app-token-overrides`, and `app-ui-kit-audit`.
- Library authoring routes through `libs/ui/AGENTS.md` and the library authoring/token/storybook skills instead.
- For storefront data work involving `@techsio/storefront-data`, read `libs/storefront-data/skills/use-storefront-data-skills/SKILL.md` first.
- Pick the smallest relevant follow-up skill: setup, migration, catalog/product reads, auth, cart/checkout, SSR/query boundaries, cache/pagination, or release audit.
- Load only the relevant skill files for the task. Do not duplicate their full instructions in this app-level file.

## UI & Token Guardrails
- Use internal UI primitives/components first (`@techsio/ui-kit`), not native controls in app code.
- Avoid raw `<button>`, `<input>`, `<select>`, `<textarea>`, `<img>`, inline SVG icons in app code unless there is a documented exception.
- For Next.js links/images, prefer the UI-kit component with `next/link` or `next/image` adapter support when that component exposes it.
- Use token-based utility classes, not raw Tailwind palette/spacing values.
- Prefer: `p-200 mt-300 gap-150 text-success bg-danger`
- Avoid: `p-4 mt-8 gap-4 bg-red-600 text-green-300`
- Put Herbatika visual differences in `src/styles/tokens/**`; avoid JSX `className` overrides that duplicate UI-kit component props/tokens.
- `src/styles/tokens/index.css` owns Tailwind, UI-kit token imports, Herbatika semantic/layout/spacing/typography tokens, and component token overrides.
- Run guardrails before handing off:
- `pnpm validate:token-usage`
- `pnpm validate:ui-primitives`
- or `pnpm validate:guardrails`

## Storefront Data Guardrails
- Herbatika composes the shared data layer in `src/lib/storefront/storefront.ts` and adjacent `storefront-definition*` files.
- Prefer the preset surface from `@techsio/storefront-data`: shared hooks, flows, query keys, query options, cache policy, and SSR helpers.
- Keep app-local code thin: SDK instance, field defaults, localized text, toasts, analytics, form DTOs, address adapters, and Herbatika-specific read models can stay here.
- Move repeated backend communication, query key construction, cache sync, mutation invalidation, or reusable Medusa behavior into `libs/storefront-data`.
- Use explicit package subpath imports. Do not import from package roots or `dist/`.
- Keep server-only and client-only boundaries explicit; use `server-only` and `getServerQueryClient` for Server Component/SSR data paths.

## Build, Run, and Validation
- `pnpm dev` runs the app on `http://localhost:3001`.
- `pnpm build` builds production output.
- `pnpm start` serves the production build.
- From `apps/herbatika`, `pnpm dev` runs the app on `http://localhost:3001`.
- From the repo root, prefer `pnpm -C apps/herbatika <script>` for app scripts.
- Do not use the root frontend-demo `localhost:3000` assumption for this app.
- `pnpm build` builds production output; `pnpm start` serves the production build.
- Temporary rule: do not run Biome commands in this app until stability issue is resolved.

## Testing Workflow
Expand Down
34 changes: 34 additions & 0 deletions apps/herbatika/src/app/reviews/product/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ProductReviewTokenPage } from "@/components/reviews/product-review-token-page";

type ProductReviewTokenRouteProps = {
params: Promise<{
token: string;
}>;
searchParams: Promise<{
product_id?: string | string[];
}>;
};

const resolveSearchParam = (value?: string | string[]) =>
Array.isArray(value) ? value[0] : value;

export const metadata = {
title: "Napísať recenziu | Herbatica",
};

export default async function ProductReviewTokenRoute({
params,
searchParams,
}: ProductReviewTokenRouteProps) {
const [{ token }, resolvedSearchParams] = await Promise.all([
params,
searchParams,
]);

return (
<ProductReviewTokenPage
productId={resolveSearchParam(resolvedSearchParams.product_id)}
token={token}
/>
);
}
110 changes: 74 additions & 36 deletions apps/herbatika/src/components/product-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,65 @@
"use client"

import { Button } from "@techsio/ui-kit/atoms/button"
import { LinkButton } from "@techsio/ui-kit/atoms/link-button"
import { StatusText } from "@techsio/ui-kit/atoms/status-text"
import NextLink from "next/link"
import { useEffect } from "react"
import { HerbatikaBreadcrumb } from "@/components/herbatika-breadcrumb"
import type { ProductDetailProps } from "@/components/product-detail/product-detail.types"
import { ProductDetailHero } from "@/components/product-detail/sections/product-detail-hero"
import { ProductDetailMetrics } from "@/components/product-detail/sections/product-detail-metrics"
import { ProductDetailOffers } from "@/components/product-detail/sections/product-detail-offers"
import { ProductDetailRelated } from "@/components/product-detail/sections/product-detail-related"
import { ProductDetailSkeleton } from "@/components/product-detail/sections/product-detail-skeleton"
import { ProductDetailTabs } from "@/components/product-detail/sections/product-detail-tabs"
import { useProductDetailController } from "@/components/product-detail/use-product-detail-controller"
import { RecentlyVisitedProductsSection } from "@/components/recently-visited-products-section"
import { runDetachedPromise } from "@/lib/storefront/detached-promise"
"use client";

import { Button } from "@techsio/ui-kit/atoms/button";
import { LinkButton } from "@techsio/ui-kit/atoms/link-button";
import { StatusText } from "@techsio/ui-kit/atoms/status-text";
import NextLink from "next/link";
import { useCallback, useEffect, useState } from "react";
import { HerbatikaBreadcrumb } from "@/components/herbatika-breadcrumb";
import type { ProductDetailProps } from "@/components/product-detail/product-detail.types";
import { ProductDetailHero } from "@/components/product-detail/sections/product-detail-hero";
import { ProductDetailMetrics } from "@/components/product-detail/sections/product-detail-metrics";
import { ProductDetailOffers } from "@/components/product-detail/sections/product-detail-offers";
import { ProductDetailRelated } from "@/components/product-detail/sections/product-detail-related";
import {
PRODUCT_DETAIL_REVIEWS_SECTION_ID,
PRODUCT_DETAIL_REVIEWS_TAB_VALUE,
} from "@/components/product-detail/sections/product-detail-review-utils";
import { ProductDetailSkeleton } from "@/components/product-detail/sections/product-detail-skeleton";
import { ProductDetailTabs } from "@/components/product-detail/sections/product-detail-tabs";
import { useProductDetailController } from "@/components/product-detail/use-product-detail-controller";
import { RecentlyVisitedProductsSection } from "@/components/recently-visited-products-section";
import { runDetachedPromise } from "@/lib/storefront/detached-promise";

export function ProductDetail({ handle }: ProductDetailProps) {
const controller = useProductDetailController({ handle })
const controller = useProductDetailController({ handle });
const [activeInfoSection, setActiveInfoSection] = useState(
controller.defaultInfoSectionValue,
);

useEffect(() => {
setActiveInfoSection(controller.defaultInfoSectionValue);
}, [controller.defaultInfoSectionValue, controller.product?.id]);

useEffect(() => {
if (window.location.hash) {
return
return;
}

window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, [handle]);

useEffect(() => {
if (window.location.hash !== `#${PRODUCT_DETAIL_REVIEWS_SECTION_ID}`) {
return;
}

window.scrollTo({ top: 0, left: 0, behavior: "auto" })
}, [])
setActiveInfoSection(PRODUCT_DETAIL_REVIEWS_TAB_VALUE);
}, [controller.product?.id]);

const handleShowAllReviews = useCallback(() => {
setActiveInfoSection(PRODUCT_DETAIL_REVIEWS_TAB_VALUE);
window.history.replaceState(
Comment thread
KaiUweCZE marked this conversation as resolved.
null,
"",
`#${PRODUCT_DETAIL_REVIEWS_SECTION_ID}`,
);
window.requestAnimationFrame(() => {
document
.getElementById(PRODUCT_DETAIL_REVIEWS_SECTION_ID)
?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}, []);

return (
<main className="mx-auto flex w-full max-w-max-w flex-col gap-product-detail-page-gap p-product-detail-page font-rubik 2xl:p-product-detail-page-lg">
Expand All @@ -43,7 +76,7 @@ export function ProductDetail({ handle }: ProductDetailProps) {
</StatusText>
<Button
onClick={() => {
runDetachedPromise(controller.productQuery.query.refetch())
runDetachedPromise(controller.productQuery.query.refetch());
}}
variant="secondary"
>
Expand All @@ -52,10 +85,10 @@ export function ProductDetail({ handle }: ProductDetailProps) {
</section>
) : null}

{controller.isBootstrappingRegion ||
controller.productQuery.isLoading ||
controller.productQuery.error ||
controller.product ? null : (
{!controller.isBootstrappingRegion &&
!controller.productQuery.isLoading &&
!controller.productQuery.error &&
!controller.product ? (
<section className="space-y-400 rounded-xl border border-border-secondary bg-surface p-600">
<StatusText showIcon status="error">
Produkt sa nepodarilo nájsť.
Expand All @@ -64,13 +97,12 @@ export function ProductDetail({ handle }: ProductDetailProps) {
Späť na domovskú stránku
</LinkButton>
</section>
)}
) : null}

{!(
controller.isBootstrappingRegion ||
controller.productQuery.isLoading ||
controller.productQuery.error
) && controller.product ? (
{!controller.isBootstrappingRegion &&
!controller.productQuery.isLoading &&
!controller.productQuery.error &&
controller.product ? (
<>
<ProductDetailHero
canAddToCart={controller.canAddToCart}
Expand All @@ -79,9 +111,9 @@ export function ProductDetail({ handle }: ProductDetailProps) {
displayOriginalLabel={controller.displayOriginalLabel}
freeShippingThresholdLabel={controller.freeShippingThresholdLabel}
galleryItems={controller.galleryItems}
mediaFacts={controller.mediaFacts}
isAdding={controller.isMainProductAdding}
maxQuantity={controller.maxQuantity}
mediaFacts={controller.mediaFacts}
offerState={controller.offerState}
onAddToCart={controller.handleAddMainProductToCart}
onQuantityChange={controller.handleQuantityChange}
Expand Down Expand Up @@ -110,10 +142,16 @@ export function ProductDetail({ handle }: ProductDetailProps) {
) : null}
</ProductDetailHero>

<ProductDetailMetrics />
<ProductDetailMetrics
onShowAllReviews={handleShowAllReviews}
productId={controller.product.id}
/>

<ProductDetailTabs
activeSectionValue={activeInfoSection}
defaultSectionValue={controller.defaultInfoSectionValue}
onSectionValueChange={setActiveInfoSection}
productId={controller.product.id}
sections={controller.productContentSections}
/>
</>
Expand All @@ -135,5 +173,5 @@ export function ProductDetail({ handle }: ProductDetailProps) {
</>
) : null}
</main>
)
);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,78 @@
"use client"
"use client";

import { ReviewsSection } from "@/components/reviews/reviews-section"
import { ReviewSkeleton } from "@/components/loading/review-skeleton";
import {
formatReviewScore,
PRODUCT_DETAIL_REVIEWS_SECTION_ID,
toReviewItem,
} from "@/components/product-detail/sections/product-detail-review-utils";
import { ReviewsSection } from "@/components/reviews/reviews-section";
import {
PRODUCT_REVIEWS_PAGE_SIZE,
useProductReviews,
} from "@/lib/storefront/reviews";

type ProductDetailMetricsProps = {
onShowAllReviews?: () => void;
productId?: string | null;
};

const PRODUCT_REVIEW_TEASER_LIMIT = 4;

function ProductDetailMetricsSkeleton() {
return (
<section className="space-y-500 pt-750">
<div className="grid grid-cols-1 gap-500 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: PRODUCT_REVIEW_TEASER_LIMIT }, (_, index) => (
<ReviewSkeleton key={`product-review-teaser-skeleton-${index + 1}`} />
))}
</div>
</section>
);
}

export function ProductDetailMetrics({
onShowAllReviews,
productId,
}: ProductDetailMetricsProps) {
const reviewsQuery = useProductReviews({
enabled: Boolean(productId),
limit: PRODUCT_REVIEWS_PAGE_SIZE,
offset: 0,
productId: productId ?? undefined,
});
const reviews = reviewsQuery.reviews
.slice(0, PRODUCT_REVIEW_TEASER_LIMIT)
.map(toReviewItem);

if (!productId) {
return null;
}

if (reviewsQuery.isLoading && reviews.length === 0) {
return <ProductDetailMetricsSkeleton />;
}

if (reviews.length === 0) {
return null;
}

export function ProductDetailMetrics() {
return (
<ReviewsSection sectionClassName="space-y-500 pt-750" variant="product" />
)
<ReviewsSection
linkHref={`#${PRODUCT_DETAIL_REVIEWS_SECTION_ID}`}
onLinkClick={(event) => {
if (!onShowAllReviews) {
return;
}

event.preventDefault();
onShowAllReviews();
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ratingValue={reviewsQuery.summary.average_rating}
reviews={reviews}
scoreLabel={formatReviewScore(reviewsQuery.summary.average_rating)}
sectionClassName="space-y-500 pt-750"
variant="product"
/>
);
}
Loading
Loading