feat: actually running the server

This commit is contained in:
2025-11-10 21:33:30 +00:00
parent d4fc41c6bf
commit 4371b26423
15 changed files with 850 additions and 920 deletions

View File

@ -1,16 +1,16 @@
import { z } from 'zod'; import { z } from "zod";
import dotenv from 'dotenv'; import dotenv from "dotenv";
dotenv.config(); dotenv.config({ quiet: true });
const envSchema = z.object({ const envSchema = z.object({
PORT: z PORT: z
.string() .string()
.refine( .refine(
(port) => parseInt(port) > 0 && parseInt(port) < 65536, (port) => parseInt(port) > 0 && parseInt(port) < 65536,
"Invalid port number" "Invalid port number",
), ),
DATABASE_URL: z.string().min(10) DATABASE_URL: z.string().min(10),
}); });
type Env = z.infer<typeof envSchema>; type Env = z.infer<typeof envSchema>;

View File

@ -0,0 +1,39 @@
import { ENV } from "./env";
import { signPetition } from "./routes/sign-petition";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
const allowCors = async (_: Request): Promise<Response> => {
return new Response(null, { status: 200, headers: CORS_HEADERS });
};
type Handler = (req: Request) => Promise<Response>;
const withCors = (fn: Handler): Handler => {
return async (req) => {
const res = await fn(req);
for (const [header, value] of Object.entries(CORS_HEADERS)) {
res.headers.set(header, value);
}
return res;
};
};
const server = Bun.serve({
port: ENV.PORT,
routes: {
"/health": new Response("alive!"),
"/sign-petition": {
POST: withCors(signPetition),
OPTIONS: allowCors,
},
},
});
console.log(`server running on ${server.url}`);

View File

@ -1,8 +1,9 @@
import { pgTable, text, uuid } from "drizzle-orm/pg-core"; import { pgTable, text, uuid, timestamp } from "drizzle-orm/pg-core";
export const signaturesTable = pgTable("signatures", { export const signaturesTable = pgTable("signatures", {
id: uuid().primaryKey().defaultRandom(), id: uuid().primaryKey().defaultRandom(),
email: text().notNull(), email: text().notNull(),
name: text(), name: text(),
comment: text(), comment: text(),
createdAt: timestamp().defaultNow().notNull(),
}); });

View File

@ -1,26 +1,29 @@
import type z from "zod";
import { insertSignature } from "../models"; import { insertSignature } from "../models";
import { signedPetitionSchema, signPetitionSchema } from 'types' import { signedPetitionSchema, signPetitionSchema } from "types";
export const signPetition = async (req: Request): Promise<Response> => { export const signPetition = async (req: Request): Promise<Response> => {
const body = await req.json() const body = await req.json();
const validatedBody = signPetitionSchema.safeParse(body); const validatedBody = signPetitionSchema.safeParse(body);
if (!validatedBody.success) { if (!validatedBody.success) {
return Response.json({ error: validatedBody.error }, { status: 400 }); console.log(validatedBody.error);
} return Response.json({ error: validatedBody.error }, { status: 400 });
}
const _insertedSignature = await insertSignature({ const insertedSignature = await insertSignature({
email: validatedBody.data.email, email: validatedBody.data.email,
name: validatedBody.data.name, name: validatedBody.data.name,
comment: validatedBody.data.comment, comment: validatedBody.data.comment,
}) });
if (!_insertedSignature) { if (!insertedSignature) {
return Response.json({ error: "inserting signature in database" }, { status: 500 }); return Response.json(
} { error: "inserting signature in database" },
{ status: 500 },
);
}
const insertedSignature = _insertedSignature satisfies z.infer<typeof signedPetitionSchema> const parsedSignedSignature = signedPetitionSchema.parse(insertedSignature);
return Response.json(insertedSignature, { status: 200 }); return Response.json(parsedSignedSignature, { status: 200 });
} };

View File

