diff --git a/app/(team)/team/accept-invite/route.ts b/app/(team)/team/accept-invite/route.ts new file mode 100644 index 000000000..9abec8889 --- /dev/null +++ b/app/(team)/team/accept-invite/route.ts @@ -0,0 +1,66 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { CookieOptions, createServerClient } from "@supabase/ssr"; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const token = searchParams.get("token"); + const next = searchParams.get("next") ?? "/dashboard"; + + if (token) { + const cookieStore = cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value; + }, + set(name: string, value: string, options: CookieOptions) { + cookieStore.set({ name, value, ...options }); + }, + remove(name: string, options: CookieOptions) { + cookieStore.delete({ name, ...options }); + }, + }, + }, + ); + + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.redirect( + `${origin}/signin?next=/team/accept-invite?token=${token}`, + ); + } + + try { + const response = await fetch( + `${process.env.PEARAI_SERVER_URL}/team/accept-invite`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ token }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to accept invitation"); + } + + return NextResponse.redirect(`${origin}${next}`); + } catch (error) { + return NextResponse.redirect( + `${origin}/team/invite-error?error=accept-failed`, + ); + } + } + + return NextResponse.redirect(`${origin}/team/invite-error?error=no-token`); +} diff --git a/app/api/create-checkout-session/route.ts b/app/api/create-checkout-session/route.ts index 86b472c59..e5fb3ee0d 100644 --- a/app/api/create-checkout-session/route.ts +++ b/app/api/create-checkout-session/route.ts @@ -10,7 +10,7 @@ async function createCheckoutSession(request: NextRequest & { user: User }) { const supabase = createClient(); try { - const { priceId } = await request.json(); + const { priceId, teamName } = await request.json(); const { data: { session }, } = await supabase.auth.getSession(); @@ -65,7 +65,7 @@ async function createCheckoutSession(request: NextRequest & { user: User }) { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ priceId }), + body: JSON.stringify({ priceId, teamName }), }); if (!response.ok) { diff --git a/app/api/get-requests-usage/route.ts b/app/api/get-requests-usage/route.ts index d0b4e7402..2e8e6367d 100644 --- a/app/api/get-requests-usage/route.ts +++ b/app/api/get-requests-usage/route.ts @@ -3,22 +3,37 @@ import { withAuth } from "@/utils/withAuth"; import { createClient } from "@/utils/supabase/server"; const getRequestsUsage = async (request: NextRequest) => { + console.log("Starting getRequestsUsage function"); const supabase = createClient(); try { + console.log("Attempting to get session"); const { data: { session }, } = await supabase.auth.getSession(); if (!session) { + console.log("No session found"); return NextResponse.json( { error: "Failed to get session" }, { status: 401 }, ); } + console.log("Session found, extracting token and user metadata"); const token = session.access_token; - const res = await fetch(`${process.env.PEARAI_SERVER_URL}/get-usage`, { + // const isTeamOwner = session.user.user_metadata.is_team_owner; + const isTeamOwner = false; // TODO: fix this + + let endpoint = `${process.env.PEARAI_SERVER_URL}/user-quota-usage`; + if (isTeamOwner) { + console.log("User is team owner, using team quota endpoint"); + endpoint = `${process.env.PEARAI_SERVER_URL}/team-quota-usage`; + } + console.log(`Using endpoint: ${endpoint}`); + + console.log("Sending request to server"); + const res = await fetch(endpoint, { method: "GET", headers: { "Content-Type": "application/json", @@ -27,6 +42,7 @@ const getRequestsUsage = async (request: NextRequest) => { }); if (!res.ok) { + console.log(`Server responded with status: ${res.status}`); if (res.status === 401) { return NextResponse.json( { error: "Unauthorized. Please log in again." }, @@ -39,9 +55,12 @@ const getRequestsUsage = async (request: NextRequest) => { ); } + console.log("Successfully received data from server"); const data = await res.json(); + console.log("Data:", data); return NextResponse.json(data); } catch (error) { + console.error("Error in getRequestsUsage:", error); return NextResponse.json( { error: "Error getting requests usage" }, { status: 500 }, diff --git a/app/api/team/route.ts b/app/api/team/route.ts new file mode 100644 index 000000000..415ff6162 --- /dev/null +++ b/app/api/team/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/utils/supabase/server"; + +export async function POST(request: NextRequest) { + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { teamId, email, role } = await request.json(); + + try { + const response = await fetch( + `${process.env.PEARAI_SERVER_URL}/team/invite`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ team_id: teamId, email, role }), + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to send invitation"); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error("Error sending invitation:", error); + return NextResponse.json( + { error: "Failed to send invitation" }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: NextRequest) { + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { teamId, memberId } = await request.json(); + + try { + const response = await fetch( + `${process.env.PEARAI_SERVER_URL}/team/member`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ team_id: teamId, member_id: memberId }), + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to delete member"); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error("Error deleting member:", error); + return NextResponse.json( + { error: "Failed to delete member" }, + { status: 500 }, + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b28b49ceb..471e17c5b 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,5 +1,5 @@ import DashboardPage from "@/components/dashboard"; -import { getUserAndSubscription } from "@/lib/data-fetching"; +import { getUserAndSubscription, getTeamData } from "@/lib/data-fetching"; import { redirect } from "next/navigation"; import { constructMetadata } from "@/lib/utils"; import { Metadata } from "next/types"; @@ -22,11 +22,28 @@ export default async function Dashboard() { return redirect(redirectTo ?? "/signin"); } + let team = null; + let isTeamOwner = false; + + if (subscription && subscription.team_id) { + const { team: fetchedTeam, error } = await getTeamData( + subscription.team_id, + ); + if (fetchedTeam) { + team = fetchedTeam; + isTeamOwner = team.owner_id === user.id; + } else if (error) { + console.error("Error fetching team data:", error); + } + } + return ( ); } diff --git a/app/dashboard/team/page.tsx b/app/dashboard/team/page.tsx new file mode 100644 index 000000000..005cef47e --- /dev/null +++ b/app/dashboard/team/page.tsx @@ -0,0 +1,29 @@ +import EnterpriseDashboard from "@/components/team/enterprise-dashboard"; +import { constructMetadata } from "@/lib/utils"; +import { Metadata } from "next/types"; +import { getUserAndSubscription, getTeamData } from "@/lib/data-fetching"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = constructMetadata({ + title: "Team Dashboard", + description: "Manage your team, members, and roles.", + canonical: "/team/dashboard", +}); + +export default async function EnterpriseDashboardPage() { + const { user, subscription } = await getUserAndSubscription(); + + if (!user || !subscription || !subscription.team_id) { + redirect("/dashboard?error=unauthorized"); + } + + const { team, error } = await getTeamData(subscription.team_id); + + if (error || !team) { + redirect("/dashboard?error=team-not-found"); + } + + return ( + + ); +} diff --git a/app/team/new-team/page.tsx b/app/team/new-team/page.tsx new file mode 100644 index 000000000..8fa4e223b --- /dev/null +++ b/app/team/new-team/page.tsx @@ -0,0 +1,45 @@ +import { createClient } from "@/utils/supabase/server"; +import CreateTeamForm from "@/components/team/create-team-form"; +import { redirect } from "next/navigation"; + +export default async function NewTeamPage({ + searchParams, +}: { + searchParams: { yearly?: string }; +}) { + const supabase = createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const initialYearly = searchParams.yearly === "true"; + + if (!user) { + return
Please log in to create a team
; + } + + const handleSignOut = async () => { + "use server"; + const supabase = createClient(); + await supabase.auth.signOut(); + redirect("/signin"); + }; + + return ( +
+
+
+
+ +
+
+
+ ); +} diff --git a/components/dashboard.tsx b/components/dashboard.tsx index cc28e3369..2b41270bc 100644 --- a/components/dashboard.tsx +++ b/components/dashboard.tsx @@ -7,28 +7,43 @@ import { toast } from "sonner"; import ProfileCard from "@/components/dashboard/profile-card"; import SubscriptionCard from "@/components/dashboard/subscription-card"; import FreeTrialCard from "@/components/dashboard/freetrial-card"; +import { Team } from "@/types/team"; type DashboardPageProps = { subscription: Subscription | null; openAppQueryParams: string; user: User; + team: Team | null; + isTeamOwner: boolean; }; export type UsageType = { - percent_credit_used: number | null; + chat_usage: { + max_quota: number; + used_quota: number; + quota_remaining: number; + }; + autocomplete_usage: { + max_quota: number; + used_quota: number; + quota_remaining: number; + }; + percent_credit_used: { + percent_credit_used: number; + }; }; export default function DashboardPage({ subscription, openAppQueryParams, user, + team, + isTeamOwner, }: DashboardPageProps) { const searchParams = useSearchParams(); const router = useRouter(); const [loading, setLoading] = useState(true); - const [usage, setUsage] = useState({ - percent_credit_used: null, - }); + const [usage, setUsage] = useState(null); useEffect(() => { const handleCallbackForApp = async () => { @@ -66,8 +81,8 @@ export default function DashboardPage({ toast.error("Failed to fetch requests usage."); return; } - const usage = await response.json(); - setUsage(usage); + const usageData: UsageType = await response.json(); + setUsage(usageData); } catch (error) { toast.error(`Error fetching requests usage: ${error}`); } finally { @@ -91,7 +106,6 @@ export default function DashboardPage({
- {/* Below commented out until we implement Free Trial */} {subscription ? ( ) : ( PearAI Credits

- {loading ? ( + {loading || !usage ? ( "-" ) : ( - {usage?.percent_credit_used != null - ? `${usage.percent_credit_used}%` + {usage.percent_credit_used?.percent_credit_used != null + ? `${usage.percent_credit_used.percent_credit_used}%` : "Cannot find used percentage. Please contact PearAI support."} )} @@ -53,13 +53,15 @@ export default function FreeTrialCard({

- {loading ? "-" : usage.percent_credit_used}% of free trial PearAI - Credits used + {loading || !usage + ? "-" + : `${usage.percent_credit_used?.percent_credit_used ?? 0}%`}{" "} + of free trial PearAI Credits used

diff --git a/components/dashboard/subscription-card.tsx b/components/dashboard/subscription-card.tsx index f0e8e366b..69f1a526f 100644 --- a/components/dashboard/subscription-card.tsx +++ b/components/dashboard/subscription-card.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { Subscription } from "@/types/subscription"; -import { capitalizeInital } from "@/lib/utils"; +import { capitalizeInitial } from "@/lib/utils"; import { Card, CardHeader, @@ -23,15 +23,17 @@ import { import { useState } from "react"; import { useCancelSubscription } from "@/hooks/useCancelSubscription"; import { User } from "@supabase/supabase-js"; -import { Info } from "lucide-react"; +import { Info, Users } from "lucide-react"; import { UsageType } from "../dashboard"; type SubscriptionCardProps = { subscription: Subscription | null; - usage?: UsageType; + usage: UsageType | null; openAppQueryParams?: string; user: User; loading: boolean; + teamName?: string; + isTeamOwner?: boolean; }; const DEFAULT_OPEN_APP_CALLBACK = "pearai://pearai.pearai/auth"; @@ -42,10 +44,17 @@ export default function SubscriptionCard({ openAppQueryParams, user, loading, + teamName, + isTeamOwner, }: SubscriptionCardProps) { const [isDialogOpen, setIsDialogOpen] = useState(false); const { handleCancelSubscription, isCanceling, isCanceled } = useCancelSubscription(user, subscription); + const percentUsed = usage?.percent_credit_used.percent_credit_used ?? 0; + const chatUsed = usage?.chat_usage.used_quota ?? 0; + const chatRemaining = usage?.chat_usage.quota_remaining ?? 0; + const autocompleteUsed = usage?.autocomplete_usage.used_quota ?? 0; + const autocompleteRemaining = usage?.autocomplete_usage.quota_remaining ?? 0; const handleCancelClick = () => { if (isCanceled) { @@ -92,6 +101,8 @@ export default function SubscriptionCard({ ); } + const isEnterprise = subscription?.pricing_tier.startsWith("enterprise_"); + return (
@@ -104,7 +115,9 @@ export default function SubscriptionCard({ variant="secondary" className="border-primary-800 bg-primary-800/10 px-2 py-1 text-xs text-primary-800" > - Pro - {capitalizeInital(subscription.pricing_tier)} + {isEnterprise + ? `Enterprise - ${subscription.pricing_tier.includes("monthly") ? "Monthly" : "Yearly"}` + : `Pro - ${capitalizeInitial(subscription.pricing_tier)}`}
@@ -112,55 +125,67 @@ export default function SubscriptionCard({ {usage && (
-

Requests

+

PearAI Credits

{loading ? ( "-" ) : ( - {usage?.percent_credit_used != null - ? `${usage.percent_credit_used}%` + {usage.percent_credit_used.percent_credit_used != null + ? `${usage.percent_credit_used.percent_credit_used}%` : "Cannot find remaining percentage. Please contact PearAI support."} )}

{loading ? "-" - : `${usage.percent_credit_used ?? 0}% of PearAI Credits used`} + : `${usage.percent_credit_used.percent_credit_used ?? 0}% of PearAI Credits used`}

)} -
-
-

Current Plan

-

- {capitalizeInital(subscription.pricing_tier)} -

-
-
-
-
-

Current Period

-

- {new Date( - subscription.current_period_start * 1000, - ).toLocaleDateString()}{" "} - -{" "} - {subscription.current_period_end - ? new Date( - subscription.current_period_end * 1000, - ).toLocaleDateString() - : "Now"} -

+ {/* {isEnterprise && ( +
+
+

Team Name

+

{teamName || "N/A"}

+
-
+ )} */} + {!isEnterprise && ( + <> +
+
+

Current Plan

+

+ {capitalizeInitial(subscription.pricing_tier)} +

+
+
+
+
+

Current Period

+

+ {new Date( + subscription.current_period_start * 1000, + ).toLocaleDateString()}{" "} + -{" "} + {subscription.current_period_end + ? new Date( + subscription.current_period_end * 1000, + ).toLocaleDateString() + : "Now"} +

+
+
+ + )}
- - - - - - - Cancel Subscription - - Are you sure you want to cancel your subscription? - You'll lose access to premium features at the end of - your current billing period. - - - - + {isEnterprise && isTeamOwner && ( + + )} + + {!isEnterprise && ( + + - - - + + + + Cancel Subscription + + Are you sure you want to cancel your subscription? + You'll lose access to premium features at the end of + your current billing period. + + + + + + + + + )}
diff --git a/components/pricing.tsx b/components/pricing.tsx index cc8549555..b1df4cc74 100755 --- a/components/pricing.tsx +++ b/components/pricing.tsx @@ -45,6 +45,7 @@ const PricingTier: React.FC = ({ features, buttonText, isFree = false, + isEnterprise = false, priceId, user, index, @@ -78,6 +79,11 @@ const PricingTier: React.FC = ({ } : {}; + const handleEnterpriseClick = (isYearly: boolean) => { + console.log("Create team:", { isYearly }); + router.push(`/team/new-team?yearly=${isYearly}`); + }; + const handleDownload = async (os_type: string) => { setIsDownloading(true); try { @@ -310,9 +316,16 @@ const PricingTier: React.FC = ({ {!isFree && ( <> - {disabled ? ( - ) : (
))} diff --git a/components/team/create-team-form.tsx b/components/team/create-team-form.tsx new file mode 100644 index 000000000..926ed087c --- /dev/null +++ b/components/team/create-team-form.tsx @@ -0,0 +1,186 @@ +"use client"; + +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCheckout } from "@/hooks/useCheckout"; +import { PRICING_TIERS, STRIPE_PRICE_IDS } from "@/utils/constants"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, CreditCard } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CreateTeamFormData, createTeamSchema } from "@/utils/form-schema"; +import { CreateTeamFormProps } from "@/types/team"; + +export default function CreateTeamForm({ + user, + initialYearly, + handleSignOut, +}: CreateTeamFormProps) { + const { handleCheckout, isSubmitting } = useCheckout(user); + + const form = useForm({ + resolver: zodResolver(createTeamSchema), + defaultValues: { + teamName: "", + isYearly: initialYearly, + }, + }); + + const isYearly = form.watch("isYearly"); + + const tierData = PRICING_TIERS.enterprise.find( + (tier) => tier.title.toLowerCase() === (isYearly ? "yearly" : "monthly"), + ); + const price = tierData?.price || "0"; + const priceId = isYearly + ? STRIPE_PRICE_IDS.ENTERPRISE_ANNUAL + : STRIPE_PRICE_IDS.ENTERPRISE_MONTHLY; + + const handleSubmit = async (data: CreateTeamFormData) => { + try { + await handleCheckout(priceId, data.teamName); + console.log("Team created successfully! Redirecting to checkout..."); + } catch (error) { + console.error("Checkout failed:", error); + } + }; + + const annualSavings = ( + parseFloat(price) * 12 - + parseFloat(price) * 10 + ).toFixed(2); + + return ( + <> + + + + Create a Team + + + +
+ + + + Logged in as: {user.email} + + + ( + + Team name +
+ + + + + + +
+ ( + +
+ + + + + field.onChange(!field.value) + } + > + Yearly + +
+
+ )} + /> +
+
+ + {isYearly + ? `You save $${annualSavings} per user annually` + : "Switch to yearly billing to save"} + +
+
+
+ +
+ )} + /> + +
+
+ + Selected Plan +
+

+ ${price}{" "} + + / user / {isYearly ? "year" : "month"} + +

+
+ + + + +
+ +
+ +
+
+
+ + ); +} diff --git a/components/team/enterprise-dashboard.tsx b/components/team/enterprise-dashboard.tsx new file mode 100644 index 000000000..17522cc58 --- /dev/null +++ b/components/team/enterprise-dashboard.tsx @@ -0,0 +1,341 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { PlusIcon, Pencil1Icon, TrashIcon } from "@radix-ui/react-icons"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Team, TeamMember, TeamInvite } from "@/types/team"; +import { Subscription } from "@/types/subscription"; +import { User } from "@supabase/auth-js"; +import { capitalizeInitial } from "@/lib/utils"; +import { toast } from "sonner"; + +type EnterpriseDashboardProps = { + team: Team; + user: User; + subscription: Subscription; +}; + +export default function EnterpriseDashboard({ + team, + user, + subscription, +}: EnterpriseDashboardProps) { + const [teamName, setTeamName] = useState(team.name); + const [isEditingTeamName, setIsEditingTeamName] = useState(false); + const [members, setMembers] = useState(team.members); + const [invites, setInvites] = useState(team.invites); + const [newMember, setNewMember] = useState({ + email: "", + role: "member" as "admin" | "member", + }); + const [editingId, setEditingId] = useState(null); + const [teamUsage, setTeamUsage] = useState(null); + + const isOwner = (memberId: string) => { + return members.find((m) => m.id === memberId)?.role === "owner"; + }; + + const isCurrentUser = (memberId: string) => { + return memberId === user.id; + }; + + const canEditMember = (memberId: string) => { + return !isOwner(memberId) && !isCurrentUser(memberId); + }; + + const addMember = async () => { + try { + const response = await fetch("/api/team", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + teamId: team.id, + email: newMember.email, + role: newMember.role, + }), + }); + + if (!response.ok) { + throw new Error("Failed to send invitation"); + } + + const newInvite: TeamInvite = { + id: Date.now().toString(), // Temporary ID, should be replaced with the one from the server + ...newMember, + status: "pending", + }; + setInvites((prevInvites) => [...(prevInvites || []), newInvite]); + setNewMember({ email: "", role: "member" }); + // Show success message to user + toast.success("Invitation sent successfully"); + } catch (error) { + console.error("Error sending invitation:", error); + // Show error message to user + toast.error("Failed to send invitation"); + } + }; + const updateMember = (id: string) => { + // In a real app, this would call an API to update the member + setMembers( + members.map((member) => + member.id === id + ? { ...member, role: newMember.role as "owner" | "admin" | "member" } + : member, + ), + ); + setEditingId(null); + setNewMember({ email: "", role: "member" }); + }; + + const deleteMember = async (id: string) => { + try { + const response = await fetch("/api/team/member", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + teamId: team.id, + memberId: id, + }), + }); + + if (!response.ok) { + throw new Error("Failed to delete member"); + } + + setMembers(members.filter((member) => member.id !== id)); + toast.success("Member removed successfully"); + } catch (error) { + console.error("Error deleting member:", error); + toast.error("Failed to remove member"); + } + }; + + const deleteInvite = (id: string) => { + // In a real app, this would call an API to cancel the invitation + setInvites(invites.filter((invite) => invite.id !== id)); + }; + useEffect(() => { + const getTeamUsage = async () => { + try { + const response = await fetch("/api/get-requests-usage", { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch team usage"); + } + + const usageData = await response.json(); + setTeamUsage(usageData); + } catch (error) { + console.error("Error fetching team usage:", error); + toast.error("Failed to fetch team usage"); + } + }; + + getTeamUsage(); + }, []); + return ( +
+ + + + {isEditingTeamName ? ( + setTeamName(e.target.value)} + className="max-w-sm" + /> + ) : ( + teamName + )} + + + + +
+

+ Manage your team members and their roles +

+
+ + Enterprise -{" "} + {subscription.pricing_tier.includes("monthly") + ? "Monthly" + : "Yearly"} + +

+ Next billing date:{" "} + {new Date( + subscription.current_period_end * 1000, + ).toLocaleDateString()} +

+
+
+
+
+ +
+

Team Members

+ + + + Name + Email + Role + Actions + + + + {members.map((member) => ( + + {member.name} + {member.email} + + {editingId === member.id ? ( + + ) : ( + capitalizeInitial(member.role) + )} + + + {editingId === member.id ? ( + + ) : ( + <> + {canEditMember(member.id) && ( + <> + + + + )} + + )} + + + ))} + +
+
+ +
+

Invite New Member

+
+ + setNewMember({ ...newMember, email: e.target.value }) + } + /> + + +
+ + {invites.length > 0 && ( +
+

Pending Invitations

+ + + + Email + Role + Actions + + + + {invites.map((invite) => ( + + {invite.email} + {invite.role} + + + + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 000000000..416157cc2 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + info: "rounded-md ring-1 ring-gray-400/40 bg-gray-300/10 backdrop-blur-md", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 000000000..9218f68ba --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 000000000..73cf6b4ad --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index 20021083f..12406bc9b 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -32,7 +32,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-hidden rounded-md border border-border bg-background px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} diff --git a/hooks/useCheckout.ts b/hooks/useCheckout.ts index d3ee89d52..51ea8e175 100644 --- a/hooks/useCheckout.ts +++ b/hooks/useCheckout.ts @@ -7,7 +7,10 @@ export const useCheckout = (user: User | null) => { const [isSubmitting, setIsSubmitting] = useState(false); const router = useRouter(); - const handleCheckout = async (priceId: string) => { + const handleCheckout = async ( + priceId: string, + teamName: string | null = null, + ) => { if (!user) { toast.error("Please log in to subscribe to this plan."); router.push("/signin"); @@ -21,7 +24,7 @@ export const useCheckout = (user: User | null) => { const response = await fetch("/api/create-checkout-session", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ priceId }), + body: JSON.stringify({ priceId, teamName }), }); if (!response.ok) { diff --git a/lib/data-fetching.ts b/lib/data-fetching.ts index c2c0219aa..652b8bd09 100644 --- a/lib/data-fetching.ts +++ b/lib/data-fetching.ts @@ -1,6 +1,7 @@ import { Subscription } from "@/types/subscription"; import { createClient } from "@/utils/supabase/server"; import { User } from "@supabase/auth-js"; +import { Team, TeamInvite, TeamMember } from "@/types/team"; type GetUserSubscriptionResult = { user: User | null; @@ -9,6 +10,11 @@ type GetUserSubscriptionResult = { redirect: string | null; }; +type GetTeamDataResult = { + team: Team | null; + error: string | null; +}; + export async function getUserAndSubscription(): Promise { const supabase = createClient(); @@ -24,22 +30,65 @@ export async function getUserAndSubscription(): Promise { + const supabase = createClient(); + + try { + // Fetch team data + const { data: teamData, error: teamError } = await supabase + .from("teams") + .select("id, name, owner_id") + .eq("id", teamId) + .single(); + + if (teamError) throw teamError; + + // Fetch active team members + const { data: membersData, error: membersError } = await supabase + .from("team_members") + .select("id, user_id, role") + .eq("team_id", teamId); + + if (membersError) throw membersError; + + // Fetch pending invites + const { data: invitesData, error: invitesError } = await supabase + .from("team_invites") + .select("id, email, role") + .eq("team_id", teamId) + .eq("status", "pending"); + + if (invitesError) throw invitesError; + + // Fetch user details for active members + const memberPromises = membersData.map(async (member) => { + const { data: userData, error: userError } = await supabase + .from("profiles") + .select("first_name, last_name, email") + .eq("user_id", member.user_id) + .single(); + + if (userError) throw userError; + + const lastName = userData.last_name ?? ""; + const fullName = `${userData.first_name} ${lastName}`.trim(); + + return { + id: member.id, + name: fullName, + email: userData.email, + role: member.role as "owner" | "admin" | "member", + }; + }); + + const members = await Promise.all(memberPromises); + + const invites: TeamInvite[] = invitesData.map((invite) => ({ + id: invite.id, + email: invite.email, + role: invite.role as "admin" | "member", + status: "pending", + })); + + const team: Team = { + ...teamData, + members, + invites, + }; + + return { team, error: null }; + } catch (error) { + console.error("Error fetching team data:", error); + return { team: null, error: "Failed to fetch team data" }; + } +} diff --git a/lib/utils.ts b/lib/utils.ts index 122465764..8969c3a2e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -94,7 +94,7 @@ export const getURL = () => { return url; }; -export function capitalizeInital(input: unknown): string | undefined { +export function capitalizeInitial(input: unknown): string | undefined { if (typeof input !== "string") { return ""; } diff --git a/package.json b/package.json index 7085caf79..93936d45a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-popover": "^1.0.7", diff --git a/types/pricing.ts b/types/pricing.ts index 457319086..36947e387 100644 --- a/types/pricing.ts +++ b/types/pricing.ts @@ -12,6 +12,7 @@ export interface PricingTierProps { user: User | null; index: number; priceUnit?: string; // Added new field + isEnterprise?: boolean; // Added new field } export interface PricingPageProps { diff --git a/types/subscription.ts b/types/subscription.ts index bb6062816..af9e9a558 100644 --- a/types/subscription.ts +++ b/types/subscription.ts @@ -9,4 +9,5 @@ export interface Subscription { canceled_at?: string; created_at?: string; updated_at?: string; + team_id?: string; } diff --git a/types/team.ts b/types/team.ts new file mode 100644 index 000000000..924c7c652 --- /dev/null +++ b/types/team.ts @@ -0,0 +1,29 @@ +import { User } from "@supabase/supabase-js"; + +export interface TeamMember { + id: string; + name: string; + email: string; + role: "owner" | "admin" | "member"; +} + +export interface Team { + id: string; + name: string; + owner_id: string; + members: TeamMember[]; + invites?: TeamInvite[]; +} + +export interface TeamInvite { + id: string; + email: string; + role: "admin" | "member"; + status: "pending"; +} + +export interface CreateTeamFormProps { + user: User; + initialYearly: boolean; + handleSignOut: () => Promise; +} diff --git a/utils/constants.ts b/utils/constants.ts index 5032a1c28..56ed99803 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -16,11 +16,21 @@ const NEXT_PUBLIC_STRIPE_WAITLIST_PRICE_ID = "price_1PZ9X608N4O93LU5yqMbGDtu"; const NEXT_PUBLIC_STRIPE_WAITLIST_PRICE_ID_TEST = "price_1PZUT208N4O93LU5jItKoEYu"; const NEXT_PUBLIC_STRIPE_MONTHLY_PRICE_ID = "price_1PoZiZ08N4O93LU5kCrdrXvI"; +// const NEXT_PUBLIC_STRIPE_MONTHLY_PRICE_ID_TEST = +// "price_1Ppa9408N4O93LU5irNxLp5p"; const NEXT_PUBLIC_STRIPE_MONTHLY_PRICE_ID_TEST = - "price_1Ppa9408N4O93LU5irNxLp5p"; + "price_1PaMw2DipRqiTgC40064Io1w"; const NEXT_PUBLIC_STRIPE_ANNUAL_PRICE_ID = "price_1PpZUO08N4O93LU5FYFUyh43"; +// const NEXT_PUBLIC_STRIPE_ANNUAL_PRICE_ID_TEST = +// "price_1PZUSi08N4O93LU5UVdlkfp2"; const NEXT_PUBLIC_STRIPE_ANNUAL_PRICE_ID_TEST = - "price_1PZUSi08N4O93LU5UVdlkfp2"; + "price_1PaMx7DipRqiTgC4vL0KbVm7"; +const NEXT_PUBLIC_STRIPE_ENTERPRISE_MONTHLY_PRICE_ID = ""; +const NEXT_PUBLIC_STRIPE_ENTERPRISE_MONTHLY_PRICE_ID_TEST = + "price_1PxJtDDipRqiTgC47WFM22pv"; +const NEXT_PUBLIC_STRIPE_ENTERPRISE_ANNUAL_PRICE_ID = ""; +const NEXT_PUBLIC_STRIPE_ENTERPRISE_ANNUAL_PRICE_ID_TEST = + "price_1PxJu0DipRqiTgC4VirNSxfJ"; export const STRIPE_PRICE_IDS = { WAITLIST: TEST_MODE_ENABLED @@ -32,6 +42,12 @@ export const STRIPE_PRICE_IDS = { ANNUAL: TEST_MODE_ENABLED ? NEXT_PUBLIC_STRIPE_ANNUAL_PRICE_ID_TEST : NEXT_PUBLIC_STRIPE_ANNUAL_PRICE_ID, + ENTERPRISE_MONTHLY: TEST_MODE_ENABLED + ? NEXT_PUBLIC_STRIPE_ENTERPRISE_MONTHLY_PRICE_ID_TEST + : NEXT_PUBLIC_STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, + ENTERPRISE_ANNUAL: TEST_MODE_ENABLED + ? NEXT_PUBLIC_STRIPE_ENTERPRISE_ANNUAL_PRICE_ID_TEST + : NEXT_PUBLIC_STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, }; export const PRICING_TIERS: { @@ -94,8 +110,9 @@ export const PRICING_TIERS: { "Private Discord Channel", ], buttonText: "Get Started", - priceId: STRIPE_PRICE_IDS.MONTHLY, + priceId: STRIPE_PRICE_IDS.ENTERPRISE_MONTHLY, index: 0, + isEnterprise: true, }, { title: "Yearly", @@ -104,8 +121,9 @@ export const PRICING_TIERS: { description: "Pay one lump sum yearly for our highest priority tier.", features: ["Everything from monthly", "Priority Customer Support"], buttonText: "Get Started", - priceId: STRIPE_PRICE_IDS.ANNUAL, + priceId: STRIPE_PRICE_IDS.ENTERPRISE_ANNUAL, index: 1, + isEnterprise: true, }, ], }; diff --git a/utils/form-schema.ts b/utils/form-schema.ts index a4e358957..22cc61e09 100644 --- a/utils/form-schema.ts +++ b/utils/form-schema.ts @@ -56,7 +56,16 @@ export const updatePasswordSchema = z path: ["confirmPassword"], }); +export const createTeamSchema = z.object({ + teamName: z + .string() + .min(3, "Team name must be at least 3 characters long") + .max(30, "Team name must be at most 30 characters long"), + isYearly: z.boolean().default(false), +}); + export type SignUpFormData = z.infer; export type SignInFormData = z.infer; export type ResetPasswordFormData = z.infer; export type UpdatePasswordFormData = z.infer; +export type CreateTeamFormData = z.infer; diff --git a/yarn.lock b/yarn.lock index 2f206b7bf..8707f9142 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1950,6 +1950,11 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-icons@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69" + integrity sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw== + "@radix-ui/react-id@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed" @@ -2093,7 +2098,7 @@ "@radix-ui/react-select@^2.1.1": version "2.1.1" - resolved "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.1.tgz#df05cb0b29d3deaef83b505917c4042e0e418a9f" + resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.1.tgz#df05cb0b29d3deaef83b505917c4042e0e418a9f" integrity sha512-8iRDfyLtzxlprOo9IicnzvpsO1wNCkuwzzCM+Z5Rb5tNOpCdMvcc2AkzX0Fz+Tz9v6NJ5B/7EEgyZveo4FBRfQ== dependencies: "@radix-ui/number" "1.1.0"