mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
....
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
create table if not exists contacts (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
user_id uuid references auth.users(id) on delete cascade,
|
||||
name text not null,
|
||||
phone text not null,
|
||||
avatar_url text,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
alter table contacts enable row level security;
|
||||
|
||||
create policy "Users see own contacts"
|
||||
on contacts for select
|
||||
using (auth.uid() = user_id);
|
||||
|
||||
create policy "Users insert own contacts"
|
||||
on contacts for insert
|
||||
with check (auth.uid() = user_id);
|
||||
|
||||
create policy "Users delete own contacts"
|
||||
on contacts for delete
|
||||
using (auth.uid() = user_id);
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, X } from "lucide-react"
|
||||
import { getSupabase } from "@/lib/supabase"
|
||||
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||
|
||||
interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface VoiceCallModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
const [contactsError, setContactsError] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [phoneNumber, setPhoneNumber] = useState("")
|
||||
const [phoneError, setPhoneError] = useState(false)
|
||||
const [lookupMessage, setLookupMessage] = useState<string | null>(null)
|
||||
const setLookupMessageWithReset = useCallback((msg: string | null) => {
|
||||
setLookupMessage(msg)
|
||||
if (msg !== "This phone number is not registered on the platform.") {
|
||||
setInviteUrl(null)
|
||||
}
|
||||
}, [])
|
||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null)
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
|
||||
const generateInvite = useCallback(async (phone: string) => {
|
||||
setInviteLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/invite/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.inviteUrl) {
|
||||
setInviteUrl(data.inviteUrl)
|
||||
}
|
||||
} catch {
|
||||
// fallback: generate client-side
|
||||
const token = crypto.randomUUID?.() || Math.random().toString(36).slice(2)
|
||||
setInviteUrl(`${window.location.origin}/join/${token}`)
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const {
|
||||
isReady,
|
||||
isCallActive,
|
||||
isIncoming,
|
||||
isMuted,
|
||||
isSpeakerOn,
|
||||
callState,
|
||||
callDuration,
|
||||
error: callError,
|
||||
callerInfo,
|
||||
makeCall,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
formatDuration,
|
||||
} = useWebRTCCall()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && callState !== "idle") {
|
||||
endCall()
|
||||
}
|
||||
}, [open, callState, endCall])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery("")
|
||||
setPhoneNumber("")
|
||||
setPhoneError(false)
|
||||
return
|
||||
}
|
||||
const fetchContacts = async () => {
|
||||
setContactsLoading(true)
|
||||
setContactsError(false)
|
||||
try {
|
||||
const sb = getSupabase()
|
||||
const { data, error } = await sb
|
||||
.from("contacts")
|
||||
.select("id, name, phone, avatar_url")
|
||||
.order("name", { ascending: true })
|
||||
if (error) throw error
|
||||
setContacts(data || [])
|
||||
} catch {
|
||||
setContactsError(true)
|
||||
} finally {
|
||||
setContactsLoading(false)
|
||||
}
|
||||
}
|
||||
fetchContacts()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
const filteredContacts = contacts.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.phone.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
CONTACTS
|
||||
</p>
|
||||
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search contacts..."
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
|
||||
{contactsLoading && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
||||
)}
|
||||
{contactsError && (
|
||||
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
|
||||
{contact.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
alt={contact.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
contact.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
|
||||
<p className="text-xs text-[#888888] dark:text-[#666666] truncate">{contact.phone}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => makeCall(contact.phone)}
|
||||
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
DIAL A NUMBER
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
|
||||
placeholder="+27 000 000 0000"
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
{phoneError && (
|
||||
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
|
||||
)}
|
||||
|
||||
{isIncoming ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="text-2xl font-bold dark:text-white text-[#111111] mb-1">
|
||||
Incoming Call
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
|
||||
{callerInfo?.firstName?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white">
|
||||
{callerInfo?.firstName} {callerInfo?.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-[#888888]">{callerInfo?.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[#888888] text-sm mb-6 animate-pulse">Ringing...</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={rejectCall}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptCall}
|
||||
className="w-14 h-14 rounded-full bg-[#00AA00] hover:bg-[#008800] dark:bg-[#00CC00] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : isCallActive ? (
|
||||
<div className="text-center py-4">
|
||||
{callState === "calling" && (
|
||||
<>
|
||||
<div className="text-xl font-bold dark:text-white text-[#111111] mb-4">
|
||||
{phoneNumber}
|
||||
</div>
|
||||
<p className="text-[#888888] text-sm mb-4 animate-pulse">Calling...</p>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={endCall}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{callState === "ringing" && (
|
||||
<>
|
||||
<div className="text-xl font-bold dark:text-white text-[#111111] mb-4">
|
||||
{phoneNumber}
|
||||
</div>
|
||||
<p className="text-[#888888] text-sm mb-4 animate-pulse">Ringing...</p>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={endCall}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{callState === "connecting" && (
|
||||
<>
|
||||
<div className="text-xl font-bold dark:text-white text-[#111111] mb-4">
|
||||
{phoneNumber}
|
||||
</div>
|
||||
<p className="text-[#888888] text-sm mb-4 animate-pulse">Connecting...</p>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={endCall}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{callState === "connected" && (
|
||||
<>
|
||||
<div className="text-2xl font-bold dark:text-white text-[#111111] mb-1">
|
||||
{phoneNumber}
|
||||
</div>
|
||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
||||
{formatDuration(callDuration)}
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={toggleSpeaker}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={endCall}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(callState === "ended" || callState === "missed" || callState === "rejected" || callState === "offline" || callState === "busy") && (
|
||||
<>
|
||||
<div className="text-xl font-bold dark:text-white text-[#111111] mb-4">
|
||||
{phoneNumber}
|
||||
</div>
|
||||
<p className="text-[#888888] text-sm mb-4">
|
||||
{callState === "missed" && "This phone number is not registered on the platform."}
|
||||
{callState === "offline" && "User is offline"}
|
||||
{callState === "rejected" && "Call rejected"}
|
||||
{callState === "busy" && "User is busy"}
|
||||
{callState === "ended" && "Call ended"}
|
||||
</p>
|
||||
{callState === "missed" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{!inviteUrl ? (
|
||||
<button
|
||||
onClick={() => generateInvite(phoneNumber)}
|
||||
disabled={inviteLoading}
|
||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
{inviteLoading ? "Generating..." : "Invite User"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-[#888888] break-all bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
{inviteUrl}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(inviteUrl)}
|
||||
className="flex-1 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ url: inviteUrl, title: "Join me on this platform!" })
|
||||
} else {
|
||||
navigator.clipboard.writeText(inviteUrl)
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{lookupMessage ? (
|
||||
<div className="text-center">
|
||||
<p className="text-[#888888] text-sm mb-4">{lookupMessage}</p>
|
||||
{lookupMessage === "This phone number is not registered on the platform." && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{!inviteUrl ? (
|
||||
<button
|
||||
onClick={() => generateInvite(phoneNumber)}
|
||||
disabled={inviteLoading}
|
||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
{inviteLoading ? "Generating..." : "Invite User"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-[#888888] break-all bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
{inviteUrl}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(inviteUrl)
|
||||
setLookupMessageWithReset(null)
|
||||
}}
|
||||
className="flex-1 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ url: inviteUrl, title: "Join me on this platform!" })
|
||||
} else {
|
||||
navigator.clipboard.writeText(inviteUrl)
|
||||
}
|
||||
setLookupMessageWithReset(null)
|
||||
}}
|
||||
className="flex-1 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const trimmed = phoneNumber.trim()
|
||||
if (!trimmed) {
|
||||
setPhoneError(true)
|
||||
return
|
||||
}
|
||||
setPhoneError(false)
|
||||
setLookupMessageWithReset(null)
|
||||
try {
|
||||
const res = await fetch(`/api/users/lookup?phone=${encodeURIComponent(trimmed)}`)
|
||||
const data = await res.json()
|
||||
if (!data.found) {
|
||||
setLookupMessageWithReset("This phone number is not registered on the platform.")
|
||||
return
|
||||
}
|
||||
makeCall(trimmed)
|
||||
} catch {
|
||||
if (isReady) {
|
||||
makeCall(trimmed)
|
||||
} else {
|
||||
setLookupMessageWithReset("Could not verify phone number. Please try again.")
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call Now
|
||||
</button>
|
||||
{callError && (
|
||||
<p className="text-[#CC0000] text-xs mt-2 text-center">{callError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js"
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""
|
||||
|
||||
let client: SupabaseClient | null = null
|
||||
|
||||
if (supabaseUrl && supabaseAnonKey) {
|
||||
client = createClient(supabaseUrl, supabaseAnonKey)
|
||||
}
|
||||
|
||||
export function getSupabase(): SupabaseClient {
|
||||
if (!client) {
|
||||
throw new Error("Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.")
|
||||
}
|
||||
return client
|
||||
}
|
||||
Reference in New Issue
Block a user