auth flow draft
This commit is contained in:
@@ -7,6 +7,7 @@ import useEditor from '@/store/use-editor'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { ToolManager } from '../tools/tool-manager'
|
||||
import { ActionMenu } from '../ui/action-menu'
|
||||
import { CloudSaveButton } from '../ui/cloud-save-button'
|
||||
import { PanelManager } from '../ui/panels/panel-manager'
|
||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
@@ -27,6 +28,7 @@ export default function Editor() {
|
||||
<div className="w-full h-full">
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
<CloudSaveButton />
|
||||
|
||||
<SidebarProvider className="fixed z-20">
|
||||
<AppSidebar />
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { Cloud } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { ProfileDropdown } from './profile-dropdown'
|
||||
import { PropertyDropdown } from './property-dropdown'
|
||||
import { SignInDialog } from './sign-in-dialog'
|
||||
|
||||
/**
|
||||
* CloudSaveButton - Shows authentication state and property management
|
||||
*
|
||||
* Not authenticated: Shows "Save to cloud" button
|
||||
* Authenticated: Shows PropertyDropdown and ProfileDropdown
|
||||
*/
|
||||
export function CloudSaveButton() {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
|
||||
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
>
|
||||
<Cloud className="h-4 w-4" />
|
||||
Save to cloud
|
||||
</button>
|
||||
</div>
|
||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<PropertyDropdown />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { createProperty } from '@/lib/properties/actions'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './primitives/dialog'
|
||||
|
||||
interface NewPropertyDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* NewPropertyDialog - Dialog for creating a new property
|
||||
*
|
||||
* TODO: Add Google Maps address search integration
|
||||
* TODO: Add address parsing and validation
|
||||
* TODO: Add duplicate checking before creation
|
||||
*/
|
||||
export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual address data from Google Maps
|
||||
const result = await createProperty({
|
||||
name,
|
||||
center: [0, 0], // TODO: Get from Google Maps
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
country: 'US',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onOpenChange(false)
|
||||
setName('')
|
||||
onSuccess?.()
|
||||
} else {
|
||||
setError(result.error || 'Failed to create property')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isCreating) {
|
||||
onOpenChange(false)
|
||||
setName('')
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Property</DialogTitle>
|
||||
<button
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||
disabled={isCreating}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{/* TODO: Add Google Maps address search component */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="property-name">
|
||||
Property Name
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isCreating}
|
||||
id="property-name"
|
||||
placeholder="Enter property name"
|
||||
required
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TODO: Add address fields with Google Maps autocomplete */}
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-muted-foreground text-sm">
|
||||
<p>TODO: Google Maps address search will be integrated here</p>
|
||||
<p className="mt-2 text-xs">
|
||||
For now, property creation is not fully functional. This is a placeholder for the UI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
disabled={isCreating}
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={isCreating || !name}
|
||||
type="submit"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Property'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './primitives/dropdown-menu'
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileDropdown - User profile menu with sign out
|
||||
*/
|
||||
export function ProfileDropdown() {
|
||||
const { user, signOut } = useAuth()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut()
|
||||
// TODO: Show sign-in dialog or redirect
|
||||
console.log('Signed out')
|
||||
}
|
||||
|
||||
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background/95 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none"
|
||||
type="button"
|
||||
>
|
||||
{initials}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{user?.name && (
|
||||
<div className="px-2 py-1.5 text-sm">
|
||||
<div className="font-medium">{user.name}</div>
|
||||
{user.email && <div className="text-muted-foreground text-xs">{user.email}</div>}
|
||||
</div>
|
||||
)}
|
||||
{user?.name && <DropdownMenuItem className="h-px bg-border" />}
|
||||
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useActiveProperty, useProperties } from '@/lib/properties/use-properties'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './primitives/dropdown-menu'
|
||||
import { NewPropertyDialog } from './new-property-dialog'
|
||||
|
||||
/**
|
||||
* PropertyDropdown - Shows active property and allows switching between properties
|
||||
*/
|
||||
export function PropertyDropdown() {
|
||||
const { properties, isLoading: propertiesLoading, refetch } = useProperties()
|
||||
const { activeProperty, setActiveProperty, isPending } = useActiveProperty()
|
||||
const [isNewPropertyDialogOpen, setIsNewPropertyDialogOpen] = useState(false)
|
||||
|
||||
const handlePropertySelect = async (propertyId: string) => {
|
||||
await setActiveProperty(propertyId)
|
||||
}
|
||||
|
||||
const handleAddNew = () => {
|
||||
setIsNewPropertyDialogOpen(true)
|
||||
}
|
||||
|
||||
const handlePropertyCreated = () => {
|
||||
refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50 focus:outline-none"
|
||||
disabled={propertiesLoading || isPending}
|
||||
type="button"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span className="max-w-[150px] truncate">
|
||||
{activeProperty
|
||||
? activeProperty.name
|
||||
: properties.length > 0
|
||||
? 'Select Property'
|
||||
: 'Add Property'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[280px]">
|
||||
{/* Property list */}
|
||||
{properties.length > 0 ? (
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{properties.map((property) => (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
'cursor-pointer text-sm',
|
||||
activeProperty?.id === property.id && 'cursor-default bg-accent',
|
||||
)}
|
||||
key={property.id}
|
||||
onClick={() =>
|
||||
activeProperty?.id === property.id ? null : handlePropertySelect(property.id)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex-1 truncate font-medium">{property.name}</div>
|
||||
{activeProperty?.id === property.id && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-muted-foreground text-sm">
|
||||
No properties yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new property option */}
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={handleAddNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span>Add new property</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<NewPropertyDialog
|
||||
open={isNewPropertyDialogOpen}
|
||||
onOpenChange={setIsNewPropertyDialogOpen}
|
||||
onSuccess={handlePropertyCreated}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { Mail, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { authClient } from '@/lib/auth/auth-client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './primitives/dialog'
|
||||
|
||||
interface SignInDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* SignInDialog - Magic link authentication dialog
|
||||
*/
|
||||
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Use better-auth's magic link sign in
|
||||
const result = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: window.location.origin,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message || 'Failed to send magic link')
|
||||
} else {
|
||||
setSuccess(true)
|
||||
setEmail('')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
onOpenChange(false)
|
||||
// Reset state after a short delay to avoid flash
|
||||
setTimeout(() => {
|
||||
setEmail('')
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in to Pascal</DialogTitle>
|
||||
<button
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||
disabled={isLoading}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</DialogHeader>
|
||||
|
||||
{success ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
|
||||
<Mail className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">Check your email</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We've sent a magic link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Click the link in the email to sign in to your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="w-full rounded-md border border-input px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="email">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
id="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={isLoading || !email}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
Sending magic link...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
We'll send you a magic link to sign in without a password.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Auth client for the editor using better-auth
|
||||
* Connects to the Pascal monorepo backend
|
||||
*/
|
||||
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import {
|
||||
customSessionClient,
|
||||
magicLinkClient,
|
||||
organizationClient,
|
||||
} from 'better-auth/client/plugins'
|
||||
|
||||
/**
|
||||
* Get the backend API URL
|
||||
* Default: http://localhost:3000 (monorepo backend)
|
||||
*/
|
||||
function getBackendURL(): string {
|
||||
// Check if we have an env variable for the backend URL
|
||||
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL
|
||||
}
|
||||
|
||||
// In browser, try to use the current origin if it's the same host
|
||||
if (typeof window !== 'undefined') {
|
||||
// For local development, always use localhost:3000 (monorepo backend)
|
||||
if (window.location.hostname === 'localhost') {
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
// For production, use the same origin
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
// SSR fallback
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth client instance with better-auth
|
||||
* Configured to work with the Pascal monorepo backend
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: getBackendURL(),
|
||||
plugins: [
|
||||
magicLinkClient(),
|
||||
organizationClient(),
|
||||
customSessionClient<{
|
||||
session: {
|
||||
activePropertyId: string | null
|
||||
activeOrganizationId: string | null
|
||||
}
|
||||
}>(),
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* Export types for use in components
|
||||
*/
|
||||
export type AuthState = {
|
||||
user: (typeof authClient)['$Infer']['Session']['user'] | null
|
||||
session: (typeof authClient)['$Infer']['Session']['session'] | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type User = NonNullable<AuthState['user']>
|
||||
export type Session = NonNullable<AuthState['session']>
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { authClient } from './auth-client'
|
||||
|
||||
/**
|
||||
* Hook to access authentication state using better-auth
|
||||
* @returns Current auth state including user, session, and loading status
|
||||
*/
|
||||
export function useAuth() {
|
||||
const session = authClient.useSession()
|
||||
|
||||
return {
|
||||
user: session.data?.user ?? null,
|
||||
session: session.data?.session ?? null,
|
||||
isAuthenticated: !!session.data?.user && !!session.data?.session,
|
||||
isLoading: session.isPending,
|
||||
signOut: () => authClient.signOut(),
|
||||
signIn: authClient.signIn,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Property actions - API client for property management
|
||||
* Makes HTTP requests to the Pascal monorepo backend
|
||||
*/
|
||||
|
||||
import type { CreatePropertyParams, Property } from './types'
|
||||
|
||||
export type ActionResult<T = unknown> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend API URL
|
||||
*/
|
||||
function getBackendURL(): string {
|
||||
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all properties for the current user
|
||||
*/
|
||||
export async function getUserProperties(): Promise<ActionResult<Property[]>> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to fetch properties',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || [],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch properties',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active property for the current session
|
||||
*/
|
||||
export async function getActiveProperty(): Promise<ActionResult<Property | null>> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties/active`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to fetch active property',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch active property',
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active property for the current session
|
||||
*/
|
||||
export async function setActiveProperty(propertyId: string | null): Promise<ActionResult> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties/active`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ propertyId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to set active property',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || (propertyId ? 'Active property updated' : 'Active property cleared'),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active property',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property with the given address already exists
|
||||
*/
|
||||
export async function checkPropertyDuplicate(params: {
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
}): Promise<
|
||||
ActionResult<{
|
||||
isDuplicate: boolean
|
||||
isUserProperty?: boolean
|
||||
existingProperty?: Property
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties/check-duplicate`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to check for duplicates',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || { isDuplicate: false },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for duplicates',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new property
|
||||
*/
|
||||
export async function createProperty(params: CreatePropertyParams): Promise<ActionResult<Property>> {
|
||||
try {
|
||||
const response = await fetch(`${getBackendURL()}/api/properties`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
error: error || 'Failed to create property',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: result.message || 'Property created successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create property',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Property-related type definitions
|
||||
* Isolated from monorepo database schema
|
||||
*/
|
||||
|
||||
export type Property = {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
organizationId: string | null
|
||||
addressId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
address: {
|
||||
id: string
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
city?: string
|
||||
state?: string
|
||||
postalCode?: string
|
||||
country?: string
|
||||
latitude?: string
|
||||
longitude?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CreatePropertyParams = {
|
||||
name: string
|
||||
center: [number, number]
|
||||
streetNumber?: string
|
||||
route?: string
|
||||
routeShort?: string
|
||||
neighborhood?: string
|
||||
city?: string
|
||||
county?: string
|
||||
state?: string
|
||||
stateLong?: string
|
||||
postalCode?: string
|
||||
postalCodeSuffix?: string
|
||||
country?: string
|
||||
countryLong?: string
|
||||
rawJson?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
getActiveProperty,
|
||||
getUserProperties,
|
||||
setActiveProperty as setActivePropertyAction,
|
||||
} from './actions'
|
||||
import type { Property } from './types'
|
||||
|
||||
interface UsePropertiesReturn {
|
||||
properties: Property[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refetch: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage user properties
|
||||
*/
|
||||
export function useProperties(): UsePropertiesReturn {
|
||||
const [properties, setProperties] = useState<Property[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchProperties = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await getUserProperties()
|
||||
|
||||
if (result.success) {
|
||||
setProperties(result.data || [])
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch properties')
|
||||
setProperties([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
setProperties([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProperties()
|
||||
}, [fetchProperties])
|
||||
|
||||
return {
|
||||
properties,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchProperties,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single property by ID
|
||||
*/
|
||||
export function useProperty(propertyId: string | undefined) {
|
||||
const [property, setProperty] = useState<Property | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!propertyId) {
|
||||
setProperty(null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const fetchProperty = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await getUserProperties()
|
||||
|
||||
if (result.success) {
|
||||
const found = result.data?.find((p) => p.id === propertyId)
|
||||
if (found) {
|
||||
setProperty(found)
|
||||
} else {
|
||||
setError('Property not found')
|
||||
setProperty(null)
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch property')
|
||||
setProperty(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
setProperty(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProperty()
|
||||
}, [propertyId])
|
||||
|
||||
return {
|
||||
property,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the active property for the current session
|
||||
*/
|
||||
export function useActiveProperty() {
|
||||
const [activeProperty, setActivePropertyState] = useState<Property | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const fetchActiveProperty = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await getActiveProperty()
|
||||
|
||||
if (result.success) {
|
||||
setActivePropertyState(result.data || null)
|
||||
|
||||
// If no active property is set, automatically set the first property as active
|
||||
if (!result.data) {
|
||||
const propertiesResult = await getUserProperties()
|
||||
|
||||
if (
|
||||
propertiesResult.success &&
|
||||
propertiesResult.data &&
|
||||
propertiesResult.data.length > 0
|
||||
) {
|
||||
const firstProperty = propertiesResult.data[0]
|
||||
if (firstProperty) {
|
||||
const setActiveResult = await setActivePropertyAction(firstProperty.id)
|
||||
|
||||
if (setActiveResult.success) {
|
||||
setActivePropertyState(firstProperty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch active property')
|
||||
setActivePropertyState(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
setActivePropertyState(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeActiveProperty = useCallback(
|
||||
async (propertyId: string | null) => {
|
||||
try {
|
||||
setIsPending(true)
|
||||
const result = await setActivePropertyAction(propertyId)
|
||||
|
||||
if (result.success) {
|
||||
// Fetch the updated active property
|
||||
if (propertyId) {
|
||||
await fetchActiveProperty()
|
||||
} else {
|
||||
setActivePropertyState(null)
|
||||
}
|
||||
} else {
|
||||
console.error(result.error || 'Failed to update active property')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : 'An unexpected error occurred')
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
},
|
||||
[fetchActiveProperty],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchActiveProperty()
|
||||
}, [fetchActiveProperty])
|
||||
|
||||
return {
|
||||
activeProperty,
|
||||
isLoading,
|
||||
error,
|
||||
setActiveProperty: changeActiveProperty,
|
||||
isPending,
|
||||
refetch: fetchActiveProperty,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"dev": "next dev --port 3002",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@repo/ui": "*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/three": "^0.182.0",
|
||||
"better-auth": "^1.4.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
|
||||
Reference in New Issue
Block a user