mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
Merge branch 'dev' into dev
This commit is contained in:
commit
9973d51245
89 changed files with 5030 additions and 120 deletions
|
|
@ -1,3 +1,5 @@
|
|||
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE
|
||||
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
|
||||
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
|
||||
# Contact Form Vars - OPTIONAL
|
||||
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres
|
||||
61
surfsense_web/app/api/contact/route.ts
Normal file
61
surfsense_web/app/api/contact/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/app/db";
|
||||
import { usersTable } from "@/app/db/schema";
|
||||
|
||||
// Define validation schema matching the database schema
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
|
||||
email: z.string().email("Invalid email address").max(255, "Email is too long"),
|
||||
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
|
||||
message: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate the request body
|
||||
const validatedData = contactSchema.parse(body);
|
||||
|
||||
// Insert into database
|
||||
const result = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
company: validatedData.company,
|
||||
message: validatedData.message,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Contact form submitted successfully",
|
||||
data: result[0],
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Validation error",
|
||||
errors: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Error submitting contact form:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Failed to submit contact form",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
12
surfsense_web/app/contact/page.tsx
Normal file
12
surfsense_web/app/contact/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
import { ContactFormGridWithDetails } from '@/components/contact/contact-form'
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<ContactFormGridWithDetails />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
|
@ -260,6 +260,17 @@ export default function EditConnectorPage() {
|
|||
placeholder="Bot token..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Luma == */}
|
||||
{connector.connector_type === "LUMA_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LUMA_API_KEY"
|
||||
fieldLabel="Luma API Key"
|
||||
fieldDescription="Update the Luma API Key if needed."
|
||||
placeholder="API Key..."
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -53,6 +53,7 @@ const getConnectorTypeDisplay = (type: string): string => {
|
|||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Connector",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Google Gmail Connector",
|
||||
AIRTABLE_CONNECTOR: "Airtable Connector",
|
||||
LUMA_CONNECTOR: "Luma Connector",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
|
|
@ -71,6 +72,7 @@ const getApiKeyFieldName = (connectorType: string): string => {
|
|||
GITHUB_CONNECTOR: "GITHUB_PAT",
|
||||
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
||||
LINKUP_API: "LINKUP_API_KEY",
|
||||
LUMA_CONNECTOR: "LUMA_API_KEY",
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Key, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const lumaConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type LumaConnectorFormValues = z.infer<typeof lumaConnectorFormSchema>;
|
||||
|
||||
export default function LumaConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { fetchConnectors, createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LumaConnectorFormValues>({
|
||||
resolver: zodResolver(lumaConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Luma Events",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connector = data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.LUMA_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, [fetchConnectors]);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LumaConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LUMA_CONNECTOR,
|
||||
config: {
|
||||
LUMA_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Luma connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Luma</h1>
|
||||
<p className="text-muted-foreground">Connect your Luma account to search events.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Your Luma Account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Luma API key to connect your account. We'll use this to access your
|
||||
events in read-only mode.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Luma Events" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Luma API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Read-only access to your Luma events</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Access works even when you're offline</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>You can disconnect anytime</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Connect Luma
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
/* Success Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Your Luma account is successfully connected!</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
{!doesConnectorExist && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Get Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log into your Luma account and navigate to your account settings to generate an
|
||||
API key.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Enter Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste your API key in the field above. We'll use this to securely access your
|
||||
events with read-only permissions.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
@ -140,6 +140,13 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
icon: getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "luma-connector",
|
||||
title: "Luma",
|
||||
description: "Connect to Luma to search events",
|
||||
icon: getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
|
||||
import React from "react";
|
||||
import { DocumentViewer } from "@/components/document-viewer";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { type Tag, TagInput } from "emblor";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
|
|
|
|||
13
surfsense_web/app/db/index.ts
Normal file
13
surfsense_web/app/db/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Configure postgres client for Vercel serverless environment
|
||||
const client = postgres(process.env.DATABASE_URL!, {
|
||||
max: 1, // Limit connections for serverless (Vercel)
|
||||
idle_timeout: 20, // Close idle connections after 20 seconds
|
||||
max_lifetime: 60 * 30, // Close connections after 30 minutes
|
||||
connect_timeout: 10, // Connection timeout in seconds
|
||||
});
|
||||
|
||||
export const db = drizzle({ client, schema });
|
||||
9
surfsense_web/app/db/schema.ts
Normal file
9
surfsense_web/app/db/schema.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { integer, pgTable, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const usersTable = pgTable("users", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
email: varchar({ length: 255 }).notNull().unique(),
|
||||
company: varchar({ length: 255 }).notNull(),
|
||||
message: text().default(""),
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
"use client";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
|
||||
export function LocalLoginForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
|
@ -19,7 +23,11 @@ export function LocalLoginForm() {
|
|||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setError(null); // Clear any previous errors
|
||||
setErrorTitle(null);
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading("Signing you in...");
|
||||
|
||||
try {
|
||||
// Create form data for the API request
|
||||
|
|
@ -42,13 +50,53 @@ export function LocalLoginForm() {
|
|||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Failed to login");
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
// Success toast
|
||||
toast.success("Login successful!", {
|
||||
id: loadingToast,
|
||||
description: "Redirecting to dashboard...",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Small delay to show success message
|
||||
setTimeout(() => {
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "An error occurred during login";
|
||||
setError(errorMessage);
|
||||
// Use auth-errors utility to get proper error details
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorCode = err.message;
|
||||
} else if (isNetworkError(err)) {
|
||||
errorCode = "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
// Get detailed error information from auth-errors utility
|
||||
const errorDetails = getAuthErrorDetails(errorCode);
|
||||
|
||||
// Set persistent error display
|
||||
setErrorTitle(errorDetails.title);
|
||||
setError(errorDetails.description);
|
||||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
||||
// Add retry action if the error is retryable
|
||||
if (shouldRetry(errorCode)) {
|
||||
toastOptions.action = {
|
||||
label: "Retry",
|
||||
onClick: () => handleSubmit(e),
|
||||
};
|
||||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -57,11 +105,67 @@ export function LocalLoginForm() {
|
|||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{/* Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && errorTitle && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
|
||||
>
|
||||
<title>Error Icon</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setErrorTitle(null);
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>Close</title>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label
|
||||
|
|
@ -76,7 +180,12 @@ export function LocalLoginForm() {
|
|||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -93,14 +202,19 @@ export function LocalLoginForm() {
|
|||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
import { LocalLoginForm } from "./LocalLoginForm";
|
||||
|
||||
function LoginContent() {
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the user was redirected from registration
|
||||
if (searchParams.get("registered") === "true") {
|
||||
setRegistrationSuccess(true);
|
||||
// Check for various URL parameters that might indicate success or error states
|
||||
const registered = searchParams.get("registered");
|
||||
const error = searchParams.get("error");
|
||||
const message = searchParams.get("message");
|
||||
const logout = searchParams.get("logout");
|
||||
|
||||
// Show registration success message
|
||||
if (registered === "true") {
|
||||
toast.success("Registration successful!", {
|
||||
description: "You can now sign in with your credentials",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
// Show logout confirmation
|
||||
if (logout === "true") {
|
||||
toast.success("Logged out successfully", {
|
||||
description: "You have been securely logged out",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// Show error messages from OAuth or other flows using auth-errors utility
|
||||
if (error) {
|
||||
// Use the auth-errors utility to get proper error details
|
||||
const errorDetails = getAuthErrorDetails(error);
|
||||
|
||||
// If we have a custom message from URL params, use it as description
|
||||
const errorDescription = message ? decodeURIComponent(message) : errorDetails.description;
|
||||
|
||||
// Set persistent error display
|
||||
setUrlError({
|
||||
title: errorDetails.title,
|
||||
message: errorDescription,
|
||||
});
|
||||
|
||||
// Show toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
description: errorDescription,
|
||||
duration: 6000,
|
||||
};
|
||||
|
||||
// Add retry action if the error is retryable
|
||||
if (shouldRetry(error)) {
|
||||
toastOptions.action = {
|
||||
label: "Retry",
|
||||
onClick: () => window.location.reload(),
|
||||
};
|
||||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
}
|
||||
|
||||
// Show general messages
|
||||
if (message && !error && !registered && !logout) {
|
||||
toast.info("Notice", {
|
||||
description: decodeURIComponent(message),
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the auth type from environment variables
|
||||
|
|
@ -54,11 +113,64 @@ function LoginContent() {
|
|||
Sign In
|
||||
</h1>
|
||||
|
||||
{registrationSuccess && (
|
||||
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
|
||||
Registration successful! You can now sign in with your credentials.
|
||||
</div>
|
||||
)}
|
||||
{/* URL Error Display */}
|
||||
<AnimatePresence>
|
||||
{urlError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-6 w-full max-w-md rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
|
||||
>
|
||||
<title>Error Icon</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{urlError.title}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUrlError(null)}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>Close</title>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<LocalLoginForm />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
|
|||
12
surfsense_web/app/pricing/page.tsx
Normal file
12
surfsense_web/app/pricing/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
import PricingBasic from '@/components/pricing/pricing-section'
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<PricingBasic />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AmbientBackground } from "../login/AmbientBackground";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -28,11 +32,20 @@ export default function RegisterPage() {
|
|||
// Form validation
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setErrorTitle("Password Mismatch");
|
||||
toast.error("Password Mismatch", {
|
||||
description: "The passwords you entered do not match",
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setError(null); // Clear any previous errors
|
||||
setErrorTitle(null);
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading("Creating your account...");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
|
||||
|
|
@ -52,15 +65,53 @@ export default function RegisterPage() {
|
|||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Registration failed");
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Redirect to login page after successful registration
|
||||
router.push("/login?registered=true");
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "An error occurred during registration";
|
||||
setError(errorMessage);
|
||||
// Success toast
|
||||
toast.success("Account created successfully!", {
|
||||
id: loadingToast,
|
||||
description: "Redirecting to login page...",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Small delay to show success message
|
||||
setTimeout(() => {
|
||||
router.push("/login?registered=true");
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
// Use auth-errors utility to get proper error details
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorCode = err.message;
|
||||
} else if (isNetworkError(err)) {
|
||||
errorCode = "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
// Get detailed error information from auth-errors utility
|
||||
const errorDetails = getAuthErrorDetails(errorCode);
|
||||
|
||||
// Set persistent error display
|
||||
setErrorTitle(errorDetails.title);
|
||||
setError(errorDetails.description);
|
||||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
||||
// Add retry action if the error is retryable
|
||||
if (shouldRetry(errorCode)) {
|
||||
toastOptions.action = {
|
||||
label: "Retry",
|
||||
onClick: () => handleSubmit(e),
|
||||
};
|
||||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -77,11 +128,67 @@ export default function RegisterPage() {
|
|||
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{/* Enhanced Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && errorTitle && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
|
||||
>
|
||||
<title>Error Icon</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setErrorTitle(null);
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>Close</title>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label
|
||||
|
|
@ -96,7 +203,12 @@ export default function RegisterPage() {
|
|||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -113,7 +225,12 @@ export default function RegisterPage() {
|
|||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -130,14 +247,19 @@ export default function RegisterPage() {
|
|||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? "Creating account..." : "Register"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,43 +3,49 @@ import type { MetadataRoute } from "next";
|
|||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: "https://www.surfsense.net/",
|
||||
url: "https://www.surfsense.com/",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/privacy",
|
||||
url: "https://www.surfsense.com/contact",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/privacy",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/terms",
|
||||
url: "https://www.surfsense.com/terms",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs",
|
||||
url: "https://www.surfsense.com/docs",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs/installation",
|
||||
url: "https://www.surfsense.com/docs/installation",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs/docker-installation",
|
||||
url: "https://www.surfsense.com/docs/docker-installation",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs/manual-installation",
|
||||
url: "https://www.surfsense.com/docs/manual-installation",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc } from "@tabler/icons-react";
|
||||
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc, IconMail } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./Logo";
|
||||
|
|
@ -54,6 +54,13 @@ export function ModernHeroWithGradients() {
|
|||
Notion, YouTube, GitHub, Discord and more.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-6 py-6 sm:flex-row">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="w-48 gap-1 rounded-full border border-blue-400 bg-gradient-to-b from-blue-100 to-blue-300 px-5 py-3 text-center text-sm font-medium text-blue-900 shadow-sm dark:border-blue-700 dark:bg-gradient-to-b dark:from-blue-900 dark:to-blue-700 dark:text-blue-100 dark:shadow-inner dark:shadow-blue-500/20 flex items-center justify-center"
|
||||
>
|
||||
<IconMail className="h-5 w-5 mr-2 text-blue-700 dark:text-blue-300" />
|
||||
<span>Contact Us</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.gg/ejRNvftDp9"
|
||||
className="w-48 gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-5 py-3 text-center text-sm font-medium text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10 flex items-center justify-center"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
|
||||
import { IconMail, IconMenu2, IconUser, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -167,7 +167,17 @@ const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
|||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Link href="/contact">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-blue-900/40 dark:hover:bg-blue-800/50 dark:text-blue-100 dark:border-blue-700 bg-blue-100 hover:bg-blue-200 text-blue-900 border-blue-400"
|
||||
>
|
||||
<IconMail className="h-4 w-4" />
|
||||
<span>Contact Us</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
|
|
@ -270,6 +280,15 @@ const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
|||
<motion.span className="block">{navItem.name}</motion.span>
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/contact" className="w-full" onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-blue-900/40 dark:hover:bg-blue-800/50 dark:text-blue-100 dark:border-blue-700 bg-blue-100 hover:bg-blue-200 text-blue-900 border-blue-400"
|
||||
>
|
||||
<IconMail className="h-4 w-4" />
|
||||
<span>Contact Us</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useInView } from "framer-motion";
|
||||
import { useInView } from "motion/react";
|
||||
import { Manrope } from "next/font/google";
|
||||
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
||||
|
|
@ -85,7 +85,6 @@ const initialState: HighlightState = {
|
|||
export function AnimatedEmptyState() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
||||
highlightReducer,
|
||||
initialState
|
||||
|
|
|
|||
357
surfsense_web/components/contact/contact-form.tsx
Normal file
357
surfsense_web/components/contact/contact-form.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { IconMailFilled } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Define validation schema matching the database schema
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
|
||||
email: z.string().email("Invalid email address").max(255, "Email is too long"),
|
||||
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
|
||||
message: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
type ContactFormData = z.infer<typeof contactFormSchema>;
|
||||
|
||||
export function ContactFormGridWithDetails() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<ContactFormData>({
|
||||
resolver: zodResolver(contactFormSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ContactFormData) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Message sent successfully!", {
|
||||
description: "We will get back to you as soon as possible.",
|
||||
});
|
||||
reset();
|
||||
} else {
|
||||
toast.error("Failed to send message", {
|
||||
description: result.message || "Please try again later.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
toast.error("Something went wrong", {
|
||||
description: "Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-10 px-4 py-10 md:px-6 md:py-20 lg:grid-cols-2">
|
||||
<div className="relative flex flex-col items-center overflow-hidden lg:items-start">
|
||||
<div className="flex items-start justify-start">
|
||||
<FeatureIconContainer className="flex items-center justify-center overflow-hidden">
|
||||
<IconMailFilled className="h-6 w-6 text-blue-500" />
|
||||
</FeatureIconContainer>
|
||||
</div>
|
||||
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-left text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
|
||||
Contact
|
||||
</h2>
|
||||
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 md:text-left dark:text-neutral-400">
|
||||
We'd love to Hear From You.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 hidden flex-col items-center gap-4 md:flex-row lg:flex">
|
||||
<Link
|
||||
href="mailto:rohan@surfsense.com"
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
rohan@surfsense.com
|
||||
</Link>
|
||||
<div className="h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-400" />
|
||||
|
||||
<Link
|
||||
href="https://cal.com/mod-surfsense"
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
https://cal.com/mod-surfsense
|
||||
</Link>
|
||||
</div>
|
||||
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 -translate-x-10 items-center justify-center [perspective:800px] [transform-style:preserve-3d] sm:-translate-x-0 lg:-translate-x-32">
|
||||
<Pin className="h-30 w-85 top-0 left-0" />
|
||||
|
||||
<Image
|
||||
src="/contact/world.svg"
|
||||
width={500}
|
||||
height={500}
|
||||
alt="world map"
|
||||
className="[transform:rotateX(45deg)_translateZ(0px)] dark:invert dark:filter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="relative mx-auto flex w-full max-w-2xl flex-col items-start gap-4 overflow-hidden rounded-3xl bg-gradient-to-b from-gray-100 to-gray-200 p-4 sm:p-10 dark:from-neutral-900 dark:to-neutral-950"
|
||||
>
|
||||
<Grid size={20} />
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="name"
|
||||
>
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
{...register("name")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.name ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="john.doe@example.com"
|
||||
{...register("email")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.email ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="company"
|
||||
>
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
placeholder="Example Inc."
|
||||
{...register("company")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.company ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.company && <p className="mt-1 text-xs text-red-500">{errors.company.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="message"
|
||||
>
|
||||
Message <span className="text-neutral-400 text-xs font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
rows={5}
|
||||
placeholder="Type your message here"
|
||||
{...register("message")}
|
||||
className={cn(
|
||||
"shadow-input w-full rounded-md border bg-white pt-4 pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.message ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.message && <p className="mt-1 text-xs text-red-500">{errors.message.message}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="relative z-10 flex items-center justify-center rounded-md border border-transparent bg-neutral-800 px-4 py-2 text-sm font-medium text-white shadow-[0px_1px_0px_0px_#FFFFFF20_inset] transition duration-200 hover:bg-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
>
|
||||
{isSubmitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Pin = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<motion.div
|
||||
style={{ transform: "translateZ(1px)" }}
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-[60] flex h-40 w-96 items-center justify-center opacity-100 transition duration-500",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<div className="absolute inset-x-0 top-0 z-20 mx-auto inline-block w-fit rounded-lg bg-neutral-200 px-2 py-1 text-xs font-normal text-neutral-700 dark:bg-neutral-800 dark:text-white">
|
||||
We are here
|
||||
<span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-blue-400/0 via-blue-400/90 to-blue-400/0 transition-opacity duration-500"></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
perspective: "800px",
|
||||
transform: "rotateX(70deg) translateZ(0px)",
|
||||
}}
|
||||
className="absolute top-1/2 left-1/2 mt-4 ml-[0.09375rem] -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0.5, 0],
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, delay: 0 }}
|
||||
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
|
||||
></motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0.5, 0],
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, delay: 2 }}
|
||||
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
|
||||
></motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0.5, 0],
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, delay: 4 }}
|
||||
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
|
||||
></motion.div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500 blur-[2px]" />
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500" />
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[4px] w-[4px] translate-x-[1.5px] translate-y-[14px] rounded-full bg-blue-600 blur-[3px]" />
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[2px] w-[2px] translate-x-[0.5px] translate-y-[14px] rounded-full bg-blue-300" />
|
||||
</>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FeatureIconContainer = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-14 w-14 rounded-md bg-gradient-to-b from-gray-50 to-neutral-200 p-[4px] dark:from-neutral-800 dark:to-neutral-950",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-20 h-full w-full rounded-[5px] bg-gray-50 dark:bg-neutral-800",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 z-30 mx-auto h-4 w-full rounded-full bg-neutral-600 opacity-50 blur-lg"></div>
|
||||
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-500 to-transparent"></div>
|
||||
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-600 to-transparent dark:h-[8px] dark:blur-sm"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Grid = ({ pattern, size }: { pattern?: number[][]; size?: number }) => {
|
||||
const p = pattern ?? [
|
||||
[9, 3],
|
||||
[8, 5],
|
||||
[10, 2],
|
||||
[7, 4],
|
||||
[9, 6],
|
||||
];
|
||||
return (
|
||||
<div className="pointer-events-none absolute top-0 left-1/2 -mt-2 -ml-20 h-full w-full [mask-image:linear-gradient(white,transparent)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-zinc-900/30 to-zinc-900/30 opacity-10 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-zinc-900/30 dark:to-zinc-900/30">
|
||||
<GridPattern
|
||||
width={size ?? 20}
|
||||
height={size ?? 20}
|
||||
x="-12"
|
||||
y="4"
|
||||
squares={p}
|
||||
className="absolute inset-0 h-full w-full fill-black/100 stroke-black/100 mix-blend-overlay dark:fill-white/100 dark:stroke-white/100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function GridPattern({ width, height, x, y, squares, ...props }: any) {
|
||||
const patternId = useId();
|
||||
|
||||
return (
|
||||
<svg aria-hidden="true" {...props}>
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${patternId})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible">
|
||||
{squares.map(([x, y]: any, idx: number) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}-${idx}`}
|
||||
width={width + 1}
|
||||
height={height + 1}
|
||||
x={x * width}
|
||||
y={y * height}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ export function DashboardBreadcrumb() {
|
|||
"tavily-api": "Tavily API",
|
||||
"serper-api": "Serper API",
|
||||
"linkup-api": "LinkUp API",
|
||||
"luma-connector": "Luma",
|
||||
};
|
||||
|
||||
const connectorLabel = connectorLabels[connectorType] || connectorType;
|
||||
|
|
|
|||
|
|
@ -43,5 +43,6 @@ export const editConnectorSchema = z.object({
|
|||
GOOGLE_CALENDAR_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN: z.string().optional(),
|
||||
GOOGLE_CALENDAR_CALENDAR_IDS: z.string().optional(),
|
||||
LUMA_API_KEY: z.string().optional(),
|
||||
});
|
||||
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertCircle, Bot, Plus, Trash2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Bot, Brain, CheckCircle, Sparkles, Zap } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
|
|
|||
202
surfsense_web/components/pricing.tsx
Normal file
202
surfsense_web/components/pricing.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { motion } from "motion/react";
|
||||
import { Check, Star } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
price: string;
|
||||
yearlyPrice: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
description: string;
|
||||
buttonText: string;
|
||||
href: string;
|
||||
isPopular: boolean;
|
||||
}
|
||||
|
||||
interface PricingProps {
|
||||
plans: PricingPlan[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function Pricing({
|
||||
plans,
|
||||
title = "Simple, Transparent Pricing",
|
||||
description = "Choose the plan that works for you\nAll plans include access to our platform, lead generation tools, and dedicated support.",
|
||||
}: PricingProps) {
|
||||
const [isMonthly, setIsMonthly] = useState(true);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const switchRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
setIsMonthly(!checked);
|
||||
if (checked && switchRef.current) {
|
||||
const rect = switchRef.current.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
|
||||
confetti({
|
||||
particleCount: 50,
|
||||
spread: 60,
|
||||
origin: {
|
||||
x: x / window.innerWidth,
|
||||
y: y / window.innerHeight,
|
||||
},
|
||||
colors: [
|
||||
"hsl(var(--primary))",
|
||||
"hsl(var(--accent))",
|
||||
"hsl(var(--secondary))",
|
||||
"hsl(var(--muted))",
|
||||
],
|
||||
ticks: 200,
|
||||
gravity: 1.2,
|
||||
decay: 0.94,
|
||||
startVelocity: 30,
|
||||
shapes: ["circle"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-20">
|
||||
<div className="text-center space-y-4 mb-12">
|
||||
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h2>
|
||||
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-10">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<Label>
|
||||
<Switch
|
||||
ref={switchRef as any}
|
||||
checked={!isMonthly}
|
||||
onCheckedChange={handleToggle}
|
||||
className="relative"
|
||||
/>
|
||||
</Label>
|
||||
</label>
|
||||
<span className="ml-2 font-semibold">
|
||||
Annual billing <span className="text-primary">(Save 20%)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 sm:2 gap-4">
|
||||
{plans.map((plan, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ y: 50, opacity: 1 }}
|
||||
whileInView={
|
||||
isDesktop
|
||||
? {
|
||||
y: plan.isPopular ? -20 : 0,
|
||||
opacity: 1,
|
||||
x: index === 2 ? -30 : index === 0 ? 30 : 0,
|
||||
scale: index === 0 || index === 2 ? 0.94 : 1.0,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
viewport={{ once: true }}
|
||||
transition={{
|
||||
duration: 1.6,
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
delay: 0.4,
|
||||
opacity: { duration: 0.5 },
|
||||
}}
|
||||
className={cn(
|
||||
`rounded-2xl border-[1px] p-6 bg-background text-center lg:flex lg:flex-col lg:justify-center relative`,
|
||||
plan.isPopular ? "border-primary border-2" : "border-border",
|
||||
"flex flex-col",
|
||||
!plan.isPopular && "mt-5",
|
||||
index === 0 || index === 2
|
||||
? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-[10deg]"
|
||||
: "z-10",
|
||||
index === 0 && "origin-right",
|
||||
index === 2 && "origin-left"
|
||||
)}
|
||||
>
|
||||
{plan.isPopular && (
|
||||
<div className="absolute top-0 right-0 bg-primary py-0.5 px-2 rounded-bl-xl rounded-tr-xl flex items-center">
|
||||
<Star className="text-primary-foreground h-4 w-4 fill-current" />
|
||||
<span className="text-primary-foreground ml-1 font-sans font-semibold">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<p className="text-base font-semibold text-muted-foreground">{plan.name}</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-x-2">
|
||||
<span className="text-5xl font-bold tracking-tight text-foreground">
|
||||
<NumberFlow
|
||||
value={isMonthly ? Number(plan.price) : Number(plan.yearlyPrice)}
|
||||
format={{
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}}
|
||||
transformTiming={{
|
||||
duration: 500,
|
||||
easing: "ease-out",
|
||||
}}
|
||||
willChange
|
||||
className="font-variant-numeric: tabular-nums"
|
||||
/>
|
||||
</span>
|
||||
{plan.period !== "Next 3 months" && (
|
||||
<span className="text-sm font-semibold leading-6 tracking-wide text-muted-foreground">
|
||||
/ {plan.period}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{isMonthly ? "billed monthly" : "billed annually"}
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 gap-2 flex flex-col">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<Check className="h-4 w-4 text-primary mt-1 flex-shrink-0" />
|
||||
<span className="text-left">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<hr className="w-full my-4" />
|
||||
|
||||
<Link
|
||||
href={plan.href}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "outline",
|
||||
}),
|
||||
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
|
||||
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
|
||||
plan.isPopular
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background text-foreground"
|
||||
)}
|
||||
>
|
||||
{plan.buttonText}
|
||||
</Link>
|
||||
<p className="mt-6 text-xs leading-5 text-muted-foreground">{plan.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
surfsense_web/components/pricing/pricing-section.tsx
Normal file
74
surfsense_web/components/pricing/pricing-section.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { Pricing } from "@/components/pricing";
|
||||
|
||||
const demoPlans = [
|
||||
{
|
||||
name: "STARTER",
|
||||
price: "50",
|
||||
yearlyPrice: "40",
|
||||
period: "per month",
|
||||
features: [
|
||||
"Up to 10 projects",
|
||||
"Basic analytics",
|
||||
"48-hour support response time",
|
||||
"Limited API access",
|
||||
"Community support",
|
||||
],
|
||||
description: "Perfect for individuals and small projects",
|
||||
buttonText: "Start Free Trial",
|
||||
href: "/sign-up",
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
name: "PROFESSIONAL",
|
||||
price: "99",
|
||||
yearlyPrice: "79",
|
||||
period: "per month",
|
||||
features: [
|
||||
"Unlimited projects",
|
||||
"Advanced analytics",
|
||||
"24-hour support response time",
|
||||
"Full API access",
|
||||
"Priority support",
|
||||
"Team collaboration",
|
||||
"Custom integrations",
|
||||
],
|
||||
description: "Ideal for growing teams and businesses",
|
||||
buttonText: "Get Started",
|
||||
href: "/sign-up",
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
name: "ENTERPRISE",
|
||||
price: "299",
|
||||
yearlyPrice: "239",
|
||||
period: "per month",
|
||||
features: [
|
||||
"Everything in Professional",
|
||||
"Custom solutions",
|
||||
"Dedicated account manager",
|
||||
"1-hour support response time",
|
||||
"SSO Authentication",
|
||||
"Advanced security",
|
||||
"Custom contracts",
|
||||
"SLA agreement",
|
||||
],
|
||||
description: "For large organizations with specific needs",
|
||||
buttonText: "Contact Sales",
|
||||
href: "/contact",
|
||||
isPopular: false,
|
||||
},
|
||||
];
|
||||
|
||||
function PricingBasic() {
|
||||
return (
|
||||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="Simple, Transparent Pricing"
|
||||
description="Choose the plan that works for you"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingBasic;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { MoveLeftIcon, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
Settings2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
|
|
@ -15,6 +14,7 @@ import {
|
|||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -112,12 +112,12 @@ const LLM_PROVIDERS = [
|
|||
example: "meta/llama-2-70b-chat",
|
||||
description: "Run models via API",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "anthropic/claude-opus-4.1, openai/gpt-5",
|
||||
description: "API gateway and LLM marketplace that provides unified access ",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "anthropic/claude-opus-4.1, openai/gpt-5",
|
||||
description: "API gateway and LLM marketplace that provides unified access ",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { motion, type SpringOptions, useSpring, useTransform } from "framer-motion";
|
||||
import { motion, type SpringOptions, useSpring, useTransform } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
|
|||
29
surfsense_web/components/ui/switch.tsx
Normal file
29
surfsense_web/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
} from "motion/react";
|
||||
import type React from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ export enum EnumConnectorName {
|
|||
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR",
|
||||
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR",
|
||||
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR",
|
||||
LUMA_CONNECTOR = "LUMA_CONNECTOR",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
IconLayoutKanban,
|
||||
IconLinkPlus,
|
||||
IconMail,
|
||||
IconSparkles,
|
||||
IconTable,
|
||||
IconTicket,
|
||||
IconWorldWww,
|
||||
|
|
@ -49,6 +50,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
return <IconBook {...iconProps} />;
|
||||
case EnumConnectorName.CLICKUP_CONNECTOR:
|
||||
return <IconChecklist {...iconProps} />;
|
||||
case EnumConnectorName.LUMA_CONNECTOR:
|
||||
return <IconSparkles {...iconProps} />;
|
||||
// Additional cases for non-enum connector types
|
||||
case "YOUTUBE_VIDEO":
|
||||
return <IconBrandYoutube {...iconProps} />;
|
||||
|
|
|
|||
11
surfsense_web/drizzle.config.ts
Normal file
11
surfsense_web/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './app/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
|
|
@ -34,7 +34,8 @@ export type DocumentType =
|
|||
| "CONFLUENCE_CONNECTOR"
|
||||
| "CLICKUP_CONNECTOR"
|
||||
| "GOOGLE_CALENDAR_CONNECTOR"
|
||||
| "GOOGLE_GMAIL_CONNECTOR";
|
||||
| "GOOGLE_GMAIL_CONNECTOR"
|
||||
| "LUMA_CONNECTOR";
|
||||
|
||||
export function useDocumentByChunk() {
|
||||
const [document, setDocument] = useState<DocumentWithChunks | null>(null);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export type DocumentType =
|
|||
| "CLICKUP_CONNECTOR"
|
||||
| "GOOGLE_CALENDAR_CONNECTOR"
|
||||
| "GOOGLE_GMAIL_CONNECTOR"
|
||||
| "AIRTABLE_CONNECTOR";
|
||||
| "AIRTABLE_CONNECTOR"
|
||||
| "LUMA_CONNECTOR";
|
||||
|
||||
export interface UseDocumentsOptions {
|
||||
page?: number;
|
||||
|
|
|
|||
39
surfsense_web/hooks/use-media-query.ts
Normal file
39
surfsense_web/hooks/use-media-query.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook that tracks if a media query matches
|
||||
* @param query - The media query string to match (e.g., "(min-width: 768px)")
|
||||
* @returns boolean - True if the media query matches, false otherwise
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in the browser (handle SSR)
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
|
||||
// Set initial value
|
||||
setMatches(mediaQuery.matches);
|
||||
|
||||
// Create event listener
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -52,6 +52,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
JIRA_BASE_URL: "",
|
||||
JIRA_EMAIL: "",
|
||||
JIRA_API_TOKEN: "",
|
||||
LUMA_API_KEY: "",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -78,6 +79,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
JIRA_BASE_URL: config.JIRA_BASE_URL || "",
|
||||
JIRA_EMAIL: config.JIRA_EMAIL || "",
|
||||
JIRA_API_TOKEN: config.JIRA_API_TOKEN || "",
|
||||
LUMA_API_KEY: config.LUMA_API_KEY || "",
|
||||
});
|
||||
if (currentConnector.connector_type === "GITHUB_CONNECTOR") {
|
||||
const savedRepos = config.repo_full_names || [];
|
||||
|
|
@ -303,6 +305,16 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
};
|
||||
}
|
||||
break;
|
||||
case "LUMA_CONNECTOR":
|
||||
if (formData.LUMA_API_KEY !== originalConfig.LUMA_API_KEY) {
|
||||
if (!formData.LUMA_API_KEY) {
|
||||
toast.error("Luma API Key cannot be empty.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
newConfig = { LUMA_API_KEY: formData.LUMA_API_KEY };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (newConfig !== null) {
|
||||
|
|
@ -365,6 +377,8 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
editForm.setValue("JIRA_BASE_URL", newlySavedConfig.JIRA_BASE_URL || "");
|
||||
editForm.setValue("JIRA_EMAIL", newlySavedConfig.JIRA_EMAIL || "");
|
||||
editForm.setValue("JIRA_API_TOKEN", newlySavedConfig.JIRA_API_TOKEN || "");
|
||||
} else if (connector.connector_type === "LUMA_CONNECTOR") {
|
||||
editForm.setValue("LUMA_API_KEY", newlySavedConfig.LUMA_API_KEY || "");
|
||||
}
|
||||
}
|
||||
if (connector.connector_type === "GITHUB_CONNECTOR") {
|
||||
|
|
|
|||
214
surfsense_web/lib/auth-errors.ts
Normal file
214
surfsense_web/lib/auth-errors.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Authentication error messages and handling utilities
|
||||
*/
|
||||
|
||||
interface AuthErrorMapping {
|
||||
[key: string]: {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
|
||||
// Common HTTP errors
|
||||
"401": {
|
||||
title: "Invalid credentials",
|
||||
description: "Please check your email and password",
|
||||
},
|
||||
"403": {
|
||||
title: "Access denied",
|
||||
description: "Your account may be suspended or restricted",
|
||||
},
|
||||
"404": {
|
||||
title: "Account not found",
|
||||
description: "No account exists with this email address",
|
||||
},
|
||||
"409": {
|
||||
title: "Account conflict",
|
||||
description: "An account with this email already exists",
|
||||
},
|
||||
"429": {
|
||||
title: "Too many attempts",
|
||||
description: "Please wait before trying again",
|
||||
},
|
||||
"500": {
|
||||
title: "Server error",
|
||||
description: "Something went wrong on our end. Please try again",
|
||||
},
|
||||
"503": {
|
||||
title: "Service unavailable",
|
||||
description: "Login service is temporarily down",
|
||||
},
|
||||
|
||||
// FastAPI specific errors
|
||||
LOGIN_BAD_CREDENTIALS: {
|
||||
title: "Invalid credentials",
|
||||
description: "The email or password you entered is incorrect",
|
||||
},
|
||||
LOGIN_USER_NOT_VERIFIED: {
|
||||
title: "Account not verified",
|
||||
description: "Please verify your email address before signing in",
|
||||
},
|
||||
USER_INACTIVE: {
|
||||
title: "Account inactive",
|
||||
description: "Your account has been deactivated. Contact support for assistance",
|
||||
},
|
||||
REGISTER_USER_ALREADY_EXISTS: {
|
||||
title: "Account already exists",
|
||||
description: "An account with this email address already exists",
|
||||
},
|
||||
REGISTER_INVALID_PASSWORD: {
|
||||
title: "Invalid password",
|
||||
description: "Password must meet security requirements",
|
||||
},
|
||||
|
||||
// OAuth errors
|
||||
access_denied: {
|
||||
title: "Access denied",
|
||||
description: "You denied access or cancelled the login process",
|
||||
},
|
||||
invalid_request: {
|
||||
title: "Invalid request",
|
||||
description: "The login request was malformed",
|
||||
},
|
||||
unauthorized_client: {
|
||||
title: "Authentication failed",
|
||||
description: "The application is not authorized to perform this action",
|
||||
},
|
||||
unsupported_response_type: {
|
||||
title: "Login method not supported",
|
||||
description: "This login method is not currently available",
|
||||
},
|
||||
invalid_scope: {
|
||||
title: "Invalid permissions",
|
||||
description: "The requested permissions are not valid",
|
||||
},
|
||||
server_error: {
|
||||
title: "Server error",
|
||||
description: "An error occurred on the authentication server",
|
||||
},
|
||||
temporarily_unavailable: {
|
||||
title: "Service unavailable",
|
||||
description: "Login is temporarily unavailable. Please try again later",
|
||||
},
|
||||
|
||||
// Network errors
|
||||
NETWORK_ERROR: {
|
||||
title: "Connection failed",
|
||||
description: "Please check your internet connection and try again",
|
||||
},
|
||||
TIMEOUT: {
|
||||
title: "Request timeout",
|
||||
description: "The login request took too long. Please try again",
|
||||
},
|
||||
|
||||
// Generic fallbacks
|
||||
UNKNOWN_ERROR: {
|
||||
title: "Login failed",
|
||||
description: "An unexpected error occurred. Please try again",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message for authentication errors
|
||||
* @param errorCode - The error code or message from the API
|
||||
* @param returnTitle - Whether to return just the title or full description
|
||||
* @returns Formatted error message
|
||||
*/
|
||||
export function getAuthErrorMessage(errorCode: string, returnTitle: boolean = false): string {
|
||||
if (!errorCode) {
|
||||
const fallback = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
return returnTitle ? fallback.title : fallback.description || fallback.title;
|
||||
}
|
||||
|
||||
// Clean up the error code
|
||||
const cleanErrorCode = errorCode.trim().toUpperCase();
|
||||
|
||||
// Try exact match first
|
||||
let errorInfo = AUTH_ERROR_MESSAGES[cleanErrorCode] || AUTH_ERROR_MESSAGES[errorCode];
|
||||
|
||||
// Try partial matches for HTTP status codes
|
||||
if (!errorInfo) {
|
||||
const statusCodeMatch = errorCode.match(/(\d{3})/);
|
||||
if (statusCodeMatch) {
|
||||
errorInfo = AUTH_ERROR_MESSAGES[statusCodeMatch[1]];
|
||||
}
|
||||
}
|
||||
|
||||
// Try partial matches for common error patterns
|
||||
if (!errorInfo) {
|
||||
const patterns = [
|
||||
{ pattern: /credential|password|email/i, code: "LOGIN_BAD_CREDENTIALS" },
|
||||
{ pattern: /verify|verification/i, code: "LOGIN_USER_NOT_VERIFIED" },
|
||||
{ pattern: /inactive|disabled|suspended/i, code: "USER_INACTIVE" },
|
||||
{ pattern: /exists|duplicate/i, code: "REGISTER_USER_ALREADY_EXISTS" },
|
||||
{ pattern: /network|connection/i, code: "NETWORK_ERROR" },
|
||||
{ pattern: /timeout/i, code: "TIMEOUT" },
|
||||
{ pattern: /rate|limit|many/i, code: "429" },
|
||||
];
|
||||
|
||||
for (const { pattern, code } of patterns) {
|
||||
if (pattern.test(errorCode)) {
|
||||
errorInfo = AUTH_ERROR_MESSAGES[code];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to unknown error
|
||||
if (!errorInfo) {
|
||||
errorInfo = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
return returnTitle ? errorInfo.title : errorInfo.description || errorInfo.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get both title and description for an error
|
||||
* @param errorCode - The error code or message from the API
|
||||
* @returns Object with title and description
|
||||
*/
|
||||
export function getAuthErrorDetails(errorCode: string): { title: string; description: string } {
|
||||
const title = getAuthErrorMessage(errorCode, true);
|
||||
const description = getAuthErrorMessage(errorCode, false);
|
||||
|
||||
return { title, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network-related error
|
||||
* @param error - The error object or message
|
||||
* @returns True if it's a network error
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return /network|connection|fetch|cors/i.test(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should trigger a retry action
|
||||
* @param errorCode - The error code or message
|
||||
* @returns True if retry is recommended
|
||||
*/
|
||||
export function shouldRetry(errorCode: string): boolean {
|
||||
const retryableCodes = [
|
||||
"500",
|
||||
"503",
|
||||
"429",
|
||||
"NETWORK_ERROR",
|
||||
"TIMEOUT",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
];
|
||||
|
||||
return retryableCodes.some(
|
||||
(code) => errorCode.includes(code) || errorCode.toUpperCase().includes(code)
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Google Gmail",
|
||||
AIRTABLE_CONNECTOR: "Airtable",
|
||||
LUMA_CONNECTOR: "Luma",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@
|
|||
"debug": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
|
||||
"debug:browser": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
|
||||
"debug:server": "cross-env NODE_OPTIONS=--inspect=0.0.0.0:9229 next dev --turbopack",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^1.2.12",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@llamaindex/chat-ui": "^0.5.17",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
|
|
@ -32,6 +37,7 @@
|
|||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
|
|
@ -41,18 +47,23 @@
|
|||
"@types/mdx": "^2.0.13",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"ai": "^4.3.19",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"emblor": "^1.4.8",
|
||||
"framer-motion": "^12.23.9",
|
||||
"fumadocs-core": "^15.6.6",
|
||||
"fumadocs-mdx": "^11.7.1",
|
||||
"fumadocs-ui": "^15.6.6",
|
||||
"geist": "^1.4.2",
|
||||
"lucide-react": "^0.477.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^15.4.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"postgres": "^3.4.7",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-dom": "^19.1.0",
|
||||
|
|
@ -76,13 +87,17 @@
|
|||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20.19.9",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
679
surfsense_web/pnpm-lock.yaml
generated
679
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
977
surfsense_web/public/contact/world.svg
Normal file
977
surfsense_web/public/contact/world.svg
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue