468 lines
21 KiB
TypeScript
468 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { nanoid } from 'nanoid';
|
|
import { AuthButton } from '@/components/AuthButton';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
// Emoji options for avatars
|
|
const EMOJI_OPTIONS = ['🐙', '🦊', '🐻', '🐱', '🦝', '🐸', '🦉', '🐧', '🦋', '🐝'];
|
|
|
|
// Generate a URL-safe room slug
|
|
function generateSlug(): string {
|
|
return nanoid(8).toLowerCase();
|
|
}
|
|
|
|
export default function HomePage() {
|
|
const router = useRouter();
|
|
const { isAuthenticated, username: authUsername } = useAuthStore();
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [joinSlug, setJoinSlug] = useState('');
|
|
const [name, setName] = useState('');
|
|
const [emoji, setEmoji] = useState('');
|
|
const [roomName, setRoomName] = useState('');
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const [lastRoom, setLastRoom] = useState<string | null>(null);
|
|
|
|
// Load saved user info from localStorage on mount
|
|
// If opened as installed PWA (standalone mode), auto-redirect to last room
|
|
useEffect(() => {
|
|
let loadedEmoji = '';
|
|
try {
|
|
const stored = localStorage.getItem('rmaps_user');
|
|
if (stored) {
|
|
const user = JSON.parse(stored);
|
|
if (user.name) setName(user.name);
|
|
if (user.emoji) {
|
|
setEmoji(user.emoji);
|
|
loadedEmoji = user.emoji;
|
|
}
|
|
}
|
|
// Load last visited room
|
|
const lastVisited = localStorage.getItem('rmaps_last_room');
|
|
if (lastVisited) {
|
|
setLastRoom(lastVisited);
|
|
|
|
// Auto-redirect if running as installed PWA and user has saved info
|
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
|
|| (navigator as unknown as { standalone?: boolean }).standalone === true;
|
|
if (isStandalone && stored) {
|
|
router.push(`/${lastVisited}`);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
// Set random emoji if none loaded
|
|
if (!loadedEmoji) {
|
|
setEmoji(EMOJI_OPTIONS[Math.floor(Math.random() * EMOJI_OPTIONS.length)]);
|
|
}
|
|
setIsLoaded(true);
|
|
}, [router]);
|
|
|
|
// Auto-fill name from EncryptID when authenticated
|
|
useEffect(() => {
|
|
if (isAuthenticated && authUsername && !name) {
|
|
setName(authUsername);
|
|
}
|
|
}, [isAuthenticated, authUsername, name]);
|
|
|
|
const handleCreateRoom = async () => {
|
|
if (!name.trim()) return;
|
|
|
|
// Require EncryptID auth to create rooms
|
|
if (!isAuthenticated) {
|
|
alert('Please sign in with EncryptID to create a room.');
|
|
return;
|
|
}
|
|
|
|
const slug = roomName.trim()
|
|
? roomName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20)
|
|
: generateSlug();
|
|
|
|
// Store user info in localStorage for the session
|
|
localStorage.setItem('rmaps_user', JSON.stringify({ name, emoji }));
|
|
localStorage.setItem('rmaps_last_room', slug);
|
|
|
|
// Navigate to the room (will create it if it doesn't exist)
|
|
router.push(`/${slug}`);
|
|
};
|
|
|
|
const handleJoinRoom = () => {
|
|
if (!name.trim() || !joinSlug.trim()) return;
|
|
|
|
localStorage.setItem('rmaps_user', JSON.stringify({ name, emoji }));
|
|
|
|
// Clean the slug
|
|
const cleanSlug = joinSlug.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
localStorage.setItem('rmaps_last_room', cleanSlug);
|
|
router.push(`/${cleanSlug}`);
|
|
};
|
|
|
|
const handleRejoinLastRoom = () => {
|
|
if (!name.trim() || !lastRoom) return;
|
|
localStorage.setItem('rmaps_user', JSON.stringify({ name, emoji }));
|
|
router.push(`/${lastRoom}`);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
|
{/* ── Header Nav ─────────────────────────────────────────── */}
|
|
<nav className="border-b border-slate-700/50 backdrop-blur-sm sticky top-0 z-50 bg-slate-900/90">
|
|
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
<Link href="/" className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
|
|
rM
|
|
</div>
|
|
<span className="font-semibold text-lg">
|
|
<span className="text-rmaps-primary">r</span>Maps
|
|
</span>
|
|
</Link>
|
|
<div className="flex items-center gap-4">
|
|
<Link
|
|
href="/demo"
|
|
className="text-sm text-slate-300 hover:text-white transition-colors"
|
|
>
|
|
Demo
|
|
</Link>
|
|
<a
|
|
href="#get-started"
|
|
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
|
|
>
|
|
Create Map
|
|
</a>
|
|
<AuthButton />
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* ── Hero Section ─────────────────────────────────────────── */}
|
|
<section className="relative overflow-hidden">
|
|
{/* Subtle background grid */}
|
|
<div className="absolute inset-0 opacity-[0.03]" style={{
|
|
backgroundImage: 'radial-gradient(circle, #10b981 1px, transparent 1px)',
|
|
backgroundSize: '40px 40px',
|
|
}} />
|
|
|
|
<div className="relative max-w-5xl mx-auto px-6 pt-16 pb-12 text-center">
|
|
{/* Logo */}
|
|
<div className="flex items-center justify-center gap-3 mb-8">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-xl flex items-center justify-center font-bold text-slate-900 text-lg">
|
|
rM
|
|
</div>
|
|
<h1 className="text-4xl sm:text-5xl font-bold">
|
|
<span className="text-rmaps-primary">r</span>Maps
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Headline */}
|
|
<h2 className="text-3xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-emerald-300 via-teal-200 to-cyan-300 bg-clip-text text-transparent leading-tight">
|
|
Find Your Friends, Anywhere
|
|
</h2>
|
|
<p className="text-lg sm:text-xl text-white/60 max-w-2xl mx-auto mb-8">
|
|
Privacy-first real-time location sharing for events, festivals, and camps.
|
|
See where your crew is without trusting a central server.
|
|
</p>
|
|
|
|
{/* CTA buttons */}
|
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
|
|
<Link
|
|
href="/demo"
|
|
className="px-8 py-3.5 bg-emerald-600 hover:bg-emerald-500 text-white font-medium rounded-xl transition-all shadow-lg shadow-emerald-900/30 text-lg"
|
|
>
|
|
Try the Demo
|
|
</Link>
|
|
<a
|
|
href="#get-started"
|
|
className="px-8 py-3.5 bg-white/5 hover:bg-white/10 text-white font-medium rounded-xl transition-colors border border-white/10 text-lg"
|
|
>
|
|
Get Started
|
|
</a>
|
|
</div>
|
|
|
|
<p className="text-sm text-white/40">
|
|
No app install. No sign-up required to join a room.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Feature Cards ────────────────────────────────────────── */}
|
|
<section className="max-w-5xl mx-auto px-6 pb-16">
|
|
<div className="grid sm:grid-cols-3 gap-6">
|
|
{/* Real-time GPS */}
|
|
<div className="bg-slate-800/40 rounded-2xl border border-slate-700/40 p-6 text-center hover:border-emerald-500/30 transition-colors">
|
|
<div className="w-14 h-14 mx-auto mb-4 bg-emerald-500/10 rounded-xl flex items-center justify-center">
|
|
<svg className="w-7 h-7 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2 text-white">Real-time GPS</h3>
|
|
<p className="text-sm text-white/50">
|
|
Share your live location with friends. See everyone on the map updating in real time as you move.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Event Maps */}
|
|
<div className="bg-slate-800/40 rounded-2xl border border-slate-700/40 p-6 text-center hover:border-indigo-500/30 transition-colors">
|
|
<div className="w-14 h-14 mx-auto mb-4 bg-indigo-500/10 rounded-xl flex items-center justify-center">
|
|
<svg className="w-7 h-7 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2 text-white">Event Maps</h3>
|
|
<p className="text-sm text-white/50">
|
|
Navigate festivals, camps, and conferences. Custom maps with labeled stages, food courts, and meeting points.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Privacy First */}
|
|
<div className="bg-slate-800/40 rounded-2xl border border-slate-700/40 p-6 text-center hover:border-rose-500/30 transition-colors">
|
|
<div className="w-14 h-14 mx-auto mb-4 bg-rose-500/10 rounded-xl flex items-center justify-center">
|
|
<svg className="w-7 h-7 text-rose-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2 text-white">Privacy First</h3>
|
|
<p className="text-sm text-white/50">
|
|
Zero-knowledge architecture. You control who sees you. Go invisible anytime. No tracking, no data collection.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── How It Works ─────────────────────────────────────────── */}
|
|
<section className="max-w-5xl mx-auto px-6 pb-20">
|
|
<h2 className="text-2xl sm:text-3xl font-bold text-center mb-10 text-white">How It Works</h2>
|
|
<div className="grid sm:grid-cols-3 gap-8">
|
|
{/* Step 1 */}
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="w-12 h-12 rounded-full bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-400 font-bold text-lg mb-4">
|
|
1
|
|
</div>
|
|
<h3 className="font-semibold text-white mb-2">Create a Map Room</h3>
|
|
<p className="text-sm text-white/50">
|
|
Sign in and create a room for your event. Get a shareable link like <span className="text-white/70 font-mono text-xs">rmaps.online/ccc-camp</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Step 2 */}
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="w-12 h-12 rounded-full bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center text-indigo-400 font-bold text-lg mb-4">
|
|
2
|
|
</div>
|
|
<h3 className="font-semibold text-white mb-2">Share with Friends</h3>
|
|
<p className="text-sm text-white/50">
|
|
Send the link to your crew. They join instantly from any device -- no app download, no account needed.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Step 3 */}
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="w-12 h-12 rounded-full bg-cyan-500/15 border border-cyan-500/30 flex items-center justify-center text-cyan-400 font-bold text-lg mb-4">
|
|
3
|
|
</div>
|
|
<h3 className="font-semibold text-white mb-2">Find Each Other</h3>
|
|
<p className="text-sm text-white/50">
|
|
See everyone on the map in real time. Set your status, share meeting points, and never lose your friends again.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connector lines (decorative) */}
|
|
<div className="hidden sm:flex items-center justify-center mt-8">
|
|
<div className="flex items-center gap-2 text-white/20 text-sm">
|
|
<span>Built for</span>
|
|
<a
|
|
href="https://events.ccc.de/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-rmaps-primary/60 hover:text-rmaps-primary transition-colors"
|
|
>
|
|
CCC events
|
|
</a>
|
|
<span>and beyond</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Get Started (existing login/room interface) ──────────── */}
|
|
<section id="get-started" className="scroll-mt-8 pb-16">
|
|
<main className="flex flex-col items-center px-4">
|
|
<div className="max-w-md w-full space-y-8">
|
|
{/* Section heading */}
|
|
<div className="text-center">
|
|
<h2 className="text-2xl sm:text-3xl font-bold mb-2 text-white">Get Started</h2>
|
|
<p className="text-white/50 text-sm mb-4">Create or join a map room</p>
|
|
<div className="mt-3">
|
|
<AuthButton />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Rejoin Card - show when user has saved info and last room */}
|
|
{isLoaded && name && lastRoom && (
|
|
<div className="room-panel rounded-2xl p-6">
|
|
<p className="text-white/60 text-sm text-center mb-4">Welcome back, {name}!</p>
|
|
<button
|
|
onClick={handleRejoinLastRoom}
|
|
className="btn-primary w-full text-lg py-3 flex items-center justify-center gap-2"
|
|
>
|
|
<span className="text-xl">{emoji}</span>
|
|
<span>Rejoin /{lastRoom}</span>
|
|
</button>
|
|
<p className="text-white/40 text-xs text-center mt-3">
|
|
Or create/join a different room below
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Card */}
|
|
<div className="room-panel rounded-2xl p-6 space-y-6">
|
|
{/* User Setup */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-medium">Your Profile</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm text-white/60 mb-2">Your Name</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Enter your name"
|
|
className="input w-full"
|
|
maxLength={20}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-white/60 mb-2">Your Avatar</label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{EMOJI_OPTIONS.map((e) => (
|
|
<button
|
|
key={e}
|
|
onClick={() => setEmoji(e)}
|
|
className={`w-10 h-10 rounded-lg 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>
|
|
</div>
|
|
|
|
<hr className="border-white/10" />
|
|
|
|
{/* Create Room */}
|
|
{!isCreating ? (
|
|
<div className="space-y-4">
|
|
<button
|
|
onClick={() => setIsCreating(true)}
|
|
className="btn-primary w-full text-lg py-3"
|
|
disabled={!name.trim() || !isAuthenticated}
|
|
title={!isAuthenticated ? 'Sign in with EncryptID to create rooms' : ''}
|
|
>
|
|
{isAuthenticated ? 'Create New Map' : 'Sign in to Create Map'}
|
|
</button>
|
|
|
|
<div className="text-center text-white/40 text-sm">or</div>
|
|
|
|
{/* Join Room */}
|
|
<div className="space-y-3">
|
|
<input
|
|
type="text"
|
|
value={joinSlug}
|
|
onChange={(e) => setJoinSlug(e.target.value)}
|
|
placeholder="Enter room name or code"
|
|
className="input w-full"
|
|
/>
|
|
<button
|
|
onClick={handleJoinRoom}
|
|
className="btn-secondary w-full"
|
|
disabled={!name.trim() || !joinSlug.trim()}
|
|
>
|
|
Join Existing Map
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-white/60 mb-2">
|
|
Room Name (optional)
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={roomName}
|
|
onChange={(e) => setRoomName(e.target.value)}
|
|
placeholder="e.g., 39c3-crew"
|
|
className="input flex-1"
|
|
maxLength={20}
|
|
/>
|
|
<span className="text-white/40">.rmaps.online</span>
|
|
</div>
|
|
<p className="text-xs text-white/40 mt-1">
|
|
Leave blank for a random code
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setIsCreating(false)}
|
|
className="btn-ghost flex-1"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleCreateRoom}
|
|
className="btn-primary flex-1"
|
|
disabled={!name.trim()}
|
|
>
|
|
Create Map
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</section>
|
|
|
|
{/* ── Ecosystem Footer ─────────────────────────────────────── */}
|
|
<footer className="border-t border-slate-700/50 py-8">
|
|
<div className="max-w-7xl mx-auto px-6">
|
|
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
|
|
<span className="font-medium text-slate-400">r* Ecosystem</span>
|
|
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">rSpace</a>
|
|
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">rMaps</a>
|
|
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">rNotes</a>
|
|
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">rVote</a>
|
|
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">rFunds</a>
|
|
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">rTrips</a>
|
|
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">rCart</a>
|
|
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">rWallet</a>
|
|
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">rFiles</a>
|
|
<a href="https://rtube.online" className="hover:text-slate-300 transition-colors">rTube</a>
|
|
<a href="https://rcal.online" className="hover:text-slate-300 transition-colors">rCal</a>
|
|
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">rNetwork</a>
|
|
<a href="https://rinbox.online" className="hover:text-slate-300 transition-colors">rInbox</a>
|
|
<a href="https://rstack.online" className="hover:text-slate-300 transition-colors">rStack</a>
|
|
<a href="https://rauctions.online" className="hover:text-slate-300 transition-colors">rAuctions</a>
|
|
<a href="https://rpubs.online" className="hover:text-slate-300 transition-colors">rPubs</a>
|
|
</div>
|
|
<p className="text-center text-xs text-slate-600">
|
|
Part of the r* ecosystem — collaborative tools for communities.
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|