@ -1,7 +1,6 @@
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner"; import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import Index from "./pages/Index"; import Index from "./pages/Index";
@ -9,27 +8,26 @@ import Testimonies from "./pages/Testimonies";
import Contact from "./pages/Contact"; import Contact from "./pages/Contact";
import Briefing from "./pages/Briefing"; import Briefing from "./pages/Briefing";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import { PetitionStateProvider } from "./state";
const queryClient = new QueryClient();
const App = () => ( const App = () => (
<QueryClientProvider client={queryClient}> <PetitionStateProvider>
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Sonner /> <Sonner />
<BrowserRouter> <BrowserRouter>
<Navbar /> <Navbar />
<Routes> <Routes>
<Route path="/" element={<Index />} /> <Route path="/" element={<Index />} />
<Route path="/testimonies" element={<Testimonies />} /> <Route path="/testimonies" element={<Testimonies />} />
<Route path="/contact" element={<Contact />} /> <Route path="/contact" element={<Contact />} />
<Route path="/briefing" element={<Briefing />} /> <Route path="/briefing" element={<Briefing />} />
{/* 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>
</BrowserRouter> </BrowserRouter>
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </PetitionStateProvider>
); );
export default App; export default App;

View File

@ -4,137 +4,111 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "sonner"; import { usePetitions } from "@/state";
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(),
});
const anonymousSchema = z.object({
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(),
});
interface PetitionFormProps { interface PetitionFormProps {
compact?: boolean; compact?: boolean;
} }
export const PetitionForm = ({ compact = false }: PetitionFormProps) => { const setUndefinedOrValue = (
const [formData, setFormData] = useState({ setter: (value: string | undefined) => void,
name: "", value: string,
email: "", ) => {
comment: "", if (value.length === 0) {
}); setter(undefined);
const [isSubmitting, setIsSubmitting] = useState(false); } else {
const [isAnonymous, setIsAnonymous] = useState(false); setter(value);
}
const handleSubmit = async (e: React.FormEvent) => { };
e.preventDefault();
export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
// Validate input based on mode const [name, setName] = useState<string | undefined>();
try { const [comment, setComment] = useState<string | undefined>();
if (isAnonymous) { const [email, setEmail] = useState<string | undefined>();
anonymousSchema.parse({ email: formData.email, comment: formData.comment });
} else { const [isSubmitting, setIsSubmitting] = useState(false);
petitionSchema.parse(formData); const [isAnonymous, setIsAnonymous] = useState(false);
}
} catch (error) { const { onSignPetition } = usePetitions();
if (error instanceof z.ZodError) {
toast.error(error.errors[0].message); const handleSubmit = async (e: React.FormEvent) => {
return; e.preventDefault();
}
} setIsSubmitting(true);
await onSignPetition({
setIsSubmitting(true); email,
comment,
try { name,
const { error } = await supabase });
.from('petition_signatures') setIsSubmitting(false);
.insert([{ };
name: isAnonymous ? 'Anonymous' : formData.name.trim(),
email: formData.email.trim(), return (
comment: formData.comment.trim() || null, <form
}]); onSubmit={handleSubmit}
className={`space-y-4 ${compact ? "max-w-md" : "max-w-xl"} mx-auto`}
if (error) throw error; >
<div className="flex items-center space-x-2 mb-4 bg-muted/50 p-3 rounded-md">
toast.success("Thank you for signing! Your voice matters."); <Checkbox
setFormData({ name: "", email: "", comment: "" }); id="anonymous"
setIsAnonymous(false); checked={isAnonymous}
} catch (error) { onCheckedChange={(checked) => setIsAnonymous(checked as boolean)}
console.error('Error signing petition:', error); />
toast.error("Failed to submit signature. Please try again."); <Label
} finally { htmlFor="anonymous"
setIsSubmitting(false); className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
} >
}; Sign anonymously (only email required)
</Label>
return ( </div>
<form onSubmit={handleSubmit} className={`space-y-4 ${compact ? 'max-w-md' : 'max-w-xl'} mx-auto`}>
<div className="flex items-center space-x-2 mb-4 bg-muted/50 p-3 rounded-md"> {!isAnonymous && (
<Checkbox <div>
id="anonymous" <Input
checked={isAnonymous} placeholder="Your Name"
onCheckedChange={(checked) => setIsAnonymous(checked as boolean)} value={name ?? ""}
/> onChange={(e) => setUndefinedOrValue(setName, e.target.value)}
<Label required
htmlFor="anonymous" className="bg-background border-border"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer" />
> </div>
Sign anonymously (only email required) )}
</Label>
</div> <div>
<Input
{!isAnonymous && ( type="email"
<div> placeholder="Your Email"
<Input value={email}
placeholder="Your Name" onChange={(e) => setUndefinedOrValue(setEmail, e.target.value)}
value={formData.name} required
onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="bg-background border-border"
required />
className="bg-background border-border" </div>
/>
</div> <div>
)} <Textarea
placeholder="Why is Victoria Way Carpark important to you? (Optional)"
<div> value={comment ?? ""}
<Input onChange={(e) => setUndefinedOrValue(setComment, e.target.value)}
type="email" className="bg-background border-border min-h-24"
placeholder="Your Email" />
value={formData.email} </div>
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required {isAnonymous && (
className="bg-background border-border" <p className="text-sm text-muted-foreground">
/> Your signature will be recorded as "Anonymous" with your email for
</div> verification.
</p>
<div> )}
<Textarea
placeholder="Why is Victoria Way Carpark important to you? (Optional)" <Button
value={formData.comment} type="submit"
onChange={(e) => setFormData({ ...formData, comment: e.target.value })} size="lg"
className="bg-background border-border min-h-24" 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"
</div> >
{isSubmitting ? "Submitting..." : "Sign the Petition"}
{isAnonymous && ( </Button>
<p className="text-sm text-muted-foreground"> </form>
Your signature will be recorded as "Anonymous" with your email for verification. );
</p>
)}
<Button
type="submit"
size="lg"
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"
>
{isSubmitting ? "Submitting..." : "Sign the Petition"}
</Button>
</form>
);
}; };

View File

@ -1,17 +0,0 @@
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
}
});

View File

@ -1,198 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.5"
}
public: {
Tables: {
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: {
petition_signatures_public: {
Row: {
comment: string | null
created_at: string | null
id: string | null
name: string | null
}
Insert: {
comment?: string | null
created_at?: string | null
id?: string | null
name?: string | null
}
Update: {
comment?: string | null
created_at?: string | null
id?: string | null
name?: string | null
}
Relationships: []
}
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const

View File

@ -1,15 +1,18 @@
import { z } from "zod"; import { z } from "zod";
import { signedPetitionSchema, signPetitionSchema } from 'types'; import { signedPetitionSchema, signPetitionSchema } from "types";
const baseURL = import.meta.env.BASE_URL; const backendUrl = import.meta.env.VITE_BACKEND_URL;
export const signSignature = async (signature: z.infer<typeof signPetitionSchema>): Promise<z.infer<typeof signedPetitionSchema>> => { export const signPetition = async (
const res = await fetch(baseURL, { signature: z.infer<typeof signPetitionSchema>,
method: 'POST', body: JSON.stringify(signature), ): Promise<z.infer<typeof signedPetitionSchema>> => {
}) const res = await fetch(`${backendUrl}/sign-petition`, {
method: "POST",
body: JSON.stringify(signature),
});
const body = await res.json(); const body = await res.json();
const validatedBody = signedPetitionSchema.parse(body); const validatedBody = signedPetitionSchema.parse(body);
return validatedBody return validatedBody;
} };

View File

@ -11,407 +11,491 @@ import emptyBuildingParking from "@/assets/empty-building-parking.jpg";
import asahiBuilding from "@/assets/asahi-building.jpg"; import asahiBuilding from "@/assets/asahi-building.jpg";
import doubletreeHilton from "@/assets/doubletree-hilton.jpg"; import doubletreeHilton from "@/assets/doubletree-hilton.jpg";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react"; import { useState } from "react";
import { supabase } from "@/integrations/supabase/client";
const STATIC_TESTEMONIES = [
{
name: "Marne Keefe",
comment:
"My daughter & her Fiancé moved into Enterprise Place last October, they saved for a long time & one of the deciding factors on purchasing their first home was Victoria Way car park. My daughter has a degenerative back disorder & ME. Since having to park in the Peacocks car park & walk through Woking after work she has been jeered at, whistled at & narrowly avoided being kicked in the face by a youth to name just a few very upsetting situations she has been confronted with. She feels unsafe & anxious, it has become a major upset to her & her partner.",
date: "1 week ago",
},
{
name: "John Costa",
comment:
"I used to use Victoria Way carpark everyday. My wife would come home and park her car there as she drivers to work everyday. Now she has to walk through town centre every time she goes and comes from work. Sometimes at night. This is not safe, not is it what we signed up for when we purchased our flat in Enterprise Place.",
date: "1 week ago",
},
{
name: "Rio Keefe",
comment:
"I am a resident of Enterprise Place. I go to work early and come home late, it is not safe for me - a young woman - to be forced to walk through the town center and main roads in the dark every night to get to my home. Not to mention the toll it takes on me with my chronic illness. I have been shouted at, stared at and almost assaulted having to do this in the last seven months.",
date: "1 week ago",
},
];
const Index = () => { const Index = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [signatureCount, setSignatureCount] = useState(0); const [signatureCount, setSignatureCount] = useState(0);
useEffect(() => { const scrollToPetition = () => {
fetchSignatureCount(); document.getElementById("petition")?.scrollIntoView({ behavior: "smooth" });
};
// 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 () => { return (
supabase.removeChannel(channel); <div className="min-h-screen bg-background">
}; {/* Hero Section with Petition */}
}, []); <section className="relative min-h-[700px] flex items-center justify-center text-center overflow-hidden py-16">
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${carparkHero})` }}
/>
<div className="absolute inset-0 bg-gradient-to-r from-primary/90 to-primary/70" />
const fetchSignatureCount = async () => { <div className="relative z-10 max-w-4xl mx-auto px-6">
const { count } = await supabase <h1 className="text-5xl md:text-6xl font-bold text-primary-foreground mb-6">
.from('petition_signatures') Victoria Way Carpark: Woking Needs Parking Solutions
.select('*', { count: 'exact', head: true }); </h1>
<p className="text-xl md:text-2xl text-primary-foreground/90 mb-8">
setSignatureCount(count || 0); We support safety-first action, but this area of Woking needs
}; adequate parking and communication from the council
</p>
const testimonials = [ <div className="bg-card/95 backdrop-blur-sm rounded-lg p-8 shadow-[var(--shadow-elevated)] mb-6">
{ <div className="mb-6">
name: "Marne Keefe", <h2 className="text-3xl font-bold text-primary mb-2">
comment: "My daughter & her Fiancé moved into Enterprise Place last October, they saved for a long time & one of the deciding factors on purchasing their first home was Victoria Way car park. My daughter has a degenerative back disorder & ME. Since having to park in the Peacocks car park & walk through Woking after work she has been jeered at, whistled at & narrowly avoided being kicked in the face by a youth to name just a few very upsetting situations she has been confronted with. She feels unsafe & anxious, it has become a major upset to her & her partner.", Sign the Petition
date: "1 week ago", </h2>
}, <p className="text-lg text-muted-foreground">
{ {signatureCount > 0 && (
name: "John Costa", <span className="font-semibold text-accent">
comment: "I used to use Victoria Way carpark everyday. My wife would come home and park her car there as she drivers to work everyday. Now she has to walk through town centre every time she goes and comes from work. Sometimes at night. This is not safe, not is it what we signed up for when we purchased our flat in Enterprise Place.", {signatureCount} people
date: "1 week ago", </span>
}, )}
{ {signatureCount > 0 ? " have" : "Be the first to"} signed so far
name: "Rio Keefe", </p>
comment: "I am a resident of Enterprise Place. I go to work early and come home late, it is not safe for me - a young woman - to be forced to walk through the town center and main roads in the dark every night to get to my home. Not to mention the toll it takes on me with my chronic illness. I have been shouted at, stared at and almost assaulted having to do this in the last seven months.", </div>
date: "1 week ago", <PetitionForm compact />
}, </div>
]; </div>
</section>
const scrollToPetition = () => { {/* Impact Stats */}
document.getElementById("petition")?.scrollIntoView({ behavior: "smooth" }); <section className="py-16 px-6 bg-muted">
}; <div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard icon={Home} number="120+" label="Affected Households" />
<StatCard icon={Users} number="300+" label="Residents Impacted" />
<StatCard icon={Car} number="0" label="Parking Spaces Provided" />
<StatCard
icon={AlertTriangle}
number="0"
label="Council Updates Since Closure"
/>
</div>
</div>
</section>
return ( {/* Affected Area */}
<div className="min-h-screen bg-background"> <section className="py-16 px-6">
{/* Hero Section with Petition */} <div className="max-w-6xl mx-auto">
<section className="relative min-h-[700px] flex items-center justify-center text-center overflow-hidden py-16"> <h2 className="text-4xl font-bold text-primary mb-8 text-center">
<div The Affected Area
className="absolute inset-0 bg-cover bg-center" </h2>
style={{ backgroundImage: `url(${carparkHero})` }} <div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
/> <div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
<div className="absolute inset-0 bg-gradient-to-r from-primary/90 to-primary/70" /> <img
src={carparkHero}
<div className="relative z-10 max-w-4xl mx-auto px-6"> alt="Victoria Way Carpark"
<h1 className="text-5xl md:text-6xl font-bold text-primary-foreground mb-6"> className="w-full h-[300px] object-cover"
Victoria Way Carpark: Woking Needs Parking Solutions />
</h1> <div className="bg-card p-4 border-t border-border">
<p className="text-xl md:text-2xl text-primary-foreground/90 mb-8"> <h3 className="font-semibold text-lg text-primary">
We support safety-first action, but this area of Woking needs adequate parking and communication from the council Victoria Way Carpark
</p> </h3>
<p className="text-sm text-muted-foreground">
<div className="bg-card/95 backdrop-blur-sm rounded-lg p-8 shadow-[var(--shadow-elevated)] mb-6"> Closed due to safety concerns
<div className="mb-6"> </p>
<h2 className="text-3xl font-bold text-primary mb-2">Sign the Petition</h2> </div>
<p className="text-lg text-muted-foreground"> </div>
{signatureCount > 0 && <span className="font-semibold text-accent">{signatureCount} people</span>} <div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
{signatureCount > 0 ? ' have' : 'Be the first to'} signed so far <img
</p> src={enterprisePlace}
</div> alt="Enterprise Place"
<PetitionForm compact /> className="w-full h-[300px] object-cover"
</div> />
</div> <div className="bg-card p-4 border-t border-border">
</section> <h3 className="font-semibold text-lg text-primary">
Enterprise Place
</h3>
<p className="text-sm text-muted-foreground">
Residents and workers without parking
</p>
</div>
</div>
</div>
</div>
</section>
{/* Impact Stats */} {/* Problem Statement */}
<section className="py-16 px-6 bg-muted"> <section className="py-16 px-6 bg-muted">
<div className="max-w-6xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6"> <div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg mb-8">
<StatCard icon={Home} number="120+" label="Affected Households" /> <h3 className="text-xl font-semibold text-primary mb-2">
<StatCard icon={Users} number="300+" label="Residents Impacted" /> We Thank the Council for Prioritising Safety
<StatCard icon={Car} number="0" label="Parking Spaces Provided" /> </h3>
<StatCard icon={AlertTriangle} number="0" label="Council Updates Since Closure" /> <p className="text-foreground">
</div> We understand that Victoria Way Carpark was closed due to safety
</div> concerns, and we fully support putting public safety first. The
</section> council made the right decision to act on these concerns.
</p>
</div>
{/* Affected Area */} <h2 className="text-4xl font-bold text-primary mb-8 text-center">
<section className="py-16 px-6"> The Problems We Face Today
<div className="max-w-6xl mx-auto"> </h2>
<h2 className="text-4xl font-bold text-primary mb-8 text-center"> <div className="prose prose-lg max-w-none text-foreground space-y-6">
The Affected Area <p className="text-lg leading-relaxed">
</h2> However, since the closure, residents and workers in this area of
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12"> Woking have been left without communication or parking solutions.
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]"> We are asking the council to address three critical issues:
<img </p>
src={carparkHero} <div className="bg-card p-8 rounded-lg shadow-[var(--shadow-card)] border border-border">
alt="Victoria Way Carpark" <h3 className="text-2xl font-semibold text-primary mb-4">
className="w-full h-[300px] object-cover" What Woking Needs:
/> </h3>
<div className="bg-card p-4 border-t border-border"> <ul className="space-y-3 text-foreground">
<h3 className="font-semibold text-lg text-primary">Victoria Way Carpark</h3> <li className="flex items-start">
<p className="text-sm text-muted-foreground">Closed due to safety concerns</p> <span className="text-accent font-bold mr-3">1.</span>
</div> <span>
</div> <strong>Regular Updates:</strong> No information has been
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]"> provided on safety surveys, remediation progress, or future
<img plans for the site
src={enterprisePlace} </span>
alt="Enterprise Place" </li>
className="w-full h-[300px] object-cover" <li className="flex items-start">
/> <span className="text-accent font-bold mr-3">2.</span>
<div className="bg-card p-4 border-t border-border"> <span>
<h3 className="font-semibold text-lg text-primary">Enterprise Place</h3> <strong>Parking in This Area:</strong> This part of Woking
<p className="text-sm text-muted-foreground">Residents and workers without parking</p> needs adequate parking facilities for residents, workers,
</div> and visitors
</div> </span>
</div> </li>
</div> <li className="flex items-start">
</section> <span className="text-accent font-bold mr-3">3.</span>
<span>
<strong>Recognition of Impact:</strong> Parents, elderly
residents, disabled individuals, night workers, and local
businesses face daily hardship and safety risks
</span>
</li>
</ul>
</div>
<div className="bg-muted p-6 rounded-lg border border-border mt-6">
<p className="text-foreground font-medium">
<strong>This area deserves proper parking:</strong> Without
adequate parking facilities, families struggle with shopping,
elderly and disabled residents face accessibility challenges,
night shift workers worry about safety, and local businesses
lose customers.
</p>
</div>
</div>
</div>
</section>
{/* Problem Statement */} {/* Potential Solutions */}
<section className="py-16 px-6 bg-muted"> <section className="py-16 px-6">
<div className="max-w-4xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg mb-8"> <h2 className="text-4xl font-bold text-primary mb-8 text-center">
<h3 className="text-xl font-semibold text-primary mb-2">We Thank the Council for Prioritising Safety</h3> Potential Solutions for Woking Residents
<p className="text-foreground"> </h2>
We understand that Victoria Way Carpark was closed due to safety concerns, and we fully support putting public safety first.
The council made the right decision to act on these concerns.
</p>
</div>
<h2 className="text-4xl font-bold text-primary mb-8 text-center"> <div className="bg-accent/10 border-l-4 border-accent p-6 rounded-r-lg mb-8">
The Problems We Face Today <h3 className="text-xl font-semibold text-primary mb-2">
</h2> The Ideal Solution Already Exists
<div className="prose prose-lg max-w-none text-foreground space-y-6"> </h3>
<p className="text-lg leading-relaxed"> <p className="text-foreground">
However, since the closure, residents and workers in this area of Woking have been left without communication or parking solutions. The DoubleTree Hilton hotel, located directly in front of
We are asking the council to address three critical issues: Enterprise Place, has an underground car park that runs beneath
</p> Enterprise Place itself. This existing infrastructure could
<div className="bg-card p-8 rounded-lg shadow-[var(--shadow-card)] border border-border"> provide immediate relief to residents. Why don't Enterprise Place
<h3 className="text-2xl font-semibold text-primary mb-4">What Woking Needs:</h3> residents have access to parking that sits directly beneath their
<ul className="space-y-3 text-foreground"> homes?
<li className="flex items-start"> </p>
<span className="text-accent font-bold mr-3">1.</span> </div>
<span><strong>Regular Updates:</strong> No information has been provided on safety surveys, remediation progress, or future plans for the site</span>
</li>
<li className="flex items-start">
<span className="text-accent font-bold mr-3">2.</span>
<span><strong>Parking in This Area:</strong> This part of Woking needs adequate parking facilities for residents, workers, and visitors</span>
</li>
<li className="flex items-start">
<span className="text-accent font-bold mr-3">3.</span>
<span><strong>Recognition of Impact:</strong> Parents, elderly residents, disabled individuals, night workers, and local businesses face daily hardship and safety risks</span>
</li>
</ul>
</div>
<div className="bg-muted p-6 rounded-lg border border-border mt-6">
<p className="text-foreground font-medium">
<strong>This area deserves proper parking:</strong> Without adequate parking facilities, families struggle with shopping,
elderly and disabled residents face accessibility challenges, night shift workers worry about safety, and local businesses lose customers.
</p>
</div>
</div>
</div>
</section>
{/* Potential Solutions */} <div className="mb-12">
<section className="py-16 px-6"> <h3 className="text-2xl font-semibold text-primary mb-6">
<div className="max-w-6xl mx-auto"> Short-Term Solutions
<h2 className="text-4xl font-bold text-primary mb-8 text-center"> </h3>
Potential Solutions for Woking Residents <div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
</h2> <ul className="space-y-3 text-foreground">
<li className="flex items-start">
<span className="text-accent font-bold mr-3">•</span>
<span>
Negotiate with nearby facilities like Dukes Court and the
Asahi Building for shared parking arrangements
</span>
</li>
<li className="flex items-start">
<span className="text-accent font-bold mr-3">•</span>
<span>
Convert unused building sites in the area to temporary
parking facilities
</span>
</li>
<li className="flex items-start">
<span className="text-accent font-bold mr-3">•</span>
<span>
Utilize underutilized parking at empty buildings in the
vicinity
</span>
</li>
<li className="flex items-start">
<span className="text-accent font-bold mr-3">•</span>
<span>
Temporary parking permits for affected Enterprise Place
residents in nearby council-owned spaces
</span>
</li>
<li className="flex items-start">
<span className="text-accent font-bold mr-3">•</span>
<span>
Discounted rates at Victoria Place and other town centre
carparks for registered residents
</span>
</li>
</ul>
</div>
</div>
<div className="bg-accent/10 border-l-4 border-accent p-6 rounded-r-lg mb-8"> <div className="mb-12">
<h3 className="text-xl font-semibold text-primary mb-2">The Ideal Solution Already Exists</h3> <h3 className="text-2xl font-semibold text-primary mb-6">
<p className="text-foreground"> Long-Term Solutions
The DoubleTree Hilton hotel, located directly in front of Enterprise Place, has an underground car park that runs beneath Enterprise Place itself. </h3>
This existing infrastructure could provide immediate relief to residents. Why don't Enterprise Place residents have access to parking that sits directly beneath their homes? <div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
</p> <ul className="space-y-3 text-foreground">
</div> <li className="flex items-start">
<span className="text-accent font-bold mr-3">1.</span>
<div className="mb-12"> <span>
<h3 className="text-2xl font-semibold text-primary mb-6">Short-Term Solutions</h3> <strong>
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8"> Provide Enterprise Place residents access to the
<ul className="space-y-3 text-foreground"> DoubleTree Hilton underground car park
<li className="flex items-start"> </strong>{" "}
<span className="text-accent font-bold mr-3">•</span> - the infrastructure already exists beneath their building
<span>Negotiate with nearby facilities like Dukes Court and the Asahi Building for shared parking arrangements</span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-accent font-bold mr-3"></span> <span className="text-accent font-bold mr-3">2.</span>
<span>Convert unused building sites in the area to temporary parking facilities</span> <span>
</li> Repair and reopen Victoria Way Carpark with proper safety
<li className="flex items-start"> measures
<span className="text-accent font-bold mr-3">•</span> </span>
<span>Utilize underutilized parking at empty buildings in the vicinity</span> </li>
</li> <li className="flex items-start">
<li className="flex items-start"> <span className="text-accent font-bold mr-3">3.</span>
<span className="text-accent font-bold mr-3">•</span> <span>
<span>Temporary parking permits for affected Enterprise Place residents in nearby council-owned spaces</span> Build new multi-storey parking facility in this area of
</li> Woking
<li className="flex items-start"> </span>
<span className="text-accent font-bold mr-3">•</span> </li>
<span>Discounted rates at Victoria Place and other town centre carparks for registered residents</span> <li className="flex items-start">
</li> <span className="text-accent font-bold mr-3">4.</span>
</ul> <span>
</div> Dedicated resident parking zones with permit systems
</div> </span>
</li>
</ul>
</div>
</div>
<div className="mb-12"> <div className="mb-12">
<h3 className="text-2xl font-semibold text-primary mb-6">Long-Term Solutions</h3> <h3 className="text-2xl font-semibold text-primary mb-6">
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8"> Available Parking Infrastructure
<ul className="space-y-3 text-foreground"> </h3>
<li className="flex items-start"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<span className="text-accent font-bold mr-3">1.</span> <div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)] border-2 border-accent">
<span><strong>Provide Enterprise Place residents access to the DoubleTree Hilton underground car park</strong> - the infrastructure already exists beneath their building</span> <img
</li> src={doubletreeHilton}
<li className="flex items-start"> alt="DoubleTree Hilton with underground parking beneath Enterprise Place"
<span className="text-accent font-bold mr-3">2.</span> className="w-full h-[250px] object-cover"
<span>Repair and reopen Victoria Way Carpark with proper safety measures</span> />
</li> <div className="bg-accent/5 p-4 border-t-2 border-accent">
<li className="flex items-start"> <h3 className="font-semibold text-lg text-primary">
<span className="text-accent font-bold mr-3">3.</span> DoubleTree Hilton - The Ideal Solution
<span>Build new multi-storey parking facility in this area of Woking</span> </h3>
</li> <p className="text-sm text-foreground font-medium">
<li className="flex items-start"> Underground car park directly beneath Enterprise Place -
<span className="text-accent font-bold mr-3">4.</span> existing infrastructure that could solve the problem
<span>Dedicated resident parking zones with permit systems</span> immediately
</li> </p>
</ul> </div>
</div> </div>
</div>
<div className="mb-12"> <div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
<h3 className="text-2xl font-semibold text-primary mb-6">Available Parking Infrastructure</h3> <img
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> src={asahiBuilding}
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)] border-2 border-accent"> alt="Asahi Building with underground parking"
<img className="w-full h-[250px] object-cover"
src={doubletreeHilton} />
alt="DoubleTree Hilton with underground parking beneath Enterprise Place" <div className="bg-card p-4 border-t border-border">
className="w-full h-[250px] object-cover" <h3 className="font-semibold text-lg text-primary">
/> Asahi Building
<div className="bg-accent/5 p-4 border-t-2 border-accent"> </h3>
<h3 className="font-semibold text-lg text-primary">DoubleTree Hilton - The Ideal Solution</h3> <p className="text-sm text-muted-foreground">
<p className="text-sm text-foreground font-medium">Underground car park directly beneath Enterprise Place - existing infrastructure that could solve the problem immediately</p> Underground parking facility that could accommodate
</div> residents through negotiation
</div> </p>
</div>
</div>
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]"> <div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
<img <img
src={asahiBuilding} src={unusedSite}
alt="Asahi Building with underground parking" alt="Unused building site that could be converted to parking"
className="w-full h-[250px] object-cover" className="w-full h-[250px] object-cover"
/> />
<div className="bg-card p-4 border-t border-border"> <div className="bg-card p-4 border-t border-border">
<h3 className="font-semibold text-lg text-primary">Asahi Building</h3> <h3 className="font-semibold text-lg text-primary">
<p className="text-sm text-muted-foreground">Underground parking facility that could accommodate residents through negotiation</p> Unused Building Site
</div> </h3>
</div> <p className="text-sm text-muted-foreground">
This nearby site has been closed for a while and could be
temporarily converted to parking
</p>
</div>
</div>
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]"> <div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
<img <img
src={unusedSite} src={dukesCourt}
alt="Unused building site that could be converted to parking" alt="Dukes Court with car park facilities"
className="w-full h-[250px] object-cover" className="w-full h-[250px] object-cover"
/> />
<div className="bg-card p-4 border-t border-border"> <div className="bg-card p-4 border-t border-border">
<h3 className="font-semibold text-lg text-primary">Unused Building Site</h3> <h3 className="font-semibold text-lg text-primary">
<p className="text-sm text-muted-foreground">This nearby site has been closed for a while and could be temporarily converted to parking</p> Dukes Court
</div> </h3>
</div> <p className="text-sm text-muted-foreground">
Nearby facility with parking that could be part of the
solution
</p>
</div>
</div>
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]"> <div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
<img <img
src={dukesCourt} src={emptyBuildingParking}
alt="Dukes Court with car park facilities" alt="Empty building with available parking spaces"
className="w-full h-[250px] object-cover" className="w-full h-[250px] object-cover"
/> />
<div className="bg-card p-4 border-t border-border"> <div className="bg-card p-4 border-t border-border">
<h3 className="font-semibold text-lg text-primary">Dukes Court</h3> <h3 className="font-semibold text-lg text-primary">
<p className="text-sm text-muted-foreground">Nearby facility with parking that could be part of the solution</p> Underutilized Parking
</div> </h3>
</div> <p className="text-sm text-muted-foreground">
Empty building with existing parking infrastructure that
could serve residents
</p>
</div>
</div>
</div>
</div>
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]"> <div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg">
<img <h3 className="text-xl font-semibold text-primary mb-2">
src={emptyBuildingParking} We Need Communication
alt="Empty building with available parking spaces" </h3>
className="w-full h-[250px] object-cover" <p className="text-foreground">
/> The council hasn't shared any updates on remediation timelines,
<div className="bg-card p-4 border-t border-border"> safety surveys, or plans for alternative parking. Regular
<h3 className="font-semibold text-lg text-primary">Underutilized Parking</h3> communication would help residents plan and understand what
<p className="text-sm text-muted-foreground">Empty building with existing parking infrastructure that could serve residents</p> solutions are being considered for this area.
</div> </p>
</div> </div>
</div> </div>
</div> </section>
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg"> {/* Testimonials */}
<h3 className="text-xl font-semibold text-primary mb-2">We Need Communication</h3> <section className="py-16 px-6 bg-muted">
<p className="text-foreground"> <div className="max-w-6xl mx-auto">
The council hasn't shared any updates on remediation timelines, safety surveys, or plans for alternative parking. <h2 className="text-4xl font-bold text-primary mb-4 text-center">
Regular communication would help residents plan and understand what solutions are being considered for this area. Hear from Your Constituents
</p> </h2>
</div> <p className="text-xl text-muted-foreground mb-12 text-center">
</div> Real stories from real Woking residents affected by this decision
</section> </p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<TestimonialCard {...STATIC_TESTEMONIES[0]} />
<TestimonialCard {...STATIC_TESTEMONIES[1]} />
<TestimonialCard {...STATIC_TESTEMONIES[2]} />
</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>
{/* Testimonials */} {/* Additional Petition Section */}
<section className="py-16 px-6 bg-muted"> <section id="petition" className="py-16 px-6">
<div className="max-w-6xl mx-auto"> <div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl font-bold text-primary mb-4 text-center"> <h2 className="text-4xl font-bold text-primary mb-4">
Hear from Your Constituents Add Your Story
</h2> </h2>
<p className="text-xl text-muted-foreground mb-12 text-center"> <p className="text-xl text-muted-foreground mb-12">
Real stories from real Woking residents affected by this decision Share why this matters to you and add your full testimony to the
</p> petition
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> </p>
{testimonials.map((testimonial, index) => ( <PetitionForm />
<TestimonialCard key={index} {...testimonial} /> </div>
))} </section>
</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>
{/* Additional Petition Section */} {/* Call to Action */}
<section id="petition" className="py-16 px-6"> <section className="py-16 px-6 bg-gradient-to-r from-primary to-primary/90 text-primary-foreground">
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl font-bold text-primary mb-4"> <h2 className="text-3xl font-bold mb-4">Every Signature Counts</h2>
Add Your Story <p className="text-lg mb-8 text-primary-foreground/90">
</h2> Help us show Woking Council that this area deserves communication,
<p className="text-xl text-muted-foreground mb-12"> updates, and adequate parking facilities
Share why this matters to you and add your full testimony to the petition </p>
</p> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<PetitionForm /> <Button
</div> size="lg"
</section> onClick={scrollToPetition}
className="bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground font-semibold shadow-[var(--shadow-elevated)]"
>
Sign the Petition
</Button>
<Button
size="lg"
variant="outline"
className="bg-primary-foreground text-primary hover:bg-primary-foreground/90 border-primary-foreground font-semibold"
>
Share This Campaign
</Button>
</div>
</div>
</section>
{/* Call to Action */} {/* Footer */}
<section className="py-16 px-6 bg-gradient-to-r from-primary to-primary/90 text-primary-foreground"> <footer className="py-8 px-6 bg-card border-t border-border">
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-6xl mx-auto text-center text-muted-foreground">
<h2 className="text-3xl font-bold mb-4"> <p>
Every Signature Counts © 2025 Save Victoria Way Carpark Campaign | For the residents and
</h2> workers of Woking
<p className="text-lg mb-8 text-primary-foreground/90"> </p>
Help us show Woking Council that this area deserves communication, updates, and adequate parking facilities </div>
</p> </footer>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> </div>
<Button );
size="lg"
onClick={scrollToPetition}
className="bg-gradient-to-r from-accent to-accent/90 hover:from-accent/90 hover:to-accent/80 text-accent-foreground font-semibold shadow-[var(--shadow-elevated)]"
>
Sign the Petition
</Button>
<Button
size="lg"
variant="outline"
className="bg-primary-foreground text-primary hover:bg-primary-foreground/90 border-primary-foreground font-semibold"
>
Share This Campaign
</Button>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-8 px-6 bg-card border-t border-border">
<div className="max-w-6xl mx-auto text-center text-muted-foreground">
<p>© 2025 Save Victoria Way Carpark Campaign | For the residents and workers of Woking</p>
</div>
</footer>
</div>
);
}; };
export default Index; export default Index;

View File

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

View File

@ -0,0 +1,97 @@
import { signPetition } from "@/network";
import {
createContext,
ReactNode,
useCallback,
useContext,
useState,
} from "react";
import { toast } from "sonner";
import { signedPetitionSchema, signPetitionSchema } from "types";
import { z } from "zod";
// submitted is used to determine if the signature was inserted correctly
type SignatureWithState = z.infer<typeof signedPetitionSchema> & {
submitted: boolean;
};
type PetitionStateType = {
signatures: SignatureWithState[];
onSignPetition: (
petition: z.infer<typeof signPetitionSchema>,
) => Promise<void>;
};
const PetitionState = createContext<PetitionStateType>({
signatures: [],
onSignPetition: async () => {},
});
export const PetitionStateProvider = ({
children,
}: {
children: ReactNode;
}) => {
const [signatures, setSignatures] = useState<PetitionStateType["signatures"]>(
[],
);
const onSignPetition = useCallback<PetitionStateType["onSignPetition"]>(
async (signature) => {
const eagerPetitionId = Date.now().toString();
setSignatures((petitions) => [
{
id: eagerPetitionId,
createdAt: new Date().toISOString(),
submitted: false,
...signature,
},
...petitions,
]);
try {
await signPetition(signature);
setSignatures((petitions) => {
const newPetitionIndex = petitions.findIndex(
(p) => p.id === eagerPetitionId,
);
if (newPetitionIndex === -1) {
throw new Error(
`new inserted petition not found: id: ${eagerPetitionId}`,
);
}
petitions[newPetitionIndex].submitted = true;
return [...petitions];
});
toast.error(
"Sorry, had a problem inserting your signature. Please try again.",
);
} catch (err) {
console.warn(err);
setSignatures((petitions) =>
petitions.filter((p) => p.id === eagerPetitionId),
);
toast.error(
"Sorry, had a problem inserting your signature. Please try again.",
);
}
},
[],
);
return (
<PetitionState.Provider value={{ signatures: signatures, onSignPetition }}>
{children}
</PetitionState.Provider>
);
};
export const usePetitions = () => useContext(PetitionState);

View File

@ -5,14 +5,16 @@ import { componentTagger } from "lovable-tagger";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
server: { server: {
host: "::", host: "::",
port: 8080, port: 8080,
}, },
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), plugins: [react(), mode === "development" && componentTagger()].filter(
resolve: { Boolean,
alias: { ),
"@": path.resolve(__dirname, "./src"), resolve: {
}, alias: {
}, "@": path.resolve(__dirname, "./src"),
},
},
})); }));

View File

@ -1,11 +1,12 @@
import z from "zod"; import z from "zod";
export const signPetitionSchema = z.object({ export const signPetitionSchema = z.object({
email: z.string(), email: z.string(),
name: z.string().trim().min(1).max(30).nullable(), name: z.string().trim().min(1).max(30).nullable(),
comment: z.string().trim().min(10).max(10_000).nullable(), comment: z.string().trim().min(5).max(10_000).nullable(),
}) });
export const signedPetitionSchema = signPetitionSchema.extend({ export const signedPetitionSchema = signPetitionSchema.extend({
id: z.uuid(), id: z.uuid(),
}) createdAt: z.date().transform((date: Date) => date.toISOString()),
});