Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/Logo-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Agro Control — Smart Farm Platform</title>
</head>
<body>
Expand Down
Binary file added public/Logo-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/Logo-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { AppRouter } from './routes/AppRouter';
import { PWAInstallBanner } from './components/pwa/PWAInstallBanner';

function App() {
return <AppRouter />;
return (
<>
<AppRouter />
<PWAInstallBanner />
</>
);
}

export default App;
158 changes: 158 additions & 0 deletions src/components/pwa/PWAInstallBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react';
import { usePWAInstall } from '../../hooks/usePWAInstall';

/**
* PWAInstallBanner
*
* Banner premium de instalación de la PWA.
* Se muestra como un banner flotante en la parte inferior de la pantalla,
* con animación de entrada/salida, glassmorphism, y lógica inteligente
* para no ser invasivo (cooldown de 7 días tras descartar).
*
* Compatible con desktop (Chrome, Edge) y móvil (Android Chrome, Samsung Internet).
* En iOS Safari no se soporta `beforeinstallprompt` — el banner no se mostrará.
*/
export function PWAInstallBanner() {
const { canInstall, isInstalled, promptInstall, dismiss } = usePWAInstall();
const [visible, setVisible] = useState(false);
const [exiting, setExiting] = useState(false);

// Mostrar con un delay de 2 segundos para no interrumpir la carga inicial
useEffect(() => {
if (!canInstall || isInstalled) {
setVisible(false);
return;
}

const timer = setTimeout(() => setVisible(true), 2000);
return () => clearTimeout(timer);
}, [canInstall, isInstalled]);

const handleDismiss = () => {
setExiting(true);
setTimeout(() => {
setVisible(false);
setExiting(false);
dismiss();
}, 300);
};

const handleInstall = async () => {
await promptInstall();
setExiting(true);
setTimeout(() => {
setVisible(false);
setExiting(false);
}, 300);
};

if (!visible) return null;

return (
<>
{/* Backdrop sutil — solo en móvil para dar enfoque */}
<div
className={`fixed inset-0 z-[9998] bg-black/20 backdrop-blur-[2px] transition-opacity duration-300 xl:hidden ${exiting ? 'opacity-0' : 'opacity-100'}`}
onClick={handleDismiss}
aria-hidden="true"
/>

<div
role="dialog"
aria-label="Instalar Agro Control"
className={`pwa-install-banner fixed z-[9999] transition-all duration-300 ease-out
${exiting ? 'pwa-install-banner--exit' : 'pwa-install-banner--enter'}
/* Mobile: full-width bottom */
inset-x-0 bottom-0
/* Desktop: centered bottom with max-width */
xl:inset-x-auto xl:left-1/2 xl:bottom-6 xl:-translate-x-1/2 xl:max-w-[480px] xl:rounded-2xl
`}
>
<div
className={`
relative overflow-hidden
border-t border-white/10 bg-[#0b1a18]/90 backdrop-blur-xl
xl:rounded-2xl xl:border xl:border-white/10 xl:shadow-[0_8px_40px_rgba(0,0,0,0.5)]
p-4 sm:p-5
`}
>
{/* Gradiente decorativo superior */}
<div className="absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-transparent via-emerald-400 to-transparent opacity-60" />

{/* Glow ambiental */}
<div className="absolute -top-12 left-1/2 h-24 w-48 -translate-x-1/2 rounded-full bg-emerald-500/10 blur-3xl" />

<div className="relative flex items-start gap-4">
{/* Ícono de la app */}
<div className="flex-shrink-0">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-gradient-to-br from-emerald-500/20 to-emerald-700/20 shadow-[0_0_20px_rgba(16,185,129,0.15)]">
<img
src="/Logo.png"
alt="Agro Control"
className="h-10 w-10 rounded-lg object-contain"
/>
</div>
</div>

{/* Contenido */}
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-bold tracking-[-0.3px] text-white">
Instalar Agro Control
</h3>
<p className="mt-1 text-[12.5px] leading-relaxed text-slate-400">
Accede más rápido y trabaja sin conexión. Funciona como app nativa en tu dispositivo.
</p>

{/* Chips de beneficios */}
<div className="mt-3 flex flex-wrap gap-2">
{[
{ icon: 'fa-bolt', text: 'Acceso rápido' },
{ icon: 'fa-wifi-slash', text: 'Sin conexión' },
{ icon: 'fa-expand', text: 'Pantalla completa' },
].map((chip) => (
<span
key={chip.text}
className="inline-flex items-center gap-1.5 rounded-full border border-white/5 bg-white/[0.04] px-2.5 py-1 text-[10.5px] font-medium text-emerald-300/80"
>
<i className={`fas ${chip.icon} text-[9px] opacity-70`} />
{chip.text}
</span>
))}
</div>

{/* Botones */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={handleInstall}
className="group relative flex items-center gap-2 rounded-xl border border-emerald-400/30 bg-gradient-to-r from-emerald-600 to-emerald-500 px-5 py-2.5 text-[13px] font-semibold text-white shadow-[0_0_20px_rgba(16,185,129,0.25)] transition-all duration-200 hover:border-emerald-400/50 hover:shadow-[0_0_28px_rgba(16,185,129,0.4)] active:scale-[0.97]"
>
<i className="fas fa-download text-[12px] transition-transform duration-200 group-hover:-translate-y-0.5" />
Instalar
<span className="absolute inset-0 rounded-xl bg-white/10 opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</button>
<button
type="button"
onClick={handleDismiss}
className="rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-[13px] font-medium text-slate-400 transition-all duration-200 hover:border-white/20 hover:bg-white/[0.08] hover:text-white active:scale-[0.97]"
>
Ahora no
</button>
</div>
</div>

{/* Botón cerrar (X) */}
<button
type="button"
onClick={handleDismiss}
aria-label="Cerrar"
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-[11px] text-slate-500 transition-all duration-200 hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
<i className="fas fa-times" />
</button>
</div>
</div>
</div>
</>
);
}
106 changes: 106 additions & 0 deletions src/hooks/usePWAInstall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from 'react';

interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
prompt(): Promise<void>;
}

