# Cart Added Snackbar Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Show a global Snackbar notification with a "Перейти в корзину" button when a product is added to cart. **Architecture:** Effector store (`$cartSnackOpen`) as single source of truth. `CartSnackbar` component subscribes via `useUnit` and renders MUI Snackbar. `AddToCartButton` and `ToggleCartIcon` fire `cartAdded()` event on mutation success. **Tech Stack:** effector/effector-react, @mui/material (Snackbar, Alert), react-router-dom (useNavigate), vitest + testing-library --- ### Task 1: Effector store for cart notification **Files:** - Create: `client/src/shared/model/cart-notifications.ts` - [ ] **Step 1: Write the store** ```ts import { createEvent, createStore } from 'effector' export const cartAdded = createEvent() export const cartDismissed = createEvent() export const $cartSnackOpen = createStore(false) .on(cartAdded, () => true) .on(cartDismissed, () => false) ``` - [ ] **Step 2: Commit** ```bash git add client/src/shared/model/cart-notifications.ts git commit -m "feat: add cart notification effector store" ``` --- ### Task 2: CartSnackbar component **Files:** - Create: `client/src/shared/ui/CartSnackbar.tsx` - Test: `client/src/shared/ui/__tests__/CartSnackbar.test.tsx` - [ ] **Step 1: Write the failing test** ```tsx import { render, screen, fireEvent, act } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { MemoryRouter } from 'react-router-dom' import { cartAdded, cartDismissed, $cartSnackOpen } from '@/shared/model/cart-notifications' import { CartSnackbar } from '@/shared/ui/CartSnackbar' function renderWithRouter() { render( , ) } describe('CartSnackbar', () => { it('is hidden when store is false', () => { cartDismissed() renderWithRouter() expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument() }) it('shows snackbar when cartAdded is fired', () => { renderWithRouter() cartAdded() expect(screen.getByText(/товар добавлен/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /перейти в корзину/i })).toBeInTheDocument() }) it('closes on dismiss button click', () => { renderWithRouter() cartAdded() const closeBtn = screen.getByLabelText(/закрыть/i) fireEvent.click(closeBtn) expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument() }) it('auto-closes after 4 seconds', () => { vi.useFakeTimers() renderWithRouter() cartAdded() act(() => { vi.advanceTimersByTime(4000) }) expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument() vi.useRealTimers() }) it('navigates to /cart and closes on "Перейти в корзину" click', () => { renderWithRouter() cartAdded() const goBtn = screen.getByRole('button', { name: /перейти в корзину/i }) fireEvent.click(goBtn) expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument() }) }) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd client && npx vitest run src/shared/ui/__tests__/CartSnackbar.test.tsx ``` Expected: FAIL — module `@/shared/model/cart-notifications` not found or component not exported. - [ ] **Step 3: Write the component** ```tsx import { useEffect } from 'react' import Alert from '@mui/material/Alert' import Button from '@mui/material/Button' import Snackbar from '@mui/material/Snackbar' import { useNavigate } from 'react-router-dom' import { useUnit } from 'effector-react' import { $cartSnackOpen, cartDismissed } from '@/shared/model/cart-notifications' export function CartSnackbar() { const open = useUnit($cartSnackOpen) const navigate = useNavigate() useEffect(() => { if (!open) return const timer = setTimeout(() => cartDismissed(), 4000) return () => clearTimeout(timer) }, [open]) const handleClose = () => cartDismissed() const handleGoToCart = () => { cartDismissed() navigate('/cart') } return ( Перейти в корзину }> Товар добавлен в корзину ) } ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd client && npx vitest run src/shared/ui/__tests__/CartSnackbar.test.tsx ``` Expected: All 5 tests PASS. - [ ] **Step 5: Commit** ```bash git add client/src/shared/ui/CartSnackbar.tsx client/src/shared/ui/__tests__/CartSnackbar.test.tsx git commit -m "feat: add CartSnackbar component with tests" ``` --- ### Task 3: Mount CartSnackbar in AppProviders **Files:** - Modify: `client/src/app/providers/AppProviders.tsx` - [ ] **Step 1: Add CartSnackbar to AppProviders** Add import at the top (after existing imports): ```tsx import { CartSnackbar } from '@/shared/ui/CartSnackbar' ``` Add `` inside `QueryClientProvider`, after ``: ```tsx return ( {children} ) ``` - [ ] **Step 2: Verify no lint errors** ```bash cd client && npx eslint src/app/providers/AppProviders.tsx ``` Expected: No errors. - [ ] **Step 3: Commit** ```bash git add client/src/app/providers/AppProviders.tsx git commit -m "feat: mount CartSnackbar in AppProviders" ``` --- ### Task 4: Integrate cartAdded into AddToCartButton **Files:** - Modify: `client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx` - [ ] **Step 1: Add cartAdded call to onSuccess** Add import: ```tsx import { cartAdded } from '@/shared/model/cart-notifications' ``` Change the `onSuccess` in `addMut`: ```tsx const addMut = useMutation({ mutationFn: () => addToCart({ productId, qty }), onSuccess: () => { void qc.invalidateQueries({ queryKey: ['me', 'cart'] }) cartAdded() }, }) ``` Full file after change: ```tsx import Button from '@mui/material/Button' import type { ButtonProps } from '@mui/material/Button' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { addToCart } from '@/entities/cart/api/cart-api' import { $user } from '@/shared/model/auth' import { cartAdded } from '@/shared/model/cart-notifications' type Props = { productId: string qty?: number loggedOutLabel?: string } & Omit export function AddToCartButton(props: Props) { const { productId, qty = 1, loggedOutLabel = 'Войдите, чтобы купить', disabled, children, ...rest } = props const qc = useQueryClient() const user = useUnit($user) const addMut = useMutation({ mutationFn: () => addToCart({ productId, qty }), onSuccess: () => { void qc.invalidateQueries({ queryKey: ['me', 'cart'] }) cartAdded() }, }) return ( ) } ``` - [ ] **Step 2: Verify no lint errors** ```bash cd client && npx eslint src/features/cart/add-to-cart/ui/AddToCartButton.tsx ``` Expected: No errors. - [ ] **Step 3: Commit** ```bash git add client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx git commit -m "feat: fire cartAdded event in AddToCartButton" ``` --- ### Task 5: Integrate cartAdded into ToggleCartIcon **Files:** - Modify: `client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx` - [ ] **Step 1: Add cartAdded call to add-mutation onSuccess only** Add import: ```tsx import { cartAdded } from '@/shared/model/cart-notifications' ``` Change the `onSuccess` in `addMut` (NOT `removeMut`): ```tsx const addMut = useMutation({ mutationFn: () => addToCart({ productId, qty: 1 }), onSuccess: () => { void qc.invalidateQueries({ queryKey: ['me', 'cart'] }) cartAdded() }, }) ``` Full file after change: ```tsx import IconButton from '@mui/material/IconButton' import Tooltip from '@mui/material/Tooltip' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { ShoppingCart } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api' import { $user } from '@/shared/model/auth' import { cartAdded } from '@/shared/model/cart-notifications' export function ToggleCartIcon(props: { productId: string size?: 'small' | 'medium' disabledReason?: string | null }) { const { productId, size = 'small', disabledReason = null } = props const user = useUnit($user) const qc = useQueryClient() const navigate = useNavigate() const cartQuery = useQuery({ queryKey: ['me', 'cart'], queryFn: fetchMyCart, enabled: Boolean(user), }) const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null const inCart = Boolean(existing) const addMut = useMutation({ mutationFn: () => addToCart({ productId, qty: 1 }), onSuccess: () => { void qc.invalidateQueries({ queryKey: ['me', 'cart'] }) cartAdded() }, }) const removeMut = useMutation({ mutationFn: () => removeCartItem(existing!.id), onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), }) const disabled = Boolean(disabledReason) const busy = addMut.isPending || removeMut.isPending const onClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() if (disabledReason) return if (!user) { navigate('/auth') return } if (inCart) removeMut.mutate() else addMut.mutate() } const tooltip = disabledReason ? disabledReason : !user ? 'Авторизуйтесь для совершения покупок' : inCart ? 'Убрать из корзины' : 'В корзину' return ( {user ? inCart ? : : } ) } ``` - [ ] **Step 2: Verify no lint errors** ```bash cd client && npx eslint src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx ``` Expected: No errors. - [ ] **Step 3: Commit** ```bash git add client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx git commit -m "feat: fire cartAdded event in ToggleCartIcon add mutation" ``` --- ### Task 6: Final verification - [ ] **Step 1: Run full client lint** ```bash cd client && npm run lint ``` Expected: 0 errors (warnings OK). - [ ] **Step 2: Run full client test suite** ```bash cd client && npm test ``` Expected: All tests PASS including new CartSnackbar tests. - [ ] **Step 3: Run Prettier check** ```bash cd client && npm run format:check ``` Expected: All files match formatting. - [ ] **Step 4: Final commit if any changes** ```bash git add -A git commit -m "chore: lint and format fixes for cart snackbar" ```