diff --git a/app/dashboard/[planId]/_components/plan-dashboard.tsx b/app/[lang]/dashboard/[planId]/_components/plan-dashboard.tsx similarity index 85% rename from app/dashboard/[planId]/_components/plan-dashboard.tsx rename to app/[lang]/dashboard/[planId]/_components/plan-dashboard.tsx index 4174a6a..d0495d9 100644 --- a/app/dashboard/[planId]/_components/plan-dashboard.tsx +++ b/app/[lang]/dashboard/[planId]/_components/plan-dashboard.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react" import { format, eachDayOfInterval, isSameDay } from "date-fns" +import { de, enUS } from "date-fns/locale" import { CalendarIcon, User, Home, X, Info } from "lucide-react" import { toast } from "sonner" @@ -38,13 +39,22 @@ type Plan = { bookings: Booking[] } -export function PlanDashboard({ plan }: { plan: Plan }) { +interface PlanDashboardProps { + plan: Plan; + dict: any; + settingsDict: any; + lang: string; +} + +export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardProps) { const [selectedDate, setSelectedDate] = useState(null) const [sitterName, setSitterName] = useState("") const [bookingType, setBookingType] = useState<"SITTER" | "OWNER_HOME">("SITTER") const [isDialogOpen, setIsDialogOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) + const dateLocale = lang === "de" ? de : enUS + // Load saved name from localStorage useEffect(() => { const savedName = localStorage.getItem("sitter_name") @@ -60,38 +70,36 @@ export function PlanDashboard({ plan }: { plan: Plan }) { const handleBook = async () => { if (!selectedDate) return if (bookingType === "SITTER" && !sitterName.trim()) { - toast.error("Please enter your name") + toast.error(dict.namePlaceholder) // Could use a more specific key if needed return } setIsSubmitting(true) try { - await createBooking(plan.id, selectedDate, bookingType === "SITTER" ? sitterName : "Owner", bookingType) + await createBooking(plan.id, selectedDate, bookingType === "SITTER" ? sitterName : "Owner", bookingType, lang) // Save name to localStorage if it's a sitter booking if (bookingType === "SITTER") { localStorage.setItem("sitter_name", sitterName) } - toast.success("Spot booked!") + toast.success(dict.bookedSuccess) setIsDialogOpen(false) - // We keep the sitterName in state for the next booking } catch (error) { - toast.error("Failed to book spot. Maybe it was just taken?") + toast.error(dict.bookError) } finally { setIsSubmitting(false) } } const handleCancel = async (bookingId: number) => { - // Optimistic UI could stay here, but relying on revalidatePath is safer for simple apps - if (!confirm("Are you sure you want to remove this entry?")) return + if (!confirm(dict.cancelConfirm)) return try { - await deleteBooking(bookingId, plan.id) - toast.success("Entry removed") + await deleteBooking(bookingId, plan.id, lang) + toast.success(dict.cancelSuccess) } catch { - toast.error("Failed to remove entry") + toast.error(dict.cancelError) } } @@ -99,18 +107,18 @@ export function PlanDashboard({ plan }: { plan: Plan }) {
-

Overview

+

{dict.overview}

