12 KiB
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
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
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
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
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
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
cd client && npx vitest run src/shared/ui/__tests__/CartSnackbar.test.tsx
Expected: All 5 tests PASS.
- Step 5: Commit
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):
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
Add <CartSnackbar /> inside QueryClientProvider, after <SseProvider />:
return (
<QueryClientProvider client={queryClient}>
<SseProvider />
<CartSnackbar />
<ThemeControllerProvider>
<AppThemeInner>{children}</AppThemeInner>
</ThemeControllerProvider>
</QueryClientProvider>
)
- Step 2: Verify no lint errors
cd client && npx eslint src/app/providers/AppProviders.tsx
Expected: No errors.
- Step 3: Commit
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:
import { cartAdded } from '@/shared/model/cart-notifications'
Change the onSuccess in addMut:
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
Full file after change:
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
cd client && npx eslint src/features/cart/add-to-cart/ui/AddToCartButton.tsx
Expected: No errors.
- Step 3: Commit
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:
import { cartAdded } from '@/shared/model/cart-notifications'
Change the onSuccess in addMut (NOT removeMut):
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
Full file after change:
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
cd client && npx eslint src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
Expected: No errors.
- Step 3: Commit
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
cd client && npm run lint
Expected: 0 errors (warnings OK).
- Step 2: Run full client test suite
cd client && npm test
Expected: All tests PASS including new CartSnackbar tests.
- Step 3: Run Prettier check
cd client && npm run format:check
Expected: All files match formatting.
- Step 4: Final commit if any changes
git add -A
git commit -m "chore: lint and format fixes for cart snackbar"