Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { useState, useEffect } from 'react' 2import { createRoot } from 'react-dom/client' 3import { Button } from '@public/components/ui/button' 4import { 5 Card, 6 CardContent, 7 CardDescription, 8 CardHeader, 9 CardTitle 10} from '@public/components/ui/card' 11import { Input } from '@public/components/ui/input' 12import { Label } from '@public/components/ui/label' 13import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react' 14import Layout from '@public/layouts' 15 16type OnboardingStep = 'domain' | 'upload' | 'complete' 17 18function Onboarding() { 19 const [step, setStep] = useState<OnboardingStep>('domain') 20 const [handle, setHandle] = useState('') 21 const [isCheckingAvailability, setIsCheckingAvailability] = useState(false) 22 const [isAvailable, setIsAvailable] = useState<boolean | null>(null) 23 const [domain, setDomain] = useState('') 24 const [isClaimingDomain, setIsClaimingDomain] = useState(false) 25 const [claimedDomain, setClaimedDomain] = useState('') 26 27 const [siteName, setSiteName] = useState('') 28 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 29 const [isUploading, setIsUploading] = useState(false) 30 const [uploadProgress, setUploadProgress] = useState('') 31 32 // Check domain availability as user types 33 useEffect(() => { 34 if (!handle || handle.length < 3) { 35 setIsAvailable(null) 36 setDomain('') 37 return 38 } 39 40 const timeoutId = setTimeout(async () => { 41 setIsCheckingAvailability(true) 42 try { 43 const response = await fetch( 44 `/api/domain/check?handle=${encodeURIComponent(handle)}` 45 ) 46 const data = await response.json() 47 setIsAvailable(data.available) 48 setDomain(data.domain || '') 49 } catch (err) { 50 console.error('Error checking availability:', err) 51 setIsAvailable(false) 52 } finally { 53 setIsCheckingAvailability(false) 54 } 55 }, 500) 56 57 return () => clearTimeout(timeoutId) 58 }, [handle]) 59 60 const handleClaimDomain = async () => { 61 if (!handle || !isAvailable) return 62 63 setIsClaimingDomain(true) 64 try { 65 const response = await fetch('/api/domain/claim', { 66 method: 'POST', 67 headers: { 'Content-Type': 'application/json' }, 68 body: JSON.stringify({ handle }) 69 }) 70 71 const data = await response.json() 72 if (data.success) { 73 setClaimedDomain(data.domain) 74 setStep('upload') 75 } else { 76 alert('Failed to claim domain. Please try again.') 77 } 78 } catch (err) { 79 console.error('Error claiming domain:', err) 80 alert('Failed to claim domain. Please try again.') 81 } finally { 82 setIsClaimingDomain(false) 83 } 84 } 85 86 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 87 if (e.target.files && e.target.files.length > 0) { 88 setSelectedFiles(e.target.files) 89 } 90 } 91 92 const handleUpload = async () => { 93 if (!siteName) { 94 alert('Please enter a site name') 95 return 96 } 97 98 setIsUploading(true) 99 setUploadProgress('Preparing files...') 100 101 try { 102 const formData = new FormData() 103 formData.append('siteName', siteName) 104 105 if (selectedFiles) { 106 for (let i = 0; i < selectedFiles.length; i++) { 107 formData.append('files', selectedFiles[i]) 108 } 109 } 110 111 setUploadProgress('Uploading to AT Protocol...') 112 const response = await fetch('/wisp/upload-files', { 113 method: 'POST', 114 body: formData 115 }) 116 117 const data = await response.json() 118 if (data.success) { 119 setUploadProgress('Upload complete!') 120 // Redirect to the claimed domain 121 setTimeout(() => { 122 window.location.href = `https://${claimedDomain}` 123 }, 1500) 124 } else { 125 throw new Error(data.error || 'Upload failed') 126 } 127 } catch (err) { 128 console.error('Upload error:', err) 129 alert( 130 `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 131 ) 132 setIsUploading(false) 133 setUploadProgress('') 134 } 135 } 136 137 const handleSkipUpload = () => { 138 // Redirect to editor without uploading 139 window.location.href = '/editor' 140 } 141 142 return ( 143 <div className="w-full min-h-screen bg-background"> 144 {/* Header */} 145 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 146 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 147 <div className="flex items-center gap-2"> 148 <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 149 <Globe className="w-5 h-5 text-primary-foreground" /> 150 </div> 151 <span className="text-xl font-semibold text-foreground"> 152 wisp.place 153 </span> 154 </div> 155 </div> 156 </header> 157 158 <div className="container mx-auto px-4 py-12 max-w-2xl"> 159 {/* Progress indicator */} 160 <div className="mb-8"> 161 <div className="flex items-center justify-center gap-2 mb-4"> 162 <div 163 className={`w-8 h-8 rounded-full flex items-center justify-center ${ 164 step === 'domain' 165 ? 'bg-primary text-primary-foreground' 166 : 'bg-green-500 text-white' 167 }`} 168 > 169 {step === 'domain' ? ( 170 '1' 171 ) : ( 172 <CheckCircle2 className="w-5 h-5" /> 173 )} 174 </div> 175 <div className="w-16 h-0.5 bg-border"></div> 176 <div 177 className={`w-8 h-8 rounded-full flex items-center justify-center ${ 178 step === 'upload' 179 ? 'bg-primary text-primary-foreground' 180 : step === 'domain' 181 ? 'bg-muted text-muted-foreground' 182 : 'bg-green-500 text-white' 183 }`} 184 > 185 {step === 'complete' ? ( 186 <CheckCircle2 className="w-5 h-5" /> 187 ) : ( 188 '2' 189 )} 190 </div> 191 </div> 192 <div className="text-center"> 193 <h1 className="text-2xl font-bold mb-2"> 194 {step === 'domain' && 'Claim Your Free Domain'} 195 {step === 'upload' && 'Deploy Your First Site'} 196 {step === 'complete' && 'All Set!'} 197 </h1> 198 <p className="text-muted-foreground"> 199 {step === 'domain' && 200 'Choose a subdomain on wisp.place'} 201 {step === 'upload' && 202 'Upload your site or start with an empty one'} 203 {step === 'complete' && 'Redirecting to your site...'} 204 </p> 205 </div> 206 </div> 207 208 {/* Domain registration step */} 209 {step === 'domain' && ( 210 <Card> 211 <CardHeader> 212 <CardTitle>Choose Your Domain</CardTitle> 213 <CardDescription> 214 Pick a unique handle for your free *.wisp.place 215 subdomain 216 </CardDescription> 217 </CardHeader> 218 <CardContent className="space-y-4"> 219 <div className="space-y-2"> 220 <Label htmlFor="handle">Your Handle</Label> 221 <div className="flex gap-2"> 222 <div className="relative flex-1"> 223 <Input 224 id="handle" 225 placeholder="my-awesome-site" 226 value={handle} 227 onChange={(e) => 228 setHandle( 229 e.target.value 230 .toLowerCase() 231 .replace(/[^a-z0-9-]/g, '') 232 ) 233 } 234 className="pr-10" 235 /> 236 {isCheckingAvailability && ( 237 <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" /> 238 )} 239 {!isCheckingAvailability && 240 isAvailable !== null && ( 241 <div 242 className={`absolute right-3 top-1/2 -translate-y-1/2 ${ 243 isAvailable 244 ? 'text-green-500' 245 : 'text-red-500' 246 }`} 247 > 248 {isAvailable ? '✓' : '✗'} 249 </div> 250 )} 251 </div> 252 </div> 253 {domain && ( 254 <p className="text-sm text-muted-foreground"> 255 Your domain will be:{' '} 256 <span className="font-mono">{domain}</span> 257 </p> 258 )} 259 {isAvailable === false && handle.length >= 3 && ( 260 <p className="text-sm text-red-500"> 261 This handle is not available or invalid 262 </p> 263 )} 264 </div> 265 266 <Button 267 onClick={handleClaimDomain} 268 disabled={ 269 !isAvailable || 270 isClaimingDomain || 271 isCheckingAvailability 272 } 273 className="w-full" 274 > 275 {isClaimingDomain ? ( 276 <> 277 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 278 Claiming Domain... 279 </> 280 ) : ( 281 <>Claim Domain</> 282 )} 283 </Button> 284 </CardContent> 285 </Card> 286 )} 287 288 {/* Upload step */} 289 {step === 'upload' && ( 290 <Card> 291 <CardHeader> 292 <CardTitle>Deploy Your Site</CardTitle> 293 <CardDescription> 294 Upload your static site files or start with an empty 295 site (you can upload later) 296 </CardDescription> 297 </CardHeader> 298 <CardContent className="space-y-6"> 299 <div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg"> 300 <div className="flex items-center gap-2 text-green-600 dark:text-green-400"> 301 <CheckCircle2 className="w-4 h-4" /> 302 <span className="font-medium"> 303 Domain claimed: {claimedDomain} 304 </span> 305 </div> 306 </div> 307 308 <div className="space-y-2"> 309 <Label htmlFor="site-name">Site Name</Label> 310 <Input 311 id="site-name" 312 placeholder="my-site" 313 value={siteName} 314 onChange={(e) => setSiteName(e.target.value)} 315 /> 316 <p className="text-xs text-muted-foreground"> 317 A unique identifier for this site in your account 318 </p> 319 </div> 320 321 <div className="space-y-2"> 322 <Label>Upload Files (Optional)</Label> 323 <div className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-accent transition-colors"> 324 <Upload className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> 325 <input 326 type="file" 327 id="file-upload" 328 multiple 329 onChange={handleFileSelect} 330 className="hidden" 331 {...(({ webkitdirectory: '', directory: '' } as any))} 332 /> 333 <label 334 htmlFor="file-upload" 335 className="cursor-pointer" 336 > 337 <Button 338 variant="outline" 339 type="button" 340 onClick={() => 341 document 342 .getElementById('file-upload') 343 ?.click() 344 } 345 > 346 Choose Folder 347 </Button> 348 </label> 349 {selectedFiles && selectedFiles.length > 0 && ( 350 <p className="text-sm text-muted-foreground mt-3"> 351 {selectedFiles.length} files selected 352 </p> 353 )} 354 </div> 355 <p className="text-xs text-muted-foreground"> 356 Supported: HTML, CSS, JS, images, fonts, and more 357 </p> 358 </div> 359 360 {uploadProgress && ( 361 <div className="p-4 bg-muted rounded-lg"> 362 <div className="flex items-center gap-2"> 363 <Loader2 className="w-4 h-4 animate-spin" /> 364 <span className="text-sm"> 365 {uploadProgress} 366 </span> 367 </div> 368 </div> 369 )} 370 371 <div className="flex gap-3"> 372 <Button 373 onClick={handleSkipUpload} 374 variant="outline" 375 className="flex-1" 376 disabled={isUploading} 377 > 378 Skip for Now 379 </Button> 380 <Button 381 onClick={handleUpload} 382 className="flex-1" 383 disabled={!siteName || isUploading} 384 > 385 {isUploading ? ( 386 <> 387 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 388 Uploading... 389 </> 390 ) : ( 391 <> 392 {selectedFiles && selectedFiles.length > 0 393 ? 'Upload & Deploy' 394 : 'Create Empty Site'} 395 </> 396 )} 397 </Button> 398 </div> 399 </CardContent> 400 </Card> 401 )} 402 </div> 403 </div> 404 ) 405} 406 407const root = createRoot(document.getElementById('elysia')!) 408root.render( 409 <Layout> 410 <Onboarding /> 411 </Layout> 412)