# 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(
    <MemoryRouter>
      <CartSnackbar />
    </MemoryRouter>,
  )
}

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 (
    <Snackbar
      open={open}
      onClose={handleClose}
      anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
      autoHideDuration={4000}
    >
      <Alert severity="success" onClose={handleClose} action={
        <Button color="success" size="small" onClick={handleGoToCart}>
          Перейти в корзину
        </Button>
      }>
        Товар добавлен в корзину
      </Alert>
    </Snackbar>
  )
}
```

- [ ] **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 `<CartSnackbar />` inside `QueryClientProvider`, after `<SseProvider />`:
```tsx
return (
  <QueryClientProvider client={queryClient}>
    <SseProvider />
    <CartSnackbar />
    <ThemeControllerProvider>
      <AppThemeInner>{children}</AppThemeInner>
    </ThemeControllerProvider>
  </QueryClientProvider>
)
```

- [ ] **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<ButtonProps, 'onClick'>

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 (
    <Button
      {...rest}
      disabled={Boolean(disabled) || !user || addMut.isPending}
      onClick={(e) => {
        e.preventDefault()
        e.stopPropagation()
        addMut.mutate()
      }}
    >
      {user ? (children ?? 'В корзину') : loggedOutLabel}
    </Button>
  )
}
```

- [ ] **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 (
    <Tooltip title={tooltip}>
      <span>
        <IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip} type="button">
          {user ? inCart ? <ShoppingCart fill="currentColor" /> : <ShoppingCart /> : <ShoppingCart />}
        </IconButton>
      </span>
    </Tooltip>
  )
}
```

- [ ] **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"
```
