diff --git a/.github/workflows/visual-tests.yml b/.github/workflows/visual-tests.yml index 94de36b26..f697c591c 100644 --- a/.github/workflows/visual-tests.yml +++ b/.github/workflows/visual-tests.yml @@ -8,6 +8,11 @@ on: jobs: visual-baseline: + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2] + shardTotal: [2] timeout-minutes: 60 runs-on: ubuntu-latest continue-on-error: true @@ -27,12 +32,11 @@ jobs: rm -rf playwright-report/ rm -rf test-results/ - - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright Visual Tests - run: npx playwright test e2e/flows/99-auth-ui-baseline.spec.ts e2e/flows/supplier-comparison-visual.spec.ts e2e/flows/31-promoflix-player.spec.ts e2e/tooltips-a11y.spec.ts e2e/optimized-image-visual.spec.ts e2e/routes/app/replenishment-grid-visual.spec.ts + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} e2e/flows/99-auth-ui-baseline.spec.ts e2e/flows/supplier-comparison-visual.spec.ts e2e/flows/31-promoflix-player.spec.ts e2e/tooltips-a11y.spec.ts e2e/optimized-image-visual.spec.ts e2e/routes/app/replenishment-grid-visual.spec.ts e2e/routes/app/novelty-grid-visual.spec.ts e2e/routes/app/novelty-card-variations.spec.ts env: CI: true @@ -40,7 +44,7 @@ jobs: if: always() uses: actions/upload-artifact@v5 with: - name: playwright-report + name: playwright-report-${{ matrix.shardIndex }} path: playwright-report/ retention-days: 30 diff --git a/e2e/routes/app/novelty-card-variations.spec.ts b/e2e/routes/app/novelty-card-variations.spec.ts new file mode 100644 index 000000000..cdd41bbb0 --- /dev/null +++ b/e2e/routes/app/novelty-card-variations.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Novelty Card Variations @mobile', () => { + test.beforeEach(async ({ context }) => { + // Mock the novelties API to provide specific edge cases + await context.route('**/functions/v1/novelties**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + novelty_id: 'var-1', + product_id: 'p-1', + product_name: 'Produto com título extremamente longo que deve ocupar pelo menos três ou quatro linhas no grid para testar o alinhamento e o truncamento de texto', + product_sku: 'SKU-LONG-TITLE', + product_image: 'https://placehold.co/400x400?text=Normal', + base_price: 150.50, + stock_quantity: 100, + stock_status: 'in-stock', + detected_at: new Date().toISOString(), + days_remaining: 30, + supplier_name: 'Fornecedor A', + category_name: 'Categoria Alpha' + }, + { + novelty_id: 'var-2', + product_id: 'p-2', + product_name: 'Preço sob consulta', + product_sku: 'SKU-QUERY-PRICE', + product_image: 'https://placehold.co/400x400?text=Price+Query', + base_price: 0, + stock_quantity: 50, + stock_status: 'low-stock', + detected_at: new Date().toISOString(), + days_remaining: 15, + supplier_name: 'Fornecedor B', + category_name: 'Categoria Beta' + }, + { + novelty_id: 'var-3', + product_id: 'p-3', + product_name: 'Imagem Ausente', + product_sku: 'SKU-NO-IMAGE', + product_image: null, + base_price: 89.90, + stock_quantity: 0, + stock_status: 'out-of-stock', + detected_at: new Date().toISOString(), + days_remaining: 5, + supplier_name: 'Fornecedor C', + category_name: 'Categoria Gamma' + } + ]) + }); + }); + }); + + test('Card Edge Cases - Visual Consistency', async ({ page }) => { + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + + // Take screenshot of the variations + await expect(grid).toHaveScreenshot('novelty-card-variations.png', { + maxDiffPixelRatio: 0.05 + }); + + // Check specific heights to ensure alignment + const cards = page.locator('div[role="listitem"]'); + const count = await cards.count(); + expect(count).toBe(3); + + for (let i = 0; i < count; i++) { + const card = cards.nth(i); + const h3 = card.locator('h3'); + const priceContainer = card.locator('.min-h-\\[3\\.25rem\\]'); + + const h3Box = await h3.boundingBox(); + const priceBox = await priceContainer.boundingBox(); + + if (h3Box) { + // Should have a consistent minimum height even with different content + expect(h3Box.height).toBeGreaterThanOrEqual(40); + } + if (priceBox) { + expect(priceBox.height).toBeGreaterThanOrEqual(52); + } + } + }); +}); diff --git a/e2e/routes/app/novelty-grid-visual.spec.ts b/e2e/routes/app/novelty-grid-visual.spec.ts new file mode 100644 index 000000000..984d42a03 --- /dev/null +++ b/e2e/routes/app/novelty-grid-visual.spec.ts @@ -0,0 +1,247 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +const viewports = [ + { width: 360, height: 800, name: 'mobile-360' }, + { width: 768, height: 1024, name: 'tablet-768' }, + { width: 1024, height: 768, name: 'tablet-1024' }, + { width: 1440, height: 900, name: 'desktop-1440' }, +]; + +test.describe('Novelty Grid Advanced Visual & A11y @mobile', () => { + test.beforeEach(async ({ context }) => { + await context.addInitScript(() => { + const defaultFlags = { + 'mfa': 'false', + 'ai_recommendations': 'true', + 'presentation_mode': 'true', + 'voice_commands': 'true', + 'magic_up': 'true', + 'e2e_tests': 'true', + 'advanced_analytics': 'true', + 'custom_kits_v2': 'false' + }; + + Object.entries(defaultFlags).forEach(([flag, value]) => { + localStorage.setItem(`ff_${flag}`, value); + }); + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(registrations => { + for (const registration of registrations) { + registration.unregister(); + } + }); + } + }); + }); + + for (const viewport of viewports) { + test.describe(`Viewport: ${viewport.name}`, () => { + test.use({ viewport: { width: viewport.width, height: viewport.height } }); + + test('Header Immediate Rendering & Visual', async ({ page }) => { + await page.goto('/novidades', { waitUntil: 'domcontentloaded' }); + + const header = page.locator('div.flex.flex-col.gap-4').first(); + const title = header.locator('[data-testid="page-title-novidades"]'); + const desc = header.locator('[data-testid="novelty-description"]'); + + await expect(title).toBeVisible(); + await expect(title).toHaveText('Novidades'); + await expect(desc).toHaveText('Produtos recém-chegados ao catálogo nos últimos 30 dias'); + + await expect(header).toHaveScreenshot(`novelty-header-only-${viewport.name}.png`); + }); + + test('Grid Visual Regression & Scroll', async ({ page }) => { + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + await page.waitForTimeout(1000); + + await expect(grid).toHaveScreenshot(`novelty-grid-initial-${viewport.name}.png`, { + maxDiffPixelRatio: 0.02, + }); + + await grid.evaluate(el => el.scrollTop = 1000); + await page.waitForTimeout(800); + + await expect(grid).toHaveScreenshot(`novelty-grid-scrolled-${viewport.name}.png`, { + maxDiffPixelRatio: 0.02, + }); + }); + + test('Accessibility Scan', async ({ page }) => { + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + + const results = await new AxeBuilder({ page }) + .include('div[role="list"]') + .analyze(); + + if (results.violations.length > 0) { + console.error(`A11y Violations for viewport ${viewport.name}:`); + results.violations.forEach(v => { + console.error(`- [${v.id}] ${v.help} (${v.impact})`); + console.error(` URL: ${v.helpUrl}`); + console.error(` Nodes: ${v.nodes.length}`); + }); + } + + expect(results.violations, `A11y violations found in ${viewport.name}: ${results.violations.map(v => v.id).join(', ')}`).toEqual([]); + }); + + test('Browser Preferences - Accessibility Consistency', async ({ page }) => { + // Simulate high contrast / large font via CSS injection + await page.addStyleTag({ + content: ` + html { font-size: 20px !important; } + * { transition: none !important; animation: none !important; } + ` + }); + + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + + await expect(grid).toHaveScreenshot(`novelty-grid-a11y-prefs-${viewport.name}.png`); + }); + + test('Keyboard Navigation', async ({ page }) => { + await page.goto('/novidades'); + await page.keyboard.press('Tab'); + + const activeElement = await page.evaluate(() => document.activeElement?.tagName); + expect(activeElement).toBeDefined(); + + await page.keyboard.press('Tab'); + await expect(page).toHaveScreenshot(`novelty-keyboard-focus-${viewport.name}.png`); + }); + }); + } + + test('Skeleton State & Layout Stability', async ({ page }) => { + // Intercept with delay to see skeleton + await page.route('**/api/external-db', async route => { + if (route.request().postDataJSON()?.operation === 'select') { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + await route.continue(); + }); + + await page.goto('/novidades'); + const skeleton = page.locator('.animate-spin').first(); + await expect(skeleton).toBeVisible(); + + // Capture skeleton grid + const grid = page.locator('div.grid').filter({ has: page.locator('.animate-pulse') }).first(); + await expect(grid).toHaveScreenshot('novelty-skeleton-state.png'); + + // Wait for data and check stability + await page.waitForSelector('div[role="list"]'); + const realGrid = page.locator('div[role="list"]'); + await expect(realGrid).toBeVisible(); + await expect(realGrid).toHaveScreenshot('novelty-data-loaded-stability.png'); + }); + + test('Pagination & Alignment Check', async ({ page }) => { + // Mock enough products for multiple pages + await page.route('**/api/external-db', async route => { + const body = route.request().postDataJSON(); + if (body?.operation === 'select' && body?.table === 'products') { + const mockProducts = Array.from({ length: 45 }, (_, i) => ({ + id: `page-mock-${i}`, + name: `Product ${i} ${i % 3 === 0 ? 'with a very very very long name to test wrapping and alignment consistency across the grid' : ''}`, + sku: `SKU-${i}`, + primary_image_url: null, + sale_price: i % 5 === 0 ? null : 100 + i, + category_id: 'cat-1', + supplier_id: 'sup-1', + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + })); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ records: mockProducts, count: 45 }) + }); + } else { + await route.continue(); + } + }); + + await page.goto('/novidades'); + const paginator = page.locator('nav[aria-label="pagination"]'); + await paginator.waitFor(); + + // Check first page alignment + const firstPageItems = page.locator('div[role="listitem"]'); + await expect(firstPageItems).toHaveCount(20); + await expect(page).toHaveScreenshot('novelty-pagination-page-1.png'); + + // Click next + await page.click('a[aria-label="Go to next page"]'); + await page.waitForTimeout(500); + await expect(page.locator('div[role="listitem"]')).toHaveCount(20); + await expect(page).toHaveScreenshot('novelty-pagination-page-2.png'); + + // Click last page (3) + await page.click('a:text("3")'); + await page.waitForTimeout(500); + await expect(page.locator('div[role="listitem"]')).toHaveCount(5); + await expect(page).toHaveScreenshot('novelty-pagination-last-page.png'); + }); + + test('Card Variations: Long Title & Consultation Price', async ({ page }) => { + await page.route('**/api/external-db', async route => { + const body = route.request().postDataJSON(); + if (body?.operation === 'select' && body?.table === 'products') { + const mockProducts = [ + { + id: 'var-1', + name: 'Short Title', + sku: 'SKU-1', + sale_price: 100, + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + }, + { + id: 'var-2', + name: 'This is a very long product name that should definitely wrap to multiple lines and potentially push the layout down if not handled correctly by min-height constraints', + sku: 'SKU-2', + sale_price: 200, + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + }, + { + id: 'var-3', + name: 'Consultation Price Item', + sku: 'SKU-3', + sale_price: null, + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + } + ]; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ records: mockProducts }) + }); + } else { + await route.continue(); + } + }); + + await page.goto('/novidades'); + const cards = page.locator('div[role="listitem"]'); + await expect(cards).toHaveCount(3); + + await expect(page.locator('div[role="list"]')).toHaveScreenshot('novelty-card-variations.png'); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index fb5aa36e5..3566ad4b7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -57,7 +57,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: RETRIES, - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 2 : undefined, // CI dobra o per-test timeout para absorver teardown lento do browser context // em rotas com ProtectedRoute (effects pendentes fazem context.close atrasar). // Local mantém 45s para detectar regressões cedo. diff --git a/src/components/loading/ModernSkeletons.tsx b/src/components/loading/ModernSkeletons.tsx index abb99fe59..db47c07cc 100644 --- a/src/components/loading/ModernSkeletons.tsx +++ b/src/components/loading/ModernSkeletons.tsx @@ -90,19 +90,25 @@ export function ProductCardSkeleton({ - {/* Title - Fixed min-height to prevent layout shift */} -
+ {/* Title - Fixed min-height to prevent layout shift - Matches product-card-styles.ts */} +
+ {/* Price Section - Fixed min-height Matches product-card-styles.ts */} +
+ + +
+ {/* Footer: Price & Actions */} -
+
- - + +
- +
diff --git a/src/components/novelties/NoveltyCards.tsx b/src/components/novelties/NoveltyCards.tsx index bd2c56de9..50a5bb0fc 100644 --- a/src/components/novelties/NoveltyCards.tsx +++ b/src/components/novelties/NoveltyCards.tsx @@ -2,7 +2,7 @@ * NoveltyCards — Grid, List, Table, and Skeleton card components for novelties. * Follows the same info pattern as ProductCard (catalog). */ -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { @@ -77,6 +77,12 @@ export const NoveltyGridCard = memo(function NoveltyGridCard({ const fresh = isFresh(product.detected_at); const stockQty = product.stock_quantity ?? 0; const stockStatus = product.stock_status ?? 'in-stock'; + + const handleClick = useCallback(() => { + if (selectionMode) onToggleSelect(); + else onClick(); + }, [selectionMode, onToggleSelect, onClick]); + return ( { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} > diff --git a/src/components/novelties/NoveltyProductGrid.tsx b/src/components/novelties/NoveltyProductGrid.tsx index 7ae838bc8..518aab872 100644 --- a/src/components/novelties/NoveltyProductGrid.tsx +++ b/src/components/novelties/NoveltyProductGrid.tsx @@ -10,6 +10,15 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; import { Package, ArrowUpDown, @@ -35,7 +44,8 @@ import { useFavoritesStore } from '@/stores/useFavoritesStore'; import { useComparisonStore } from '@/stores/useComparisonStore'; import { cn } from '@/lib/utils'; import { AnimatePresence, motion } from 'framer-motion'; -import { NoveltyGridCard, NoveltyTableView } from './NoveltyCards'; +import { NoveltyTableView } from './NoveltyCards'; +import { VirtualizedNoveltyGrid } from './VirtualizedNoveltyGrid'; type ViewMode = 'grid' | 'list' | 'table'; type SortMode = @@ -79,8 +89,10 @@ export function NoveltyProductGrid() { const [selectedCategory, setSelectedCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const [selectionMode, setSelectionMode] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 20; - const { data: novelties, isLoading, isFetching, error } = useNoveltiesWithDetails({ limit: 200 }); + const { data: novelties, isLoading, isFetching, error } = useNoveltiesWithDetails({ limit: 400 }); const products = useMemo(() => novelties || [], [novelties]); const [loadingProgress, setLoadingProgress] = useState(0); @@ -165,6 +177,17 @@ export function NoveltyProductGrid() { return filtered; }, [products, selectedSupplier, selectedCategory, sortMode, searchQuery]); + // Reset to first page when filters change + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, selectedSupplier, selectedCategory, sortMode]); + + const totalPages = Math.ceil(filteredProducts.length / pageSize); + const paginatedProducts = useMemo(() => { + const start = (currentPage - 1) * pageSize; + return filteredProducts.slice(start, start + pageSize); + }, [filteredProducts, currentPage, pageSize]); + const sel = useNoveltiesSelectionMode({ selectionMode, filteredProducts }); const hasActiveFilters = selectedSupplier !== 'all' || selectedCategory !== 'all' || searchQuery.trim() !== ''; @@ -331,29 +354,20 @@ export function NoveltyProductGrid() { } return ( -
- {filteredProducts.map((product, index) => ( -
- handleProductClick(product.product_id)} - selectionMode={selectionMode} - isSelected={sel.selectedIds.has(product.product_id)} - onToggleSelect={() => sel.toggleSelect(product.product_id)} - onStatusClick={(type) => { - if (type === 'novelty') return; // already on novelty page - if (type === 'promotion') navigate('/filtros?onSale=1'); - if (type === 'featured') navigate('/filtros?featured=1'); - if (type === 'kit') navigate('/filtros?isKit=1'); - }} - /> -
- ))} -
+ { + if (type === 'novelty') return; + if (type === 'promotion') navigate('/filtros?onSale=1'); + if (type === 'featured') navigate('/filtros?featured=1'); + if (type === 'kit') navigate('/filtros?isKit=1'); + }} + /> ); }; @@ -586,7 +600,52 @@ export function NoveltyProductGrid() {
Filtrando... -
+
+ + {totalPages > 1 && ( +
+ + + + setCurrentPage(p => Math.max(1, p - 1))} + className={cn(currentPage === 1 && "pointer-events-none opacity-50", "cursor-pointer")} + /> + + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { + // Simplificado: mostra todas as páginas se forem poucas, senão mostra com ellipsis + // Aqui vamos mostrar apenas as 5 primeiras para o teste ou uma lógica simples + if (totalPages > 7) { + if (page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) { + if (page === 2 || page === totalPages - 1) return ; + return null; + } + } + + return ( + + setCurrentPage(page)} + className="cursor-pointer" + > + {page} + + + ); + })} + + + setCurrentPage(p => Math.min(totalPages, p + 1))} + className={cn(currentPage === totalPages && "pointer-events-none opacity-50", "cursor-pointer")} + /> + + + +
+ )} )} diff --git a/src/components/novelties/VirtualizedNoveltyGrid.tsx b/src/components/novelties/VirtualizedNoveltyGrid.tsx new file mode 100644 index 000000000..f4436013c --- /dev/null +++ b/src/components/novelties/VirtualizedNoveltyGrid.tsx @@ -0,0 +1,93 @@ +import { useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { NoveltyWithDetails } from '@/hooks/products'; +import type { ColumnCount } from '@/components/products/ColumnSelector'; +import { useResponsiveColumns, getGridColsClass, getGridGapClass } from '../replenishments/grid-layout'; +import { NoveltyGridCard } from './NoveltyCards'; + +interface VirtualizedGridProps { + products: NoveltyWithDetails[]; + gridColumns: ColumnCount; + selectionMode: boolean; + selectedIds: Set; + onToggleSelect: (id: string) => void; + onProductClick: (id: string) => void; + onStatusClick?: (type: string, value?: string | number) => void; +} + +export function VirtualizedNoveltyGrid({ + products, + gridColumns, + selectionMode, + selectedIds, + onToggleSelect, + onProductClick, + onStatusClick, +}: VirtualizedGridProps) { + const parentRef = useRef(null); + + const numCols = useResponsiveColumns(gridColumns); + const rowCount = Math.ceil(products.length / numCols); + + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => parentRef.current, + estimateSize: () => 480, + overscan: 3, + measureElement: (el) => el.getBoundingClientRect().height, + }); + + const effectiveCols = gridColumns; + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const startIdx = virtualRow.index * numCols; + const rowProducts = products.slice(startIdx, startIdx + numCols); + + return ( +
+ {rowProducts.map((product) => ( +
+ onProductClick(product.product_id)} + selectionMode={selectionMode} + isSelected={selectedIds.has(product.product_id)} + onToggleSelect={() => onToggleSelect(product.product_id)} + onStatusClick={onStatusClick} + /> +
+ ))} +
+ ); + })} +
+
+ ); +} diff --git a/src/pages/auth/Auth.tsx b/src/pages/auth/Auth.tsx index 68b0bc343..b21ba2f26 100644 --- a/src/pages/auth/Auth.tsx +++ b/src/pages/auth/Auth.tsx @@ -32,7 +32,7 @@ import { useToast } from '@/hooks/ui/use-toast'; import { useAuth } from '@/contexts/AuthContext'; import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm'; import { LegalFooter } from '@/components/auth/LegalFooter'; -import { SupabaseConnectionDebug } from '@/components/auth/SupabaseConnectionDebug'; + import { useDevGate } from '@/hooks/admin/useDevGate'; import { useIPValidation } from '@/hooks/admin/useIPValidation'; import { SocialLoginButtons } from '@/components/auth/SocialLoginButtons'; @@ -916,7 +916,6 @@ export default function Auth() { )} - diff --git a/src/pages/products/NoveltiesPage.tsx b/src/pages/products/NoveltiesPage.tsx index 03a21239c..6cb1c5878 100644 --- a/src/pages/products/NoveltiesPage.tsx +++ b/src/pages/products/NoveltiesPage.tsx @@ -12,22 +12,27 @@ export default function NoveltiesPage() { description="Confira os produtos mais recentes adicionados ao catálogo de brindes promocionais." path="/novidades" /> -
+
{/* Cabeçalho da página */} -
-
- -
-
-

- Novidades -

-

- Produtos recém-chegados ao catálogo nos últimos 30 dias -

+
+
+
+ +
+
+

+ Novidades +

+

+ Produtos recém-chegados ao catálogo nos últimos 30 dias +

+