Implement petition signature storage

This commit is contained in:
gpt-engineer-app[bot]
2025-10-24 12:58:03 +00:00
parent 23fe4e8d93
commit d5a282f525
6 changed files with 289 additions and 9 deletions

View File

@ -4,6 +4,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index"; import Index from "./pages/Index";
import Testimonies from "./pages/Testimonies";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -16,6 +17,7 @@ const App = () => (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Index />} /> <Route path="/" element={<Index />} />
<Route path="/testimonies" element={<Testimonies />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@ -3,6 +3,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { z } from "zod";
const petitionSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(100, "Name must be less than 100 characters"),
email: z.string().trim().email("Invalid email address").max(255, "Email must be less than 255 characters"),
comment: z.string().trim().max(1000, "Comment must be less than 1000 characters").optional(),
});
export const PetitionForm = () => { export const PetitionForm = () => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -10,12 +18,42 @@ export const PetitionForm = () => {
email: "", email: "",
comment: "", comment: "",
}); });
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// In a real application, this would submit to a backend
toast.success("Thank you for signing! Your voice matters."); // Validate input
setFormData({ name: "", email: "", comment: "" }); try {
petitionSchema.parse(formData);
} catch (error) {
if (error instanceof z.ZodError) {
toast.error(error.errors[0].message);
return;
}
}
setIsSubmitting(true);
try {
const { error } = await supabase
.from('petition_signatures')
.insert([{
name: formData.name.trim(),
email: formData.email.trim(),
comment: formData.comment.trim() || null,
}]);
if (error) throw error;
toast.success("Thank you for signing! Your voice matters.");
setFormData({ name: "", email: "", comment: "" });
} catch (error) {
console.error('Error signing petition:', error);
toast.error("Failed to submit signature. Please try again.");
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
@ -50,9 +88,10 @@ export const PetitionForm = () => {
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
className="w-full bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground shadow-[var(--shadow-elevated)] font-semibold" disabled={isSubmitting}
className="w-full bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground shadow-[var(--shadow-elevated)] font-semibold disabled:opacity-50"
> >
Sign the Petition {isSubmitting ? "Submitting..." : "Sign the Petition"}
</Button> </Button>
</form> </form>
); );

View File

