feedback feature
This commit is contained in:
@@ -10,6 +10,7 @@ import { useAuth } from '@/features/community/lib/auth/hooks'
|
|||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
|
import { FeedbackDialog } from '../feedback-dialog'
|
||||||
import { PascalRadio } from '../pascal-radio'
|
import { PascalRadio } from '../pascal-radio'
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
import { PanelManager } from '../ui/panels/panel-manager'
|
||||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||||
@@ -56,10 +57,13 @@ export default function Editor({ projectId }: EditorProps) {
|
|||||||
<HelperManager />
|
<HelperManager />
|
||||||
|
|
||||||
{/* Top-right controls */}
|
{/* Top-right controls */}
|
||||||
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
|
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-center gap-2">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<PascalRadio />
|
<PascalRadio />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<FeedbackDialog />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-20">
|
<SidebarProvider className="fixed z-20">
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { submitFeedback } from '@/features/community/lib/feedback/actions'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/primitives/dialog'
|
||||||
|
import { Button } from '@/components/ui/primitives/button'
|
||||||
|
|
||||||
|
export function FeedbackDialog() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true)
|
||||||
|
setSent(false)
|
||||||
|
setError(null)
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isSubmitting) return
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
const result = await submitFeedback(message)
|
||||||
|
setIsSubmitting(false)
|
||||||
|
if (result.success) {
|
||||||
|
setSent(true)
|
||||||
|
setTimeout(() => setOpen(false), 1500)
|
||||||
|
} else {
|
||||||
|
setError(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleOpen}
|
||||||
|
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 hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
Feedback
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Send Feedback</DialogTitle>
|
||||||
|
<DialogDescription>We'd love to hear your thoughts</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
Thanks for your feedback!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="feedback-message" className="text-sm font-medium">
|
||||||
|
Your feedback
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="feedback-message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
|
||||||
|
rows={5}
|
||||||
|
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !message.trim()}>
|
||||||
|
{isSubmitting ? 'Sending...' : 'Send Feedback'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -135,12 +135,17 @@ export default function CommunityHub() {
|
|||||||
|
|
||||||
<main className="container mx-auto px-6 py-8 space-y-12">
|
<main className="container mx-auto px-6 py-8 space-y-12">
|
||||||
{/* User's Projects Section */}
|
{/* User's Projects Section */}
|
||||||
{isAuthenticated && (userProjects.length > 0 || localProjects.length > 0) && (
|
{isAuthenticated && (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-semibold">My Projects</h2>
|
<h2 className="text-xl font-semibold">My Projects</h2>
|
||||||
<CreateProjectButton onCreateProject={handleCreateProject} />
|
<CreateProjectButton onCreateProject={handleCreateProject} />
|
||||||
</div>
|
</div>
|
||||||
|
{userProjects.length === 0 && localProjects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
|
||||||
|
<p className="text-muted-foreground">You don't have any projects yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ProjectGrid
|
<ProjectGrid
|
||||||
projects={[...userProjects, ...localProjects]}
|
projects={[...userProjects, ...localProjects]}
|
||||||
onProjectClick={handleProjectClick}
|
onProjectClick={handleProjectClick}
|
||||||
@@ -149,7 +154,6 @@ export default function CommunityHub() {
|
|||||||
showOwner={false}
|
showOwner={false}
|
||||||
canEdit
|
canEdit
|
||||||
onUpdate={() => {
|
onUpdate={() => {
|
||||||
// Reload projects after settings update
|
|
||||||
if (!authLoading) {
|
if (!authLoading) {
|
||||||
getUserProjects().then((result) => {
|
getUserProjects().then((result) => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -159,6 +163,7 @@ export default function CommunityHub() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useState } from 'react'
|
|||||||
import { createProject } from '../lib/projects/actions'
|
import { createProject } from '../lib/projects/actions'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||||
import { Switch } from '@/components/ui/primitives/switch'
|
import { Switch } from '@/components/ui/primitives/switch'
|
||||||
import { GoogleAddressSearch } from './google-address-search'
|
|
||||||
|
|
||||||
interface NewProjectDialogProps {
|
interface NewProjectDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -18,38 +17,20 @@ interface NewProjectDialogProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddressData {
|
|
||||||
streetNumber?: string
|
|
||||||
route?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
postalCode?: string
|
|
||||||
country?: string
|
|
||||||
center: [number, number]
|
|
||||||
formattedAddress: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NewProjectDialog - Dialog for creating a new project with optional Google Maps address search
|
* NewProjectDialog - Dialog for creating a new project with optional Google Maps address search
|
||||||
*/
|
*/
|
||||||
export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectData }: NewProjectDialogProps) {
|
export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectData }: NewProjectDialogProps) {
|
||||||
const [projectName, setProjectName] = useState(localProjectData?.name || '')
|
const [projectName, setProjectName] = useState(localProjectData?.name || '')
|
||||||
const [address, setAddress] = useState<AddressData | null>(null)
|
|
||||||
const [showAddressSearch, setShowAddressSearch] = useState(false)
|
|
||||||
const [isPrivate, setIsPrivate] = useState(false)
|
const [isPrivate, setIsPrivate] = useState(false)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleAddressSelect = (addressData: AddressData) => {
|
|
||||||
setAddress(addressData)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const name = projectName.trim() || (address?.formattedAddress ?? 'Untitled Project')
|
const name = projectName.trim() || 'Untitled Project'
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
setError('Please enter a project name')
|
setError('Please enter a project name')
|
||||||
@@ -61,13 +42,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
try {
|
try {
|
||||||
const result = await createProject({
|
const result = await createProject({
|
||||||
name,
|
name,
|
||||||
center: address?.center,
|
|
||||||
streetNumber: address?.streetNumber,
|
|
||||||
route: address?.route,
|
|
||||||
city: address?.city,
|
|
||||||
state: address?.state,
|
|
||||||
postalCode: address?.postalCode,
|
|
||||||
country: address?.country || 'US',
|
|
||||||
isPrivate,
|
isPrivate,
|
||||||
sceneGraph: localProjectData?.sceneGraph,
|
sceneGraph: localProjectData?.sceneGraph,
|
||||||
})
|
})
|
||||||
@@ -75,8 +49,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setProjectName('')
|
setProjectName('')
|
||||||
setAddress(null)
|
|
||||||
setShowAddressSearch(false)
|
|
||||||
setIsPrivate(false)
|
setIsPrivate(false)
|
||||||
onSuccess?.(result.data.id)
|
onSuccess?.(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
@@ -93,8 +65,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
if (!isCreating) {
|
if (!isCreating) {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setProjectName('')
|
setProjectName('')
|
||||||
setAddress(null)
|
|
||||||
setShowAddressSearch(false)
|
|
||||||
setIsPrivate(false)
|
setIsPrivate(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
@@ -136,44 +106,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Address Section */}
|
|
||||||
{!showAddressSearch ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddressSearch(true)}
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
+ Add an address (optional)
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium">Address (optional)</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddressSearch(false)
|
|
||||||
setAddress(null)
|
|
||||||
}}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Privacy Toggle */}
|
{/* Privacy Toggle */}
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { createId } from '@pascal-app/db'
|
||||||
|
import { createServerSupabaseClient } from '../database/server'
|
||||||
|
import { getSession } from '../auth/server'
|
||||||
|
|
||||||
|
export async function submitFeedback(
|
||||||
|
message: string,
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
try {
|
||||||
|
const trimmed = message.trim()
|
||||||
|
if (!trimmed) return { success: false, error: 'Message cannot be empty' }
|
||||||
|
|
||||||
|
const session = await getSession()
|
||||||
|
const supabase = await createServerSupabaseClient()
|
||||||
|
|
||||||
|
const { error } = await supabase.from('feedback').insert({
|
||||||
|
id: createId('feedback'),
|
||||||
|
user_id: session?.user?.id ?? null,
|
||||||
|
message: trimmed,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) return { success: false, error: error.message }
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to submit feedback',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { pgTable } from 'drizzle-orm/pg-core'
|
||||||
|
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||||
|
import { id, createdAt } from '../../helpers'
|
||||||
|
|
||||||
|
export const feedback = pgTable('feedback', (t) => ({
|
||||||
|
id: id('feedback'),
|
||||||
|
userId: t.text('user_id'), // nullable — stores Better Auth user ID or null for anonymous
|
||||||
|
message: t.text('message').notNull(),
|
||||||
|
createdAt,
|
||||||
|
})).enableRLS()
|
||||||
|
|
||||||
|
export type Feedback = typeof feedback.$inferSelect
|
||||||
|
export type NewFeedback = typeof feedback.$inferInsert
|
||||||
|
export const insertFeedbackSchema = createInsertSchema(feedback)
|
||||||
|
export const selectFeedbackSchema = createSelectSchema(feedback)
|
||||||
@@ -5,6 +5,9 @@ export * from './auth/sessions'
|
|||||||
export * from './auth/users'
|
export * from './auth/users'
|
||||||
export * from './auth/verifications'
|
export * from './auth/verifications'
|
||||||
|
|
||||||
|
// Feedback table
|
||||||
|
export * from './feedback/feedback'
|
||||||
|
|
||||||
// Project tables
|
// Project tables
|
||||||
export * from './projects/addresses'
|
export * from './projects/addresses'
|
||||||
export * from './projects/likes'
|
export * from './projects/likes'
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Create feedback table
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable Row Level Security
|
||||||
|
ALTER TABLE feedback ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Allow anyone (authenticated or anonymous) to submit feedback
|
||||||
|
CREATE POLICY "Anyone can insert feedback"
|
||||||
|
ON feedback
|
||||||
|
FOR INSERT
|
||||||
|
TO anon, authenticated
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Allow service role full access (for admin review)
|
||||||
|
CREATE POLICY "Service role full access"
|
||||||
|
ON feedback
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
Reference in New Issue
Block a user