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
164 changes: 164 additions & 0 deletions src/components/blocks/schedule/schedule-room-directions-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"use client";

import {
ExternalLinkIcon,
LoaderCircleIcon,
MapPinnedIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTranslations } from "@/contexts/language-context";
import {
getScheduleRoomDirectionsUrl,
getScheduleRoomLocation,
getScheduleRoomMapEmbedUrl,
} from "@/lib/schedule-rooms";

type ScheduleRoomDirectionsDialogProps = {
room: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};

type GeolocationState =
| { status: "idle" }
| { status: "loading" }
| { status: "ready"; lat: number; lng: number }
| { status: "denied" | "unavailable"; message: string };

function ScheduleRoomDirectionsDialog({
room,
open,
onOpenChange,
}: ScheduleRoomDirectionsDialogProps) {
const { t } = useTranslations();
const [geo, setGeo] = useState<GeolocationState>({ status: "idle" });

const location = useMemo(
() => (room ? getScheduleRoomLocation(room) : undefined),
[room],
);

useEffect(() => {
if (!open || !location) {
setGeo({ status: "idle" });
return;
}

if (!navigator.geolocation) {
setGeo({
status: "unavailable",
message: t("blocks.scheduleUi.roomMap.geoUnavailable"),
});
return;
}

let cancelled = false;
setGeo({ status: "loading" });

navigator.geolocation.getCurrentPosition(
(position) => {
if (cancelled) return;
setGeo({
status: "ready",
lat: position.coords.latitude,
lng: position.coords.longitude,
});
},
(error) => {
if (cancelled) return;
setGeo({
status:
error.code === error.PERMISSION_DENIED ? "denied" : "unavailable",
message:
error.code === error.PERMISSION_DENIED
? t("blocks.scheduleUi.roomMap.geoDenied")
: t("blocks.scheduleUi.roomMap.geoUnavailable"),
});
},
{
enableHighAccuracy: true,
timeout: 12_000,
maximumAge: 60_000,
},
);

return () => {
cancelled = true;
};
}, [location, open, t]);

if (!location) {
return null;
}

const directionsUrl = getScheduleRoomDirectionsUrl(
location,
geo.status === "ready" ? { lat: geo.lat, lng: geo.lng } : undefined,
);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl gap-4 overflow-hidden p-0 sm:max-w-2xl">
<div className="space-y-4 p-6 pb-0">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-left">
<MapPinnedIcon className="text-primary size-5 shrink-0" />
{location.name}
</DialogTitle>
<DialogDescription className="text-left">
{t("blocks.scheduleUi.roomMap.description")}
</DialogDescription>
</DialogHeader>

{geo.status === "loading" || geo.status === "idle" ? (
<p className="text-muted-foreground flex items-center gap-2 text-sm">
<LoaderCircleIcon className="size-4 animate-spin" />
{t("blocks.scheduleUi.roomMap.locating")}
</p>
) : geo.status === "ready" ? (
<p className="text-sm text-emerald-700 dark:text-emerald-400">
{t("blocks.scheduleUi.roomMap.located")}
</p>
) : (
<p className="text-muted-foreground text-sm">{geo.message}</p>
)}
</div>

<div className="bg-muted relative aspect-[16/10] w-full overflow-hidden border-y">
<iframe
title={`${t("blocks.scheduleUi.roomMap.mapTitle")}: ${location.name}`}
src={getScheduleRoomMapEmbedUrl(location)}
className="absolute inset-0 size-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>

<DialogFooter className="gap-2 p-6 pt-0 sm:justify-between">
<p className="text-muted-foreground text-left text-xs sm:max-w-xs">
{t("blocks.scheduleUi.roomMap.hint")}
</p>
<Button asChild>
<a href={directionsUrl} target="_blank" rel="noopener noreferrer">
{t("blocks.scheduleUi.roomMap.openDirections")}
<ExternalLinkIcon className="size-4" />
</a>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

export default ScheduleRoomDirectionsDialog;
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type ScheduleEvent,
scheduleDays,
} from "@/assets/data/schedule";
import ScheduleRoomDirectionsDialog from "@/components/blocks/schedule/schedule-room-directions-dialog";
import SpeakerImage from "@/components/blocks/speakers/speaker-image";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -139,6 +140,7 @@ type ScheduleEventCardProps = {
t: (key: string) => string;
locale: "en" | "es";
isNow: boolean;
onRoomClick: (room: string) => void;
};