@ -14,7 +14,30 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
[_ in never]: never petition_signatures: {
Row: {
comment: string | null
created_at: string
email: string
id: string
name: string
}
Insert: {
comment?: string | null
created_at?: string
email: string
id?: string
name: string
}
Update: {
comment?: string | null
created_at?: string
email?: string
id?: string
name?: string
}
Relationships: []
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never

View File

@ -2,10 +2,48 @@ import { Button } from "@/components/ui/button";
import { TestimonialCard } from "@/components/TestimonialCard"; import { TestimonialCard } from "@/components/TestimonialCard";
import { PetitionForm } from "@/components/PetitionForm"; import { PetitionForm } from "@/components/PetitionForm";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { Home, Users, Car, AlertTriangle } from "lucide-react"; import { Home, Users, Car, AlertTriangle, MessageSquare } from "lucide-react";
import carparkHero from "@/assets/carpark-hero.jpg"; import carparkHero from "@/assets/carpark-hero.jpg";
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
const Index = () => { const Index = () => {
const navigate = useNavigate();
const [signatureCount, setSignatureCount] = useState(0);
useEffect(() => {
fetchSignatureCount();
// Set up realtime subscription for signature count
const channel = supabase
.channel('signature-count')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'petition_signatures'
},
() => {
setSignatureCount(prev => prev + 1);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const fetchSignatureCount = async () => {
const { count } = await supabase
.from('petition_signatures')
.select('*', { count: 'exact', head: true });
setSignatureCount(count || 0);
};
const testimonials = [ const testimonials = [
{ {
name: "Sarah Mitchell", name: "Sarah Mitchell",
@ -116,11 +154,21 @@ const Index = () => {
<p className="text-xl text-muted-foreground mb-12 text-center"> <p className="text-xl text-muted-foreground mb-12 text-center">
Real stories from real residents affected by this decision Real stories from real residents affected by this decision
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{testimonials.map((testimonial, index) => ( {testimonials.map((testimonial, index) => (
<TestimonialCard key={index} {...testimonial} /> <TestimonialCard key={index} {...testimonial} />
))} ))}
</div> </div>
<div className="text-center">
<Button
size="lg"
onClick={() => navigate('/testimonies')}
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
>
<MessageSquare className="w-5 h-5" />
Read All {signatureCount > 0 && `${signatureCount} `}Testimonies
</Button>
</div>
</div> </div>
</section> </section>

136
src/pages/Testimonies.tsx Normal file
View File

@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { TestimonialCard } from "@/components/TestimonialCard";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, Users } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { formatDistanceToNow } from "date-fns";
interface Signature {
id: string;
name: string;
comment: string | null;
created_at: string;
}
const Testimonies = () => {
const [signatures, setSignatures] = useState<Signature[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
fetchSignatures();
// Set up realtime subscription for new signatures
const channel = supabase
.channel('schema-db-changes')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'petition_signatures'
},
(payload) => {
const newSignature = payload.new as Signature;
setSignatures(prev => [newSignature, ...prev]);
setTotalCount(prev => prev + 1);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const fetchSignatures = async () => {
try {
// Get total count
const { count } = await supabase
.from('petition_signatures')
.select('*', { count: 'exact', head: true });
setTotalCount(count || 0);
// Get signatures with comments
const { data, error } = await supabase
.from('petition_signatures')
.select('*')
.not('comment', 'is', null)
.order('created_at', { ascending: false });
if (error) throw error;
setSignatures(data || []);
} catch (error) {
console.error('Error fetching signatures:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="bg-card border-b border-border sticky top-0 z-10 shadow-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Button
variant="ghost"
onClick={() => navigate('/')}
className="gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back to Campaign
</Button>
<div className="flex items-center gap-2 text-primary">
<Users className="w-5 h-5" />
<span className="font-semibold">{totalCount} Signatures</span>
</div>
</div>
</header>
{/* Content */}
<main className="max-w-6xl mx-auto px-6 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-4">
Community Testimonies
</h1>
<p className="text-xl text-muted-foreground">
Real stories from residents affected by the closure of Victoria Way Carpark
</p>
</div>
{isLoading ? (
<div className="text-center py-12">
<p className="text-muted-foreground">Loading testimonies...</p>
</div>
) : signatures.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No testimonies yet. Be the first to share your story!</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{signatures.map((signature) => (
<TestimonialCard
key={signature.id}
name={signature.name}
comment={signature.comment || ""}
date={formatDistanceToNow(new Date(signature.created_at), { addSuffix: true })}
/>
))}
</div>
)}
</main>
{/* Footer */}
<footer className="py-8 px-6 bg-card border-t border-border mt-12">
<div className="max-w-6xl mx-auto text-center text-muted-foreground">
<p>© 2025 Save Victoria Way Carpark Campaign | For the residents of Enterprise Place, Woking</p>
</div>
</footer>
</div>
);
};
export default Testimonies;

View File

@ -0,0 +1,32 @@
-- Create petition signatures table
CREATE TABLE public.petition_signatures (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE public.petition_signatures ENABLE ROW LEVEL SECURITY;
-- Allow anyone to insert signatures (public petition)
CREATE POLICY "Anyone can sign the petition"
ON public.petition_signatures
FOR INSERT
TO anon, authenticated
WITH CHECK (true);
-- Allow anyone to view signatures and testimonies
CREATE POLICY "Anyone can view signatures"
ON public.petition_signatures
FOR SELECT
TO anon, authenticated
USING (true);
-- Create index for faster queries by date
CREATE INDEX idx_petition_signatures_created_at
ON public.petition_signatures(created_at DESC);
-- Enable realtime for live signature updates
ALTER PUBLICATION supabase_realtime ADD TABLE public.petition_signatures;