Files
litoral-central-frontend/src/features/meteo/components/WeatherForecastCard.tsx
T
2026-05-25 16:37:00 +01:00

551 lines
19 KiB
TypeScript

import {
CloudRain,
Droplets,
MapPin,
Navigation,
Sun,
Thermometer,
Wind,
ArrowRight,
Cloud,
type LucideIcon,
} from "lucide-react";
import type { WeatherForecastResponse } from "../../../types/weather";
type Props = {
theme: "dark" | "light";
forecast: WeatherForecastResponse | null;
loading: boolean;
error: string | null;
compact?: boolean;
onOpenMeteo?: () => void;
};
const RADIUS = "rounded-[5px]";
export function WeatherForecastCard({
theme,
forecast,
loading,
error,
compact = false,
onOpenMeteo,
}: Props) {
const isDark = theme === "dark";
if (compact) {
return (
<CompactWeatherCard
theme={theme}
forecast={forecast}
loading={loading}
error={error}
onOpenMeteo={onOpenMeteo}
/>
);
}
return (
<section
className={
isDark
? `${RADIUS} border border-white/10 bg-[#111827] p-5 shadow-[0_12px_30px_rgba(0,0,0,0.22)]`
: `${RADIUS} border border-slate-200 bg-white p-5 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`
}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className={isDark ? eyebrowDark : eyebrowLight}>
Previsão meteorológica
</p>
<h2 className={isDark ? titleDark : titleLight}>
{forecast
? `${forecast.location.name}, ${forecast.location.country}`
: "Meteorologia externa"}
</h2>
<p className={isDark ? subtitleDark : subtitleLight}>
<MapPin className="h-4 w-4" />
Dados externos de previsão
</p>
</div>
{forecast?.current.condition?.icon && (
<img
src={forecast.current.condition.icon}
alt={forecast.current.condition.text}
className="h-12 w-12 opacity-90"
/>
)}
</div>
{loading ? (
<StateMessage theme={theme}>A carregar previsão meteorológica...</StateMessage>
) : error ? (
<StateMessage theme={theme}>{error}</StateMessage>
) : !forecast ? (
<StateMessage theme={theme}>Sem previsão meteorológica disponível.</StateMessage>
) : (
<div className="space-y-4">
<TodayForecastHero
theme={theme}
forecast={forecast}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{forecast.daily.slice(1, 7).map((day) => (
<DailyForecastTile
key={day.date}
theme={theme}
day={day}
/>
))}
</div>
</div>
)}
</section>
);
}
function TodayForecastHero({
theme,
forecast,
}: {
theme: "dark" | "light";
forecast: WeatherForecastResponse;
}) {
const isDark = theme === "dark";
const today = forecast.daily[0];
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5`
: `${RADIUS} border border-slate-200 bg-slate-50 p-5`
}
>
<div className="grid gap-4 xl:grid-cols-[320px_1fr]">
<div className="flex items-center gap-4">
{today.condition?.icon && (
<img
src={today.condition.icon}
alt={today.condition.text}
className="h-16 w-16 shrink-0 opacity-90"
/>
)}
<div>
<p
className={
isDark
? "text-xs font-bold uppercase tracking-[0.16em] text-slate-500"
: "text-xs font-bold uppercase tracking-[0.16em] text-slate-400"
}
>
Hoje
</p>
<p
className={
isDark
? "mt-1 text-sm font-semibold text-slate-400"
: "mt-1 text-sm font-semibold text-slate-500"
}
>
{formatDay(today.date)}
</p>
<div
className={
isDark
? "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-100"
: "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-950"
}
>
{Math.round(today.maxTemperatureC)}°
<span className="ml-1 text-lg tracking-normal text-slate-500">
/ {Math.round(today.minTemperatureC)}°
</span>
</div>
<p
className={
isDark
? "mt-2 text-sm font-semibold text-slate-300"
: "mt-2 text-sm font-semibold text-slate-600"
}
>
{today.condition?.text ?? "--"}
</p>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<WeatherMiniStat
theme={theme}
icon={CloudRain}
label="Prob. chuva"
value={`${today.dailyRainChance}%`}
/>
<WeatherMiniStat
theme={theme}
icon={Sun}
label="UV"
value={today.uv.toFixed(0)}
/>
<WeatherMiniStat
theme={theme}
icon={Wind}
label="Vento"
value={`${forecast.current.windKph.toFixed(1)} km/h`}
/>
<WeatherMiniStat
theme={theme}
icon={Droplets}
label="Humidade"
value={`${forecast.current.humidity}%`}
/>
</div>
</div>
</div>
);
}
function DailyForecastTile({
theme,
day,
}: {
theme: "dark" | "light";
day: WeatherForecastResponse["daily"][number];
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] p-3`
: `${RADIUS} border border-slate-200 bg-slate-50 p-3`
}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p
className={
isDark
? "text-sm font-black text-slate-100"
: "text-sm font-black text-slate-950"
}
>
{formatDay(day.date)}
</p>
<p
className={
isDark
? "mt-1 truncate text-xs text-slate-400"
: "mt-1 truncate text-xs text-slate-500"
}
>
{day.condition?.text ?? "--"}
</p>
</div>
{day.condition?.icon && (
<img
src={day.condition.icon}
alt={day.condition.text}
className="h-9 w-9 shrink-0 opacity-90"
/>
)}
</div>
<div className="mt-4">
<p
className={
isDark
? "text-2xl font-black text-slate-100"
: "text-2xl font-black text-slate-950"
}
>
{Math.round(day.maxTemperatureC)}°
<span className="ml-1 text-sm font-bold text-slate-500">
/ {Math.round(day.minTemperatureC)}°
</span>
</p>
</div>
<div className="mt-4 flex items-center justify-between gap-2 text-xs">
<span
className={
isDark
? "flex items-center gap-1 font-semibold text-slate-400"
: "flex items-center gap-1 font-semibold text-slate-500"
}
>
<CloudRain className="h-3.5 w-3.5" />
{day.dailyRainChance}%
</span>
<span
className={
isDark
? "flex items-center gap-1 font-semibold text-slate-400"
: "flex items-center gap-1 font-semibold text-slate-500"
}
>
<Sun className="h-3.5 w-3.5" />
UV {day.uv.toFixed(0)}
</span>
</div>
</div>
);
}
function WeatherMiniStat({
theme,
icon: Icon,
label,
value,
}: {
theme: "dark" | "light";
icon: LucideIcon;
label: string;
value: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-[#0b1220] px-3 py-2.5`
: `${RADIUS} border border-slate-200 bg-white px-3 py-2.5`
}
>
<Icon
className={
isDark
? "mb-1.5 h-4 w-4 text-slate-400"
: "mb-1.5 h-4 w-4 text-slate-500"
}
/>
<p className="text-[11px] text-slate-500">{label}</p>
<p
className={
isDark
? "mt-0.5 text-sm font-black text-slate-100"
: "mt-0.5 text-sm font-black text-slate-950"
}
>
{value}
</p>
</div>
);
}
function StateMessage({
theme,
children,
}: {
theme: "dark" | "light";
children: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5 text-sm text-slate-400`
: `${RADIUS} border border-slate-200 bg-slate-50 p-5 text-sm text-slate-500`
}
>
{children}
</div>
);
}
function formatDay(date: string) {
return new Date(date).toLocaleDateString("pt-PT", {
weekday: "short",
day: "2-digit",
});
}
const eyebrowDark =
"text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500";
const eyebrowLight =
"text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400";
const titleDark = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-100";
const titleLight = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-950";
const subtitleDark = "mt-1 flex items-center gap-2 text-sm text-slate-400";
const subtitleLight = "mt-1 flex items-center gap-2 text-sm text-slate-500";
function CompactWeatherCard({
theme,
forecast,
loading,
error,
onOpenMeteo,
}: {
theme: "dark" | "light";
forecast: WeatherForecastResponse | null;
loading: boolean;
error: string | null;
onOpenMeteo?: () => void;
}) {
const isDark = theme === "dark";
const today = forecast?.daily?.[0];
return (
<article
className={
isDark
? "absolute right-0 top-10 w-[340px] rounded-[8px] border border-white/10 bg-[#07111f]/92 p-7 shadow-[0_22px_60px_rgba(0,0,0,0.42)] backdrop-blur-xl"
: "absolute right-0 top-10 w-[340px] rounded-[8px] border border-slate-200/80 bg-white/96 p-7 shadow-[0_14px_40px_rgba(15,23,42,0.10)] backdrop-blur-xl"
}
>
<p className="flex items-center gap-2 text-sm font-bold">
<MapPin className="h-4 w-4" />
{forecast
? `${forecast.location.name}, ${forecast.location.country}`
: "Meteorologia"}
</p>
{loading ? (
<div className="mt-5 text-sm text-slate-400">
A carregar previsão...
</div>
) : error ? (
<div className="mt-5 text-sm text-red-400">
{error}
</div>
) : today ? (
<>
<div className="mt-6 flex items-start justify-between gap-5">
<div>
<p
className={
isDark
? "text-xs font-bold uppercase tracking-[0.18em] text-slate-400"
: "text-xs font-bold uppercase tracking-[0.18em] text-slate-500"
}
>
Hoje
</p>
<p
className={
isDark
? "mt-1 text-sm font-semibold text-slate-300"
: "mt-1 text-sm font-semibold text-slate-600"
}
>
{formatDay(today.date)}
</p>
<div className="mt-5 flex items-end">
<span className="text-[44px] font-black leading-none tracking-[-0.06em]">
{Math.round(today.maxTemperatureC)}°
</span>
<span className="mb-1 ml-2 text-base font-bold text-slate-500">
/ {Math.round(today.minTemperatureC)}°
</span>
</div>
<p
className={
isDark
? "mt-2 text-sm text-slate-300"
: "mt-2 text-sm text-slate-700"
}
>
{today.condition?.text ?? "--"}
</p>
<div
className={
isDark
? "mt-4 flex flex-wrap gap-2 text-xs text-slate-400"
: "mt-4 flex flex-wrap gap-2 text-xs text-slate-600"
}
>
<span
className={
isDark
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
}
>
Chuva {today.dailyRainChance}%
</span>
<span
className={
isDark
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
}
>
UV {today.uv.toFixed(0)}
</span>
</div>
</div>
{today.condition?.icon ? (
<img
src={today.condition.icon}
alt={today.condition.text}
className="h-16 w-16 shrink-0 opacity-90"
/>
) : (
<Cloud
className={
isDark
? "h-16 w-16 shrink-0 text-slate-200"
: "h-16 w-16 shrink-0 text-slate-400"
}
strokeWidth={1.8}
/>
)}
</div>
<div
className={
isDark
? "mt-6 border-t border-white/10 pt-4"
: "mt-6 border-t border-slate-200 pt-4"
}
>
<button
type="button"
onClick={onOpenMeteo}
className="flex items-center gap-3 text-sm text-slate-500 transition hover:text-emerald-400"
>
Ver meteorologia
<ArrowRight className="h-4 w-4 text-emerald-400" />
</button>
</div>
</>
) : (
<div className="mt-5 text-sm text-slate-400">
Sem previsão diária disponível.
</div>
)}
</article>
);
}