fix: make supplier invite flow seamless
Made-with: Cursor
This commit is contained in:
parent
036ad53acb
commit
b426372f89
@ -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)
|
||||||
|
|||||||
55
src/app/api/supplier/vendors/route.ts
vendored
55
src/app/api/supplier/vendors/route.ts
vendored
@ -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(
|
||||||
|
|||||||
65
src/app/supplier/vendors/page.tsx
vendored
65
src/app/supplier/vendors/page.tsx
vendored
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user