diff --git a/pages/_app.jsx b/pages/_app.jsx index 5929631..c64b171 100644 --- a/pages/_app.jsx +++ b/pages/_app.jsx @@ -1,14 +1,16 @@ import React from 'react'; import Head from 'next/head'; import 'styles/global.css'; +import RouteProgressBar from '@/src/components/RouteProgressBar/RouteProgressBar'; export default function MyApp({ Component, pageProps }) { - + return ( <> + ) diff --git a/pages/launches/[launchName].tsx b/pages/launches/[launchName].tsx index c6384a2..266dc1e 100644 --- a/pages/launches/[launchName].tsx +++ b/pages/launches/[launchName].tsx @@ -1,7 +1,10 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { Alert, Box, Button, Card, CardContent, Chip, CircularProgress, Container, Stack, Typography } from '@mui/material'; +import { Alert, Box, Button, Card, CardContent, CircularProgress, Container, Stack, Typography } from '@mui/material'; +import HeightIcon from '@mui/icons-material/Height'; +import ScheduleIcon from '@mui/icons-material/Schedule'; +import RouteIcon from '@mui/icons-material/Route'; import { formatAltitude, formatAltitudeInKm, @@ -10,9 +13,11 @@ import { slugifyLaunchName } from '@/src/shared/utils/formatters.utils'; import { useGetLaunchContent } from '@/src/core/services/launches/useGetLaunchContent.service'; +import { getAllLaunches } from '@/src/core/services/launches.service'; import { LaunchSummary } from '@/src/shared/types/api/launches-api.types'; import dynamic from 'next/dynamic'; import LaunchAndLandingCities from '@/src/components/LaunchAndLandingCities/LaunchAndLandingCities'; +import StatCard from '@/src/components/StatCard/StatCard'; const SELECTED_LAUNCH_STORAGE_KEY = 'zenith-selected-launch'; @@ -32,6 +37,7 @@ export default function LaunchDetailsPage() { const launchName = typeof router.query.launchName === 'string' ? router.query.launchName : ''; const [launch, setLaunch] = useState(null); const [launchResolved, setLaunchResolved] = useState(false); + const [launchNotFound, setLaunchNotFound] = useState(false); const { records, isLoadingRecords, recordsError } = useGetLaunchContent(launch?.download_url ?? '', Boolean(launch)); @@ -44,45 +50,82 @@ export default function LaunchDetailsPage() { [] ); + useEffect(() => { + router.prefetch('/launches'); + }, [router]); + useEffect(() => { if (!router.isReady) { return; } - setLaunchResolved(false); + const controller = new AbortController(); - const storedLaunch = sessionStorage.getItem(SELECTED_LAUNCH_STORAGE_KEY); + const resolveFromStorage = (): LaunchSummary | null => { + const storedLaunch = sessionStorage.getItem(SELECTED_LAUNCH_STORAGE_KEY); + if (!storedLaunch) { + return null; + } - if (!storedLaunch) { - setLaunch(null); - setLaunchResolved(true); - return; - } + try { + const parsedLaunch: LaunchSummary = JSON.parse(storedLaunch); + return slugifyLaunchName(parsedLaunch.name) === launchName ? parsedLaunch : null; + } catch { + return null; + } + }; - try { - const parsedLaunch: LaunchSummary = JSON.parse(storedLaunch); - const storedLaunchName = slugifyLaunchName(parsedLaunch.name); + const resolveLaunch = async () => { + setLaunchResolved(false); + setLaunchNotFound(false); - if (storedLaunchName !== launchName) { - setLaunch(null); + const storedMatch = resolveFromStorage(); + if (storedMatch) { + setLaunch(storedMatch); setLaunchResolved(true); return; } - setLaunch(parsedLaunch); - } catch { - setLaunch(null); - } finally { - setLaunchResolved(true); - } + try { + const allLaunches = await getAllLaunches(); + if (controller.signal.aborted) { + return; + } + + const matchedLaunch = allLaunches.find((item) => slugifyLaunchName(item.name) === launchName) ?? null; + + if (matchedLaunch) { + sessionStorage.setItem(SELECTED_LAUNCH_STORAGE_KEY, JSON.stringify(matchedLaunch)); + setLaunch(matchedLaunch); + } else { + setLaunch(null); + setLaunchNotFound(true); + } + } catch { + if (!controller.signal.aborted) { + setLaunch(null); + setLaunchNotFound(true); + } + } finally { + if (!controller.signal.aborted) { + setLaunchResolved(true); + } + } + }; + + resolveLaunch(); + + return () => { + controller.abort(); + }; }, [launchName, router.isReady]); return ( - + ); } + +const cardSx = { height: '100%', borderRadius: 3 }; +const chipSx = { backgroundColor: colors.primary[600] }; +const cardHeaderSx = { pb: 0 }; +const titleBoxSx = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }; +const titleTypographySx = { fontWeight: 700 }; +const cardContentSx = { py: 0, mb: 0 }; +const citiesBoxSx = { display: 'flex', alignItems: 'flex-start' }; +const cardActionsSx = { justifyContent: 'flex-end', pt: 0 }; +const detailsButtonSx = { color: colors.secondary[500] }; diff --git a/src/components/Map/MapTrajectory.tsx b/src/components/Map/MapTrajectory.tsx index f5e8de3..dd29e28 100644 --- a/src/components/Map/MapTrajectory.tsx +++ b/src/components/Map/MapTrajectory.tsx @@ -1,11 +1,15 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import L from 'leaflet'; import { LayersControl, MapContainer, Marker, Polyline, Popup, TileLayer, useMap } from 'react-leaflet'; import 'leaflet/dist/leaflet.css'; import 'leaflet-defaulticon-compatibility'; import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css'; +import FullscreenIcon from '@mui/icons-material/Fullscreen'; +import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; +import { IconButton, Tooltip } from '@mui/material'; import { MapProps } from '@/src/shared/types/map.types'; import { formatAltitudeInKm, formatLaunchDatetime } from '@/src/shared/utils/formatters.utils'; +import { colors } from '@/src/shared/styles/colors'; const parachutIconUrl = '/images/markersSondehub/parachute.svg'; const payloadNotRecoveredIconUrl = '/images/markersSondehub/payload-not-recovered.png'; @@ -100,8 +104,9 @@ export default function MapTrajectory(props: MapProps) { landingCity = '', lineColor = '#d32f2f', lineWeight = 2, - mapHeight = '100vh' + mapHeight = '360px' } = props; + const [isFullscreen, setIsFullscreen] = useState(false); const hasTrajectory = trajectory.length > 1; const startPosition = hasTrajectory ? trajectory[0] : position; const endPosition = hasTrajectory ? trajectory[trajectory.length - 1] : position; @@ -113,18 +118,69 @@ export default function MapTrajectory(props: MapProps) { ); const finalMarkerIcon = isUnknownEndPoint ? unknownEndMarkerIcon : endMarkerIcon; + useEffect(() => { + if (!isFullscreen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsFullscreen(false); + } + }; + + document.body.style.overflow = 'hidden'; + window.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = ''; + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isFullscreen]); + return ( -
+
+ + setIsFullscreen((current) => !current)} + sx={{ + position: 'absolute', + top: 90, + left: 8, + zIndex: 1400, + width: 40, + height: 40, + backgroundColor: 'background.paper', + boxShadow: 1, + '&:hover': { backgroundColor: 'background.paper' } + }} + > + {isFullscreen ? : } + + + - + - + { + const handleStart = () => setIsNavigating(true); + const handleDone = () => setIsNavigating(false); + + router.events.on('routeChangeStart', handleStart); + router.events.on('routeChangeComplete', handleDone); + router.events.on('routeChangeError', handleDone); + + return () => { + router.events.off('routeChangeStart', handleStart); + router.events.off('routeChangeComplete', handleDone); + router.events.off('routeChangeError', handleDone); + }; + }, [router]); + + if (!isNavigating) return null; + + return ; +} + +const barSx = { + position: 'fixed', + top: 0, + left: 0, + height: 3, + width: '100%', + zIndex: 2000, + backgroundColor: colors.secondary[500], + transformOrigin: 'left', + animation: 'route-progress-bar 1.2s ease-in-out infinite', + '@keyframes route-progress-bar': { + '0%': { transform: 'scaleX(0)', opacity: 1 }, + '70%': { transform: 'scaleX(0.85)', opacity: 1 }, + '100%': { transform: 'scaleX(0.95)', opacity: 0.6 } + } +}; diff --git a/src/components/StatCard/StatCard.tsx b/src/components/StatCard/StatCard.tsx new file mode 100644 index 0000000..162431b --- /dev/null +++ b/src/components/StatCard/StatCard.tsx @@ -0,0 +1,50 @@ +import { ReactNode } from 'react'; +import { Box, Paper, Typography } from '@mui/material'; +import { colors } from '@/src/shared/styles/colors'; + +type StatCardProps = { + label: string; + value: ReactNode; + icon: ReactNode; +}; + +export default function StatCard({ label, value, icon }: StatCardProps) { + return ( + + + + {label} + + {icon} + + {typeof value === 'string' ? ( + + {value} + + ) : ( + value + )} + + ); +} + +const cardSx = { + flex: '1 1 200px', + minWidth: 200, + borderRadius: 3, + border: '1px solid', + borderColor: 'divider', + p: 2.5 +}; +const headerSx = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }; +const iconCircleSx = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 36, + height: 36, + borderRadius: '50%', + backgroundColor: colors.primary[50], + color: colors.primary[600] +}; +const valueSx = { fontWeight: 700 }; diff --git a/src/shared/styles/colors.ts b/src/shared/styles/colors.ts new file mode 100644 index 0000000..26803f9 --- /dev/null +++ b/src/shared/styles/colors.ts @@ -0,0 +1,48 @@ +export const BACKGROUND_COLOR = '#EFEFEF' as const; + +export const ZINC950 = '#09090B' as const; + +export const colors = { + // O Azul (#0193ED) - Ideal para a marca principal, links e botões primários + primary: { + 50: '#eff7ff', + 100: '#deefff', + 200: '#b6e1ff', + 300: '#75c9ff', + 400: '#2db0ff', + 500: '#0193ed', // <-- cor base + 600: '#007cd3', + 700: '#0062aa', + 800: '#00518c', + 900: '#064474', + 950: '#042b4d' + }, + // O Laranja (#F28705) - Ideal para botões de ação (Call to Action), destaque secundário + secondary: { + 50: '#fff5d4', + 100: '#ffe8a7', + 200: '#ffd770', + 300: '#ffba36', + 400: '#ffa30f', + 500: '#f28705', // <-- cor base + 600: '#c86606', + 700: '#9e500e', + 800: '#7f430f', + 900: '#452005', + 950: '#452009' + }, + // O Amarelo (#FED329) - Ideal para pequenos detalhes, ícones, alertas e "energia" + accent: { + 50: '#fffce6', + 100: '#fefce8', + 200: '#fffac2', + 300: '#fff188', + 400: '#ffe144', + 500: '#fed329', // <-- cor base + 600: '#eeb504', + 700: '#cd8b01', + 800: '#a46204', + 900: '#874d0c', + 950: '#733f10' + } +}; diff --git a/src/shared/utils/formatters.utils.ts b/src/shared/utils/formatters.utils.ts index c335333..9d8a255 100644 --- a/src/shared/utils/formatters.utils.ts +++ b/src/shared/utils/formatters.utils.ts @@ -61,6 +61,6 @@ export const formatLaunchDatetime = (datetime: string): string => { }; export const formatAltitude = (altitude: number) => - new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(altitude) + ' m'; + new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(altitude) + ' m'; export const formatAltitudeInKm = (altitude: number) => - `${new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(altitude / 1000)} km`; + `${new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(altitude / 1000)} km`;