feat: Direct room URL navigation + fix c3nav iframe blocking

- Add inline JoinForm when navigating directly to room URL
- No longer redirects to home page for new users
- Replace c3nav iframe with fallback UI (c3nav blocks iframe embedding)
- Add 'Open c3nav' button to open indoor map in new tab
- Still shows friend markers with indoor locations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-29 13:26:14 +01:00
parent 908767be15
commit 796fd2c727
3 changed files with 140 additions and 104 deletions

View File

@ -13,6 +13,7 @@ import ShareModal from '@/components/room/ShareModal';
import MeetingPointModal from '@/components/room/MeetingPointModal';
import WaypointModal from '@/components/room/WaypointModal';
import InstallBanner from '@/components/room/InstallBanner';
import JoinForm from '@/components/room/JoinForm';
import type { Participant, ParticipantLocation, Waypoint } from '@/types';
// Dynamic import for map to avoid SSR issues with MapLibre
@ -38,24 +39,31 @@ export default function RoomPage() {
const [selectedWaypoint, setSelectedWaypoint] = useState<Waypoint | null>(null);
const [shouldAutoStartSharing, setShouldAutoStartSharing] = useState(false);
const [zoomToLocation, setZoomToLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const [needsJoin, setNeedsJoin] = useState(false);
// Load user and sharing preference from localStorage
useEffect(() => {
const stored = localStorage.getItem('rmaps_user');
if (stored) {
setCurrentUser(JSON.parse(stored));
// Check if user had location sharing enabled for this room
const sharingPref = localStorage.getItem(`rmaps_sharing_${slug}`);
if (sharingPref === 'true') {
setShouldAutoStartSharing(true);
}
} else {
// Redirect to home if no user info
router.push('/');
return;
// Show join form instead of redirecting
setNeedsJoin(true);
}
}, [slug]);
// Check if user had location sharing enabled for this room
const sharingPref = localStorage.getItem(`rmaps_sharing_${slug}`);
if (sharingPref === 'true') {
setShouldAutoStartSharing(true);
}
}, [router, slug]);
// Handle joining from the inline form
const handleJoin = (name: string, emoji: string) => {
const user = { name, emoji };
localStorage.setItem('rmaps_user', JSON.stringify(user));
setCurrentUser(user);
setNeedsJoin(false);
};
// Room hook (only initialize when we have user info)
const {
@ -276,6 +284,11 @@ export default function RoomPage() {
setShowParticipants(false); // Close participant list to show map
};
// Show join form if user hasn't entered their name yet
if (needsJoin) {
return <JoinForm roomSlug={slug} onJoin={handleJoin} />;
}
// Loading state
if (!currentUser || isLoading) {
return (

View File

@ -1,19 +1,14 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import type { Participant } from '@/types';
interface C3NavEmbedProps {
/** Event identifier (e.g., '39c3', 'eh2025') */
eventId?: string;
/** Initial location to show */
initialLocation?: string;
/** Participants to show on the map overlay */
participants?: Participant[];
/** Current user ID */
currentUserId?: string;
/** Callback when user taps a location */
onLocationSelect?: (location: { slug: string; name: string }) => void;
/** Show the indoor/outdoor toggle */
showToggle?: boolean;
/** Callback when toggling to outdoor mode */
@ -32,105 +27,37 @@ const C3NAV_EVENTS: Record<string, string> = {
export default function C3NavEmbed({
eventId = '39c3',
initialLocation,
participants = [],
currentUserId,
onLocationSelect,
showToggle = true,
onToggleOutdoor,
}: C3NavEmbedProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get the c3nav base URL for the event
const baseUrl = C3NAV_EVENTS[eventId] || C3NAV_EVENTS['39c3'];
// Build the embed URL
const embedUrl = new URL(baseUrl);
embedUrl.searchParams.set('embed', '1');
if (initialLocation) {
embedUrl.searchParams.set('o', initialLocation);
}
// Handle iframe load
const handleLoad = () => {
setIsLoading(false);
setError(null);
};
// Handle iframe error
const handleError = () => {
setIsLoading(false);
setError('Failed to load indoor map');
};
// Listen for messages from c3nav iframe
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Only accept messages from c3nav
if (!event.origin.includes('c3nav.de')) return;
try {
const data = event.data;
if (data.type === 'c3nav:location' && onLocationSelect) {
onLocationSelect({
slug: data.slug,
name: data.name,
});
}
} catch (e) {
// Ignore invalid messages
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onLocationSelect]);
// c3nav blocks iframes, so show fallback UI with link to open in new tab
return (
<div className="relative w-full h-full">
{/* c3nav iframe */}
<iframe
ref={iframeRef}
src={embedUrl.toString()}
className="w-full h-full border-0"
allow="geolocation"
onLoad={handleLoad}
onError={handleError}
title="c3nav indoor navigation"
/>
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 bg-rmaps-dark flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-2 border-rmaps-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<div className="text-white/60 text-sm">Loading indoor map...</div>
</div>
</div>
)}
{/* Error overlay */}
{error && (
<div className="absolute inset-0 bg-rmaps-dark flex items-center justify-center">
<div className="text-center p-4">
<div className="text-red-400 mb-2">{error}</div>
<button
onClick={() => {
setIsLoading(true);
setError(null);
if (iframeRef.current) {
iframeRef.current.src = embedUrl.toString();
}
}}
className="btn-ghost text-sm"
>
Retry
</button>
</div>
</div>
)}
<div className="relative w-full h-full bg-rmaps-dark">
{/* Fallback UI since c3nav blocks iframe embedding */}
<div className="absolute inset-0 flex flex-col items-center justify-center p-6 text-center">
<div className="text-6xl mb-4">🏢</div>
<h3 className="text-xl font-semibold text-white mb-2">Indoor Navigation</h3>
<p className="text-white/60 text-sm mb-6 max-w-xs">
Open c3nav to view the indoor map and navigate the venue
</p>
<button
onClick={() => window.open(baseUrl, '_blank')}
className="btn-primary flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open c3nav
</button>
<p className="text-white/40 text-xs mt-4">
{eventId.toUpperCase()} venue map
</p>
</div>
{/* Friend markers overlay */}
{participants.length > 0 && (

View File

@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
const EMOJIS = ['😀', '😎', '🤓', '🥳', '🦊', '🐱', '🐶', '🦄', '🌟', '🔥', '💜', '🎮'];
interface JoinFormProps {
roomSlug: string;
onJoin: (name: string, emoji: string) => void;
}
export default function JoinForm({ roomSlug, onJoin }: JoinFormProps) {
const [name, setName] = useState('');
const [emoji, setEmoji] = useState(EMOJIS[Math.floor(Math.random() * EMOJIS.length)]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onJoin(name.trim(), emoji);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-rmaps-dark">
<div className="room-panel rounded-2xl p-6 max-w-sm w-full">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">Join Room</h1>
<p className="text-white/60 text-sm">
You're joining <span className="text-rmaps-primary font-medium">/{roomSlug}</span>
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Emoji picker */}
<div>
<label className="block text-white/60 text-sm mb-2">Pick your avatar</label>
<div className="flex flex-wrap gap-2 justify-center">
{EMOJIS.map((e) => (
<button
key={e}
type="button"
onClick={() => setEmoji(e)}
className={`w-10 h-10 rounded-full text-xl flex items-center justify-center transition-all ${
emoji === e
? 'bg-rmaps-primary scale-110'
: 'bg-white/10 hover:bg-white/20'
}`}
>
{e}
</button>
))}
</div>
</div>
{/* Name input */}
<div>
<label className="block text-white/60 text-sm mb-2">Your name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
className="w-full px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/40 focus:border-rmaps-primary focus:outline-none"
maxLength={20}
autoFocus
/>
</div>
{/* Preview */}
<div className="flex items-center justify-center gap-3 py-3 bg-white/5 rounded-lg">
<div className="w-12 h-12 rounded-full bg-rmaps-primary/30 flex items-center justify-center text-2xl">
{emoji}
</div>
<span className="text-white font-medium">
{name || 'Your Name'}
</span>
</div>
{/* Submit */}
<button
type="submit"
disabled={!name.trim()}
className="w-full btn-primary py-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
Join Room
</button>
</form>
{/* Location sharing note */}
<p className="text-white/40 text-xs text-center mt-4">
Location sharing is optional and can be toggled anytime
</p>
</div>
</div>
);
}