Skip to content

La aplicación base con React ​

php artisan app:setup --react monta la interfaz con React 19, React Router 7 (data router) y Zustand 5. Es la misma aplicación que la de Vue, con el mismo contrato. Aquí están el código, el arranque y los ajustes, que en React viven en config.js.

Los archivos ​

text
package.json
vite.config.js
resources/views/app.blade.php
resources/react/
├── routes.json                  lo reescribe php artisan route:json
├── index.js                     el módulo de LaraPack (generado)
├── src/                         sus modelos (generados)
└── app/                         la aplicación base: tuya
    ├── main.jsx                 arranque
    ├── App.jsx                  RouterProvider, ToastRegion y ConfirmHost
    ├── config.js                los ajustes de la aplicación
    ├── http.js                  axios e interceptores
    ├── i18n.js                  setupI18n
    ├── lang/es.json
    ├── router/
    │   ├── index.jsx            árbol de rutas y aviso de acceso denegado
    │   ├── guards.js            guardas como loaders
    │   ├── menu.js              buildMenu
    │   └── RootLayout.jsx       título de la pestaña y ScrollRestoration
    ├── stores/
    │   ├── auth.js              sesión y perfil
    │   ├── options.js           opciones, useOption, useSiteName
    │   ├── notifications.js     campana
    │   └── ui.js                modo oscuro, barra lateral, useIsDark
    ├── auth/                    AuthLayout, LoginView, RegisterView,
    │                            ForgotPasswordView, ResetPasswordView
    ├── admin/
    │   ├── AdminLayout.jsx
    │   ├── DashboardView.jsx
    │   ├── ProfileView.jsx
    │   ├── SiteEditorView.jsx
    │   ├── siteEditor.js        lógica del editor, sin React
    │   └── components/          JsonEditor, MenuLink, NotificationBell,
    │                            SectionEditor, SessionBanners, SidebarMenu, UserMenu
    ├── components/              ThemeToggle, UserAvatar
    ├── errors/                  ErrorView, NotFoundView
    ├── forms/
    │   ├── errors.js            errorMessage
    │   └── useValidator.js      validación con js-validator
    ├── site/
    │   ├── SitePage.jsx
    │   ├── ThemeManager.jsx
    │   └── sections/
    │       ├── index.js         el registro de secciones
    │       ├── shared.jsx       piezas y helpers comunes
    │       └── legacy/          header, hero, section, footer, cookie-consent
    └── styles/
        ├── app.css
        └── site.css             lo importa SitePage.jsx

Dependencias y compilación ​

package.json trae axios, react, react-dom, react-router-dom, zustand, innoboxrr-form-core, innoboxrr-react-form-elements, innoboxrr-react-datatable, innoboxrr-http-request, innoboxrr-i18n, innoboxrr-js-validator e innoboxrr-route-resolver. En desarrollo trae vite 8, @vitejs/plugin-react 6, laravel-vite-plugin 3 y concurrently.

vite.config.js:

  • Entrada resources/react/app/main.jsx.
  • Alias @app → resources/react/app.
  • resolve.dedupe de react, react-dom, react-router-dom, zustand y axios. Con dos copias de axios, los interceptores no verían las peticiones del módulo; con dos de React o del router, sus hooks fallan.
  • storage/framework/views fuera del watcher.

resources/views/app.blade.php lleva el mismo script de modo oscuro que Vue, @viteReactRefresh antes de @vite('resources/react/app/main.jsx') y un <noscript>.

El arranque ​