type ScheduleSpeakerRowProps = {
Expand Down Expand Up @@ -238,6 +240,7 @@ function ScheduleEventCard({
t,
locale,
isNow,
onRoomClick,
}: ScheduleEventCardProps) {
const category = getScheduleEventCategory(event);
const sponsor = resolveSponsorForScheduleEvent(event);
Expand Down Expand Up @@ -345,10 +348,15 @@ function ScheduleEventCard({
))}
</div>

<div className="text-muted-foreground flex items-center justify-center gap-2 text-sm">
<button
type="button"
onClick={() => onRoomClick(event.room)}
className="text-muted-foreground hover:text-primary mx-auto flex items-center justify-center gap-2 text-sm underline-offset-4 transition-colors hover:underline"
aria-label={`${t("blocks.scheduleUi.roomMap.openRoom")}: ${event.room}`}
>
<MapPinIcon className="size-4 shrink-0" />
<span>{event.room}</span>
</div>
</button>

{showSpeakers ? (
<div className="flex flex-col items-center gap-2">
Expand Down Expand Up @@ -386,6 +394,8 @@ const ScheduleCard = ({ scheduleData }: ScheduleCardProps) => {
const [activeTab, setActiveTab] = useState<ScheduleTab>("all");
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<"time" | "name" | "default">("default");
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
const [roomDialogOpen, setRoomDialogOpen] = useState(false);
const currentScheduleDate = scheduleNow
? getScheduleDateTime(scheduleNow).date
: undefined;
Expand Down Expand Up @@ -659,6 +669,10 @@ const ScheduleCard = ({ scheduleData }: ScheduleCardProps) => {
? isScheduleEventNow(event, scheduleNow)
: false
}
onRoomClick={(room) => {
setSelectedRoom(room);
setRoomDialogOpen(true);
}}
/>
))}
</div>
Expand All @@ -667,6 +681,17 @@ const ScheduleCard = ({ scheduleData }: ScheduleCardProps) => {
</div>
</TabsContent>
</Tabs>

<ScheduleRoomDirectionsDialog
room={selectedRoom}
open={roomDialogOpen}
onOpenChange={(open) => {
setRoomDialogOpen(open);
if (!open) {
setSelectedRoom(null);
}
}}
/>
</CardContent>
</Card>
);
Expand Down
14 changes: 14 additions & 0 deletions src/lib/i18n/blocks-en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,20 @@ export const blocksEn = {
viewWorkshop: "View workshop",
viewKeynote: "View keynote",
footerNote: "Schedule subject to change. Medellín, Colombia.",
roomMap: {
description:
"See this room on the map and get walking or driving directions from your current location.",
locating: "Getting your current location…",
located: "Location found. Directions will start from where you are.",
geoDenied:
"Location permission denied. You can still open Google Maps and set your starting point there.",
geoUnavailable:
"Could not read your location. You can still open Google Maps to get directions.",
mapTitle: "Room map",
hint: "Google Maps will use your position when available, or ask you for a starting point.",
openDirections: "Open route in Google Maps",
openRoom: "Show room on map",
},
},
headerUi: {
brandAlt: "PyCon Colombia",
Expand Down
14 changes: 14 additions & 0 deletions src/lib/i18n/blocks-es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,20 @@ export const blocksEs: BlocksMessages = {
viewWorkshop: "Ver taller",
viewKeynote: "Ver keynote",
footerNote: "Agenda sujeta a cambios. Medellín, Colombia.",
roomMap: {
description:
"Mira esta sala en el mapa y obtén la ruta a pie o en vehículo desde tu ubicación actual.",
locating: "Obteniendo tu ubicación actual…",
located: "Ubicación encontrada. La ruta partirá desde donde estás.",
geoDenied:
"Permiso de ubicación denegado. Aún puedes abrir Google Maps y definir el punto de partida allí.",
geoUnavailable:
"No se pudo leer tu ubicación. Aún puedes abrir Google Maps para obtener la ruta.",
mapTitle: "Mapa de la sala",
hint: "Google Maps usará tu posición si está disponible, o te pedirá un punto de partida.",
openDirections: "Abrir ruta en Google Maps",
openRoom: "Ver sala en el mapa",
},
},
headerUi: {
brandAlt: "PyCon Colombia",
Expand Down
67 changes: 67 additions & 0 deletions src/lib/schedule-rooms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export type ScheduleRoomLocation = {
name: string;
lat: number;
lng: number;
};

/** Campus destinations for schedule rooms (Universidad EAFIT, Medellín). */
const FUNDADORES: ScheduleRoomLocation = {
name: "Main Auditorium (Fundadores)",
lat: 6.200277,
lng: -75.5788236,
};

/** https://www.google.com/maps/dir//6.2016024,-75.5783229 */
const BLOQUE_38: ScheduleRoomLocation = {
name: "Bloque 38",
lat: 6.2016024,
lng: -75.5783229,
};

/** https://www.google.com/maps/dir//6.2012795,-75.5788686 */
const BLOQUE_35: ScheduleRoomLocation = {
name: "Bloque 35",
lat: 6.2012795,
lng: -75.5788686,
};

const scheduleRoomLocations: Record<string, ScheduleRoomLocation> = {
"Main Auditorium (Fundadores)": FUNDADORES,
"Auxiliar Room (101 - Bloque 38)": {
...BLOQUE_38,
name: "Auxiliar Room (101 - Bloque 38)",
},
"Auxiliar Room (110 - Bloque 38)": {
...BLOQUE_38,
name: "Auxiliar Room (110 - Bloque 38)",
},
};

for (let room = 1; room <= 15; room += 1) {
const name = `Room ${room} - Workshops`;
scheduleRoomLocations[name] = { ...BLOQUE_35, name };
}

export function getScheduleRoomLocation(
room: string,
): ScheduleRoomLocation | undefined {
return scheduleRoomLocations[room];
}

export function getScheduleRoomMapEmbedUrl(location: ScheduleRoomLocation) {
const query = `${location.lat},${location.lng}`;
return `https://maps.google.com/maps?q=${encodeURIComponent(query)}&z=17&output=embed`;
}

export function getScheduleRoomDirectionsUrl(
location: ScheduleRoomLocation,
origin?: { lat: number; lng: number },
) {
const destination = `${location.lat},${location.lng}`;

if (origin) {
return `https://www.google.com/maps/dir/${origin.lat},${origin.lng}/${destination}`;
}

return `https://www.google.com/maps/dir//${destination}`;
}
Loading