551 lines
19 KiB
TypeScript
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>
|
|
);
|
|
} |