fix: make supplier invite flow seamless

Made-with: Cursor
This commit is contained in:
Saad Mohsin 2026-03-31 18:16:15 +05:00
parent 036ad53acb
commit b426372f89
3 changed files with 163 additions and 35 deletions

View File

@ -99,7 +99,7 @@ export async function PUT(
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
} }
// Verify connection belongs to supplier // First try normal connection update flow
const connection = await prisma.connection.findFirst({ const connection = await prisma.connection.findFirst({
where: { where: {
id: connectionId, id: connectionId,
@ -107,18 +107,80 @@ export async function PUT(
} }
}) })
if (!connection) { if (connection) {
return NextResponse.json({ message: "Connection not found" }, { status: 404 }) const updatedConnection = await prisma.connection.update({
where: { id: connectionId },
data: { status: status as "CONNECTED" | "REJECTED" }
})
return NextResponse.json({
message: `Connection ${status.toLowerCase()} successfully`,
connection: updatedConnection
})
} }
const updatedConnection = await prisma.connection.update({ // If not a connection, try treating `connectionId` as a pending invitation id.
where: { id: connectionId }, const invitation = await prisma.invitation.findFirst({
data: { status: status as "CONNECTED" | "REJECTED" } where: {
id: connectionId,
supplierId: supplierProfile.id,
used: false,
expiresAt: {
gt: new Date()
}
}
})
if (!invitation) {
return NextResponse.json({ message: "Connection or invitation not found" }, { status: 404 })
}
if (status === "CONNECTED") {
// Accept invitation: mark used and create connection in one transaction.
const createdConnection = await prisma.$transaction(async (tx) => {
await tx.invitation.update({
where: { id: invitation.id },
data: {
used: true,
usedAt: new Date()
}
})
return tx.connection.upsert({
where: {
storeId_supplierId: {
storeId: invitation.storeId,
supplierId: invitation.supplierId
}
},
update: {
status: "CONNECTED"
},
create: {
storeId: invitation.storeId,
supplierId: invitation.supplierId,
status: "CONNECTED"
}
})
})
return NextResponse.json({
message: "Invitation accepted successfully",
connection: createdConnection
})
}
// Reject invitation: consume it so it disappears from pending list.
await prisma.invitation.update({
where: { id: invitation.id },
data: {
used: true,
usedAt: new Date()
}
}) })
return NextResponse.json({ return NextResponse.json({
message: `Connection ${status.toLowerCase()} successfully`, message: "Invitation rejected successfully"
connection: updatedConnection
}) })
} catch (error) { } catch (error) {
console.error("Error updating connection:", error) console.error("Error updating connection:", error)

View File

@ -48,7 +48,60 @@ export async function GET() {
} }
}) })
return NextResponse.json({ connections }) // Include pending invitations so supplier sees new invites immediately
const pendingInvitations = await prisma.invitation.findMany({
where: {
supplierId: supplierProfile.id,
used: false,
expiresAt: {
gt: new Date()
}
},
include: {
store: {
select: {
id: true,
businessName: true,
shopifyDomain: true,
shopifyStoreName: true,
user: {
select: {
name: true,
email: true
}
}
}
}
},
orderBy: {
createdAt: "desc"
}
})
type ConnectionRow = (typeof connections)[number]
type InvitationRow = (typeof pendingInvitations)[number]
const connectionStoreIds = new Set(
connections.map((c: ConnectionRow) => c.store.id)
)
// If a real connection already exists for a store, hide pending invitation for that same store.
const pendingAsConnections = pendingInvitations
.filter((inv: InvitationRow) => !connectionStoreIds.has(inv.store.id))
.map((inv: InvitationRow) => ({
id: inv.id,
status: "PENDING" as const,
createdAt: inv.createdAt,
store: inv.store,
invitationToken: inv.token,
invitationExpiresAt: inv.expiresAt,
}))
const allConnections = [...connections, ...pendingAsConnections].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
return NextResponse.json({ connections: allConnections })
} catch (error) { } catch (error) {
console.error("Error fetching vendor connections:", error) console.error("Error fetching vendor connections:", error)
return NextResponse.json( return NextResponse.json(

View File

@ -65,10 +65,13 @@ function SupplierVendorsContent() {
const [invitationToken, setInvitationToken] = useState("") const [invitationToken, setInvitationToken] = useState("")
const [acceptingToken, setAcceptingToken] = useState(false) const [acceptingToken, setAcceptingToken] = useState(false)
const [showProductSyncs, setShowProductSyncs] = useState(false) const [showProductSyncs, setShowProductSyncs] = useState(false)
const [isInitialLoad, setIsInitialLoad] = useState(true)
useEffect(() => { useEffect(() => {
fetchConnections() void (async () => {
fetchProductSyncs() await Promise.all([fetchConnections({ silent: false }), fetchProductSyncs()])
setIsInitialLoad(false)
})()
// Check if token is in URL // Check if token is in URL
const token = searchParams.get("token") const token = searchParams.get("token")
@ -78,19 +81,15 @@ function SupplierVendorsContent() {
} }
}, [searchParams]) }, [searchParams])
// Poll for updates every 5 seconds if there are pending connections or product syncs // Poll for updates every 5 seconds so new invitations appear without manual refresh
useEffect(() => { useEffect(() => {
const hasPending = connections.some(c => c.status === "PENDING")
const hasPendingSyncs = productSyncs.some(s => s.status === "PENDING")
if (!hasPending && !hasPendingSyncs) return // Don't poll if no pending items
const interval = setInterval(() => { const interval = setInterval(() => {
fetchConnections() void fetchConnections({ silent: true })
fetchProductSyncs() void fetchProductSyncs()
}, 5000) // Poll every 5 seconds }, 5000) // Poll every 5 seconds
return () => clearInterval(interval) return () => clearInterval(interval)
}, [connections, productSyncs]) // Re-run when connections or syncs change }, [])
useEffect(() => { useEffect(() => {
if (success) { if (success) {
@ -106,9 +105,9 @@ function SupplierVendorsContent() {
} }
}, [error]) }, [error])
const fetchConnections = async () => { const fetchConnections = async ({ silent = false }: { silent?: boolean } = {}) => {
try { try {
setLoading(true) if (!silent) setLoading(true)
const res = await fetch("/api/supplier/vendors") const res = await fetch("/api/supplier/vendors")
if (!res.ok) throw new Error("Failed to fetch connections") if (!res.ok) throw new Error("Failed to fetch connections")
const data = await res.json() const data = await res.json()
@ -117,7 +116,7 @@ function SupplierVendorsContent() {
} catch { } catch {
setError("Failed to load vendor connections") setError("Failed to load vendor connections")
} finally { } finally {
setLoading(false) if (!silent) setLoading(false)
} }
} }
@ -142,7 +141,7 @@ function SupplierVendorsContent() {
}) })
if (!res.ok) throw new Error("Failed to update connection") if (!res.ok) throw new Error("Failed to update connection")
setSuccess(`Connection ${status.toLowerCase()} successfully`) setSuccess(`Connection ${status.toLowerCase()} successfully`)
fetchConnections() void fetchConnections({ silent: true })
} catch { } catch {
setError("Failed to update connection") setError("Failed to update connection")
} }
@ -157,7 +156,7 @@ function SupplierVendorsContent() {
}) })
if (!res.ok) throw new Error("Failed to delete connection") if (!res.ok) throw new Error("Failed to delete connection")
setSuccess("Connection removed successfully") setSuccess("Connection removed successfully")
fetchConnections() void fetchConnections({ silent: true })
} catch { } catch {
setError("Failed to remove connection") setError("Failed to remove connection")
} }
@ -175,8 +174,8 @@ function SupplierVendorsContent() {
throw new Error(data.message || "Failed to update sync request") throw new Error(data.message || "Failed to update sync request")
} }
setSuccess(`Product sync ${action === "accept" ? "accepted" : "rejected"} successfully`) setSuccess(`Product sync ${action === "accept" ? "accepted" : "rejected"} successfully`)
fetchProductSyncs() void fetchProductSyncs()
fetchConnections() void fetchConnections({ silent: true })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to update sync request") setError(err instanceof Error ? err.message : "Failed to update sync request")
} }
@ -201,7 +200,7 @@ function SupplierVendorsContent() {
setSuccess(data.message || "Invitation accepted successfully! You are now connected.") setSuccess(data.message || "Invitation accepted successfully! You are now connected.")
setInvitationToken("") setInvitationToken("")
setShowTokenInput(false) setShowTokenInput(false)
fetchConnections() void fetchConnections({ silent: true })
// Send supplier to JUNO CHAT so encryption keys auto-setup on first open // Send supplier to JUNO CHAT so encryption keys auto-setup on first open
setTimeout(() => router.push("/supplier/chat"), 1500) setTimeout(() => router.push("/supplier/chat"), 1500)
} catch (err) { } catch (err) {
@ -423,7 +422,7 @@ function SupplierVendorsContent() {
))} ))}
</div> </div>
{loading ? ( {loading && isInitialLoad ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" /> <Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
</div> </div>
@ -437,15 +436,29 @@ function SupplierVendorsContent() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredConnections.map((connection) => ( {filteredConnections.map((connection) => (
<button <div
key={connection.id} key={connection.id}
type="button"
onClick={() => { onClick={() => {
if (connection.status === "CONNECTED") { if (connection.status === "CONNECTED") {
router.push(`/supplier/vendors/${connection.id}`) router.push(`/supplier/vendors/${connection.id}`)
} }
}} }}
className="bg-white/5 rounded-xl p-6 border border-white/10 hover:border-white/20 transition-colors text-left w-full" className={`bg-white/5 rounded-xl p-6 border border-white/10 transition-colors text-left w-full ${
connection.status === "CONNECTED"
? "hover:border-white/20 cursor-pointer"
: "cursor-default"
}`}
role={connection.status === "CONNECTED" ? "button" : undefined}
tabIndex={connection.status === "CONNECTED" ? 0 : -1}
onKeyDown={(e) => {
if (
connection.status === "CONNECTED" &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault()
router.push(`/supplier/vendors/${connection.id}`)
}
}}
> >
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -463,7 +476,7 @@ function SupplierVendorsContent() {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleDelete(connection.id) void handleDelete(connection.id)
}} }}
className="p-1 hover:bg-red-500/20 rounded text-slate-400 hover:text-red-400 transition-colors" className="p-1 hover:bg-red-500/20 rounded text-slate-400 hover:text-red-400 transition-colors"
title="Remove" title="Remove"
@ -507,7 +520,7 @@ function SupplierVendorsContent() {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleStatusChange(connection.id, "CONNECTED") void handleStatusChange(connection.id, "CONNECTED")
}} }}
className="flex-1 px-3 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="flex-1 px-3 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
> >
@ -518,7 +531,7 @@ function SupplierVendorsContent() {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleStatusChange(connection.id, "REJECTED") void handleStatusChange(connection.id, "REJECTED")
}} }}
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
> >
@ -527,7 +540,7 @@ function SupplierVendorsContent() {
</button> </button>
</div> </div>
)} )}
</button> </div>
))} ))}
</div> </div>
)} )}