auth flow draft

This commit is contained in:
wass08
2026-02-10 15:58:34 +09:00
parent e8b2b5b2d1
commit c7e5aa48c6
13 changed files with 1070 additions and 1 deletions
+2
View File
@@ -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>
)
}
+65
View File
@@ -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']>
+20
View File
@@ -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,
}
}
+216
View File
@@ -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',
}
}
}
+43
View File
@@ -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,
}
}
+2 -1
View File
@@ -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",