Files
shop-server/docs/superpowers/plans/2026-05-25-cart-added-snackbar.md
T

448 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```