{plan.instructions && ( - Cat Care Instructions + {dict.instructionsTitle}
{plan.instructions}
@@ -121,7 +129,7 @@ export function PlanDashboard({ plan }: { plan: Plan }) {
@@ -151,7 +161,7 @@ export function PlanDashboard({ plan }: { plan: Plan }) {
- {format(day, "EEEE, MMMM do")} + {format(day, "PPPP", { locale: dateLocale })}
{booking && ( + - Book {format(day, "MMMM do")} + {dict.bookTitle.replace("{date}", format(day, "PPP", { locale: dateLocale }))} - Who is taking care of the cats? + {dict.bookDesc} @@ -201,22 +211,22 @@ export function PlanDashboard({ plan }: { plan: Plan }) { setBookingType(v)} className="flex gap-4">
- +
- +
{bookingType === "SITTER" && (
- + setSitterName(e.target.value)} - placeholder="Your Name" + placeholder={dict.namePlaceholder} autoFocus />
@@ -225,7 +235,7 @@ export function PlanDashboard({ plan }: { plan: Plan }) {
diff --git a/app/dashboard/[planId]/page.tsx b/app/[lang]/dashboard/[planId]/page.tsx similarity index 65% rename from app/dashboard/[planId]/page.tsx rename to app/[lang]/dashboard/[planId]/page.tsx index c3065b1..1d9962a 100644 --- a/app/dashboard/[planId]/page.tsx +++ b/app/[lang]/dashboard/[planId]/page.tsx @@ -2,11 +2,17 @@ import { notFound } from "next/navigation" import { cookies } from "next/headers" import prisma from "@/lib/prisma" import { PlanLoginForm } from "@/components/plan-login-form" -import { Toaster } from "@/components/ui/sonner" import { PlanDashboard } from "./_components/plan-dashboard" +import { getDictionary } from "@/get-dictionary" + +export default async function DashboardPage({ + params +}: { + params: Promise<{ planId: string, lang: string }> +}) { + const { planId, lang } = await params + const dict = await getDictionary(lang as any) -export default async function DashboardPage({ params }: { params: Promise<{ planId: string }> }) { - const { planId } = await params const plan = await prisma.plan.findUnique({ where: { id: planId }, include: { bookings: true }, @@ -22,7 +28,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ plan if (!isAuthenticated) { return (
- +
) } @@ -32,18 +38,18 @@ export default async function DashboardPage({ params }: { params: Promise<{ plan
-

Cat Sitting Plan

+

{dict.home.title}

- {plan.startDate.toLocaleDateString()} - {plan.endDate.toLocaleDateString()} + {plan.startDate.toLocaleDateString(lang)} - {plan.endDate.toLocaleDateString(lang)}

- Group ID: {plan.id} + Plan ID: {plan.id}
- +
diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx new file mode 100644 index 0000000..1814974 --- /dev/null +++ b/app/[lang]/layout.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "../globals.css"; +import { Toaster } from "@/components/ui/sonner"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +import { getDictionary } from "@/get-dictionary"; + +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise { + const { lang } = await params; + const dict = await getDictionary(lang as any); + + return { + title: dict.home.title, + description: dict.home.description, + }; +} + +export async function generateStaticParams() { + return [{ lang: "en" }, { lang: "de" }]; +} + +export default async function RootLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + + return ( + + + {children} + + + + ); +} diff --git a/app/page.tsx b/app/[lang]/page.tsx similarity index 67% rename from app/page.tsx rename to app/[lang]/page.tsx index 8426b2f..065cff7 100644 --- a/app/page.tsx +++ b/app/[lang]/page.tsx @@ -1,8 +1,16 @@ import Image from "next/image"; import { CreatePlanForm } from "@/components/create-plan-form"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { getDictionary } from "@/get-dictionary"; + +export default async function Home({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const dict = await getDictionary(lang as any); -export default function Home() { return (
@@ -19,22 +27,21 @@ export default function Home() {
-

Cat Sitting Planner

-

Coordinate care for your furry friends while you're away.

+

{dict.home.title}

+

{dict.home.description}

- Create a New Plan - Select your travel dates and set a group password. + {dict.home.createPlan} + {dict.home.createPlanDesc} - + ); } - diff --git a/app/actions/booking.ts b/app/actions/booking.ts index c30ad39..736b124 100644 --- a/app/actions/booking.ts +++ b/app/actions/booking.ts @@ -4,8 +4,11 @@ import prisma from "@/lib/prisma" import { revalidatePath } from "next/cache" import { headers } from "next/headers" import { sendNotification } from "@/lib/notifications" +import { getDictionary } from "@/get-dictionary" + +export async function createBooking(planId: string, date: Date, name: string, type: "SITTER" | "OWNER_HOME" = "SITTER", lang: string = "en") { + const dict = await getDictionary(lang as any) -export async function createBooking(planId: string, date: Date, name: string, type: "SITTER" | "OWNER_HOME" = "SITTER") { // Simple check to ensure no double booking on server side const existing = await prisma.booking.findFirst({ where: { @@ -34,19 +37,22 @@ export async function createBooking(planId: string, date: Date, name: string, ty if (plan?.webhookUrl && plan.notifyAll) { const host = (await headers()).get("host") const protocol = host?.includes("localhost") ? "http" : "https" - const planUrl = `${protocol}://${host}/dashboard/${planId}` + const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}` - const dateStr = date.toLocaleDateString() + const dateStr = date.toLocaleDateString(lang) const message = type === "OWNER_HOME" - ? `🏠 OWNER HOME: Marked for ${dateStr}.\nPlan: ${planUrl}` - : `✅ NEW BOOKING: ${name} is sitting on ${dateStr}.\nPlan: ${planUrl}` + ? dict.notifications.ownerHome.replace("{date}", dateStr).replace("{url}", planUrl) + : dict.notifications.newBooking.replace("{name}", name).replace("{date}", dateStr).replace("{url}", planUrl) + await sendNotification(plan.webhookUrl, message) } - revalidatePath(`/dashboard/${planId}`) + revalidatePath(`/${lang}/dashboard/${planId}`) } -export async function deleteBooking(bookingId: number, planId: string) { +export async function deleteBooking(bookingId: number, planId: string, lang: string = "en") { + const dict = await getDictionary(lang as any) + const booking = await prisma.booking.findUnique({ where: { id: bookingId }, include: { plan: true } @@ -61,11 +67,16 @@ export async function deleteBooking(bookingId: number, planId: string) { if (booking.plan.webhookUrl) { const host = (await headers()).get("host") const protocol = host?.includes("localhost") ? "http" : "https" - const planUrl = `${protocol}://${host}/dashboard/${planId}` + const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}` - const dateStr = booking.date.toLocaleDateString() - await sendNotification(booking.plan.webhookUrl, `🚨 CANCELLATION: ${booking.sitterName} removed their booking for ${dateStr}.\nPlan: ${planUrl}`) + const dateStr = booking.date.toLocaleDateString(lang) + const message = dict.notifications.cancellation + .replace("{name}", booking.sitterName || "Someone") + .replace("{date}", dateStr) + .replace("{url}", planUrl) + + await sendNotification(booking.plan.webhookUrl, message) } - revalidatePath(`/dashboard/${planId}`) + revalidatePath(`/${lang}/dashboard/${planId}`) } diff --git a/app/actions/plan.ts b/app/actions/plan.ts index 6a77eea..10a8879 100644 --- a/app/actions/plan.ts +++ b/app/actions/plan.ts @@ -4,8 +4,15 @@ import prisma from "@/lib/prisma" import { revalidatePath } from "next/cache" import { headers } from "next/headers" import { sendNotification } from "@/lib/notifications" +import { getDictionary } from "@/get-dictionary" + +export async function updatePlan( + planId: string, + data: { instructions?: string; webhookUrl?: string; notifyAll?: boolean }, + lang: string = "en" +) { + const dict = await getDictionary(lang as any) -export async function updatePlan(planId: string, data: { instructions?: string; webhookUrl?: string; notifyAll?: boolean }) { const plan = await prisma.plan.update({ where: { id: planId }, data: { @@ -18,9 +25,13 @@ export async function updatePlan(planId: string, data: { instructions?: string; if (data.instructions && plan.webhookUrl && plan.notifyAll) { const host = (await headers()).get("host") const protocol = host?.includes("localhost") ? "http" : "https" - const planUrl = `${protocol}://${host}/dashboard/${planId}` - await sendNotification(plan.webhookUrl, `📝 UPDATED: Cat instructions have been modified.\nPlan: ${planUrl}`) + const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}` + + await sendNotification( + plan.webhookUrl, + dict.notifications.instructionsUpdated.replace("{url}", planUrl) + ) } - revalidatePath(`/dashboard/${planId}`) + revalidatePath(`/${lang}/dashboard/${planId}`) } diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index fd1690b..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; -import { Toaster } from "@/components/ui/sonner"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata: Metadata = { - title: "Cat Sitting Planner", - description: "Simple and collaborative cat sitting coordination for your neighborhood.", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - - ); -} diff --git a/components/create-plan-form.tsx b/components/create-plan-form.tsx index 59459be..eec0665 100644 --- a/components/create-plan-form.tsx +++ b/components/create-plan-form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" +import { de, enUS } from "date-fns/locale" import { CalendarIcon } from "lucide-react" import { useForm } from "react-hook-form" import { z } from "zod" @@ -23,19 +24,25 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { toast } from "sonner" import { useRouter } from "next/navigation" -const formSchema = z.object({ - dateRange: z.object({ - from: z.date(), - to: z.date(), - }), - password: z.string().min(4, { - message: "Password must be at least 4 characters.", - }), - instructions: z.string().optional(), -}) +interface CreatePlanFormProps { + dict: any; + lang: string; +} -export function CreatePlanForm() { +export function CreatePlanForm({ dict, lang }: CreatePlanFormProps) { const router = useRouter() + + const formSchema = z.object({ + dateRange: z.object({ + from: z.date(), + to: z.date(), + }), + password: z.string().min(4, { + message: dict.passwordError, + }), + instructions: z.string().optional(), + }) + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -44,6 +51,8 @@ export function CreatePlanForm() { }, }) + const dateLocale = lang === "de" ? de : enUS + async function onSubmit(values: z.infer) { try { const response = await fetch("/api/plan", { @@ -62,10 +71,10 @@ export function CreatePlanForm() { } const data = await response.json() - toast.success("Plan created successfully!") - router.push(`/dashboard/${data.planId}`) + toast.success(dict.success) + router.push(`/${lang}/dashboard/${data.planId}`) } catch (error) { - toast.error("Something went wrong. Please try again.") + toast.error(dict.error) console.error(error) } } @@ -78,7 +87,7 @@ export function CreatePlanForm() { name="dateRange" render={({ field }) => ( - Travel Dates + {dict.travelDates} @@ -92,14 +101,14 @@ export function CreatePlanForm() { {field.value?.from ? ( field.value.to ? ( <> - {format(field.value.from, "LLL dd, y")} -{" "} - {format(field.value.to, "LLL dd, y")} + {format(field.value.from, "PPP", { locale: dateLocale })} -{" "} + {format(field.value.to, "PPP", { locale: dateLocale })} ) : ( - format(field.value.from, "LLL dd, y") + format(field.value.from, "PPP", { locale: dateLocale }) ) ) : ( - Pick a date range + {dict.pickDateRange} )} @@ -114,11 +123,12 @@ export function CreatePlanForm() { date < new Date(new Date().setHours(0, 0, 0, 0)) } initialFocus + locale={dateLocale} /> - Select the days you will be away. + {dict.dateRangeDesc} @@ -129,18 +139,18 @@ export function CreatePlanForm() { name="password" render={({ field }) => ( - Group Password + {dict.groupPassword} - + - Share this password with your cat sitters. + {dict.passwordDesc} )} /> - + ) diff --git a/components/plan-login-form.tsx b/components/plan-login-form.tsx index aefd0d2..660e622 100644 --- a/components/plan-login-form.tsx +++ b/components/plan-login-form.tsx @@ -1,77 +1,63 @@ "use client" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { z } from "zod" +import { useState } from "react" import { useRouter } from "next/navigation" -import { toast } from "sonner" -import { Lock } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" import { verifyPlanPassword } from "@/app/actions/auth" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { toast } from "sonner" -const formSchema = z.object({ - password: z.string().min(1, "Password is required"), -}) +interface PlanLoginFormProps { + planId: string; + dict: any; +} -export function PlanLoginForm({ planId }: { planId: string }) { +export function PlanLoginForm({ planId, dict }: PlanLoginFormProps) { + const [password, setPassword] = useState("") + const [isPending, setIsPending] = useState(false) const router = useRouter() - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - password: "", - }, - }) - async function onSubmit(values: z.infer) { + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setIsPending(true) + try { - const success = await verifyPlanPassword(planId, values.password) + const success = await verifyPlanPassword(planId, password) if (success) { - toast.success("Access granted!") - router.refresh() // Refresh to trigger server re-render with cookie + router.refresh() } else { - toast.error("Invalid password") + toast.error(dict.error) } - } catch { - toast.error("Something went wrong") + } catch (error) { + toast.error("An error occurred") + } finally { + setIsPending(false) } } return ( -
-
- -
-

Enter Plan Password

-
- - ( - - Password - - - - - - )} + + + {dict.title} + + {dict.description} + + + + + setPassword(e.target.value)} + required /> - - -
+ + ) } diff --git a/components/plan-settings.tsx b/components/plan-settings.tsx index efbd09d..5b32b1c 100644 --- a/components/plan-settings.tsx +++ b/components/plan-settings.tsx @@ -1,12 +1,11 @@ "use client" import { useState } from "react" -import { useRouter } from "next/navigation" -import { toast } from "sonner" -import { Settings } from "lucide-react" import { useForm } from "react-hook-form" -import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Settings } from "lucide-react" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { @@ -32,46 +31,53 @@ import { Textarea } from "@/components/ui/textarea" import { Checkbox } from "@/components/ui/checkbox" import { updatePlan } from "@/app/actions/plan" -const formSchema = z.object({ - webhookUrl: z.string().optional().or(z.literal("")), - instructions: z.string().optional().or(z.literal("")), - notifyAll: z.boolean(), -}) - -type FormValues = z.infer - -type PlanSettingsProps = { +interface PlanSettingsProps { planId: string - initialWebhookUrl?: string | null - initialInstructions?: string | null - initialNotifyAll?: boolean + initialInstructions: string | null + initialWebhookUrl: string | null + initialNotifyAll: boolean + dict: any + lang: string } -export function PlanSettings({ planId, initialWebhookUrl, initialInstructions, initialNotifyAll = true }: PlanSettingsProps) { +export function PlanSettings({ + planId, + initialInstructions, + initialWebhookUrl, + initialNotifyAll, + dict, + lang +}: PlanSettingsProps) { const [open, setOpen] = useState(false) - const router = useRouter() + const [isPending, setIsPending] = useState(false) + + const formSchema = z.object({ + instructions: z.string().optional(), + webhookUrl: z.string().url().optional().or(z.literal("")), + notifyAll: z.boolean(), + }) + + type FormValues = z.infer const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - webhookUrl: initialWebhookUrl || "", instructions: initialInstructions || "", + webhookUrl: initialWebhookUrl || "", notifyAll: initialNotifyAll, }, }) - async function onSubmit(values: FormValues) { + async function onSubmit(data: FormValues) { + setIsPending(true) try { - await updatePlan(planId, { - webhookUrl: values.webhookUrl || "", - instructions: values.instructions || "", - notifyAll: values.notifyAll, - }) - toast.success("Settings updated") + await updatePlan(planId, data, lang) + toast.success(dict.success) setOpen(false) - router.refresh() - } catch { - toast.error("Failed to update settings") + } catch (error) { + toast.error(dict.error) + } finally { + setIsPending(false) } } @@ -80,49 +86,32 @@ export function PlanSettings({ planId, initialWebhookUrl, initialInstructions, i - Plan Settings + {dict.title} - Configure notification webhooks and cat care instructions. + {dict.description}
( - Cat Instructions + {dict.webhookLabel} -