feat: actually running the server
This commit is contained in:
10
packages/backend/src/env/index.ts
vendored
10
packages/backend/src/env/index.ts
vendored
@ -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>;
|
||||||
|
|||||||
39
packages/backend/src/index.ts
Normal file
39
packages/backend/src/index.ts
Normal 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}`);
|
||||||
@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
console.log(validatedBody.error);
|
||||||
return Response.json({ error: validatedBody.error }, { status: 400 });
|
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 });
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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,11 +8,10 @@ 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 />
|
||||||
@ -29,7 +27,7 @@ const App = () => (
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</PetitionStateProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -4,77 +4,50 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setUndefinedOrValue = (
|
||||||
|
setter: (value: string | undefined) => void,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
if (value.length === 0) {
|
||||||
|
setter(undefined);
|
||||||
|
} else {
|
||||||
|
setter(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
|
export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
|
||||||
const [formData, setFormData] = useState({
|
const [name, setName] = useState<string | undefined>();
|
||||||
name: "",
|
const [comment, setComment] = useState<string | undefined>();
|
||||||
email: "",
|
const [email, setEmail] = useState<string | undefined>();
|
||||||
comment: "",
|
|
||||||
});
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isAnonymous, setIsAnonymous] = useState(false);
|
const [isAnonymous, setIsAnonymous] = useState(false);
|
||||||
|
|
||||||
|
const { onSignPetition } = usePetitions();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validate input based on mode
|
|
||||||
try {
|
|
||||||
if (isAnonymous) {
|
|
||||||
anonymousSchema.parse({ email: formData.email, comment: formData.comment });
|
|
||||||
} else {
|
|
||||||
petitionSchema.parse(formData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
toast.error(error.errors[0].message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
await onSignPetition({
|
||||||
try {
|
email,
|
||||||
const { error } = await supabase
|
comment,
|
||||||
.from('petition_signatures')
|
name,
|
||||||
.insert([{
|
});
|
||||||
name: isAnonymous ? 'Anonymous' : 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: "" });
|
|
||||||
setIsAnonymous(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error signing petition:', error);
|
|
||||||
toast.error("Failed to submit signature. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className={`space-y-4 ${compact ? 'max-w-md' : 'max-w-xl'} mx-auto`}>
|
<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">
|
<div className="flex items-center space-x-2 mb-4 bg-muted/50 p-3 rounded-md">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="anonymous"
|
id="anonymous"
|
||||||
@ -93,8 +66,8 @@ export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Your Name"
|
placeholder="Your Name"
|
||||||
value={formData.name}
|
value={name ?? ""}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setUndefinedOrValue(setName, e.target.value)}
|
||||||
required
|
required
|
||||||
className="bg-background border-border"
|
className="bg-background border-border"
|
||||||
/>
|
/>
|
||||||
@ -105,8 +78,8 @@ export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
|
|||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Your Email"
|
placeholder="Your Email"
|
||||||
value={formData.email}
|
value={email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setUndefinedOrValue(setEmail, e.target.value)}
|
||||||
required
|
required
|
||||||
className="bg-background border-border"
|
className="bg-background border-border"
|
||||||
/>
|
/>
|
||||||
@ -115,15 +88,16 @@ export const PetitionForm = ({ compact = false }: PetitionFormProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Why is Victoria Way Carpark important to you? (Optional)"
|
placeholder="Why is Victoria Way Carpark important to you? (Optional)"
|
||||||
value={formData.comment}
|
value={comment ?? ""}
|
||||||
onChange={(e) => setFormData({ ...formData, comment: e.target.value })}
|
onChange={(e) => setUndefinedOrValue(setComment, e.target.value)}
|
||||||
className="bg-background border-border min-h-24"
|
className="bg-background border-border min-h-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAnonymous && (
|
{isAnonymous && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Your signature will be recorded as "Anonymous" with your email for verification.
|
Your signature will be recorded as "Anonymous" with your email for
|
||||||
|
verification.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -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
|
|
||||||
@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@ -11,63 +11,33 @@ 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 Index = () => {
|
const STATIC_TESTEMONIES = [
|
||||||
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: "Marne Keefe",
|
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.",
|
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",
|
date: "1 week ago",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "John Costa",
|
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.",
|
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",
|
date: "1 week ago",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Rio Keefe",
|
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.",
|
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",
|
date: "1 week ago",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const Index = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [signatureCount, setSignatureCount] = useState(0);
|
||||||
|
|
||||||
const scrollToPetition = () => {
|
const scrollToPetition = () => {
|
||||||
document.getElementById("petition")?.scrollIntoView({ behavior: "smooth" });
|
document.getElementById("petition")?.scrollIntoView({ behavior: "smooth" });
|
||||||
};
|
};
|
||||||
@ -87,15 +57,22 @@ const Index = () => {
|
|||||||
Victoria Way Carpark: Woking Needs Parking Solutions
|
Victoria Way Carpark: Woking Needs Parking Solutions
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl md:text-2xl text-primary-foreground/90 mb-8">
|
<p className="text-xl md:text-2xl text-primary-foreground/90 mb-8">
|
||||||
We support safety-first action, but this area of Woking needs adequate parking and communication from the council
|
We support safety-first action, but this area of Woking needs
|
||||||
|
adequate parking and communication from the council
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-card/95 backdrop-blur-sm rounded-lg p-8 shadow-[var(--shadow-elevated)] mb-6">
|
<div className="bg-card/95 backdrop-blur-sm rounded-lg p-8 shadow-[var(--shadow-elevated)] mb-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-3xl font-bold text-primary mb-2">Sign the Petition</h2>
|
<h2 className="text-3xl font-bold text-primary mb-2">
|
||||||
|
Sign the Petition
|
||||||
|
</h2>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
{signatureCount > 0 && <span className="font-semibold text-accent">{signatureCount} people</span>}
|
{signatureCount > 0 && (
|
||||||
{signatureCount > 0 ? ' have' : 'Be the first to'} signed so far
|
<span className="font-semibold text-accent">
|
||||||
|
{signatureCount} people
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{signatureCount > 0 ? " have" : "Be the first to"} signed so far
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PetitionForm compact />
|
<PetitionForm compact />
|
||||||
@ -110,7 +87,11 @@ const Index = () => {
|
|||||||
<StatCard icon={Home} number="120+" label="Affected Households" />
|
<StatCard icon={Home} number="120+" label="Affected Households" />
|
||||||
<StatCard icon={Users} number="300+" label="Residents Impacted" />
|
<StatCard icon={Users} number="300+" label="Residents Impacted" />
|
||||||
<StatCard icon={Car} number="0" label="Parking Spaces Provided" />
|
<StatCard icon={Car} number="0" label="Parking Spaces Provided" />
|
||||||
<StatCard icon={AlertTriangle} number="0" label="Council Updates Since Closure" />
|
<StatCard
|
||||||
|
icon={AlertTriangle}
|
||||||
|
number="0"
|
||||||
|
label="Council Updates Since Closure"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -129,8 +110,12 @@ const Index = () => {
|
|||||||
className="w-full h-[300px] object-cover"
|
className="w-full h-[300px] 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">Victoria Way Carpark</h3>
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
<p className="text-sm text-muted-foreground">Closed due to safety concerns</p>
|
Victoria Way Carpark
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Closed due to safety concerns
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)]">
|
||||||
@ -140,8 +125,12 @@ const Index = () => {
|
|||||||
className="w-full h-[300px] object-cover"
|
className="w-full h-[300px] 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">Enterprise Place</h3>
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
<p className="text-sm text-muted-foreground">Residents and workers without parking</p>
|
Enterprise Place
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Residents and workers without parking
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -152,10 +141,13 @@ const Index = () => {
|
|||||||
<section className="py-16 px-6 bg-muted">
|
<section className="py-16 px-6 bg-muted">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg mb-8">
|
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg mb-8">
|
||||||
<h3 className="text-xl font-semibold text-primary mb-2">We Thank the Council for Prioritising Safety</h3>
|
<h3 className="text-xl font-semibold text-primary mb-2">
|
||||||
|
We Thank the Council for Prioritising Safety
|
||||||
|
</h3>
|
||||||
<p className="text-foreground">
|
<p className="text-foreground">
|
||||||
We understand that Victoria Way Carpark was closed due to safety concerns, and we fully support putting public safety first.
|
We understand that Victoria Way Carpark was closed due to safety
|
||||||
The council made the right decision to act on these concerns.
|
concerns, and we fully support putting public safety first. The
|
||||||
|
council made the right decision to act on these concerns.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -164,30 +156,48 @@ const Index = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-lg max-w-none text-foreground space-y-6">
|
<div className="prose prose-lg max-w-none text-foreground space-y-6">
|
||||||
<p className="text-lg leading-relaxed">
|
<p className="text-lg leading-relaxed">
|
||||||
However, since the closure, residents and workers in this area of Woking have been left without communication or parking solutions.
|
However, since the closure, residents and workers in this area of
|
||||||
|
Woking have been left without communication or parking solutions.
|
||||||
We are asking the council to address three critical issues:
|
We are asking the council to address three critical issues:
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-card p-8 rounded-lg shadow-[var(--shadow-card)] border border-border">
|
<div className="bg-card p-8 rounded-lg shadow-[var(--shadow-card)] border border-border">
|
||||||
<h3 className="text-2xl font-semibold text-primary mb-4">What Woking Needs:</h3>
|
<h3 className="text-2xl font-semibold text-primary mb-4">
|
||||||
|
What Woking Needs:
|
||||||
|
</h3>
|
||||||
<ul className="space-y-3 text-foreground">
|
<ul className="space-y-3 text-foreground">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-accent font-bold mr-3">1.</span>
|
<span className="text-accent font-bold mr-3">1.</span>
|
||||||
<span><strong>Regular Updates:</strong> No information has been provided on safety surveys, remediation progress, or future plans for the site</span>
|
<span>
|
||||||
|
<strong>Regular Updates:</strong> No information has been
|
||||||
|
provided on safety surveys, remediation progress, or future
|
||||||
|
plans for the site
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-accent font-bold mr-3">2.</span>
|
<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>
|
<span>
|
||||||
|
<strong>Parking in This Area:</strong> This part of Woking
|
||||||
|
needs adequate parking facilities for residents, workers,
|
||||||
|
and visitors
|
||||||
|
</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">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>
|
<span>
|
||||||
|
<strong>Recognition of Impact:</strong> Parents, elderly
|
||||||
|
residents, disabled individuals, night workers, and local
|
||||||
|
businesses face daily hardship and safety risks
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted p-6 rounded-lg border border-border mt-6">
|
<div className="bg-muted p-6 rounded-lg border border-border mt-6">
|
||||||
<p className="text-foreground font-medium">
|
<p className="text-foreground font-medium">
|
||||||
<strong>This area deserves proper parking:</strong> Without adequate parking facilities, families struggle with shopping,
|
<strong>This area deserves proper parking:</strong> Without
|
||||||
elderly and disabled residents face accessibility challenges, night shift workers worry about safety, and local businesses lose customers.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -202,67 +212,108 @@ const Index = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="bg-accent/10 border-l-4 border-accent p-6 rounded-r-lg mb-8">
|
<div className="bg-accent/10 border-l-4 border-accent p-6 rounded-r-lg mb-8">
|
||||||
<h3 className="text-xl font-semibold text-primary mb-2">The Ideal Solution Already Exists</h3>
|
<h3 className="text-xl font-semibold text-primary mb-2">
|
||||||
|
The Ideal Solution Already Exists
|
||||||
|
</h3>
|
||||||
<p className="text-foreground">
|
<p className="text-foreground">
|
||||||
The DoubleTree Hilton hotel, located directly in front of Enterprise Place, has an underground car park that runs beneath Enterprise Place itself.
|
The DoubleTree Hilton hotel, located directly in front of
|
||||||
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?
|
Enterprise Place, has an underground car park that runs beneath
|
||||||
|
Enterprise Place itself. 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?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h3 className="text-2xl font-semibold text-primary mb-6">Short-Term Solutions</h3>
|
<h3 className="text-2xl font-semibold text-primary mb-6">
|
||||||
|
Short-Term Solutions
|
||||||
|
</h3>
|
||||||
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
||||||
<ul className="space-y-3 text-foreground">
|
<ul className="space-y-3 text-foreground">
|
||||||
<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">•</span>
|
||||||
<span>Negotiate with nearby facilities like Dukes Court and the Asahi Building for shared parking arrangements</span>
|
<span>
|
||||||
|
Negotiate with nearby facilities like Dukes Court and the
|
||||||
|
Asahi Building for shared parking arrangements
|
||||||
|
</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">•</span>
|
||||||
<span>Convert unused building sites in the area to temporary parking facilities</span>
|
<span>
|
||||||
|
Convert unused building sites in the area to temporary
|
||||||
|
parking facilities
|
||||||
|
</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">•</span>
|
||||||
<span>Utilize underutilized parking at empty buildings in the vicinity</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">•</span>
|
<span className="text-accent font-bold mr-3">•</span>
|
||||||
<span>Temporary parking permits for affected Enterprise Place residents in nearby council-owned spaces</span>
|
<span>
|
||||||
|
Temporary parking permits for affected Enterprise Place
|
||||||
|
residents in nearby council-owned spaces
|
||||||
|
</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">•</span>
|
||||||
<span>Discounted rates at Victoria Place and other town centre carparks for registered residents</span>
|
<span>
|
||||||
|
Discounted rates at Victoria Place and other town centre
|
||||||
|
carparks for registered residents
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
|
Long-Term Solutions
|
||||||
|
</h3>
|
||||||
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
<div className="bg-card p-6 rounded-lg shadow-[var(--shadow-card)] border border-border mb-8">
|
||||||
<ul className="space-y-3 text-foreground">
|
<ul className="space-y-3 text-foreground">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-accent font-bold mr-3">1.</span>
|
<span className="text-accent font-bold mr-3">1.</span>
|
||||||
<span><strong>Provide Enterprise Place residents access to the DoubleTree Hilton underground car park</strong> - the infrastructure already exists beneath their building</span>
|
<span>
|
||||||
|
<strong>
|
||||||
|
Provide Enterprise Place residents access to the
|
||||||
|
DoubleTree Hilton underground car park
|
||||||
|
</strong>{" "}
|
||||||
|
- the infrastructure already exists beneath their building
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-accent font-bold mr-3">2.</span>
|
<span className="text-accent font-bold mr-3">2.</span>
|
||||||
<span>Repair and reopen Victoria Way Carpark with proper safety measures</span>
|
<span>
|
||||||
|
Repair and reopen Victoria Way Carpark with proper safety
|
||||||
|
measures
|
||||||
|
</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">3.</span>
|
||||||
<span>Build new multi-storey parking facility in this area of Woking</span>
|
<span>
|
||||||
|
Build new multi-storey parking facility in this area of
|
||||||
|
Woking
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-accent font-bold mr-3">4.</span>
|
<span className="text-accent font-bold mr-3">4.</span>
|
||||||
<span>Dedicated resident parking zones with permit systems</span>
|
<span>
|
||||||
|
Dedicated resident parking zones with permit systems
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h3 className="text-2xl font-semibold text-primary mb-6">Available Parking Infrastructure</h3>
|
<h3 className="text-2xl font-semibold text-primary mb-6">
|
||||||
|
Available Parking Infrastructure
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)] border-2 border-accent">
|
<div className="rounded-lg overflow-hidden shadow-[var(--shadow-card)] border-2 border-accent">
|
||||||
<img
|
<img
|
||||||
@ -271,8 +322,14 @@ const Index = () => {
|
|||||||
className="w-full h-[250px] object-cover"
|
className="w-full h-[250px] object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="bg-accent/5 p-4 border-t-2 border-accent">
|
<div className="bg-accent/5 p-4 border-t-2 border-accent">
|
||||||
<h3 className="font-semibold text-lg text-primary">DoubleTree Hilton - The Ideal Solution</h3>
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
<p className="text-sm text-foreground font-medium">Underground car park directly beneath Enterprise Place - existing infrastructure that could solve the problem immediately</p>
|
DoubleTree Hilton - The Ideal Solution
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-foreground font-medium">
|
||||||
|
Underground car park directly beneath Enterprise Place -
|
||||||
|
existing infrastructure that could solve the problem
|
||||||
|
immediately
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -283,8 +340,13 @@ const Index = () => {
|
|||||||
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>
|
Asahi Building
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Underground parking facility that could accommodate
|
||||||
|
residents through negotiation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -295,8 +357,13 @@ const Index = () => {
|
|||||||
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>
|
Unused Building Site
|
||||||
|
</h3>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
@ -307,8 +374,13 @@ const Index = () => {
|
|||||||
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>
|
Dukes Court
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nearby facility with parking that could be part of the
|
||||||
|
solution
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -319,18 +391,27 @@ const Index = () => {
|
|||||||
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">Underutilized Parking</h3>
|
<h3 className="font-semibold text-lg text-primary">
|
||||||
<p className="text-sm text-muted-foreground">Empty building with existing parking infrastructure that could serve residents</p>
|
Underutilized Parking
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Empty building with existing parking infrastructure that
|
||||||
|
could serve residents
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg">
|
<div className="bg-secondary/10 border-l-4 border-secondary p-6 rounded-r-lg">
|
||||||
<h3 className="text-xl font-semibold text-primary mb-2">We Need Communication</h3>
|
<h3 className="text-xl font-semibold text-primary mb-2">
|
||||||
|
We Need Communication
|
||||||
|
</h3>
|
||||||
<p className="text-foreground">
|
<p className="text-foreground">
|
||||||
The council hasn't shared any updates on remediation timelines, safety surveys, or plans for alternative parking.
|
The council hasn't shared any updates on remediation timelines,
|
||||||
Regular communication would help residents plan and understand what solutions are being considered for this area.
|
safety surveys, or plans for alternative parking. Regular
|
||||||
|
communication would help residents plan and understand what
|
||||||
|
solutions are being considered for this area.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -346,14 +427,14 @@ const Index = () => {
|
|||||||
Real stories from real Woking residents affected by this decision
|
Real stories from real Woking residents affected by this decision
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
{testimonials.map((testimonial, index) => (
|
<TestimonialCard {...STATIC_TESTEMONIES[0]} />
|
||||||
<TestimonialCard key={index} {...testimonial} />
|
<TestimonialCard {...STATIC_TESTEMONIES[1]} />
|
||||||
))}
|
<TestimonialCard {...STATIC_TESTEMONIES[2]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={() => navigate('/testimonies')}
|
onClick={() => navigate("/testimonies")}
|
||||||
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
|
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-5 h-5" />
|
<MessageSquare className="w-5 h-5" />
|
||||||
@ -370,7 +451,8 @@ const Index = () => {
|
|||||||
Add Your Story
|
Add Your Story
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-muted-foreground mb-12">
|
<p className="text-xl text-muted-foreground mb-12">
|
||||||
Share why this matters to you and add your full testimony to the petition
|
Share why this matters to you and add your full testimony to the
|
||||||
|
petition
|
||||||
</p>
|
</p>
|
||||||
<PetitionForm />
|
<PetitionForm />
|
||||||
</div>
|
</div>
|
||||||
@ -379,11 +461,10 @@ const Index = () => {
|
|||||||
{/* Call to Action */}
|
{/* Call to Action */}
|
||||||
<section className="py-16 px-6 bg-gradient-to-r from-primary to-primary/90 text-primary-foreground">
|
<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-3xl font-bold mb-4">
|
<h2 className="text-3xl font-bold mb-4">Every Signature Counts</h2>
|
||||||
Every Signature Counts
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-8 text-primary-foreground/90">
|
<p className="text-lg mb-8 text-primary-foreground/90">
|
||||||
Help us show Woking Council that this area deserves communication, updates, and adequate parking facilities
|
Help us show Woking Council that this area deserves communication,
|
||||||
|
updates, and adequate parking facilities
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<Button
|
<Button
|
||||||
@ -407,7 +488,10 @@ const Index = () => {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="py-8 px-6 bg-card border-t border-border">
|
<footer className="py-8 px-6 bg-card border-t border-border">
|
||||||
<div className="max-w-6xl mx-auto text-center text-muted-foreground">
|
<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>
|
<p>
|
||||||
|
© 2025 Save Victoria Way Carpark Campaign | For the residents and
|
||||||
|
workers of Woking
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
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;
|
||||||
@ -16,60 +15,8 @@ interface Signature {
|
|||||||
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(() => {
|
|
||||||
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -77,7 +24,7 @@ const Testimonies = () => {
|
|||||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate("/")}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@ -97,36 +44,32 @@ const Testimonies = () => {
|
|||||||
Community Testimonies
|
Community Testimonies
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-muted-foreground">
|
<p className="text-xl text-muted-foreground">
|
||||||
Real stories from residents affected by the closure of Victoria Way Carpark
|
Real stories from residents affected by the closure of Victoria Way
|
||||||
|
Carpark
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{signatures.map((signature) => (
|
{signatures.map((signature) => (
|
||||||
<TestimonialCard
|
<TestimonialCard
|
||||||
key={signature.id}
|
key={signature.id}
|
||||||
name={signature.name}
|
name={signature.name}
|
||||||
comment={signature.comment || ""}
|
comment={signature.comment || ""}
|
||||||
date={formatDistanceToNow(new Date(signature.created_at), { addSuffix: true })}
|
date={formatDistanceToNow(new Date(signature.created_at), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="py-8 px-6 bg-card border-t border-border mt-12">
|
<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">
|
<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>
|
<p>
|
||||||
|
© 2025 Save Victoria Way Carpark Campaign | For the residents of
|
||||||
|
Enterprise Place, Woking
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
97
packages/frontend/src/state/index.tsx
Normal file
97
packages/frontend/src/state/index.tsx
Normal 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);
|
||||||
@ -9,7 +9,9 @@ export default defineConfig(({ mode }) => ({
|
|||||||
host: "::",
|
host: "::",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
},
|
},
|
||||||
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
|
plugins: [react(), mode === "development" && componentTagger()].filter(
|
||||||
|
Boolean,
|
||||||
|
),
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|||||||
@ -3,9 +3,10 @@ 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()),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user