main.jsx importa innoboxrr-form-core/styles, innoboxrr-react-form-elements/src/css/form-elements.css y styles/app.css. Carga el módulo con import.meta.glob('../index.js', { eager: true }) y ../src/theme.js si existe. Después, en boot():

  1. configureHttp(axios): withCredentials, withXSRFToken, Accept y X-Requested-With.
  2. installInterceptors(axios, { onUnauthenticated }) con unauthenticatedHandler: ante un 401, limpia la sesión y navega a /auth/login?redirect=… salvo que ya esté en /auth/….
  3. setRoutes(routes) con resources/react/routes.json.
  4. setupI18n(module.translations): módulo primero, app/lang/*.json después, y setLocale(document.documentElement.lang || 'en').
  5. useUiStore.getState().initTheme().
  6. module.registerModuleRoutes?.(adminBase): registra los nombres de ruta del módulo en innoboxrr-react-datatable con el mismo prefijo con el que se montan, para que la tabla construya sus enlaces por nombre.
  7. await Promise.all([auth.load(), options.load()]). Cada estado captura sus propios errores, así que uno que falle no para el arranque.
  8. createAppRouter({ moduleRoutes: module.routes ?? [] }).
  9. createRoot(#app).render(<StrictMode><App router={router} /></StrictMode>).

App.jsx monta RouterProvider, ToastRegionComponent (con la etiqueta «Alerts») y ConfirmHostComponent, una sola vez.

config.js ​

Lo que decide la aplicación y el módulo de LaraPack no sabe:

ExportPor omisiónQué decide
adminOnly['AdminUsers']Ids de las rutas del módulo que sólo ve un administrador. Un id cubre también a sus hijas.
adminBase'/admin'Prefijo con el que se registran los nombres de ruta del módulo y se construyen los enlaces del menú.
userUpdateRoute'api.app.user.update'La ruta del update del usuario generado, que usan el perfil y la foto.
adminToolsRegistros (/log-viewer) y Entorno (/env-editor)Herramientas del grupo «Administración» que sirve Laravel: { id, label, href, icon }. Se abren en otra pestaña y label pasa por t().
notificationsInterval60_000Milisegundos entre consultas del contador de notificaciones.
sitePageshome, privacy, terms, contact, joinPáginas del sitio: { key, path, name, label }. Crean las rutas y las pestañas del editor.

adminBase no mueve el administrador por sí solo

El árbol de router/index.jsx monta el administrador en path: 'admin', y las guardas y varias pantallas redirigen a /admin escrito a mano. Si cambias adminBase, cambia también esas rutas.

El router (data router) ​

router/index.jsx exporta buildRoutes({ moduleRoutes, adminOnly, getSession, onDenied }), createAppRouter(options) (con createBrowserRouter) y announceDenied.

IdRutahandleElemento
root/—RootLayout; errorElement: <ErrorView />; HydrateFallback: () => null
site.homeíndice de /{ page: 'home' }<SitePage page="home" />
site.privacy, site.terms, site.contact, site.joinprivacy, terms, …{ page }<SitePage page={key} />
authauth{ guest: true }AuthLayout
auth.loginauth/logintitle: 'Log in', guestLoginView
auth.registerauth/registertitle: 'Create an account', guestRegisterView
auth.forgot-passwordauth/forgot-passwordtitle: 'Forgot your password?', guestForgotPasswordView
auth.reset-passwordauth/reset-password/:token/:emailtitle: 'Choose a new password', guestResetPasswordView
adminadmin{ auth: true }<AdminLayout moduleRoutes={…} />
admin.dashboardíndice de admintitle: 'Home', authDashboardView
admin.profileadmin/profiletitle: 'Profile', authProfileView (lazy)
admin.siteadmin/sitetitle: 'Site', auth, adminSiteEditorView (lazy: trae CodeMirror, y el sitio público no tiene por qué descargarlo)
las del módulohijas de admintitle, authlas del módulo
not-found*title: 'Page not found'NotFoundView
  • Los títulos son getters que traducen al leerse.
  • errorElement enseña ErrorView si una pantalla falla al cargar, por ejemplo un chunk que ya no existe tras un despliegue. Ofrece recargar o ir al inicio.
  • RootLayout pone el título <título> · <nombre del sitio> en toda ruta que no es del sitio y monta ScrollRestoration. Las páginas del sitio ponen el suyo desde SitePage.

Las guardas ​

React Router no tiene beforeEach, así que router/guards.js pone un loader con su guarda en cada ruta del árbol. No basta con el padre: React Router no vuelve a ejecutar el loader de un padre al navegar entre sus hijas, y la sesión puede haber cambiado.

ExportQué hace
withGuards(routes, { adminOnly, getSession, onDenied })Copia el árbol y pone en cada ruta loader: createGuardLoader(…), sin perder el loader que ya tuviera.
requirementsFor(route, inherited, adminOnly){ guest, auth, admin } sumando los padres. admin es handle.admin === true o el id en adminOnly; admin implica auth.
checkAccess(requirements, session, target)null o { reason, redirect }.
createGuardLoader(requirements, { getSession, onDenied }, loader)Si hay acceso denegado, llama a onDenied y lanza redirect(…).
safeRedirect(value, fallback = '/admin')El valor si es una ruta interna; si no, fallback.
loginPath(target)/auth/login?redirect=<target codificado>
hasParams(path)Si la ruta tiene : o *.
CasoRedirige areason
guest y hay sesión/adminguest
auth y no hay sesión/auth/login?redirect=…auth
admin y is_admin no es true/adminadmin

announceDenied enseña «That section is only for administrators.». Evita que salga dos veces, porque el loader del padre y el de la hija se evalúan a la vez.

Las guardas no son seguridad

Deciden qué pantallas se enseñan. Lo que protege los datos es el backend: políticas, ManagedFilter y el middleware admin.

El menú ​

buildMenu({ moduleRoutes, isAdmin, adminOnly, base, tools, translate }) en router/menu.js devuelve { main, admin }:

js
{
    main: [
        { id: 'admin.dashboard', label: 'Inicio', to: '/admin', icon: 'home', end: true },
        // rutas del módulo que no están en adminOnly
    ],
    admin: [ // vacío si no es administrador
        // rutas del módulo que están en adminOnly
        { id: 'admin.site', label: 'Sitio', to: '/admin/site', icon: 'mdi:web' },
        { id: 'log-viewer', label: 'Registros', href: '/log-viewer', icon: 'mdi:text-box-search-outline', external: true },
        { id: 'env-editor', label: 'Entorno', href: '/env-editor', icon: 'mdi:tune-variant', external: true },
    ],
}
  • Entra cada ruta de primer nivel del módulo que no es index, tiene path sin parámetros y tiene handle.title, en el orden en que la exporta el módulo.
  • Una entrada es { id, label, to: base + path, icon: handle.icon ?? 'box', restricted }.
  • AdminLayout construye el menú y lo pasa a sus hijas como contexto del Outlet (la clave menu); DashboardView lo lee con useOutletContext().
  • SidebarMenu pinta los dos grupos y MenuLink cada entrada: NavLink para una pantalla de la SPA, enlace en otra pestaña para una herramienta.

Los estados (Zustand) ​

Cada estado se crea con una fábrica que recibe sus dependencias (http, resolve, …), para poder probarlo sin red. Dentro de un componente se lee con un selector; fuera, con getState():

jsx
const user = useAuthStore((state) => state.user)
await useAuthStore.getState().load()

stores/auth.js ​

Estado: user, authenticated, is_admin, verified, impersonating y loaded.

AcciónPeticiónDespués
load()GET auth.get.auth con skipAuthRedirectGuarda los campos; si falla, escribe en consola y queda como visitante.
login({ email, password, remember })GET cookie CSRF, POST auth.loginload(); devuelve data
register({ name, email, password, password_confirmation })GET cookie CSRF, POST auth.registerload()
logout()POST auth.logout con skipAuthRedirectclear() siempre
forgotPassword({ email })GET cookie CSRF, POST auth.forgot.passworddevuelve data
resetPassword({ token, email, password, password_confirmation })GET cookie CSRF, POST auth.reset.passworddevuelve data
updatePassword({ old_password, password, password_confirmation })POST auth.update.passworddevuelve data
resendVerification()POST auth.email.verification.notificationdevuelve data
revertImpersonation()POST auth.revert.impersonateload()
updateProfile({ name, email })PUT userUpdateRoute con user_idload()
updateAvatar(file)POST lu.upload.file (multipart file, visibility=public), luego PUT userUpdateRoute con avatarload(); devuelve el uri
removeAvatar()PUT userUpdateRoute con avatar: ''load()
clear()—sesión vacía

payloadOf(user) y avatarOf(user) leen payload.avatar, venga payload como objeto o como texto JSON.

stores/options.js ​

Estado: values, records (clave → { id, key, name }) y loaded.

FunciónQué hace
load()GET api.laravel-options.option.index con paginate: 0 y hydrate(list). Si falla, escribe en consola.
hydrate(list)Decodifica objetos y arreglos JSON, y deja el resto como texto.
option(path, fallback = null)Lectura por puntos: option('theme.home.title').
save(key, value)Con id, PUT api.laravel-options.option.update con { option_id, name, key, value }. Sin él, POST api.laravel-options.option.create con { key, name: key, value }.

Los hooks useOption(path, fallback) y useSiteName() se suscriben a values y resuelven fuera del selector: un selector que devuelve un objeto nuevo en cada lectura hace que Zustand repinte sin fin. useSiteName() cae en el título que puso Blade (config('app.name')) si falta site_name.

stores/notifications.js ​

Estado: count, items y loading.

FunciónPetición
fetchCount()GET innoboxrr.notifications.index.unread.count
fetchLatest(limit = 10)GET innoboxrr.notifications.index con limit
markAsRead(notification)POST innoboxrr.notifications.mark.as.read. Devuelve resolveAction(…).
markAllAsRead()POST innoboxrr.notifications.mark.all.as.read
startPolling(interval = notificationsInterval)Contador al empezar, cada interval y al volver a la pestaña. Devuelve la función que lo detiene.
reset()—

stores/ui.js ​

Estado: theme ('dark', 'light' o null) y sidebarOpen. Acciones: initTheme(), toggleTheme() y setSidebarOpen(open). El hook useIsDark() combina la elección con prefers-color-scheme mediante useSyncExternalStore. La clave de localStorage es theme, la misma que en Vue y en el script de Blade.

Peticiones: http.js ​

ExportQué hace
configureHttp(http)Los valores por omisión de axios.
csrfCookieUrl()route('sanctum.csrf-cookie') si existe; si no, /sanctum/csrf-cookie.
createErrorHandler({ http, onUnauthenticated, csrfUrl })Ante un 419 pide otra cookie y repite la petición una vez. Ante un 401 llama a onUnauthenticated, salvo que la petición lleve skipAuthRedirect: true.
installInterceptors(http, options)Registra el manejador.
unauthenticatedHandler({ clearSession, navigate, currentPath })Limpia la sesión y navega al login, salvo que ya esté en /auth/….

A diferencia de Vue, no hay apiUrl: los estados llaman a route(name, params) de innoboxrr-route-resolver directamente.

Formularios ​

Los formularios usan TextInputComponent de innoboxrr-react-form-elements (value y onChange(value), validators, autoComplete, showPasswordLabel y hidePasswordLabel) con dos helpers:

  • forms/useValidator.js devuelve { form, showServerErrors, clear }. form es la ref del <form>; el hook crea new JSValidator(form, { messages }).init() y lo destruye al desmontar. showServerErrors(error) coloca los errores de un 422 junto a cada campo y devuelve true, o false si no era un 422.
  • forms/errors.js → errorMessage(error, fallback): sin respuesta, error de conexión; 429; 403; el primer error de validación; data.message; o uno genérico.
jsx
const { form, showServerErrors } = useValidator()

const submit = async (event) => {
    event.preventDefault()

    try {
        await save(values)
    } catch (error) {
        if (! showServerErrors(error)) {
            notifyError(errorMessage(error))
        }
    }
}

return <form ref={form} onSubmit={submit} noValidate>…</form>

El sitio ​

  • SitePage({ page }) lee theme.<page>, pone el título y, si la página no existe, enseña «This page has no content yet». Importa styles/site.css.
  • ThemeManager({ sections, registry }) filtra con visibleSections y separa cabecera y pie con splitLandmarks. Envuelve cada sección en un SectionBoundary: una que falla no se lleva la página.
  • Cada sección recibe una sola prop, props, con el objeto del JSON: <HeroOne props={…} />.
  • sections/shared.jsx exporta asArray, asText, asObject, isOn, isInternal, safeHref, SmartLink, OptionalImage, Brand, SOCIAL_NETWORKS, SocialLinks, SectionHeader, Checklist y HeroCopy.

Los detalles de cada sección están en El sitio y su editor.

Dónde se cambia cada cosa ​

QuéDónde
Rutas del módulo sólo para administradoresconfig.js → adminOnly (ids)
Páginas del sitioconfig.js → sitePages
Herramientas del grupo «Administración»config.js → adminTools; «Sitio» está en router/menu.js
Intervalo de notificacionesconfig.js → notificationsInterval
Update del usuarioconfig.js → userUpdateRoute; la ruta de subida lu.upload.file está en stores/auth.js
Prefijo del administradorconfig.js → adminBase, más path: 'admin' en router/index.jsx y los /admin de guardas y pantallas
A dónde va quien entraauth/LoginView.jsx (safeRedirect, por omisión /admin)
Aviso de ruta denegadarouter/index.jsx → announceDenied
Secciones del sitiosite/sections/index.js
Textoslang/es.json
Estilosstyles/app.css, styles/site.css

Las recetas completas están en Personalizar y ampliar.

Tests ​

Viven en el paquete innoboxrr/laravel-setup, en tests/Frontend/react, y prueban stubs/app/react/resources/react/app. No se copian a tu aplicación.

ArchivoQué prueba
auth.test.jsEl estado de la sesión y el perfil
guards.test.jsxrequirementsFor, checkAccess, withGuards, safeRedirect
menu.test.jsbuildMenu y adminOnly
notifications.test.jsxCampana y estado de notificaciones
options.test.jsDecodificar, readPath, save
screens.test.jsxPantallas de acceso y administrador
sections.test.jsxLas secciones y sus props
site-editor.test.jsxEl editor del sitio
theme-manager.test.jsxvisibleSections y splitLandmarks
bash
cd tests/Frontend/react
npm install
npx vitest run
  • Vitest 3, jsdom y Testing Library.
  • @app apunta a los stubs, y resolve.dedupe resuelve sus imports desde esta carpeta.
  • Los paquetes innoboxrr-* se compilan en línea porque publican JSX sin compilar.

Por qué el arnés usa Vite 7

La aplicación pide vite ^8 y @vitejs/plugin-react ^6, pero el arnés de tests se queda en vite ^7.1 y @vitejs/plugin-react ^5. Vitest 4 con Vite 8.3 rompe npm install en npm 10.