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,929 lines (1,669 loc) • 62.9 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";
export async function createProject(config) {
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);
// 1. Criação do projeto Next.js
let createCommand = `npx create-next-app@latest ${appName} --typescript --eslint --app --src-dir --import-alias "@/*"`;
createCommand += useTailwind ? " --tailwind" : " --no-tailwind";
if (useEmpty) createCommand += " --empty";
execCommand(createCommand);
if (!fs.existsSync(appPath)) {
console.error(`❌ Erro: Diretório ${appPath} não foi criado`);
process.exit(1);
}
process.chdir(appPath);
// 2. Instalação de dependências
let prodDependencies = [
"react-redux",
"@reduxjs/toolkit",
"immer",
"redux@latest",
"clsx",
"class-variance-authority",
"lucide-react",
];
if (finalChoice === "styled-components")
prodDependencies.unshift("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");
let devDependencies = [
"eslint-plugin-prettier",
"prettier",
"eslint-config-prettier",
];
if (finalChoice === "styled-components")
devDependencies.push("@types/styled-components");
if (installTests)
devDependencies.push(
"jest",
"@testing-library/react",
"@testing-library/jest-dom",
"@testing-library/user-event",
"jest-environment-jsdom"
);
installDependencies(prodDependencies, devDependencies);
// 3. Estrutura de pastas
const folders = [
"src/styles",
"src/lib",
"src/hooks",
"src/utils",
"src/redux",
"src/redux/slices",
".vscode",
];
if (!useEmpty)
folders.push(
"src/app/(private)",
"src/app/(public)",
"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/layout",
"src/components/layout/header",
"src/components/layout/footer"
);
if (installTests) folders.push("__tests__", "src/__tests__");
if (installBackend) folders.push("prisma");
await ensureFolders(appPath, folders);
// 4. Criação de arquivos de configuração (exemplo)
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
`
);
// Next.js config (sem experimental turbo)
let nextConfig = `/** @type {import('next').NextConfig} */
const nextConfig = {`;
if (finalChoice === "styled-components") {
nextConfig += `
compiler: {
styledComponents: true,
},`;
}
nextConfig += `
images: {
formats: ['image/avif', 'image/webp'],
domains: ['placehold.co'],
},
}
module.exports = nextConfig
`;
await writeFile(path.join(appPath, "next.config.js"), nextConfig);
// Jest config se testes foram escolhidos
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'
`
)
);
}
//Cria arquivo middleware.ts na raiz do projeto
await writeFile(
path.join(appPath, "middleware.ts"),
`
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* Middleware para controle de autenticação e redirecionamentos
*
* Este middleware é executado antes de cada requisição e permite:
* - Verificar autenticação do usuário
* - Redirecionar usuários não autenticados
* - Proteger rotas privadas
* - Adicionar headers customizados
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Rotas que não precisam de autenticação
const publicPaths = [
'/',
'/login',
'/register',
'/forgot-password',
'/about',
'/contact',
'/terms',
'/privacy'
];
// Rotas da API que não precisam de autenticação
const publicApiPaths = [
'/api/auth/login',
'/api/auth/register',
'/api/auth/forgot-password',
'/api/public'
];
// Rotas administrativas (requerem role específico)
const adminPaths = [
'/admin',
'/dashboard/admin'
];
// Verificar se é uma rota pública
const isPublicPath = publicPaths.some(path =>
pathname === path || pathname.startsWith(\`\${path}/\`)
);
const isPublicApiPath = publicApiPaths.some(path =>
pathname.startsWith(path)
);
// Se for rota pública, permitir acesso
if (isPublicPath || isPublicApiPath) {
return NextResponse.next();
}
// Verificar token de autenticação
const token = request.cookies.get('auth-token')?.value;
const userRole = request.cookies.get('user-role')?.value;
// Se não há token e está tentando acessar rota privada
if (!token && !isPublicPath) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// Verificar acesso a rotas administrativas
const isAdminPath = adminPaths.some(path =>
pathname.startsWith(path)
);
if (isAdminPath && userRole !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
// Se usuário está logado e tenta acessar login/register, redirecionar para dashboard
if (token && (pathname === '/login' || pathname === '/register')) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Adicionar headers customizados
const response = NextResponse.next();
// Headers de segurança
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'origin-when-cross-origin');
// Header com informações do usuário (se autenticado)
if (token) {
response.headers.set('X-User-Authenticated', 'true');
if (userRole) {
response.headers.set('X-User-Role', userRole);
}
}
return response;
}
// Configurar em quais rotas o middleware deve ser executado
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api/public (public API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder files
*/
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
};
`
);
//Cria arquivo types.d.ts na raiz do projeto
await writeFile(
path.join(appPath, "types.d.ts"),
`
/**
* Arquivo de tipos globais do projeto
*
* Adicione aqui todos os tipos TypeScript que serão utilizados
* em múltiplos arquivos do projeto, incluindo:
*
* - Interfaces de API
* - Tipos de dados do banco
* - Tipos de componentes compartilhados
* - Tipos de estado global (Redux)
* - Tipos de formulários
* - Enums e constantes tipadas
*/
// Exemplo: Tipos de usuário
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
MODERATOR = 'moderator'
}
// Exemplo: Tipos de API Response
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
errors?: string[];
}
// Exemplo: Tipos de formulário
export interface LoginForm {
email: string;
password: string;
rememberMe?: boolean;
}
export interface RegisterForm {
name: string;
email: string;
password: string;
confirmPassword: string;
}
// Exemplo: Tipos de componentes
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
// Exemplo: Tipos de estado
export interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
// Exemplo: Tipos de produtos (e-commerce)
export interface Product {
id: string;
name: string;
description: string;
price: number;
images: string[];
category: string;
stock: number;
featured: boolean;
}
export interface CartItem {
product: Product;
quantity: number;
}
export interface CartState {
items: CartItem[];
total: number;
isOpen: boolean;
}
`
);
//Cria arquivo useAppDispatch.ts dentro de src/hooks
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
`
);
// Criar colorUtils
await writeFile(
path.join(appPath, "src/utils/colorUtils.ts"),
`// 🎨 COLOR UTILS - Utilitários para geração de variantes de cores HSL
export function colorHSLVariants(h: number, s: number, l: number) {
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)}%)\`
}
}
`
);
// Theme configuration atualizado
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'
export const media = {
pc: '@media (max-width: 1024px)',
tablet: '@media (max-width: 768px)',
mobile: '@media (max-width: 480px)',
}
export const transitions = {
default: 'all 0.2s ease'
}
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 theme = {
colors: {
baseBlue: baseBlue,
baseGreen: baseGreen,
baseRed: baseRed,
baseCyan: baseCyan,
primaryColor: '#011627',
secondaryColor: '#023864',
thirdColor: '#0d6efd',
forthColor: '#E25010',
textColor: '#fff',
yellow: '#ffff00',
yellow2: '#E1A32A',
blue: '#0000FF',
blue2: '#1E90FF',
gray: '#666666',
gray2: '#a1a1a1',
orange: '#ff4500',
orange2: '#ff7f50',
black: '#000',
red: '#FF0000',
redHover: '#FF4837',
error: '#AB2E46',
green: '#008000',
green2: '#44BD32',
neonBlue: '#00FFD5 ',
neonGree: '#00FF6A '
}
}
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',
neon: {
pink1: '#FF1493',
pink2: '#FF00FF',
green1: '#39FF14',
green2: '#00FF7F',
blue1: '#00BFFF',
blue2: '#00FFFF'
}
}
}
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',
neon: {
pink1: '#FF1493',
pink2: '#FF00FF',
green1: '#39FF14',
green2: '#00FF7F',
blue1: '#00FFFF',
blue2: '#00BFFF'
}
}
}
export const themeConfig = {
light: lightTheme,
dark: darkTheme
}
`
);
// Cria componente de Botão estilizado
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" | "outline" | "ghost" | "danger";
type ButtonSizes = "sm" | "md" | "lg";
type AnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
};
type NativeButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
href?: undefined;
};
export interface CommonButtonProps {
variant?: ButtonVariants;
size?: ButtonSizes;
loading?: boolean;
fullWidth?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
children?: React.ReactNode;
}
export type ButtonProps = CommonButtonProps & (AnchorProps | NativeButtonProps);
export const Button = forwardRef<
HTMLButtonElement | HTMLAnchorElement,
ButtonProps
>(
(
{
variant = "primary",
size = "md",
loading = false,
fullWidth = false,
leftIcon,
rightIcon,
disabled,
children,
...props
},
ref
) => {
const isDisabled = disabled || loading;
const content = (
<StyledButton
as={(props as AnchorProps).href ? "a" : "button"}
ref={ref as any}
$variant={variant}
$size={size}
$loading={loading}
$fullWidth={fullWidth}
disabled={isDisabled}
aria-disabled={isDisabled}
{...(props as any)}
>
<ButtonContent $loading={loading}>
{leftIcon && <IconWrapper>{leftIcon}</IconWrapper>}
{children}
{rightIcon && <IconWrapper>{rightIcon}</IconWrapper>}
</ButtonContent>
</StyledButton>
);
if ((props as AnchorProps).href) {
return (
<Link href={(props as AnchorProps).href} passHref legacyBehavior>
{content}
</Link>
);
}
return content;
}
);
Button.displayName = "Button";
export default Button;
`
);
// Cria estilos do Botão
await writeFile(
path.join(appPath, "src/components/ui/Button/ButtonStyles.ts"),
String.raw`
import styled, { css } from 'styled-components';
interface StyledButtonProps {
$variant: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
$size: 'sm' | 'md' | 'lg';
$loading: boolean;
$fullWidth: boolean;
}
const buttonVariants = {
primary: css\`
background-color: \${({ theme }) => theme.colors.primary.base};
color: \${({ theme }) => theme.colors.white};
border: 2px solid \${({ theme }) => theme.colors.primary.base};
&:hover:not(:disabled) {
background-color: \${({ theme }) => theme.colors.primary.dark};
border-color: \${({ theme }) => theme.colors.primary.dark};
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: \${({ theme }) => theme.colors.primary.dark20};
transform: translateY(0);
}
\`,
secondary: css\`
background-color: \${({ theme }) => theme.colors.secondary.base};
color: \${({ theme }) => theme.colors.white};
border: 2px solid \${({ theme }) => theme.colors.secondary.base};
&:hover:not(:disabled) {
background-color: \${({ theme }) => theme.colors.secondary.dark};
border-color: \${({ theme }) => theme.colors.secondary.dark};
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: \${({ theme }) => theme.colors.secondary.dark20};
transform: translateY(0);
}
\`,
outline: css\`
background-color: transparent;
color: \${({ theme }) => theme.colors.primary.base};
border: 2px solid \${({ theme }) => theme.colors.primary.base};
&:hover:not(:disabled) {
background-color: \${({ theme }) => theme.colors.primary.base};
color: \${({ theme }) => theme.colors.white};
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: \${({ theme }) => theme.colors.primary.dark};
transform: translateY(0);
}
\`,
ghost: css\`
background-color: transparent;
color: \${({ theme }) => theme.colors.text.primary};
border: 2px solid transparent;
&:hover:not(:disabled) {
background-color: \${({ theme }) => theme.colors.gray.light40};
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: \${({ theme }) => theme.colors.gray.light30};
transform: translateY(0);
}
\`,
danger: css\`
background-color: \${({ theme }) => theme.colors.error.base};
color: \${({ theme }) => theme.colors.white};
border: 2px solid \${({ theme }) => theme.colors.error.base};
&:hover:not(:disabled) {
background-color: \${({ theme }) => theme.colors.error.dark};
border-color: \${({ theme }) => theme.colors.error.dark};
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: \${({ theme }) => theme.colors.error.dark20};
transform: translateY(0);
}
\`
};
const buttonSizes = {
sm: css\`
padding: 8px 16px;
font-size: 14px;
min-height: 36px;
\`,
md: css\`
padding: 12px 24px;
font-size: 16px;
min-height: 44px;
\`,
lg: css\`
padding: 16px 32px;
font-size: 18px;
min-height: 52px;
\`
};
export const StyledButton = styled.button<StyledButtonProps>\`
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: \${({ theme }) => theme.fonts.primary};
font-weight: 600;
line-height: 1;
text-decoration: none;
text-align: center;
white-space: nowrap;
border-radius: \${({ theme }) => theme.borderRadius.md};
cursor: pointer;
transition: all 0.2s ease-in-out;
position: relative;
overflow: hidden;
\${({ $size }) => buttonSizes[$size]}
\${({ $variant }) => buttonVariants[$variant]}
\${({ $fullWidth }) => $fullWidth && css\`
width: 100%;
\`}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
\${({ $loading }) => $loading && css\`
cursor: not-allowed;
&::before {
content: '';
position: absolute;
top: 50%;
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); }
}
\`}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.3s, height 0.3s;
}
&:active:not(:disabled)::after {
width: 300px;
height: 300px;
}
&:focus-visible {
outline: 2px solid \${({ theme }) => theme.colors.primary.base};
outline-offset: 2px;
}
@media (max-width: \${({ theme }) => theme.breakpoints.sm}) {
\${({ $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;
svg {
width: 1em;
height: 1em;
}
\`;
`
);
// Cria componente de CartWrapper
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>
);
};
`
);
//cria componente de ErrorMessage
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>
)
`
);
// cria estilo do ErrorMessage
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;
}
\`;
`
);
// Cria componente de MaskedInput
await writeFile(
path.join(appPath, "src/components/ui/MaskedInput/MaskedInput.tsx"),
`'use client';
import React, { forwardRef, useEffect, useRef, useImperativeHandle } from 'react';
import { IMask, IMaskInput } from 'react-imask';
import styled from 'styled-components';
const StyledInputWrapper = styled.div<{ $hasError: boolean; $fullWidth: boolean }>\`
display: \${({ $fullWidth }) => $fullWidth ? 'block' : 'inline-block'};
width: \${({ $fullWidth }) => $fullWidth ? '100%' : 'auto'};
position: relative;
\`;
const StyledInput = styled.input<{ $hasError: boolean }>\`
width: 100%;
padding: 12px 16px;
font-size: 16px;
font-family: \${({ theme }) => theme.fonts.primary};
line-height: 1.5;
background-color: \${({ theme }) => theme.colors.background.secondary};
border: 2px solid \${({ theme, $hasError }) =>
$hasError ? theme.colors.error.base : theme.colors.gray.light20};
border-radius: \${({ theme }) => theme.borderRadius.md};
color: \${({ theme }) => theme.colors.text.primary};
transition: all 0.2s ease-in-out;
&:focus {
outline: none;
border-color: \${({ theme, $hasError }) =>
$hasError ? theme.colors.error.base : theme.colors.primary.base};
box-shadow: 0 0 0 3px \${({ theme, $hasError }) =>
$hasError ? theme.colors.error.light40 : theme.colors.primary.light40};
}
&:hover:not(:focus) {
border-color: \${({ theme, $hasError }) =>
$hasError ? theme.colors.error.dark : theme.colors.gray.base};
}
&:disabled {
background-color: \${({ theme }) => theme.colors.gray.light50};
border-color: \${({ theme }) => theme.colors.gray.light30};
color: \${({ theme }) => theme.colors.text.disabled};
cursor: not-allowed;
}
&::placeholder {
color: \${({ theme }) => theme.colors.text.placeholder};
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type=number] {
-moz-appearance: textfield;
}
\`;
const Label = styled.label<{ $required: boolean }>\`
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 600;
color: \${({ theme }) => theme.colors.text.primary};
\${({ $required }) => $required && \`
&::after {
content: ' *';
color: \${({ theme }) => theme.colors.error.base};
}
\`}
\`;
const ErrorMessage = styled.span\`
display: block;
margin-top: 4px;
font-size: 12px;
color: \${({ theme }) => theme.colors.error.base};
\`;
const HelperText = styled.span\`
display: block;
margin-top: 4px;
font-size: 12px;
color: \${({ theme }) => theme.colors.text.secondary};
\`;
export interface MaskedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
mask: string | RegExp | Array<string | RegExp> | Function;
maskOptions?: any;
label?: string;
required?: boolean;
error?: string;
helperText?: string;
fullWidth?: boolean;
onChange?: (value: string, unmaskedValue: string) => void;
onAccept?: (value: string, mask: IMask.InputMask<any>) => void;
onComplete?: (value: string, mask: IMask.InputMask<any>) => void;
}
export const MaskedInput = forwardRef<HTMLInputElement, MaskedInputProps>(
(
{
mask,
maskOptions = {},
label,
required = false,
error,
helperText,
fullWidth = false,
onChange,
onAccept,
onComplete,
id,
...props
},
ref
) => {
const inputRef = useRef<HTMLInputElement>(null);
const maskRef = useRef<IMask.InputMask<any>>();
useImperativeHandle(ref, () => inputRef.current!);
const inputId = id || \`masked-input-\${Math.random().toString(36).substr(2, 9)}\`;
const hasError = Boolean(error);
const handleAccept = (value: string, mask: IMask.InputMask<any>) => {
const unmaskedValue = mask.unmaskedValue;
onChange?.(value, unmaskedValue);
onAccept?.(value, mask);
};
const handleComplete = (value: string, mask: IMask.InputMask<any>) => {
onComplete?.(value, mask);
};
return (
<StyledInputWrapper $hasError={hasError} $fullWidth={fullWidth}>
{label && (
<Label htmlFor={inputId} $required={required}>
{label}
</Label>
)}
<IMaskInput
{...props}
id={inputId}
ref={inputRef}
mask={mask}
{...maskOptions}
onAccept={handleAccept}
onComplete={handleComplete}
render={(ref, props) => (
<StyledInput
{...props}
ref={ref}
$hasError={hasError}
/>
)}
/>
{error && <ErrorMessage>{error}</ErrorMessage>}
{!error && helperText && <HelperText>{helperText}</HelperText>}
</StyledInputWrapper>
);
}
);
MaskedInput.displayName = 'MaskedInput';
export default MaskedInput;
export const commonMasks = {
cpf: '000.000.000-00',
cnpj: '00.000.000/0000-00',
phone: '(00) 00000-0000',
landline: '(00) 0000-0000',
cep: '00000-000',
date: '00/00/0000',
time: '00:00',
rg: '00.000.000-0',
currency: 'R$ num',
percentage: 'num %',
creditCard: '0000 0000 0000 0000',
cvv: '000',
expiryDate: '00/00'
};
export const commonMaskOptions = {
currency: {
blocks: {
num: {
mask: Number,
scale: 2,
thousandsSeparator: '.',
radix: ',',
mapToRadix: ['.'],
min: 0
}
}
},
percentage: {
blocks: {
num: {
mask: Number,
scale: 2,
min: 0,
max: 100
}
}
}
};
`
);
// cria componente ModalWrapper
await writeFile(
path.join(appPath, "src/components/ui/ModalWrapper/ModalWrapper.tsx"),
`
import { AnimatePresence, motion } from "framer-motion";
import { ReactNode } from "react";
type ModalWrapperProps = {
isOpen: boolean;
children: ReactNode;
onClose: () => void;
};
export const ModalWrapper = ({ isOpen, children, onClose }: ModalWrapperProps) => {
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="modal"
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -30 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
zIndex: 100,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
backdropFilter: "blur(5px)",
}}
onClick={onClose}
>
<div onClick={(e) => e.stopPropagation()}>
{children}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
`
);
// Criar arquivos específicos baseados na escolha
if (finalChoice === "styled-components") {
await createStyledComponentsFiles(appPath);
} else {
await createTailwindFiles(appPath);
}
// Middleware
await writeFile(
path.join(appPath, "src/middleware.ts"),
`// 🔒 MIDDLEWARE - Controle de autenticação e rotas
import { MiddlewareConfig, NextRequest, NextResponse } from 'next/server'
const publicRoutes = [
{ path: '/', whenAuthenticated: 'next' },
{ path: '/sign-in', whenAuthenticated: 'redirect' },
{ path: '/register', whenAuthenticated: 'redirect' },
{ path: '/pricing', whenAuthenticated: 'next' }
] as const
const REDIRECT_WHEN_NOT_AUTHENTICATED_ROUTE = '/sign-in'
export function middleware(request: NextRequest) {
const path = request.nextUrl.pathname
const matchedPublicRoute = publicRoutes.find(route => route.path === path)
const authToken = request.cookies.get('token')
//1 - Se o usuário não estiver autenticado e o caminho da rota não for público, redireciona para a página de login
if (!authToken && matchedPublicRoute) {
return NextResponse.next()
}
//2 - se o usuario não estiver autenticado e o caminho da rota não for público, redireciona para a página de login
if (!authToken && !matchedPublicRoute) {
const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = REDIRECT_WHEN_NOT_AUTHENTICATED_ROUTE
return NextResponse.redirect(redirectUrl)
}
//3 - Se o usuário estiver autenticado e o caminho da rota não for público, redireciona para a página inicial
if (authToken && matchedPublicRoute?.whenAuthenticated === 'redirect') {
const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = '/'
return NextResponse.redirect(redirectUrl)
}
return NextResponse.next()
}
export const config: MiddlewareConfig = {
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)']
}
`
);
// Providers
await writeFile(
path.join(appPath, "src/components/providers.tsx"),
`'use client'
// 🔧 PROVIDERS - Configuração de contextos globais
import { Provider } from 'react-redux'
import { store } from '@/redux/store'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<Provider store={store}>
{children}
</Provider>
)
}`
);
// .env file
await writeFile(
path.join(appPath, ".env"),
`# 🔐 VARIÁVEIS DE AMBIENTE - Configurações do projeto
# Next.js
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-secret-key-here
# Database (se backend foi escolhido)
${
installBackend
? `DATABASE_URL="mysql://username:password@localhost:3306/database_name"`
: `# DATABASE_URL="mysql://username:password@localhost:3306/database_name"`
}
# API Keys
# API_KEY=your-api-key-here
`
);
// Redux Store baseado na escolha de testes
if (installTests) {
await writeFile(
path.join(appPath, "src/redux/store.ts"),
`// 🏪 REDUX STORE - Configuração do gerenciamento de estado com preloaded state para testes
import { configureStore } from '@reduxjs/toolkit'
import authReducer from './slices/authSlice'
export const store = configureStore({
reducer: {
auth: authReducer
}
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
`
);
// AuthSlice para testes
await writeFile(
path.join(appPath, "src/redux/slices/authSlice.ts"),
`// 🔐 AUTH SLICE - Gerenciamento de estado de autenticação
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface AuthState {
user: {
id: string
name: string
email: string
} | null
isAuthenticated: boolean
loading: boolean
}
const initialState: AuthState = {
user: null,
isAuthenticated: false,
loading: false
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginStart: (state) => {
state.loading = true
},
loginSuccess: (state, action: PayloadAction<{ id: string; name: string; email: string }>) => {
state.loading = false
state.isAuthenticated = true
state.user = action.payload
},
loginFailure: (state) => {
state.loading = false
state.isAuthenticated = false
state.user = null
},
logout: (state) => {
state.isAuthenticated = false
state.user = null
state.loading = false
}
}
})
export const { loginStart, loginSuccess, loginFailure, logout } = authSlice.actions
export default authSlice.reducer
`
);
} else {
await writeFile(
path.join(appPath, "src/redux/store.ts"),
`// 🏪 REDUX STORE - Configuração simples do gerenciamento de estado
import { configureStore } from '@reduxjs/toolkit'
import authReducer from './slices/authSlice'
export const store = configureStore({
reducer: {
auth: authReducer
}
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
`
);
// AuthSlice simples
await writeFile(
path.join(appPath, "src/redux/slices/authSlice.ts"),
`// 🔐 AUTH SLICE - Gerenciamento de estado de autenticação
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface AuthState {
user: {
id: string
name: string
email: string
} | null
isAuthenticated: boolean
}
const initialState: AuthState = {
user: null,
isAuthenticated: false
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess: (state, action: PayloadAction<{ id: string; name: string; email: string }>) => {
state.isAuthenticated = true
state.user = action.payload
},
logout: (state) => {
state.isAuthenticated = false
state.user = null
}
}
})
export const { loginSuccess, logout } = authSlice.actions
export default authSlice.reducer
`
);
}
// Configuração do Prisma se backend foi escolhido
if (installBackend) {
console.log("🗄️ Configurando Prisma...");
// Executar prisma init
execCommand("npx prisma init");
// Schema do Prisma com comentários
await writeFile(
path.join(appPath, "prisma/schema.prisma"),
`// 🗄️ PRISMA SCHEMA - Configuração do banco de dados
// Este arquivo define a estrutura do seu banco de dados
// Configuração do gerador do Prisma Client
generator client {
provider = "prisma-client-js"
}
// Configuração da conexão com o banco de dados
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// 👤 MODEL USER - Modelo básico de usuário
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
// 📝 COMO USAR ESTE ARQUIVO:
//
// 1. Configure sua DATABASE_URL no arquivo .env
// Exemplo: DATABASE_URL="mysql://username:password@localhost:3306/database_name"
//
// 2. Para criar o banco de dados e tabelas:
// npx prisma db push
//
// 3. Para gerar o Prisma Client:
// npx prisma generate
//
// 4. Para visualizar o banco no Prisma Studio:
// npx prisma studio
//
// 5. Para usar um banco existente:
// npx prisma db pull (puxa a estrutura do banco existente)
// npx prisma generate (gera o client baseado na estrutura)
//
// 6. Para criar e aplicar migrations:
// npx prisma migrate dev --name init
//
// 📚 DOCUMENTAÇÃO: https://www.prisma.io/docs
`
);
// Arquivo de configuração do Prisma Client
await writeFile(
path.join(appPath, "src/lib/prisma.ts"),
`// 🗄️ PRISMA CLIENT - Configuração da conexão com o banco de dados
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
`
);
}
// Criar layout baseado na escolha
await createLayout(appPath, finalChoice, useEmpty);
// Criar arquivos de exemplo se não for projeto vazio
if (!useEmpty) {
await createExampleFiles(appPath, finalChoice, installTests, appName);
}
console.log("\n" + "=".repeat(50));
console.log("✅ PROJETO CRIADO COM SUCESSO!");
console.log("=".repeat(50));
console.log(`📁 Navegue para: cd ${appName}`);
console.log("🚀 Execute: npm run dev");
console.log(
`🎨 CSS: ${
finalChoice === "styled-components" ? "Styled Components" : "Tailwind CSS"
}`
);
console.log(`📦 Tipo: ${useEmpty ? "Projeto limpo" : "Com exemplos"}`);
if (installTests) {
console.log("🧪 Testes: npm test");
}
if (installExtraDeps) {
console.log("📚 Dependências adicionais instaladas");
}
if (installBackend) {
console.log("🗄️ Backend: Prisma + MySQL configurado");
console.log(" - Configure DATABASE_URL no .env");
console.log(" - Execute: npx prisma db push");
console.log(" - Execute: npx prisma generate");
}
console.log("💙 Criado por RNT");
console.log("=".repeat(50));
}
async function createStyledComponentsFiles(appPath) {
// Global Styles para Styled Components atualizado
await writeFile(
path.join(appPath, "src/styles/globalStyles.tsx"),
`'use client'
// 🎨 GLOBAL STYLES - Estilos globais com Styled Components
import styled, { createGlobalStyle } from 'styled-components';
import { theme } from './theme';
export const GlobalStyles = createGlobalStyle\`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background-color: \${theme.colors.baseBlue.dark20};
color: \${theme.colors.baseBlue.dark50};
}
.container {
max-width: 1024px;
margin: 0 auto;
}
\`;
export const OverlayBlur = styled.div\`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(5px);
z-index: 100;
\`
export const OverlayDarck = styled.div\`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 10;
\`
export const CloseButton = styled.button\`
border-radius: 50%;
margin: 0;
padding: 0;
position: absolute;
top: 0px;
right: 0px;
background-color: transparent;
border: transparent;
cursor: pointer;
svg {
font-size: 24px;
color: \${theme.colors.baseBlue.dark20};
}
&:hover {
svg {
color: \${theme.colors.baseBlue.light};
}
}
\`
export const TitleH2 = styled.h2\`
font-size: 24px;
font-weight: 600;
color: \${theme.colors.baseBlue.light30};
\`
export const TitleH3 = styled.h3\`
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
color: \${theme.colors.baseBlue.dark30};
\`
export const MinorTextH4 = styled.h3\`
font-size: 14px;
font-weight: 300;
margin-bottom: 8px;
color: \${theme.colors.baseBlue.dark30};
\`
`
);
// Styled Components Registry
await writeFile(
path.join(appPath, "src/lib/styled-components-registry.tsx"),
`'use client'
// 🔧 STYLED COMPONENTS REGISTRY - Necessário para SSR
import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode
}) {
// Only create stylesheet once with lazy initial state
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement()
styledComponentsStyleSheet.instance.clearTag()
return <>{styles}</>
})
if (typeof window !== 'undefined') return <>{children}</>
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
)
}`
);
}
async function createTailwindFiles(appPath) {
// Verificar se o globals.css já existe e atualizar
const globalsPath = "src/app/globals.css";
if (fs.existsSync(globalsPath)) {
// Ler o conteúdo existente e adicionar customizações
let existingContent = await fs.readFile(globalsPath, "utf8");
// Adicionar customizações se não existirem
if (!existingContent.includes("/* RNT Custom Styles */")) {
const customStyles = `
/* RNT Custom Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
list-style: none;
}
body {
font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #011627;
color: #fff;
transition: background-color 0.3s, color 0.3s;
}
html {
scroll-behavior: smooth;
}
.container {
max-width: 1024px;
margin: 0 auto;
}
`;
await writeFile(globalsPath, existingContent + customStyles);
}
}
}
async function createLayout(appPath, cssChoice, useEmpty) {
if (cssChoice === "styled-components") {
// Layout com Styled Components
await writeFile(
path.join(appPath, "src/app/layout.tsx"),
`import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import StyledComponentsRegistry from '@/lib/styled-components-registry'
import { GlobalStyles } from '@/styles/globalStyles'
import { Providers } from '@/components/providers'${
!useEmpty
? `
import Header from '@/components/layout/header/Header'
import Footer from '@/components/layout/footer/Footer'`
: ""
}
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'RNT Next App',
description: 'Aplicação Next.js criada com RNT CLI',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR">
<body className={inter.className}>
<StyledComponentsRegistry>
<GlobalStyles />
<Providers>${
!useEmpty
? `
<Header />
{children}
<Footer />`
: `
{children}`
}
</Providers>
</StyledComponentsRegistry>
</body>
</html>
)
}
`
);
} else {
// Layout com Tailwind
await writeFile(
path.join(appPath, "src/app/layout.tsx"),
`import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from '@/components/providers'${
!useEmpty
? `
import Header from '@/components/layout/header/Header'
import Footer from '@/components/layout/footer/Footer'`
: ""
}
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'RNT Next App',
description: 'Aplicação Next.js criada com RNT CLI',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR">
<body className={inter.className}>
<Providers>${
!useEmpty
? `
<Header