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 })
}
// Verify connection belongs to supplier
// First try normal connection update flow
const connection = await prisma.connection.findFirst({
where: {
id: connectionId,
@ -107,10 +107,7 @@ export async function PUT(
}
})
if (!connection) {
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
}
if (connection) {
const updatedConnection = await prisma.connection.update({
where: { id: connectionId },
data: { status: status as "CONNECTED" | "REJECTED" }
@ -120,6 +117,71 @@ export async function PUT(
message: `Connection ${status.toLowerCase()} successfully`,
connection: updatedConnection
})
}
// If not a connection, try treating `connectionId` as a pending invitation id.
const invitation = await prisma.invitation.findFirst({
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({
message: "Invitation rejected successfully"
})
} catch (error) {
console.error("Error updating connection:", error)
return NextResponse.json(

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) {
console.error("Error fetching vendor connections:", error)
return NextResponse.json(

View File

@ -65,10 +65,13 @@ function SupplierVendorsContent() {
const [invitationToken, setInvitationToken] = useState("")
const [acceptingToken, setAcceptingToken] = useState(false)
const [showProductSyncs, setShowProductSyncs] = useState(false)
const [isInitialLoad, setIsInitialLoad] = useState(true)
useEffect(() => {
fetchConnections()
fetchProductSyncs()
void (async () => {
await Promise.all([fetchConnections({ silent: false }), fetchProductSyncs()])
setIsInitialLoad(false)
})()
// Check if token is in URL
const token = searchParams.get("token")
@ -78,19 +81,15 @@ function SupplierVendorsContent() {
}
}, [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(() => {
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(() => {
fetchConnections()
fetchProductSyncs()
void fetchConnections({ silent: true })
void fetchProductSyncs()
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [connections, productSyncs]) // Re-run when connections or syncs change
}, [])
useEffect(() => {
if (success) {
@ -106,9 +105,9 @@ function SupplierVendorsContent() {
}
}, [error])
const fetchConnections = async () => {
const fetchConnections = async ({ silent = false }: { silent?: boolean } = {}) => {
try {
setLoading(true)
if (!silent) setLoading(true)
const res = await fetch("/api/supplier/vendors")
if (!res.ok) throw new Error("Failed to fetch connections")
const data = await res.json()
@ -117,7 +116,7 @@ function SupplierVendorsContent() {
} catch {
setError("Failed to load vendor connections")
} finally {
setLoading(false)
if (!silent) setLoading(false)
}
}
@ -142,7 +141,7 @@ function SupplierVendorsContent() {
})
if (!res.ok) throw new Error("Failed to update connection")
setSuccess(`Connection ${status.toLowerCase()} successfully`)
fetchConnections()
void fetchConnections({ silent: true })
} catch {
setError("Failed to update connection")
}
@ -157,7 +156,7 @@ function SupplierVendorsContent() {
})
if (!res.ok) throw new Error("Failed to delete connection")
setSuccess("Connection removed successfully")
fetchConnections()
void fetchConnections({ silent: true })
} catch {
setError("Failed to remove connection")
}
@ -175,8 +174,8 @@ function SupplierVendorsContent() {
throw new Error(data.message || "Failed to update sync request")
}
setSuccess(`Product sync ${action === "accept" ? "accepted" : "rejected"} successfully`)
fetchProductSyncs()
fetchConnections()
void fetchProductSyncs()
void fetchConnections({ silent: true })
} catch (err) {
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.")
setInvitationToken("")
setShowTokenInput(false)
fetchConnections()
void fetchConnections({ silent: true })
// Send supplier to JUNO CHAT so encryption keys auto-setup on first open
setTimeout(() => router.push("/supplier/chat"), 1500)
} catch (err) {
@ -423,7 +422,7 @@ function SupplierVendorsContent() {
))}
</div>
{loading ? (
{loading && isInitialLoad ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
</div>
@ -437,15 +436,29 @@ function SupplierVendorsContent() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredConnections.map((connection) => (
<button
<div
key={connection.id}
type="button"
onClick={() => {
if (connection.status === "CONNECTED") {
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-center gap-2">
@ -463,7 +476,7 @@ function SupplierVendorsContent() {
type="button"
onClick={(e) => {
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"
title="Remove"
@ -507,7 +520,7 @@ function SupplierVendorsContent() {
type="button"
onClick={(e) => {
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"
>
@ -518,7 +531,7 @@ function SupplierVendorsContent() {
type="button"
onClick={(e) => {
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"
>
@ -527,7 +540,7 @@ function SupplierVendorsContent() {
</button>
</div>
)}
</button>
</div>
))}
</div>
)}