Implement petition signature storage
This commit is contained in:
@ -4,6 +4,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Index from "./pages/Index";
|
||||
import Testimonies from "./pages/Testimonies";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@ -16,6 +17,7 @@ const App = () => (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/testimonies" element={<Testimonies />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@ -3,6 +3,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
@ -10,12 +18,42 @@ export const PetitionForm = () => {
|
||||
email: "",
|
||||
comment: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// In a real application, this would submit to a backend
|
||||
|
||||
// Validate input
|
||||
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 (
|
||||
@ -50,9 +88,10 @@ export const PetitionForm = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -14,7 +14,30 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
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: {
|
||||
[_ in never]: never
|
||||
|
||||
@ -2,10 +2,48 @@ import { Button } from "@/components/ui/button";
|
||||
import { TestimonialCard } from "@/components/TestimonialCard";
|
||||
import { PetitionForm } from "@/components/PetitionForm";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
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 = [
|
||||
{
|
||||
name: "Sarah Mitchell",
|
||||
@ -116,11 +154,21 @@ const Index = () => {
|
||||
<p className="text-xl text-muted-foreground mb-12 text-center">
|
||||
Real stories from real residents affected by this decision
|
||||
</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) => (
|
||||
<TestimonialCard key={index} {...testimonial} />
|
||||
))}
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
||||
136
src/pages/Testimonies.tsx
Normal file
136
src/pages/Testimonies.tsx
Normal 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;
|
||||
@ -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;
|
||||
Reference in New Issue
Block a user