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
JavaScript
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
* ==============================