export interface PWAInstallState {
/** true cuando el navegador ofrece la instalación y no fue descartada en esta carga de página */
canInstall: boolean;
/** true cuando la app se está ejecutando actualmente en modo standalone */
isInstalled: boolean;
/** Dispara el prompt nativo de instalación */
promptInstall: () => Promise<void>;
/** Descarta el banner para esta carga de página (se restablece al recargar) */
dismiss: () => void;
}

export function usePWAInstall(): PWAInstallState {
const deferredPrompt = useRef<BeforeInstallPromptEvent | null>(null);
const [canInstall, setCanInstall] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);

// ─── Detectar si la app corre actualmente en modo standalone ─────
useEffect(() => {
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: minimal-ui)').matches ||
(window.navigator as any).standalone === true; // Safari iOS

if (isStandalone) {
setIsInstalled(true);
return;
}

// Escuchar si la ventana cambia de modo (ej. si se instala y se abre)
const mql = window.matchMedia('(display-mode: standalone)');
const handleChange = (e: MediaQueryListEvent) => {
if (e.matches) {
setIsInstalled(true);
setCanInstall(false);
}
};
mql.addEventListener('change', handleChange);
return () => mql.removeEventListener('change', handleChange);
}, []);

// ─── Capturar el evento beforeinstallprompt ─────────────────────
useEffect(() => {
if (isInstalled) return;

const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
deferredPrompt.current = e as BeforeInstallPromptEvent;

// Si ya la descartó en esta carga de página, no volver a mostrar
if (isDismissed) {
return;
}

setCanInstall(true);
};

const handleAppInstalled = () => {
setIsInstalled(true);
setCanInstall(false);
deferredPrompt.current = null;
setIsDismissed(false);
};

window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);

return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, [isInstalled, isDismissed]);

// ─── Disparar el prompt nativo ──────────────────────────────────
const promptInstall = useCallback(async () => {
const prompt = deferredPrompt.current;
if (!prompt) return;

await prompt.prompt();
const { outcome } = await prompt.userChoice;

if (outcome === 'accepted') {
setIsInstalled(true);
setIsDismissed(false);
}

setCanInstall(false);
deferredPrompt.current = null;
}, []);

// ─── Descartar el banner (se restablece al refrescar) ───────────
const dismiss = useCallback(() => {
setCanInstall(false);
setIsDismissed(true);
}, []);

return { canInstall, isInstalled, promptInstall, dismiss };
}
70 changes: 70 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,73 @@
display: none;
}
}

/* ─── PWA Install Banner Animations ──────────────────────────────── */

.pwa-install-banner--enter {
animation: pwaSlideUp 350ms cubic-bezier(0.16, 1, 0.3, 1) both;
}

.pwa-install-banner--exit {
animation: pwaSlideDown 300ms cubic-bezier(0.55, 0, 1, 0.45) both;
}

@keyframes pwaSlideUp {
from {
opacity: 0;
transform: translateY(100%);
}

to {
opacity: 1;
transform: translateY(0);
}
}

@keyframes pwaSlideDown {
from {
opacity: 1;
transform: translateY(0);
}

to {
opacity: 0;
transform: translateY(100%);
}
}

/* Desktop center variant */
@media (min-width: 1280px) {
.pwa-install-banner--enter {
animation-name: pwaSlideUpDesktop;
}

.pwa-install-banner--exit {
animation-name: pwaSlideDownDesktop;
}

@keyframes pwaSlideUpDesktop {
from {
opacity: 0;
transform: translate(-50%, 24px) scale(0.96);
}

to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}

@keyframes pwaSlideDownDesktop {
from {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}

to {
opacity: 0;
transform: translate(-50%, 24px) scale(0.96);
}
}
}

12 changes: 12 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ import App from './App';
import './index.css';
import { BrowserRouter } from 'react-router-dom';

// Filtrar advertencias inofensivas de dimensiones de Recharts en la consola
const originalWarn = console.warn;
console.warn = (...args) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('width') || args[0].includes('height')) &&
args[0].includes('chart should be greater than 0')
) {
return;
}
originalWarn(...args);
};

ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
Expand Down
Loading
Loading