UNPKG

rnt-next

Version:

CLI para criar aplicações Next.js com configuração completa: Styled Components/Tailwind, projeto limpo/exemplos, testes opcionais, dependências adicionais e backend com Prisma/MySQL - Criado por RNT

1,940 lines (1,680 loc) 192 kB
import chalk from "chalk"; import fs from "fs-extra"; import path from "path"; import { execCommand } from "../utils/execCommand.js"; import { ensureFolders, writeFile } from "../utils/fileOps.js"; import { installDependencies } from "../utils/installDeps.js"; // ============================================================================= // FUNÇÃO PRINCIPAL DE CRIAÇÃO DO PROJETO // ============================================================================= export async function createProject(config) { // --- Bloco 1: Preparação de Variáveis e Configurações --- // Desestrutura as opções do usuário e define variáveis de controle para o script. const { appName, cssChoice, useEmpty, installTests, installExtraDeps, installBackend, } = config; const useStyledComponents = cssChoice === "Styled Components"; const useTailwind = !useStyledComponents; const finalChoice = useStyledComponents ? "styled-components" : "tailwind"; const appPath = path.join(process.cwd(), appName); console.log(chalk.blue(`🚀 Iniciando a criação do projeto '${appName}'...`)); // --- Bloco 2: Criação da Estrutura Base do Next.js --- // Executa o comando 'create-next-app' com as flags apropriadas. if (!fs.existsSync(appPath)) { fs.mkdirSync(appPath); } let createCommand = `npx create-next-app@latest . --typescript --eslint --app --src-dir --import-alias "@/*"`; createCommand += useTailwind ? " --tailwind" : " --no-tailwind"; if (useEmpty) createCommand += " --empty"; execCommand(createCommand, appPath); // Verificação de segurança para garantir que o diretório foi criado antes de prosseguir. if (!fs.existsSync(appPath)) { console.error( chalk.red( `❌ Erro Crítico: O diretório do projeto ${appPath} não foi criado. Abortando.`, ), ); process.exit(1); } // --- Bloco 3: Instalação de Dependências --- // Monta dinamicamente as listas de dependências de produção e desenvolvimento. console.log(chalk.blue("📦 Instalando dependências...")); let prodDependencies = [ "react-redux", "@reduxjs/toolkit", "immer", "redux", "redux-persist", "clsx", "class-variance-authority", "lucide-react", ]; let devDependencies = [ "eslint-plugin-prettier", "prettier", "eslint-config-prettier", ]; if (finalChoice === "styled-components") { prodDependencies.push("styled-components"); devDependencies.push("@types/styled-components"); } if (installExtraDeps) { prodDependencies.push( "formik", "yup", "imask", "react-imask", "react-hot-toast", "react-loading-skeleton", "framer-motion", "react-icons", ); } if (installBackend) { prodDependencies.push( "prisma", "@prisma/client", "jose", "bcryptjs", "cookie", "next-cloudinary", "resend", "react-email", "@react-email/components", "@react-email/render", "@stripe/react-stripe-js", "@stripe/stripe-js", "stripe", ); devDependencies.push( "@types/jsonwebtoken", "@types/bcryptjs", "@types/cookie", "@types/react-icons", ); } if (installTests) { devDependencies.push( "jest", "@testing-library/react", "@testing-library/jest-dom", "@testing-library/user-event", "jest-environment-jsdom", ); } installDependencies(prodDependencies, devDependencies, appPath); // --- Bloco 4: Criação da Estrutura de Pastas --- // Garante que a arquitetura de diretórios customizada exista. console.log(chalk.blue("📁 Criando estrutura de pastas...")); const folders = [ "src/styles", "src/lib", "src/hooks", "src/utils", "src/redux", "src/redux/slices", ".vscode", ]; if (!useEmpty) { folders.push( "src/components/ui", "src/components/ui/Button", "src/components/ui/CartWrapper", "src/components/ui/ModalWrapper", "src/components/ui/ErrorMessage", "src/components/ui/MaskedInput", "src/components/ui/Box", "src/components/layout", "src/components/layout/header", "src/components/layout/footer", ); } if (installTests) { folders.push("__tests__", "src/__tests__"); } if (installBackend) { folders.push( "src/app/api", "src/app/api/auth/login", "src/app/api/auth/logout", "src/app/api/auth/verify", "src/app/api/auth/register", "src/app/api/users", ); } await ensureFolders(appPath, folders); // --- Bloco 5: Geração de Arquivos de Configuração --- // Cria arquivos de configuração para Next.js, Jest, VSCode, Prettier, etc. console.log(chalk.blue("⚙️ Gerando arquivos de configuração...")); // Next.js config let nextConfig = `/** @type {import('next').NextConfig} */\nconst nextConfig = {`; if (finalChoice === "styled-components") { nextConfig += `\n compiler: {\n styledComponents: true,\n },`; } nextConfig += `\n images: {\n formats: ['image/avif', 'image/webp'],\n domains: ['placehold.co', 'res.cloudinary.com', 'api.cloudinary.com'],\n },\n}\n\nexport default nextConfig;\n`; // CORRIGIDO para 'export default' await writeFile(path.join(appPath, "next.config.mjs"), nextConfig); // Renomeado para .mjs para consistência com ES Modules // Jest config if (installTests) { await writeFile( path.join(appPath, "jest.config.js"), ` const nextJest = require('next/jest') const createJestConfig = nextJest({ // Provide the path to your Next.js app to load next.config.js and .env files dir: './', }) // Add any custom config to be passed to Jest const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], moduleNameMapping: { // Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths) '^@/(.*)$': '<rootDir>/src/$1', }, testEnvironment: 'jest-environment-jsdom', } // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async module.exports = createJestConfig(customJestConfig) `, ); await writeFile( path.join(appPath, "jest.setup.js"), `import '@testing-library/jest-dom'`, ); } // VSCode, Prettier, EditorConfig await writeFile( path.join(appPath, ".vscode/settings.json"), JSON.stringify( { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": true, "source.fixAll": true, }, "editor.defaultFormatter": "esbenp.prettier-vscode", "[typescriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features", }, "typescript.tsdk": "node_modules/typescript/lib", }, null, 2, ), ); await writeFile( path.join(appPath, ".prettierrc.json"), JSON.stringify( { trailingComma: "none", semi: false, singleQuote: true, printWidth: 150, arrowParens: "avoid", }, null, 2, ), ); await writeFile( path.join(appPath, ".editorconfig"), ` root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true `, ); // --- Bloco 6: Geração de Arquivos de Código Base --- // Cria arquivos de tipagem, hooks, utils, temas, etc. console.log(chalk.blue("📝 Gerando arquivos de código base...")); await writeFile( path.join(appPath, "types.d.ts"), ` import 'styled-components' import { store } from './src/redux/store' // Tipagem do Redux export type RootState = ReturnType<typeof store.getState> export type AppDispatch = typeof store.dispatch // Tipagem global pro React-Redux + RTK declare module 'react-redux' { type DefaultRootState = RootState } declare module 'styled-components' { export interface DefaultTheme { colors: { baseGlass: ColorVariants baseBlack: ColorVariants baseBlue: ColorVariants baseGreen: ColorVariants baseRed: ColorVariants baseCyan: ColorVariants baseYellow: ColorVariants } spacing: { xs: string sm: string md: string lg: string xl: string '2xl': string } radius: { xs: string sm: string md: string lg: string xl: string '2xl': string } boxShadow: { sm: string md: string lg: string } fontSize: { xs: string sm: string md: string lg: string xl: string '2xl': string '3xl': string } grid: { adaptive: string autoFit: string one: string two: string three: string four: string compact: string maxWidth: string } } } declare global { declare interface ColorVariants { base: string light: string light02: string light04: string light08: string light20: string light30: string light40: string light50: string dark: string dark02: string dark04: string dark08: string dark20: string dark30: string dark40: string dark50: string } // ------------------------------------- // Payloads e Respostas de API // ------------------------------------- interface RefreshResponse { access: string } interface LoginRequest { email: string password: string } interface LoginResponse { user: User message: string access: string refresh: string success: boolean } interface RegisterRequest { id?: number email: string full_name: string username: string password: string confirm_password: string } interface RegisterResponse { message: string access: string refresh: string } interface PaginatedResponse<T> { count: number next: string | null previous: string | null results: T[] } interface ApiResponse { status: number data: { detail: string [key: string]: string } statusText: string message: string success: boolean error: string } } `, ); await writeFile( path.join(appPath, "src/hooks/useFilteredProducts.ts"), ` 'use client' import { RootState } from '@/redux/store' import { useMemo } from 'react' import { useSelector } from 'react-redux' const filterMap: Record<string, (item: Product) => boolean> = { Todos: () => true, Destaque: item => item.highlight === true, Promoção: item => item.discount > 0, Populares: item => item.sold > 50, Inativos: item => item.active === false } export function useFilteredProducts(products: Product[]) { const activeFilter = useSelector((state: RootState) => state.filter.active) const query = useSelector((state: RootState) => state.search.query) const filteredProducts = useMemo(() => { let result = products if (query.trim() !== '') { result = result.filter(item => item.name.toLowerCase().includes(query.toLowerCase())) } else { if (filterMap[activeFilter]) { result = result.filter(filterMap[activeFilter]) } else { result = result.filter(item => item.category?.name === activeFilter) } } return result }, [products, activeFilter, query]) return { filteredProducts, activeFilter } } `, ); await writeFile( path.join(appPath, "src/hooks/useFilteredOrders.ts"), ` 'use client' import { RootState } from '@/redux/store' import { useMemo } from 'react' import { useSelector } from 'react-redux' const orderFilterMap: Record<string, (order: Order) => boolean> = { Todos: () => true, Pago: order => order.status === 'PAID', Processando: order => order.status === 'PROCESSING', Enviado: order => order.status === 'SHIPPED', Entregue: order => order.status === 'DELIVERED', Cancelado: order => order.status === 'CANCELED', Falhou: order => order.status === 'FAILED', Reembolsado: order => order.status === 'REFUNDED' } export function useFilteredOrders(orders: Order[]) { const activeFilter = useSelector((state: RootState) => state.filter.active) const query = useSelector((state: RootState) => state.search.query) const filteredOrders = useMemo(() => { let result = orders if (query.trim() !== '') { result = result.filter( order => order.id.toLowerCase().includes(query.toLowerCase()) || order.user.name.toLowerCase().includes(query.toLowerCase()) ) } else { const filterFn = orderFilterMap[activeFilter] || orderFilterMap['Todos'] result = result.filter(filterFn) } return result }, [orders, activeFilter, query]) return { filteredOrders, activeFilter } } `, ); await writeFile( path.join(appPath, "src/hooks/useAppDispatch.ts"), ` import { AppDispatch, RootState } from '@/redux/store' import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' export const useAppDispatch = () => useDispatch<AppDispatch>() export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector `, ); await writeFile( path.join(appPath, "src/utils/colorUtils.ts"), ` // 🎨 COLOR UTILS - Utilitários para geração de variantes de cores HSL export type ColorVariants = { base: string light: string light02: string light04: string light08: string light20: string light30: string light40: string light50: string dark: string dark02: string dark04: string dark08: string dark20: string dark30: string dark40: string dark50: string } export function colorHSLVariants(h: number, s: number, l: number): ColorVariants { const clamp = (val: number) => Math.min(100, Math.max(0, val)) return { base: \`hsl(\${h}, \${s}%, \${clamp(l)}%)\`, light: \`hsl(\${h}, \${s}%, \${clamp(l + 10)}%)\`, light02: \`hsla(\${h}, \${s}%, \${clamp(l + 2)}%, 0.2)\`, light04: \`hsla(\${h}, \${s}%, \${clamp(l + 4)}%, 0.4)\`, light08: \`hsla(\${h}, \${s}%, \${clamp(l + 6)}%, 0.8)\`, light20: \`hsl(\${h}, \${s}%, \${clamp(l + 20)}%)\`, light30: \`hsl(\${h}, \${s}%, \${clamp(l + 30)}%)\`, light40: \`hsl(\${h}, \${s}%, \${clamp(l + 40)}%)\`, light50: \`hsl(\${h}, \${s}%, \${clamp(l + 50)}%)\`, dark: \`hsl(\${h}, \${s}%, \${clamp(l - 10)}%)\`, dark02: \`hsla(\${h}, \${s}%, \${clamp(l - 2)}%, 0.2)\`, dark04: \`hsla(\${h}, \${s}%, \${clamp(l - 4)}%, 0.4)\`, dark08: \`hsla(\${h}, \${s}%, \${clamp(l - 6)}%, 0.8)\`, dark20: \`hsl(\${h}, \${s}%, \${clamp(l - 20)}%)\`, dark30: \`hsl(\${h}, \${s}%, \${clamp(l - 30)}%)\`, dark40: \`hsl(\${h}, \${s}%, \${clamp(l - 40)}%)\`, dark50: \`hsl(\${h}, \${s}%, \${clamp(l - 50)}%)\` } } `, ); await writeFile( path.join(appPath, "src/styles/theme.ts"), ` // 🎨 ARQUIVO DE TEMA - Configurações de cores e breakpoints do projeto import { colorHSLVariants } from '@/utils/colorUtils' import { DefaultTheme } from 'styled-components' export const media = { pc: '@media (max-width: 1024px)', tablet: '@media (max-width: 768px)', mobile: '@media (max-width: 480px)' } export const baseGlass = colorHSLVariants(210, 30, 90) export const baseBlack = colorHSLVariants(0, 0, 10) export const baseBlue = colorHSLVariants(220, 80, 50) export const baseGreen = colorHSLVariants(100, 100, 50) export const baseRed = colorHSLVariants(0, 100, 50) export const baseCyan = colorHSLVariants(180, 150, 50) export const baseYellow = colorHSLVariants(60, 100, 50) export const theme: DefaultTheme = { colors: { baseGlass, baseBlack, baseBlue, baseGreen, baseRed, baseCyan, baseYellow }, spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '2rem', xl: '3rem', '2xl': '4rem' }, radius: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '2rem', xl: '3rem', '2xl': '4rem' }, boxShadow: { sm: '0 2px 4px rgba(0, 0, 0, 0.1)', md: '0 4px 8px rgba(0, 0, 0, 0.2)', lg: '0 8px 16px rgba(0, 0, 0, 0.3)' }, fontSize: { xs: '0.75rem', sm: '0.875rem', md: '1rem', lg: '1.25rem', xl: '1.5rem', '2xl': '2rem', '3xl': '3rem' }, grid: { adaptive: 'repeat(auto-fit, minmax(280px, 1fr))', autoFit: 'repeat(auto-fit, minmax(260px, 320px))', one: 'repeat(1, 1fr)', two: 'repeat(2, 1fr)', three: 'repeat(3, 1fr)', four: 'repeat(4, 1fr)', compact: 'repeat(auto-fit, minmax(200px, 1fr))', maxWidth: '1100px' } } export const darkTheme = { colors: { primaryColor: '#13161b', secondaryColor: '#1c1f25', background: '#2F2F2F', inputColor: '#0d0e12', white: '#121212', blue: '#0d6efd', blue2: '#0000FF', red: '#FF3347', green: '#28a745', orange: '#ff4500', yellow: '#fffF00', shadow: '#000', grey: '#a1a1a1', textColor: '#f1f1f1' } } export const lightTheme = { colors: { primaryColor: '#666666', secondaryColor: '#a1a1a1', background: '#808080', inputColor: '#f1f1f1', white: '#ffffff', blue: '#3a86ff', blue2: '#0000FF', red: '#FF0000', green: '#34d399', orange: '#ff4500', yellow: '#ffff00', shadow: '#000', grey: '#a1a1a1', textColor: '#13161b' } } export const neonTheme = { colors: { neonPink: { base: '#FF2DAA', glow: '#FF5FCC', soft: '#FFB3E6' }, neonPurple: { base: '#B026FF', glow: '#D26CFF', soft: '#E6C2FF' }, neonBlue: { base: '#00E5FF', glow: '#5DF4FF', soft: '#B3FBFF' }, neonGreen: { base: '#2BFF88', glow: '#6DFFB3', soft: '#C7FFE5' }, neonRed: { base: '#FF1744', glow: '#FF5252', soft: '#FF9E9E' }, neonYellow: { base: '#FFE600', glow: '#FFF066', soft: '#FFF6B3' }, neonCyan: { base: '#00FFF0', glow: '#66FFF7', soft: '#B3FFFB' } } } export const pastelTheme = { colors: { pastelPink: { base: '#F4A9C7', glow: '#F8C5DB', soft: '#FDEAF2', dark: '#E9C4D4' }, pastelPurple: { base: '#C7B8EA', glow: '#DDD2F4', soft: '#F2EEFB', dark: '#B9A8E3' }, pastelBlue: { base: '#A9D6F5', glow: '#C7E6FA', soft: '#EAF6FD', dark: '#8C9CF6' }, pastelGreen: { base: '#B8E0C8', glow: '#D4F0DE', soft: '#EEF9F2', dark: '#7da88a' }, pastelRed: { base: '#F2B6B6', glow: '#F7CDCD', soft: '#FDEEEE', dark: '#e08888' }, pastelYellow: { base: '#F6E7A7', glow: '#FAF1C8', soft: '#FEF9E9', dark: '#E2D45C' }, pastelCyan: { base: '#AEE3E3', glow: '#CFF1F1', soft: '#ECFAFA', dark: '#94E0E3' } } } export const themeConfig = { light: lightTheme, dark: darkTheme, neon: neonTheme, pastel: pastelTheme } `, ); // --- Bloco 7: Geração de Componentes de UI --- // Cria componentes reutilizáveis como Button, Modals, Inputs, etc. if (!useEmpty) { console.log(chalk.blue("🎨 Gerando componentes de UI...")); await writeFile( path.join(appPath, "src/components/ui/Button/Button.tsx"), ` 'use client' import Link from 'next/link' import React, { forwardRef } from 'react' import { ButtonContent, IconWrapper, StyledButton } from './ButtonStyles' type ButtonVariants = 'primary' | 'secondary' | 'toggle' | 'outline' | 'ghost' | 'link' | 'danger' | 'cian' | 'pink' | 'glass' type ButtonSizes = 'xs' | 'sm' | 'md' | 'lg' export interface CommonButtonProps { variant?: ButtonVariants size?: ButtonSizes loading?: boolean fullWidth?: boolean leftIcon?: React.ReactNode rightIcon?: React.ReactNode children?: React.ReactNode disabled?: boolean type?: 'button' | 'submit' | 'reset' href?: string target?: React.HTMLAttributeAnchorTarget rel?: string $isActive?: boolean } export type ButtonProps = CommonButtonProps & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & React.AnchorHTMLAttributes<HTMLAnchorElement> export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>( ( { variant = 'primary', size = 'md', loading = false, fullWidth = false, leftIcon, rightIcon, disabled, children, href, target, rel, type = 'button', ...props }, ref ) => { const isDisabled = disabled || loading const innerContent = ( <ButtonContent $loading={loading}> {leftIcon && <IconWrapper>{leftIcon}</IconWrapper>} {children} {rightIcon && <IconWrapper>{rightIcon}</IconWrapper>} </ButtonContent> ) if (href) { return ( <Link href={href} target={target} rel={rel}> <StyledButton ref={ref as React.Ref<HTMLButtonElement>} $variant={variant} data-variantt={variant} $size={size} $loading={loading} $fullWidth={fullWidth} $isActive={!!props.$isActive} aria-disabled={isDisabled} {...props} > {innerContent} </StyledButton> </Link> ) } // Caso botão normal return ( <StyledButton ref={ref as React.Ref<HTMLButtonElement>} $variant={variant} data-variant={variant} $size={size} $loading={loading} $fullWidth={fullWidth} $isActive={!!props.$isActive} disabled={isDisabled} aria-disabled={isDisabled} type={type} {...props} > {innerContent} </StyledButton> ) } ) Button.displayName = 'Button' export default Button `, ); await writeFile( path.join(appPath, "src/components/ui/Button/ButtonStyles.tsx"), ` import { media, theme } from '@/styles/theme' import styled, { css } from 'styled-components' interface StyledButtonProps { $variant: 'primary' | 'secondary' | 'toggle' | 'outline' | 'ghost' | 'link' | 'danger' | 'cian' | 'pink' | 'glass' $size: 'xs' | 'sm' | 'md' | 'lg' $loading: boolean $fullWidth: boolean $isActive: boolean | string } const buttonVariants = { primary: css\` background-color: \${({ theme }) => theme.colors.baseBlue.base}; color: \${({ theme }) => theme.colors.textColor}; border: 2px solid \${({ theme }) => theme.colors.baseBlue.base}; &:hover:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseBlue.dark}; border-color: \${({ theme }) => theme.colors.baseBlue.dark}; transform: translateY(-1px); scale: 1.01; } &:active:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseBlue.dark20}; transform: translateY(0); } \`, secondary: css<StyledButtonProps>\` background-color: \${({ theme }) => theme.colors.baseGreen.base}; color: \${({ theme }) => theme.colors.baseBlack.base}; border: 2px solid \${({ theme }) => theme.colors.baseGreen.base}; &:hover:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseGreen.dark}; border-color: \${({ theme }) => theme.colors.baseGreen.dark}; transform: translateY(-1px); scale: 1.01; } &:active:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseGreen.dark20}; transform: translateY(0); } \`, toggle: css<StyledButtonProps>\` background-color: \${({ theme, $isActive }) => $isActive ? theme.colors.baseBlue.base : theme.colors.baseBlack.light20}; border: 2px solid \${({ theme, $isActive }) => $isActive ? theme.colors.baseBlue.base : theme.colors.baseBlack.base}; width: 44px; height: 24px; border-radius: 999px; position: relative; padding: 0; transition: background-color 0.3s ease, border-color 0.3s ease; &::before { content: ""; position: absolute; top: 50%; left: 2px; transform: \${({ $isActive }) => $isActive ? "translate(20px, -50%)" : "translate(0, -50%)"}; width: 18px; height: 18px; border-radius: 50%; background-color: \${({ theme }) => theme.colors.textColor}; transition: transform 0.3s ease; } &:hover:not(:disabled) { transform: scale(1.05); } &:active:not(:disabled) { transform: scale(0.98); } \`, outline: css\` background-color: transparent; color: \${({ theme }) => theme.colors.baseBlue.base}; border: 2px solid \${({ theme }) => theme.colors.baseBlue.base}; &:hover:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseBlue.base}; color: \${({ theme }) => theme.colors.textColor}; transform: translateY(-1px); scale: 1.01; } &:active:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseBlue.dark}; transform: translateY(0); } \`, ghost: css\` background-color: transparent; color: \${({ theme }) => theme.colors.fifthColor}; border: 2px solid transparent; &:hover:not(:disabled) { transform: translateY(-2px); scale: 1.01; } &:active:not(:disabled) { transform: translateY(0); } \`, link: css\` background-color: transparent; color: \${({ theme }) => theme.colors.baseBlue.base}; border: 2px solid transparent; text-decoration: none; flex-wrap: wrap; flex: 1; &:hover:not(:disabled) { text-decoration: underline; color: \${({ theme }) => theme.colors.baseBlue.dark}; transform: translateY(-1px); scale: 1.01; } \`, danger: css\` background-color: \${({ theme }) => theme.colors.baseRed.base}; color: \${({ theme }) => theme.colors.textColor}; border: 2px solid \${({ theme }) => theme.colors.baseRed.base}; box-shadow: 4px 4px 4px \${({ theme }) => theme.colors.baseRed.base}; &:hover:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseRed.dark}; border-color: \${({ theme }) => theme.colors.baseRed.dark}; transform: translateY(-1px); scale: 1.01; } &:active:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseRed.dark20}; transform: translateY(0); } \`, cian: css\` background-color: \${({ theme }) => theme.colors.baseCyan.base}; color: \${({ theme }) => theme.colors.baseCyan.dark40}; border: 2px solid \${({ theme }) => theme.colors.baseCyan.base}; &:hover:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseCyan.dark30}; border-color: \${({ theme }) => theme.colors.baseCyan.light30}; color: \${({ theme }) => theme.colors.baseCyan.light30}; transform: translateY(-1px); scale: 1.01; } &:active:not(:disabled) { background-color: \${({ theme }) => theme.colors.baseCyan.dark20}; transform: translateY(0); } \`, pink: css\` background: linear-gradient(180deg, \${theme.colors.pinkColor}, \${theme.colors.pinkColor2}); color: \${theme.colors.fifthColor}; border: 2px solid \${theme.colors.pinkColor2}; box-shadow: 3px 3px 0px \${theme.colors.fifthColor}; &:hover:not(:disabled) { background: linear-gradient(360deg, \${theme.colors.pinkColor}, \${theme.colors.pinkColor2}); border: 2px solid \${theme.colors.pinkColor}; color: \${({ theme }) => theme.colors.fifthColor}; transform: translateY(-1px); scale: 1.01; } &:active:not(:disabled) { background-color: \${({ theme }) => theme.colors.pinkColor}; transform: translateY(0); } \`, glass: css\` background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(10px) saturate(180%); -webkit-backdrop-filter: blur(10px) saturate(180%); border: 1px solid rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 1); /* 🌟 brilho lateral suave */ box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2); /* opcional: highlight no topo */ border-top: 1px solid rgba(255, 255, 255, 0.3); /* opcional: highlight na lateral esquerda */ border-left: 1px solid rgba(255, 255, 255, 0.3); &:hover:not(:disabled) { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 1); color: rgba(255, 255, 255, 1); transform: translateY(-1px); scale: 1.01; box-shadow: 0 4px 30px rgba(255, 255, 255, 0.5) } &:active:not(:disabled) { background-color: rgba(255, 255, 255, 1); transform: translateY(0); } \` } const activeStyles = { primary: css\` background-color: \${({ theme }) => theme.colors.baseBlue.dark20}; border-color: \${({ theme }) => theme.colors.baseBlue.base}; color: \${({ theme }) => theme.colors.baseBlue.light30}; \`, secondary: css\` background-color: \${({ theme }) => theme.colors.baseGreen.dark20}; border-color: \${({ theme }) => theme.colors.baseGreen.base}; color: \${({ theme }) => theme.colors.baseGreen.light30}; \`, toggle: css\` background-color: \${({ theme }) => theme.colors.baseGreen.dark20}; border-color: \${({ theme }) => theme.colors.baseGreen.base}; color: \${({ theme }) => theme.colors.baseGreen.light30}; \`, outline: css\` background-color: \${({ theme }) => theme.colors.baseBlue.dark}; border-color: \${({ theme }) => theme.colors.baseBlue.base}; color: \${({ theme }) => theme.colors.textColor}; \`, ghost: css\` background-color: \${({ theme }) => theme.colors.pinkColor}; border-color: transparent; color: \${({ theme }) => theme.colors.fifthColor}; \`, link: css\` background-color: transparent; color: \${({ theme }) => theme.colors.baseBlue.base}; border: 2px solid transparent; \`, danger: css\` background-color: \${({ theme }) => theme.colors.baseRed.dark20}; border-color: \${({ theme }) => theme.colors.baseRed.base}; color: \${({ theme }) => theme.colors.textColor}; \`, cian: css\` background-color: \${({ theme }) => theme.colors.baseCyan.dark20}; border-color: \${({ theme }) => theme.colors.baseCyan.base}; color: \${({ theme }) => theme.colors.baseCyan.light30}; \`, pink: css\` background: \${({ theme }) => theme.colors.pinkColor}; border-color: \${({ theme }) => theme.colors.pinkColor2}; color: \${({ theme }) => theme.colors.fifthColor}; box-shadow: 2px 2px 0px \${({ theme }) => theme.colors.fifthColor}; \`, glass: css\` background: \${theme.colors.baseglass.light04}; border-color: \${theme.colors.baseglass.light08}; color: \${theme.colors.baseBlue.base}; box-shadow: 2px 2px 2px \${theme.colors.baseglass.light08}; \` } const buttonSizes = { xs: css\` padding: 2px 8px; font-size: 14px; min-height: 22px; \`, sm: css\` padding: 4px 12px; font-size: 18px; min-height: 26px; \`, md: css\` padding: 6px 18px; font-size: 22px; min-height: 34px; \`, lg: css\` padding: 8px 24px; font-size: 26px; min-height: 42px; \` } export const StyledButton = styled.button<StyledButtonProps>\` display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; font-weight: 600; line-height: 1; text-decoration: none; text-align: center; white-space: nowrap; border-radius: 16px; cursor: pointer; transition: all 0.2s ease-in-out; position: relative; overflow: hidden; \${({ $size }) => buttonSizes[$size]} \${({ $variant }) => buttonVariants[$variant]} \${({ $variant }) => $variant === 'link' && css\` white-space: normal; text-align: left; justify-content: flex-start; \`} \${({ $fullWidth }) => $fullWidth && css\` width: 100%; \`} &:disabled { opacity: 0.6; cursor: not-allowed; } \${({ $isActive, $variant }) => $isActive && css\` \${activeStyles[$variant]} \`} \${({ $loading }) => $loading && css\` cursor: not-allowed; &::before { content: ""; position: absolute; top: 41%; left: 50%; width: 16px; height: 16px; margin: -8px 0 0 -8px; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } \`} &:focus-visible { outline: 2px solid \${({ theme }) => theme.colors.baseBlue.base}; outline-offset: 2px; } \${media.mobile} { \${({ $size }) => $size === "lg" && buttonSizes.md} \${({ $size }) => $size === "md" && buttonSizes.sm} } \` export const ButtonContent = styled.span<{ $loading: boolean }>\` display: flex; align-items: center; gap: 8px; opacity: \${({ $loading }) => ($loading ? 0 : 1)}; transition: opacity 0.2s ease-in-out; \` export const IconWrapper = styled.span\` display: flex; align-items: center; justify-content: center; overflow: hidden; img { width: 40px; height: 40px; border-radius: 12px; } svg { width: 1em; height: 1em; } span { background-color: \${({ theme }) => theme.colors.red}; color: \${({ theme }) => theme.colors.fifthColor}; display: flex; align-items: center; justify-content: center; padding: 2px 6px; border-radius: 50%; } \` `, ); await writeFile( path.join(appPath, "src/components/ui/CartWrapper/CartWrapper.tsx"), ` import { AnimatePresence, motion } from "framer-motion"; import { ReactNode } from "react"; type CartWrapperProps = { isOpen: boolean; onClose: () => void; children: ReactNode; }; export const CartWrapper = ({ isOpen, onClose, children }: CartWrapperProps) => { return ( <AnimatePresence> {isOpen && ( <> <motion.div key="cart-backdrop" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3, ease: "easeOut", opacity: { duration: 0.3, }, when: "beforeChildren", }} onClick={onClose} style={{ position: "fixed", top: 0, left: 0, width: "100vw", height: "100vh", backdropFilter: "blur(5px)", zIndex: 99, }} /> <motion.aside key="cart-panel" initial={{ x: "100%" }} animate={{ x: 0 }} exit={{ x: "100%" }} transition={{ duration: 0.3, ease: "easeInOut" }} style={{ position: "fixed", top: 0, right: 0, width: "100%", height: "100vh", display: "flex", justifyContent: "end", zIndex: 100, }} onClick={onClose} > <div onClick={(e) => e.stopPropagation()}>{children}</div> </motion.aside> </> )} </AnimatePresence> ); }; `, ); await writeFile( path.join(appPath, "src/components/ui/ErrorMessage/ErrorMessage.tsx"), ` import { BiSolidError } from "react-icons/bi"; import { ErrorMessageContainer, ErrorMessageContent } from "./ErrorMessageStyles"; type Props = { message: string } export const ErrorMessage = ({ message }: Props) => ( <ErrorMessageContainer role="alert" aria-label="Mensagem de erro" className="container"> <ErrorMessageContent> <BiSolidError /> {message} </ErrorMessageContent> </ErrorMessageContainer> ) `, ); await writeFile( path.join( appPath, "src/components/ui/ErrorMessage/ErrorMessageStyles.ts", ), ` import { theme } from '@/styles/theme'; import { styled } from 'styled-components'; export const ErrorMessageContainer = styled.div\`\`; export const ErrorMessageContent = styled.div\` padding: 1rem; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; font-weight: 500; margin: 1rem; text-align: center; color: \${theme.colors.baseBlue.light40}; background-color: \${theme.colors.baseRed.dark08}; svg { font-size: 2rem; margin-right: 0.5rem; } \`; `, ); await writeFile( path.join(appPath, "src/components/ui/MaskedInput/BaseMaskedInput.tsx"), ` "use client"; import Image from "next/image"; import { JSX, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { AiOutlineEye, AiOutlineEyeInvisible, AiOutlineSearch, } from "react-icons/ai"; import { IMaskInput } from "react-imask"; import { ErrorDiv, FileTrigger, MaskedInputContainer, PasswordToggle, PreviewImageDiv, SearchIcon } from "./MaskedInputStyles"; /* ============================================================ * TYPES * ============================================================ */ type FileChangePayload = { files: File[]; previews: string[]; }; type Variant = | "default" | "masked" | "password" | "textarea" | "select" | "file" | "search"; type BaseMaskedInputProps = { value: any; onChange: (value: any) => void; error?: string; touched?: boolean; variant?: Variant; placeholder?: string; className?: string; id?: string; type?: string; children?: React.ReactNode; showError?: boolean; mask?: string; /* Upload */ fileMode?: "local" | "cloudinary"; multiple?: boolean; onFileChange?: (payload: FileChangePayload) => void; onUploadingChange?: (uploading: boolean) => void; /* Cloudinary */ uploadPreset?: string; cloudName?: string; previewMode?: "normal" | "replace"; onClick?: () => void; }; /* ============================================================ * COMPONENT * ============================================================ */ export const BaseMaskedInput = ({ value, onChange, error, touched, variant = "default", placeholder, className, id, type = "text", children, showError = true, mask, fileMode = "cloudinary", multiple = false, onFileChange, onUploadingChange, uploadPreset, cloudName, previewMode = "normal", onClick, }: BaseMaskedInputProps) => { const [showPassword, setShowPassword] = useState(false); const [internalPreviews, setInternalPreviews] = useState<string[]>([]); const fileInputRef = useRef<HTMLInputElement | null>(null); const previousPreviews = useRef<string[]>([]); const hasError = touched && Boolean(error); const commonProps = { id, placeholder, className: \`\${className ?? ""} \${hasError ? "error" : ""}\`, }; /* ============================================================ * CLOUDINARY * ============================================================ */ const uploadToCloudinary = async (files: File[]) => { if (!uploadPreset || !cloudName) return; onUploadingChange?.(true); const urls: string[] = []; for (const file of files) { const formData = new FormData(); formData.append("file", file); formData.append("upload_preset", uploadPreset); try { const res = await fetch( \`https://api.cloudinary.com/v1_1/\${cloudName}/image/upload\`, { method: "POST", body: formData } ); const data = await res.json(); urls.push(data.secure_url); } catch { toast.error(\`Erro ao enviar \${file.name}\`); } } onChange(multiple ? urls : urls[0]); toast.success("Upload concluído!"); onUploadingChange?.(false); }; /* ============================================================ * FILE CHANGE * ============================================================ */ const handleFileChange = (fileList: FileList | null) => { if (!fileList) return; const files = Array.from(fileList); const previews = files.map((file) => URL.createObjectURL(file)); previousPreviews.current.forEach(URL.revokeObjectURL); previousPreviews.current = previews; if (previewMode !== "replace") { setInternalPreviews(previews); } onFileChange?.({ files, previews }); if (fileMode === "local") { onChange(multiple ? files : files[0]); } else { uploadToCloudinary(files); } }; useEffect(() => { return () => previousPreviews.current.forEach(URL.revokeObjectURL); }, []); /* ============================================================ * VARIANTS * ============================================================ */ const variants: Record<Variant, JSX.Element> = { default: ( <input {...commonProps} type={type} value={value ?? ""} onChange={(e) => onChange(e.target.value)} /> ), masked: ( <IMaskInput {...commonProps} mask={mask!} value={String(value ?? "")} onAccept={(val) => onChange(val)} /> ), password: ( <> <input {...commonProps} type={showPassword ? "text" : "password"} value={value ?? ""} onChange={(e) => onChange(e.target.value)} /> <PasswordToggle onClick={() => setShowPassword((s) => !s)}> {showPassword ? <AiOutlineEyeInvisible /> : <AiOutlineEye />} </PasswordToggle> </> ), textarea: ( <textarea {...commonProps} value={value ?? ""} onChange={(e) => onChange(e.target.value)} /> ), select: ( <select {...commonProps} value={value ?? ""} onChange={(e) => onChange(e.target.value)} > {children} </select> ), file: ( <> <input type="file" ref={fileInputRef} multiple={multiple} hidden onChange={(e) => handleFileChange(e.target.files)} /> <FileTrigger type="button" onClick={() => fileInputRef.current?.click()}> {multiple ? "Selecionar imagens" : "Selecionar imagem"} </FileTrigger> {previewMode !== "replace" && internalPreviews.length > 0 && ( <PreviewImageDiv> {internalPreviews.map((src, i) => ( <Image key={i} src={src} alt="Preview" width={88} height={88} /> ))} </PreviewImageDiv> )} </> ), search: ( <> <SearchIcon> <AiOutlineSearch /> </SearchIcon> <input {...commonProps} onClick={onClick} type="search" value={value ?? ""} placeholder={placeholder ?? "Pesquisar..."} onChange={(e) => onChange(e.target.value)} /> </> ), }; return ( <MaskedInputContainer $variant={variant}> {variants[variant]} {showError && hasError && <ErrorDiv>{error}</ErrorDiv>} </MaskedInputContainer> ); }; `, ); await writeFile( path.join(appPath, "src/components/ui/MaskedInput/FormikMaskedInput.tsx"), ` "use client"; import { useField } from "formik"; import { BaseMaskedInput } from "./BaseMaskedInput"; type FormikMaskedInputProps = { name: string; } & Omit<React.ComponentProps<typeof BaseMaskedInput>, "value" | "onChange" | "error" | "touched" >; export const FormikMaskedInput = ({ name, ...props }: FormikMaskedInputProps) => { const [field, meta, helpers] = useField(name); return ( <BaseMaskedInput {...props} value={field.value} onChange={helpers.setValue} error={meta.error} touched={meta.touched} /> ); }; `, ); await writeFile( path.join(appPath, "src/components/ui/MaskedInput/MaskedInputStyles.ts"), ` import { theme } from '@/styles/theme' import styled from 'styled-components' /* ============================================================ * CONTAINER * ============================================================ */ export const MaskedInputContainer = styled.div<{ $variant?: string; $hasToggle?: boolean }>\` position: relative; width: 100%; display: flex; flex-direction: column; gap: 6px; input, textarea, select { width: 100%; padding: 12px; border-radius: 18px; border: 2px solid \${theme.colors.baseBlue.light20}; font-size: 1rem; line-height: 1.4; color: \${theme.colors.baseBlack.base}; background-color: \${theme.colors.baseBlue.light50}; z-index: 2; transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; \${({ $hasToggle }) => $hasToggle && \`padding-right: 44px;\`} input.input-hidden { position: absolute; inset: 0; opacity: 0; cursor: pointer; } /* Hover */ &:hover { border-color: \${theme.colors.baseBlue.base}; } /* Focus */ &:focus { outline: none; border-color: \${theme.colors.baseBlue.dark}; box-shadow: 0 0 0 3px \${theme.colors.baseBlue.light20}; background-color: #fff; } /* Disabled */ &:disabled { background-color: \${theme.colors.baseBlue.light20}; cursor: not-allowed; opacity: 0.7; } /* Error */ &.error { border-color: \${theme.colors.baseRed.base}; background-color: \${theme.colors.baseRed.light02}; color: \${theme.colors.baseRed.light30}; &:focus { box-shadow: 0 0 0 3px \${theme.colors.baseRed.light20}; } } &::placeholder { color: \${theme.colors.baseBlack.light50}; } &.error::placeholder { color: \${theme.colors.baseRed.light50}; } } input, .imask-input, select { height: 44px; } /* ===================== TEXTAREA ===================== */ textarea { min-height: 96px; resize: none; scrollbar-width: thin; scrollbar-color: \${theme.colors.baseBlue.base} \${theme.colors.baseBlue.light20}; } /* ===================== SELECT ===================== */ select { appearance: none; padding-right: 40px; cursor: pointer; background-image: url("data:image/svg+xml;utf8,<svg fill='%23444' height='16' viewBox='0 0 24 24' width='16' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/></svg>"); background-repeat: no-repeat; background-position: right 14px center; background-size: 14px; } \` /* ============================================================ * SEARCH ICON * ============================================================ */ export const SearchIcon = styled.div\` position: absolute; left: 14px; top: 12px; font-size: 1.1rem; color: \${theme.colors.baseBlack.light50}; pointer-events: none; \` /* ============================================================ * PASSWORD TOGGLE * ============================================================ */ export const PasswordToggle = styled.div\` position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; z-index: 2; color: \${theme.colors.baseBlue.light}; svg { font-size: 1.5rem; } \` /* ============================================================ * FILE PREVIEW * ============================================================ */ export const PreviewImageDiv = styled.div\` margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 10px; img { border-radius: 10px; object-fit: cover; border: 2px solid \${theme.colors.baseBlue.light20}; background: #fff; } \` /* ============================================================ * FILE BUTTON * ==============================