isolate cloud-sync feature

This commit is contained in:
wass08
2026-02-11 07:57:10 +09:00
parent c7e5aa48c6
commit c1ff61ad5b
19 changed files with 791 additions and 268 deletions
+1 -1
View File
@@ -7,7 +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 { CloudSaveButton } from '@/features/cloud-sync/components/cloud-save-button'
import { PanelManager } from '../ui/panels/panel-manager'
import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
+159
View File
@@ -0,0 +1,159 @@
# Cloud Sync Feature
This directory contains the **optional** cloud synchronization and authentication features for the Pascal Editor. This feature is specific to the Pascal platform and can be safely removed if you're using the editor standalone.
## What This Does
The cloud sync feature provides:
- **Authentication** - Sign in with magic link via Better Auth
- **Property Management** - Create and manage properties with Google Maps address search
- **Database Sync** - Save and load editor state from a PostgreSQL database via Supabase
## Architecture
```
features/cloud-sync/
├── lib/
│ ├── auth/
│ │ ├── client.ts # Better Auth client configuration
│ │ ├── server.ts # Server-side session handling
│ │ └── hooks.ts # useAuth React hook
│ ├── properties/
│ │ ├── actions.ts # Server actions for CRUD operations
│ │ ├── types.ts # TypeScript types for properties
│ │ └── hooks.ts # useProperties and useActiveProperty hooks
│ ├── database/
│ │ └── server.ts # Supabase server client with service role
│ └── utils/
│ └── id-generator.ts # nanoid-based ID generation (matches backend)
├── components/
│ ├── cloud-save-button.tsx # Main UI entry point (top-right button)
│ ├── sign-in-dialog.tsx # Magic link sign-in dialog
│ ├── profile-dropdown.tsx # User profile menu
│ ├── property-dropdown.tsx # Property selector dropdown
│ ├── new-property-dialog.tsx # Create new property dialog
│ └── google-address-search.tsx # Google Maps autocomplete
└── README.md # This file
```
## How It Works
### Authentication Flow
1. User clicks "Save to cloud" button
2. Signs in with magic link (email-based, no password)
3. Better Auth session is stored in cookies
4. Server actions validate session by calling the monorepo backend
### Property Management
1. User creates a property with a real-world address (Google Maps)
2. Address and property are saved to PostgreSQL via Supabase
3. Properties are associated with the authenticated user
4. User can switch between properties
### Database Integration
- Uses Supabase (PostgreSQL) for database access
- Server actions use service role key to bypass RLS
- Permissions enforced by filtering on `owner_id`
- Table names: `properties`, `properties_addresses`, `auth_sessions`
## Required Environment Variables
```bash
# Backend API URL (Pascal monorepo - for better-auth only)
NEXT_PUBLIC_API_URL=http://localhost:3000
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
# Google Maps API Key (for address search)
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_key_here
```
## Dependencies
The cloud sync feature requires these packages:
```json
{
"better-auth": "^1.4.18",
"@supabase/supabase-js": "^2.95.3",
"@react-google-maps/api": "^2.20.8",
"nanoid": "^5.1.6"
}
```
## How to Remove (For Open Source Users)
If you want to use the editor without cloud sync:
### 1. Delete this directory
```bash
rm -rf features/cloud-sync
```
### 2. Remove the CloudSaveButton from the editor
Edit `components/editor/index.tsx`:
```diff
- import { CloudSaveButton } from '@/features/cloud-sync/components/cloud-save-button'
export default function Editor() {
return (
<div className="w-full h-full">
<ActionMenu />
<PanelManager />
- <CloudSaveButton />
```
### 3. Remove dependencies (optional)
Edit `package.json`:
```diff
- "better-auth": "^1.4.18",
- "@supabase/supabase-js": "^2.95.3",
- "@react-google-maps/api": "^2.20.8",
- "nanoid": "^5.1.6"
```
### 4. Remove environment variables
Delete from `.env.local` and `.env.example`:
```diff
- NEXT_PUBLIC_API_URL=...
- NEXT_PUBLIC_SUPABASE_URL=...
- NEXT_PUBLIC_SUPABASE_ANON_KEY=...
- SUPABASE_SERVICE_ROLE_KEY=...
- NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
```
That's it! The editor will work as a standalone application without any cloud features.
## Backend Requirements
This feature requires the Pascal monorepo backend running with:
- Better Auth configured with magic link support
- PostgreSQL database with the following tables:
- `properties` - Property records
- `properties_addresses` - Property addresses
- `auth_sessions` - Better Auth sessions
- `auth_users` - Better Auth users
- Supabase local instance for database access
## Development
To work on this feature:
1. Ensure the monorepo backend is running on port 3000
2. Ensure Supabase local is running on port 54321
3. Configure all environment variables
4. Run the editor: `bun dev`
The editor will be available at `http://localhost:3002` (different port to avoid conflicts with the monorepo).
## Notes
- This feature uses **server actions** (Next.js App Router) for all database operations
- Authentication is handled by the monorepo backend via Better Auth
- The editor queries the database directly using Supabase with service role key
- IDs are generated using nanoid with custom alphabet to match the backend schema
- All table names match the monorepo's database schema exactly
@@ -2,7 +2,7 @@
import { Cloud } from 'lucide-react'
import { useState } from 'react'
import { useAuth } from '@/lib/auth/use-auth'
import { useAuth } from '../lib/auth/hooks'
import { ProfileDropdown } from './profile-dropdown'
import { PropertyDropdown } from './property-dropdown'
import { SignInDialog } from './sign-in-dialog'
@@ -0,0 +1,113 @@
'use client'
import { Autocomplete, LoadScript } from '@react-google-maps/api'
import { MapPin } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
const libraries: ('places')[] = ['places']
interface AddressComponents {
streetNumber?: string
route?: string
city?: string
state?: string
postalCode?: string
country?: string
center: [number, number]
formattedAddress: string
}
interface GoogleAddressSearchProps {
onAddressSelect: (address: AddressComponents) => void
disabled?: boolean
}
export function GoogleAddressSearch({ onAddressSelect, disabled }: GoogleAddressSearchProps) {
const [autocomplete, setAutocomplete] = useState<google.maps.places.Autocomplete | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
// Fix Google Maps autocomplete dropdown z-index and pointer events to work with dialog
useEffect(() => {
const style = document.createElement('style')
style.textContent = `
.pac-container {
z-index: 9999 !important;
pointer-events: auto !important;
}
`
document.head.appendChild(style)
return () => {
document.head.removeChild(style)
}
}, [])
if (!apiKey) {
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
<p className="font-medium">Google Maps API Key Missing</p>
<p className="mt-1 text-xs">
Add NEXT_PUBLIC_GOOGLE_MAPS_API_KEY to your .env.local file
</p>
</div>
)
}
const onLoad = (autocompleteInstance: google.maps.places.Autocomplete) => {
setAutocomplete(autocompleteInstance)
}
const onPlaceChanged = () => {
if (!autocomplete) return
const place = autocomplete.getPlace()
if (!place.geometry?.location || !place.address_components) return
const components: AddressComponents = {
center: [place.geometry.location.lng(), place.geometry.location.lat()],
formattedAddress: place.formatted_address || '',
}
// Parse address components
for (const component of place.address_components) {
const types = component.types
if (types.includes('street_number')) {
components.streetNumber = component.long_name
} else if (types.includes('route')) {
components.route = component.long_name
} else if (types.includes('locality')) {
components.city = component.long_name
} else if (types.includes('administrative_area_level_1')) {
components.state = component.short_name
} else if (types.includes('postal_code')) {
components.postalCode = component.long_name
} else if (types.includes('country')) {
components.country = component.short_name
}
}
onAddressSelect(components)
}
return (
<LoadScript googleMapsApiKey={apiKey} libraries={libraries}>
<div className="space-y-2">
<label className="flex items-center gap-2 font-medium text-sm">
<MapPin className="h-4 w-4" />
Property Address
</label>
<Autocomplete onLoad={onLoad} onPlaceChanged={onPlaceChanged}>
<input
ref={inputRef}
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={disabled}
placeholder="Search for an address..."
type="text"
/>
</Autocomplete>
</div>
</LoadScript>
)
}
@@ -2,8 +2,9 @@
import { X } from 'lucide-react'
import { useState } from 'react'
import { createProperty } from '@/lib/properties/actions'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './primitives/dialog'
import { createProperty } from '../lib/properties/actions'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
import { GoogleAddressSearch } from './google-address-search'
interface NewPropertyDialogProps {
open: boolean
@@ -11,37 +12,57 @@ interface NewPropertyDialogProps {
onSuccess?: () => void
}
interface AddressData {
streetNumber?: string
route?: string
city?: string
state?: string
postalCode?: string
country?: string
center: [number, number]
formattedAddress: string
}
/**
* 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
* NewPropertyDialog - Dialog for creating a new property with Google Maps address search
*/
export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewPropertyDialogProps) {
const [name, setName] = useState('')
const [address, setAddress] = useState<AddressData | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleAddressSelect = (addressData: AddressData) => {
setAddress(addressData)
setError(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!address) {
setError('Please select an address')
return
}
setIsCreating(true)
try {
// TODO: Replace with actual address data from Google Maps
// Use formatted address as property name (like monorepo)
const result = await createProperty({
name,
center: [0, 0], // TODO: Get from Google Maps
city: '',
state: '',
postalCode: '',
country: 'US',
name: address.formattedAddress,
center: address.center,
streetNumber: address.streetNumber,
route: address.route,
city: address.city,
state: address.state,
postalCode: address.postalCode,
country: address.country || 'US',
})
if (result.success) {
onOpenChange(false)
setName('')
setAddress(null)
onSuccess?.()
} else {
setError(result.error || 'Failed to create property')
@@ -56,14 +77,17 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
const handleClose = () => {
if (!isCreating) {
onOpenChange(false)
setName('')
setAddress(null)
setError(null)
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<Dialog open={open} onOpenChange={handleClose} modal={false}>
<DialogContent
className="sm:max-w-[500px]"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Add New Property</DialogTitle>
<button
@@ -77,30 +101,16 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
</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>
{/* Google Maps Address Search */}
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
{/* 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>
{/* Show selected address */}
{address && (
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
<p className="font-medium">Selected Address:</p>
<p className="mt-1 text-muted-foreground">{address.formattedAddress}</p>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
@@ -119,7 +129,7 @@ export function NewPropertyDialog({ open, onOpenChange, onSuccess }: NewProperty
</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}
disabled={isCreating || !address}
type="submit"
>
{isCreating ? 'Creating...' : 'Create Property'}
@@ -1,12 +1,12 @@
'use client'
import { useAuth } from '@/lib/auth/use-auth'
import { useAuth } from '../lib/auth/hooks'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './primitives/dropdown-menu'
} from '@/components/ui/primitives/dropdown-menu'
function getInitials(name: string): string {
return name
@@ -2,14 +2,14 @@
import { Check, ChevronDown, Home, Plus } from 'lucide-react'
import { useState } from 'react'
import { useActiveProperty, useProperties } from '@/lib/properties/use-properties'
import { useActiveProperty, useProperties } from '../lib/properties/hooks'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './primitives/dropdown-menu'
} from '@/components/ui/primitives/dropdown-menu'
import { NewPropertyDialog } from './new-property-dialog'
/**
@@ -2,8 +2,8 @@
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'
import { authClient } from '../lib/auth/client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
interface SignInDialogProps {
open: boolean
@@ -1,6 +1,6 @@
'use client'
import { authClient } from './auth-client'
import { authClient } from './client'
/**
* Hook to access authentication state using better-auth
@@ -0,0 +1,48 @@
import { headers as nextHeaders } from 'next/headers'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
/**
* Get the current session from Better Auth backend (server-side)
*/
export async function getSession() {
try {
const headersList = await nextHeaders()
// Make authenticated request to the auth backend to get session
const response = await fetch(`${API_URL}/api/auth/get-session`, {
headers: {
cookie: headersList.get('cookie') || '',
},
credentials: 'include',
cache: 'no-store',
})
if (!response.ok) {
return null
}
const data = await response.json()
// Better Auth returns the session data directly
if (data?.user && data?.session) {
return {
user: data.user,
session: data.session,
}
}
return null
} catch (error) {
console.error('Failed to get session:', error)
return null
}
}
/**
* Get the current user from the session
*/
export async function getUser() {
const session = await getSession()
return session?.user ?? null
}
@@ -0,0 +1,26 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceRoleKey) {
throw new Error(
'Missing Supabase environment variables. Add NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY to your .env.local file.',
)
}
/**
* Create a Supabase client for server-side use with service role key
* This bypasses RLS and allows server actions to query the database directly
* Authentication is handled by Better Auth, permissions enforced by filtering on user_id
*/
export async function createServerSupabaseClient() {
const client = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
})
return client
}
@@ -0,0 +1,326 @@
/**
* Property actions - Server actions for property management
* Uses Better Auth session + Supabase to query the same database as the monorepo
*/
'use server'
import { createServerSupabaseClient } from '../database/server'
import { getSession } from '../auth/server'
import { createId } from '../utils/id-generator'
import type { CreatePropertyParams, Property } from './types'
export type ActionResult<T = unknown> = {
success: boolean
data?: T
error?: string
message?: string
}
/**
* Fetch all properties for the current user
*/
export async function getUserProperties(): Promise<ActionResult<Property[]>> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
data: [],
}
}
const supabase = await createServerSupabaseClient()
// Query properties table with address relation
const { data, error } = await supabase
.from('properties')
.select(`
*,
address:properties_addresses(*)
`)
.eq('owner_id', session.user.id)
if (error) {
return {
success: false,
error: error.message,
data: [],
}
}
return {
success: true,
data: data as Property[],
}
} 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 session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
data: null,
}
}
const supabase = await createServerSupabaseClient()
// Get session's active_property_id from sessions table
const { data: sessionData, error: sessionError } = await supabase
.from('auth_sessions')
.select('active_property_id')
.eq('user_id', session.user.id)
.single()
if (sessionError || !sessionData?.active_property_id) {
return {
success: true,
data: null,
}
}
// Get the property with address
const { data, error } = await supabase
.from('properties')
.select(`
*,
address:properties_addresses(*)
`)
.eq('id', sessionData.active_property_id)
.single()
if (error) {
return {
success: false,
error: error.message,
data: null,
}
}
return {
success: true,
data: data as Property,
}
} 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 session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Update session's active_property_id
const { error } = await supabase
.from('auth_sessions')
.update({ active_property_id: propertyId })
.eq('user_id', session.user.id)
if (error) {
return {
success: false,
error: error.message,
}
}
return {
success: true,
message: propertyId ? 'Active property updated' : 'Active property cleared',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set active property',
}
}
}
/**
* Create a new property
*/
export async function createProperty(params: CreatePropertyParams): Promise<ActionResult<Property>> {
try {
const session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Generate IDs for address and property
const addressId = createId('address')
const propertyId = createId('property')
// First, create the address
const { data: address, error: addressError } = await supabase
.from('properties_addresses')
.insert({
id: addressId,
street_number: params.streetNumber,
route: params.route,
city: params.city || '',
state: params.state || '',
postal_code: params.postalCode || '',
country: params.country || 'US',
latitude: params.center[1].toString(),
longitude: params.center[0].toString(),
})
.select()
.single()
if (addressError || !address) {
return {
success: false,
error: addressError?.message || 'Failed to create address',
}
}
// Create the property
const { data, error } = await supabase
.from('properties')
.insert({
id: propertyId,
name: params.name,
address_id: address.id,
owner_id: session.user.id,
details_json: {
coordinates: params.center,
createdFrom: 'editor-app',
},
})
.select(`
*,
address:properties_addresses(*)
`)
.single()
if (error) {
return {
success: false,
error: error.message,
}
}
return {
success: true,
data: data as Property,
message: 'Property created successfully',
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create 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 session = await getSession()
if (!session?.user) {
return {
success: false,
error: 'Not authenticated',
}
}
const supabase = await createServerSupabaseClient()
// Query for existing property with matching address
const { data, error } = await supabase
.from('properties')
.select(`
*,
address:properties_addresses!inner(*)
`)
.eq('address.street_number', params.streetNumber || '')
.eq('address.route', params.route || '')
.eq('address.city', params.city || '')
.eq('address.state', params.state || '')
.eq('address.postal_code', params.postalCode || '')
.limit(1)
if (error) {
return {
success: false,
error: error.message,
}
}
if (data && data.length > 0) {
const existingProperty = data[0] as Property
return {
success: true,
data: {
isDuplicate: true,
isUserProperty: existingProperty.ownerId === session.user.id,
existingProperty,
},
}
}
return {
success: true,
data: {
isDuplicate: false,
},
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to check for duplicates',
}
}
}
@@ -0,0 +1,13 @@
import { customAlphabet } from 'nanoid'
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const nanoid = customAlphabet(alphabet, 16)
/**
* Generate a unique ID with optional prefix (matches monorepo implementation)
* @example createId('user') => 'user_Abc123...'
*/
export const createId = (prefix?: string) => {
const id = nanoid()
return prefix ? `${prefix}_${id}` : id
}
-216
View File
@@ -1,216 +0,0 @@
/**
* 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',
}
}
}
+3
View File
@@ -25,8 +25,10 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-google-maps/api": "^2.20.8",
"@react-three/uikit-lucide": "^1.0.60",
"@repo/ui": "*",
"@supabase/supabase-js": "^2.95.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/three": "^0.182.0",
"better-auth": "^1.4.18",
@@ -34,6 +36,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next": "16.1.0",
"postcss": "^8.5.6",
"react": "^19.2.0",