Add Google Calendar integration and improve station selection
Implement full OAuth 2.0 flow for Google Calendar, including token exchange, refresh, and status checks. Add UI for connecting, syncing, and disconnecting Google accounts in the Calendar panel. Additionally: - Make mobile station selection async with error handling and alerts - Add HAFAS LocMatch method to API client for finding nearest station - Expose Google OAuth env vars in Next.js config
This commit is contained in:
@@ -92,12 +92,17 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
|
||||
};
|
||||
|
||||
const selectStation = (station: Station) => {
|
||||
const selectStation = async (station: Station) => {
|
||||
setOrigin(station);
|
||||
setQuery(station.name);
|
||||
setResults([]);
|
||||
saveOriginStation(station);
|
||||
rescheduleAllNotifications(); // Recalculate when origin changes
|
||||
try {
|
||||
await saveOriginStation(station);
|
||||
await rescheduleAllNotifications();
|
||||
Alert.alert('Station gespeichert', station.name);
|
||||
} catch {
|
||||
Alert.alert('Fehler', 'Station konnte nicht gespeichert werden.');
|
||||
}
|
||||
};
|
||||
|
||||
const useCurrentLocation = async () => {
|
||||
@@ -114,29 +119,40 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const userLat = loc.coords.latitude;
|
||||
const userLng = loc.coords.longitude;
|
||||
|
||||
// Find real public-transport stops near the user's GPS coordinates
|
||||
// via the WienerLinien nearby-stops proxy.
|
||||
const stops = await api.findNearbyStops(userLat, userLng, 2000);
|
||||
if (stops.length === 0) {
|
||||
// Try the WienerLinien nearby-stops proxy first
|
||||
let stops: Awaited<ReturnType<typeof api.findNearbyStops>> | null = null;
|
||||
try {
|
||||
stops = await api.findNearbyStops(userLat, userLng, 2000);
|
||||
} catch {
|
||||
// API unavailable, will fall back to HAFAS LocMatch below
|
||||
}
|
||||
|
||||
// If nearby-stops returned results, pick the closest one
|
||||
if (stops && stops.length > 0) {
|
||||
const closest = stops.reduce((best, candidate) => {
|
||||
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
|
||||
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
|
||||
return candDist < bestDist ? candidate : best;
|
||||
}, stops[0]);
|
||||
|
||||
const station: Station = {
|
||||
name: closest.name,
|
||||
extId: closest.id,
|
||||
lat: closest.lat,
|
||||
lng: closest.lng,
|
||||
};
|
||||
await selectStation(station);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: use HAFAS LocMatch directly (same pattern as the web app)
|
||||
const nearestStation = await api.findNearestStationByCoords(userLat, userLng);
|
||||
if (!nearestStation) {
|
||||
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the closest stop to the user's actual position
|
||||
const closest = stops.reduce((best, candidate) => {
|
||||
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
|
||||
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
|
||||
return candDist < bestDist ? candidate : best;
|
||||
}, stops[0]);
|
||||
|
||||
// Build a Station with the real stop id as extId — HAFAS can look this up.
|
||||
const station: Station = {
|
||||
name: closest.name,
|
||||
extId: closest.id,
|
||||
lat: closest.lat,
|
||||
lng: closest.lng,
|
||||
};
|
||||
selectStation(station);
|
||||
await selectStation(nearestStation);
|
||||
} catch (_err) {
|
||||
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const nextConfig: NextConfig = {
|
||||
env: {
|
||||
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
|
||||
DEPLOYMENT_URL: process.env.DEPLOYMENT_URL,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.DEPLOYMENT_URL || "http://localhost:3000";
|
||||
}
|
||||
|
||||
function calendarRedirect(params: Record<string, string>) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return NextResponse.redirect(`${getBaseUrl()}/calendar?${qs}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const oauthError = searchParams.get("error");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (oauthError) {
|
||||
cookieStore.delete("google_oauth_state");
|
||||
return calendarRedirect({ google_error: oauthError });
|
||||
}
|
||||
|
||||
const storedState = cookieStore.get("google_oauth_state")?.value;
|
||||
cookieStore.delete("google_oauth_state");
|
||||
|
||||
if (!state || !storedState || state !== storedState) {
|
||||
return calendarRedirect({ google_error: "invalid_state" });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return calendarRedirect({ google_error: "missing_code" });
|
||||
}
|
||||
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
return calendarRedirect({ google_error: "not_configured" });
|
||||
}
|
||||
|
||||
const redirectUri =
|
||||
process.env.GOOGLE_REDIRECT_URI || `${getBaseUrl()}/api/auth/google/callback`;
|
||||
|
||||
let tokenData: {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
return calendarRedirect({ google_error: "token_exchange_failed" });
|
||||
}
|
||||
|
||||
tokenData = await tokenResponse.json();
|
||||
} catch {
|
||||
return calendarRedirect({ google_error: "token_exchange_failed" });
|
||||
}
|
||||
|
||||
cookieStore.set(
|
||||
"google_tokens",
|
||||
JSON.stringify({
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token ?? null,
|
||||
expires_at: Date.now() + tokenData.expires_in * 1000,
|
||||
}),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
path: "/",
|
||||
},
|
||||
);
|
||||
|
||||
return calendarRedirect({ google_connected: "1" });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete("google_tokens");
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.DEPLOYMENT_URL || "http://localhost:3000";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
return NextResponse.json({ error: "Google Calendar is not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const redirectUri =
|
||||
process.env.GOOGLE_REDIRECT_URI || `${getBaseUrl()}/api/auth/google/callback`;
|
||||
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("google_oauth_state", state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 300,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "https://www.googleapis.com/auth/calendar.readonly",
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
state,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(
|
||||
`https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
if (!configured) {
|
||||
return NextResponse.json({ configured: false, connected: false });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const connected = cookieStore.has("google_tokens");
|
||||
return NextResponse.json({ configured: true, connected });
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { CalendarEvent } from "@timetoleave/core";
|
||||
import { cleanLocation } from "@/lib/calendar-utils";
|
||||
import { DEFAULT_DAYS } from "@/lib/constants";
|
||||
|
||||
interface GoogleTokens {
|
||||
access_token: string;
|
||||
refresh_token: string | null;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface GoogleEventItem {
|
||||
id: string;
|
||||
summary?: string;
|
||||
location?: string;
|
||||
start?: { dateTime?: string; date?: string };
|
||||
}
|
||||
|
||||
async function refreshAccessToken(tokens: GoogleTokens): Promise<GoogleTokens | null> {
|
||||
if (!tokens.refresh_token) return null;
|
||||
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
refresh_token: tokens.refresh_token,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data: { access_token: string; expires_in: number } = await response.json();
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const tokensRaw = cookieStore.get("google_tokens")?.value;
|
||||
|
||||
if (!tokensRaw) {
|
||||
return NextResponse.json({ error: "Not authenticated with Google" }, { status: 401 });
|
||||
}
|
||||
|
||||
let tokens: GoogleTokens;
|
||||
try {
|
||||
tokens = JSON.parse(tokensRaw) as GoogleTokens;
|
||||
} catch {
|
||||
cookieStore.delete("google_tokens");
|
||||
return NextResponse.json({ error: "Invalid token data" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Refresh if expired or within 5 minutes of expiry
|
||||
if (Date.now() > tokens.expires_at - 5 * 60 * 1000) {
|
||||
const refreshed = await refreshAccessToken(tokens);
|
||||
if (!refreshed) {
|
||||
cookieStore.delete("google_tokens");
|
||||
return NextResponse.json({ error: "Token expired — please reconnect Google Calendar" }, { status: 401 });
|
||||
}
|
||||
tokens = refreshed;
|
||||
cookieStore.set("google_tokens", JSON.stringify(tokens), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const days = parseInt(searchParams.get("days") ?? String(DEFAULT_DAYS), 10);
|
||||
const now = new Date();
|
||||
const timeMax = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
timeMin: now.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
singleEvents: "true",
|
||||
orderBy: "startTime",
|
||||
maxResults: "100",
|
||||
});
|
||||
|
||||
let gcalData: { items?: GoogleEventItem[] };
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/primary/events?${params}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
cookieStore.delete("google_tokens");
|
||||
return NextResponse.json({ error: "Google token rejected — please reconnect" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "Failed to fetch Google Calendar events" }, { status: 502 });
|
||||
}
|
||||
|
||||
gcalData = await response.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to reach Google Calendar API" }, { status: 502 });
|
||||
}
|
||||
|
||||
const events: CalendarEvent[] = (gcalData.items ?? [])
|
||||
.filter((item) => item.location && item.start?.dateTime)
|
||||
.map((item) => ({
|
||||
id: `gcal_${item.id}`,
|
||||
title: item.summary ?? "Untitled",
|
||||
destination: cleanLocation(item.location!),
|
||||
eventTime: item.start!.dateTime!,
|
||||
source: "google_calendar",
|
||||
}));
|
||||
|
||||
return NextResponse.json(events);
|
||||
}
|
||||
@@ -3,15 +3,17 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useCalendar } from "@/hooks/useCalendar";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import type { CalendarEvent } from "@timetoleave/core";
|
||||
import UrlTab from "./UrlTab";
|
||||
import FileTab from "./FileTab";
|
||||
import GoogleTab from "./GoogleTab";
|
||||
|
||||
type CalendarPanelProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
||||
const [activeTab, setActiveTab] = useState<"url" | "file">("url");
|
||||
const [activeTab, setActiveTab] = useState<"url" | "file" | "google">("url");
|
||||
const { events: calendarEvents, loading, error, fetchCalendarFromUrl, parseCalendarFromFile } = useCalendar();
|
||||
const { mergeEvents } = useEventsStore();
|
||||
|
||||
@@ -30,6 +32,17 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleEventsLoaded = (events: CalendarEvent[]) => {
|
||||
mergeEvents(events);
|
||||
};
|
||||
|
||||
const tabClass = (tab: "url" | "file" | "google") =>
|
||||
`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
activeTab === tab
|
||||
? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]"
|
||||
: "text-[#F4F1EA]/58 hover:text-white"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={`brand-panel overflow-hidden rounded-2xl ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
@@ -40,27 +53,26 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<nav className="inline-flex rounded-full border border-white/10 bg-black/20 p-1" aria-label="Tabs">
|
||||
<button
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "url" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
||||
onClick={() => setActiveTab("url")}
|
||||
disabled={loading}
|
||||
>
|
||||
<button className={tabClass("url")} onClick={() => setActiveTab("url")} disabled={loading}>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "file" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
||||
onClick={() => setActiveTab("file")}
|
||||
disabled={loading}
|
||||
>
|
||||
<button className={tabClass("file")} onClick={() => setActiveTab("file")} disabled={loading}>
|
||||
File
|
||||
</button>
|
||||
<button className={tabClass("google")} onClick={() => setActiveTab("google")} disabled={loading}>
|
||||
Google
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{activeTab === "url" ? (
|
||||
{activeTab === "url" && (
|
||||
<UrlTab onLoadCalendar={(url) => handleLoadCalendar(url)} loading={loading} error={error} />
|
||||
) : (
|
||||
)}
|
||||
{activeTab === "file" && (
|
||||
<FileTab onLoadCalendar={(file) => handleLoadCalendar(file)} loading={loading} error={error} />
|
||||
)}
|
||||
{activeTab === "google" && (
|
||||
<GoogleTab onEventsLoaded={handleGoogleEventsLoaded} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import Button from "@/app/ui/Button";
|
||||
import type { CalendarEvent } from "@timetoleave/core";
|
||||
|
||||
type GoogleStatus = "loading" | "not-configured" | "not-connected" | "connected";
|
||||
|
||||
type GoogleTabProps = {
|
||||
onEventsLoaded: (events: CalendarEvent[]) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" }) => {
|
||||
const [status, setStatus] = useState<GoogleStatus>("loading");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncedCount, setSyncedCount] = useState<number | null>(null);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/google/status");
|
||||
const data: { configured: boolean; connected: boolean } = await res.json();
|
||||
if (!data.configured) {
|
||||
setStatus("not-configured");
|
||||
} else if (data.connected) {
|
||||
setStatus("connected");
|
||||
} else {
|
||||
setStatus("not-connected");
|
||||
}
|
||||
} catch {
|
||||
setStatus("not-connected");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle OAuth return params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const connected = params.get("google_connected");
|
||||
const oauthError = params.get("google_error");
|
||||
|
||||
if (connected || oauthError) {
|
||||
const clean = new URL(window.location.href);
|
||||
clean.searchParams.delete("google_connected");
|
||||
clean.searchParams.delete("google_error");
|
||||
window.history.replaceState(null, "", clean.toString());
|
||||
}
|
||||
|
||||
if (oauthError) {
|
||||
setError(friendlyOAuthError(oauthError));
|
||||
setStatus("not-connected");
|
||||
return;
|
||||
}
|
||||
|
||||
checkStatus().then(() => {
|
||||
if (connected) {
|
||||
syncEvents();
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const syncEvents = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/calendar/google");
|
||||
if (res.status === 401) {
|
||||
setStatus("not-connected");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body: { error?: string } = await res.json().catch(() => ({}));
|
||||
setError(body.error ?? "Failed to fetch Google Calendar events");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
const events: CalendarEvent[] = await res.json();
|
||||
onEventsLoaded(events);
|
||||
setSyncedCount(events.length);
|
||||
} catch {
|
||||
setError("Failed to reach the server");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [onEventsLoaded]);
|
||||
|
||||
const disconnect = async () => {
|
||||
await fetch("/api/auth/google/disconnect", { method: "POST" });
|
||||
setStatus("not-connected");
|
||||
setSyncedCount(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className={`p-1 ${className}`}>
|
||||
<p className="text-sm text-[#F4F1EA]/50">Checking connection…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "not-configured") {
|
||||
return (
|
||||
<div className={`p-1 ${className}`}>
|
||||
<p className="text-sm text-[#F4F1EA]/66">
|
||||
Google Calendar is not configured. Add{" "}
|
||||
<code className="rounded bg-white/10 px-1 py-0.5 text-xs">GOOGLE_CLIENT_ID</code> and{" "}
|
||||
<code className="rounded bg-white/10 px-1 py-0.5 text-xs">GOOGLE_CLIENT_SECRET</code> to
|
||||
your environment.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-1 space-y-4 ${className}`}>
|
||||
{status === "not-connected" ? (
|
||||
<div>
|
||||
<p className="mb-3 text-sm text-[#F4F1EA]/66">
|
||||
Connect your Google account to import events from Google Calendar.
|
||||
</p>
|
||||
<a href="/api/auth/google">
|
||||
<Button type="button">
|
||||
<GoogleIcon />
|
||||
Connect Google Calendar
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.7)]" />
|
||||
<p className="text-sm font-medium text-white">Google Calendar connected</p>
|
||||
</div>
|
||||
{syncedCount !== null && (
|
||||
<p className="mb-3 text-sm text-[#F4F1EA]/66">
|
||||
{syncedCount === 0
|
||||
? "No upcoming events with a location found."
|
||||
: `${syncedCount} event${syncedCount === 1 ? "" : "s"} imported.`}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={syncEvents} disabled={syncing}>
|
||||
{syncing ? "Syncing…" : "Sync Now"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={disconnect} disabled={syncing}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" className="text-[#FF2D8D] text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function friendlyOAuthError(error: string): string {
|
||||
switch (error) {
|
||||
case "access_denied":
|
||||
return "Google Calendar access was denied.";
|
||||
case "invalid_state":
|
||||
return "Security check failed. Please try connecting again.";
|
||||
case "token_exchange_failed":
|
||||
return "Could not complete Google sign-in. Please try again.";
|
||||
case "not_configured":
|
||||
return "Google Calendar is not configured on this server.";
|
||||
default:
|
||||
return `Google sign-in failed: ${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default GoogleTab;
|
||||
Reference in New Issue
Block a user