diff --git a/bun.lock b/bun.lock index 517d57c..3e89b5d 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@vis.gl/react-google-maps": "^1.8.3", "better-auth": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -172,6 +173,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@googlemaps/js-api-loader": ["@googlemaps/js-api-loader@2.0.2", "", { "dependencies": { "@types/google.maps": "^3.53.1" } }, "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -442,6 +445,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/google.maps": ["@types/google.maps@3.58.1", "", {}, "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], @@ -528,6 +533,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@vis.gl/react-google-maps": ["@vis.gl/react-google-maps@1.8.3", "", { "dependencies": { "@googlemaps/js-api-loader": "^2.0.2", "@types/google.maps": "^3.54.10", "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc", "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], diff --git a/src/app/api/location-autocomplete/route.ts b/src/app/api/location-autocomplete/route.ts new file mode 100644 index 0000000..e708a60 --- /dev/null +++ b/src/app/api/location-autocomplete/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { + getGoogleMapsLocationCapability, + getGoogleMapsServerApiKey, + mapGooglePlacesSuggestions, +} from "@/lib/google-maps"; + +const AUTOCOMPLETE_FIELD_MASK = [ + "suggestions.placePrediction.place", + "suggestions.placePrediction.placeId", + "suggestions.placePrediction.text.text", + "suggestions.placePrediction.structuredFormat.mainText.text", + "suggestions.placePrediction.structuredFormat.secondaryText.text", +].join(","); + +export async function GET(request: Request) { + const capability = getGoogleMapsLocationCapability(); + if (!capability.enabled) { + return NextResponse.json({ suggestions: [] }, { status: 503 }); + } + + const apiKey = getGoogleMapsServerApiKey(); + if (!apiKey) { + return NextResponse.json({ suggestions: [] }, { status: 503 }); + } + + const { searchParams } = new URL(request.url); + const input = searchParams.get("input")?.trim(); + const sessionToken = searchParams.get("sessionToken")?.trim(); + + if (!input) { + return NextResponse.json({ suggestions: [] }); + } + + const response = await fetch( + "https://places.googleapis.com/v1/places:autocomplete", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": AUTOCOMPLETE_FIELD_MASK, + }, + body: JSON.stringify({ + input, + sessionToken: sessionToken || undefined, + }), + }, + ); + + if (!response.ok) { + return NextResponse.json({ suggestions: [] }, { status: 502 }); + } + + const payload = (await response.json()) as Parameters< + typeof mapGooglePlacesSuggestions + >[0]; + return NextResponse.json({ + suggestions: mapGooglePlacesSuggestions(payload), + }); +} diff --git a/src/components/event-dialog.tsx b/src/components/event-dialog.tsx index ea62126..859f7a9 100644 --- a/src/components/event-dialog.tsx +++ b/src/components/event-dialog.tsx @@ -3,8 +3,8 @@ import { addHours, addMinutes, isValid, parseISO } from "date-fns"; import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { LucideMapPin } from "lucide-react"; import { DateTimePicker } from "@/components/date-time-picker"; +import { LocationAutocomplete } from "@/components/location-autocomplete"; import { RecurrencePicker } from "@/components/recurrence-picker"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -19,12 +19,12 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { validateRecurrence, parseRecurrenceRule } from "@/lib/recurrence"; import { + type EventFormValues, getDefaultEventFormValues, validateEventFormValues, - type EventFormValues, } from "@/lib/event-form"; +import { parseRecurrenceRule, validateRecurrence } from "@/lib/recurrence"; interface EventDialogProps { open: boolean; @@ -46,7 +46,11 @@ export const EventDialog = ({ onReset, }: EventDialogProps) => { const isAiDraft = dialogSource === "ai" && !editingId; - const titleText = editingId ? "Edit Event" : isAiDraft ? "Review AI Draft" : "New Event"; + const titleText = editingId + ? "Edit Event" + : isAiDraft + ? "Review AI Draft" + : "New Event"; const descriptionText = editingId ? "Update the event details below. Title and start date are required." : isAiDraft @@ -89,11 +93,16 @@ export const EventDialog = ({ { label: "+3 hours", minutes: 180 }, ]; - const handleApplyDuration = (minutes: number, currentAllDay: boolean, currentStart: string) => { + const handleApplyDuration = ( + minutes: number, + currentAllDay: boolean, + currentStart: string, + ) => { if (!currentStart) return; const base = parseISO(currentStart); if (!isValid(base)) return; - const next = minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60); + const next = + minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60); const pad = (value: number) => String(value).padStart(2, "0"); const result = currentAllDay ? `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}` @@ -108,14 +117,18 @@ export const EventDialog = ({ for (const [fieldName, messages] of Object.entries(fieldErrors)) { const firstMessage = messages?.[0]; if (firstMessage) { - setError(fieldName as keyof EventFormValues, { message: firstMessage }); + setError(fieldName as keyof EventFormValues, { + message: firstMessage, + }); } } return; } if (values.recurrenceRule) { - const recurrenceValidation = validateRecurrence(parseRecurrenceRule(values.recurrenceRule)); + const recurrenceValidation = validateRecurrence( + parseRecurrenceRule(values.recurrenceRule), + ); if (!recurrenceValidation.isValid) { setError("recurrenceRule", { message: @@ -143,14 +156,22 @@ export const EventDialog = ({