@liiift-studio/deploy-vercel-from-sanity 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@liiift-studio/deploy-vercel-from-sanity",
3
+ "version": "0.1.1",
4
+ "description": "Sanity Studio v5 plugin — trigger and monitor Vercel deployments with full status, history, and build logs",
5
+ "license": "MIT",
6
+ "author": "Liiift Studio",
7
+ "keywords": ["sanity", "sanity-plugin", "vercel", "deploy", "deployment"],
8
+ "main": "./dist/index.cjs",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "source": "./src/index.ts",
14
+ "import": "./dist/index.js",
15
+ "require": "./dist/index.cjs",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": ["dist", "src"],
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "dev": "tsup --watch",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "peerDependencies": {
26
+ "react": ">=18",
27
+ "sanity": ">=5"
28
+ },
29
+ "devDependencies": {
30
+ "@sanity/icons": "^3",
31
+ "@sanity/ui": "^3",
32
+ "@types/react": "^19",
33
+ "react": "^19",
34
+ "sanity": "^5",
35
+ "tsup": "^8",
36
+ "typescript": "^5"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/Liiift-Studio/Deploy-Vercel-from-Sanity"
41
+ }
42
+ }
@@ -0,0 +1,157 @@
1
+ // Deployment history modal — shows last 10 deployments for a target
2
+ import { useEffect, useState, useCallback } from 'react'
3
+ import {
4
+ Dialog, Card, Box, Stack, Flex, Text, Badge, Spinner, Button,
5
+ } from '@sanity/ui'
6
+ import { LaunchIcon, CloseIcon } from '@sanity/icons'
7
+ import { listDeployments } from '../lib/api'
8
+ import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref } from '../lib/helpers'
9
+ import type { DeployTarget, VercelDeployment } from '../types'
10
+
11
+ interface DeployHistoryProps {
12
+ target: DeployTarget
13
+ token: string
14
+ onClose: () => void
15
+ }
16
+
17
+ export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
18
+ const [deployments, setDeployments] = useState<VercelDeployment[]>([])
19
+ const [loading, setLoading] = useState(true)
20
+ const [error, setError] = useState<string | null>(null)
21
+
22
+ const { projectId, hookId } = parseHookUrl(target.url)
23
+
24
+ const load = useCallback(async () => {
25
+ setLoading(true)
26
+ setError(null)
27
+ try {
28
+ const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId, limit: 10 })
29
+ setDeployments(data)
30
+ } catch (err) {
31
+ setError(err instanceof Error ? err.message : 'Failed to load history')
32
+ } finally {
33
+ setLoading(false)
34
+ }
35
+ }, [projectId, hookId, token, target.teamId])
36
+
37
+ useEffect(() => { load() }, [load])
38
+
39
+ return (
40
+ <Dialog
41
+ header={`${target.name} — Deployment History`}
42
+ id="deploy-history"
43
+ onClose={onClose}
44
+ width={2}
45
+ footer={
46
+ <Box padding={3}>
47
+ <Button text="Close" icon={CloseIcon} mode="ghost" onClick={onClose} />
48
+ </Box>
49
+ }
50
+ >
51
+ <Box padding={4}>
52
+ {loading && (
53
+ <Flex justify="center" padding={6}>
54
+ <Spinner muted />
55
+ </Flex>
56
+ )}
57
+
58
+ {error && (
59
+ <Card tone="critical" padding={4} radius={2}>
60
+ <Text size={1}>{error}</Text>
61
+ </Card>
62
+ )}
63
+
64
+ {!loading && !error && deployments.length === 0 && (
65
+ <Card tone="transparent" padding={4}>
66
+ <Text size={1} muted align="center">No deployments found for this hook.</Text>
67
+ </Card>
68
+ )}
69
+
70
+ {!loading && deployments.length > 0 && (
71
+ <Stack space={2}>
72
+ {/* Column headers */}
73
+ <Card padding={3} radius={2} tone="transparent">
74
+ <Flex gap={3}>
75
+ <Box flex={2}><Text size={0} weight="semibold" muted>Preview URL</Text></Box>
76
+ <Box flex={1}><Text size={0} weight="semibold" muted>Status</Text></Box>
77
+ <Box flex={2}><Text size={0} weight="semibold" muted>Branch · Commit</Text></Box>
78
+ <Box flex={1}><Text size={0} weight="semibold" muted>Deployed</Text></Box>
79
+ <Box style={{ width: 64 }}><Text size={0} weight="semibold" muted>Logs</Text></Box>
80
+ </Flex>
81
+ </Card>
82
+
83
+ {deployments.map(d => {
84
+ const { label, tone } = stateLabel(d.state)
85
+ const branch = d.meta?.githubCommitRef ?? '—'
86
+ const sha = shortSha(d.meta?.githubCommitSha)
87
+ const message = d.meta?.githubCommitMessage?.split('\n')[0] ?? ''
88
+
89
+ return (
90
+ <Card key={d.uid} padding={3} radius={2} shadow={1} tone="default">
91
+ <Flex gap={3} align="center">
92
+ {/* Preview URL */}
93
+ <Box flex={2} style={{ overflow: 'hidden' }}>
94
+ {d.url ? (
95
+ <a
96
+ href={`https://${d.url}`}
97
+ target="_blank"
98
+ rel="noreferrer"
99
+ style={{ color: 'inherit' }}
100
+ >
101
+ <Text size={1} style={{ textDecoration: 'underline' }}>
102
+ {d.url.length > 36 ? `${d.url.slice(0, 36)}…` : d.url}
103
+ </Text>
104
+ </a>
105
+ ) : (
106
+ <Text size={1} muted>—</Text>
107
+ )}
108
+ </Box>
109
+
110
+ {/* Status */}
111
+ <Box flex={1}>
112
+ <Badge tone={tone} mode="outline">{label}</Badge>
113
+ </Box>
114
+
115
+ {/* Branch + commit */}
116
+ <Box flex={2} style={{ overflow: 'hidden' }}>
117
+ <Stack space={1}>
118
+ <Text size={1}>{branch}</Text>
119
+ {sha && (
120
+ <Text size={0} muted>
121
+ {sha}{message ? ` · ${message.slice(0, 40)}${message.length > 40 ? '…' : ''}` : ''}
122
+ </Text>
123
+ )}
124
+ </Stack>
125
+ </Box>
126
+
127
+ {/* Time */}
128
+ <Box flex={1}>
129
+ <Text size={1} muted>{timeAgo(d.created)}</Text>
130
+ </Box>
131
+
132
+ {/* Build logs link */}
133
+ <Box style={{ width: 64 }}>
134
+ {safeHref(d.inspectorUrl) ? (
135
+ <a href={safeHref(d.inspectorUrl)} target="_blank" rel="noreferrer">
136
+ <Button
137
+ text="Logs"
138
+ mode="ghost"
139
+ tone="default"
140
+ icon={LaunchIcon}
141
+ style={{ fontSize: '12px' }}
142
+ />
143
+ </a>
144
+ ) : (
145
+ <Text size={1} muted>—</Text>
146
+ )}
147
+ </Box>
148
+ </Flex>
149
+ </Card>
150
+ )
151
+ })}
152
+ </Stack>
153
+ )}
154
+ </Box>
155
+ </Dialog>
156
+ )
157
+ }
@@ -0,0 +1,298 @@
1
+ // Per-deploy-target card — shows status, build timer, history, cancel, and deploy
2
+ import { useState, useEffect, useCallback, useRef } from 'react'
3
+ import {
4
+ Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner, MenuButton, Menu, MenuItem,
5
+ } from '@sanity/ui'
6
+ import {
7
+ RocketIcon, ClockIcon, HistoryIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
8
+ } from '@sanity/icons'
9
+ import { listDeployments, cancelDeployment, triggerDeploy } from '../lib/api'
10
+ import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref } from '../lib/helpers'
11
+ import { StatusBadge } from './StatusBadge'
12
+ import { DeployHistory } from './DeployHistory'
13
+ import type { DeployTarget, VercelDeployment } from '../types'
14
+
15
+ const POLL_INTERVAL_MS = 5_000
16
+
17
+ interface DeployItemProps {
18
+ target: DeployTarget
19
+ token: string
20
+ onDelete: (target: DeployTarget) => void
21
+ }
22
+
23
+ export function DeployItem({ target, token, onDelete }: DeployItemProps) {
24
+ const { projectId, hookId } = parseHookUrl(target.url)
25
+
26
+ const [deployments, setDeployments] = useState<VercelDeployment[]>([])
27
+ const [loadingInitial, setLoadingInitial] = useState(true)
28
+ const [triggering, setTriggering] = useState(false) // hook fired, waiting for deploy to appear
29
+ const [canceling, setCanceling] = useState(false)
30
+ const [deployError, setDeployError] = useState<string | null>(null)
31
+ const [showHistory, setShowHistory] = useState(false)
32
+ const [elapsed, setElapsed] = useState(0) // build timer in seconds
33
+
34
+ const latest = deployments[0]
35
+ const isActive = triggering || isActiveState(latest?.state)
36
+
37
+ // ── Fetch deployments ──────────────────────────────────────────────────────
38
+ const fetchDeployments = useCallback(async () => {
39
+ if (!projectId || !hookId || !token) return
40
+ try {
41
+ const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId })
42
+ setDeployments(data)
43
+ } catch (err) {
44
+ console.error('deploy-vercel-from-sanity: fetch error', err)
45
+ }
46
+ }, [projectId, hookId, token, target.teamId])
47
+
48
+ // Initial fetch
49
+ useEffect(() => {
50
+ fetchDeployments().finally(() => setLoadingInitial(false))
51
+ }, [fetchDeployments])
52
+
53
+ // Polling while active
54
+ useEffect(() => {
55
+ if (!isActive) return
56
+ const id = setInterval(fetchDeployments, POLL_INTERVAL_MS)
57
+ return () => clearInterval(id)
58
+ }, [isActive, fetchDeployments])
59
+
60
+ // Stop triggering once a deployment appears in an active state
61
+ useEffect(() => {
62
+ if (triggering && latest && latest.state !== undefined) {
63
+ setTriggering(false)
64
+ }
65
+ }, [triggering, latest])
66
+
67
+ // ── Build timer ───────────────────────────────────────────────────────────
68
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
69
+ useEffect(() => {
70
+ if (isActive) {
71
+ const start = latest?.created ?? Date.now()
72
+ setElapsed(Math.floor((Date.now() - start) / 1000))
73
+ timerRef.current = setInterval(() => {
74
+ setElapsed(Math.floor((Date.now() - start) / 1000))
75
+ }, 1000)
76
+ } else {
77
+ setElapsed(0)
78
+ if (timerRef.current) clearInterval(timerRef.current)
79
+ }
80
+ return () => {
81
+ if (timerRef.current) clearInterval(timerRef.current)
82
+ }
83
+ }, [isActive, latest?.created])
84
+
85
+ // ── Actions ───────────────────────────────────────────────────────────────
86
+ const deploy = useCallback(async () => {
87
+ setDeployError(null)
88
+ setTriggering(true)
89
+ try {
90
+ await triggerDeploy(target.url)
91
+ // Poll immediately to pick up the new deployment faster
92
+ setTimeout(fetchDeployments, 2000)
93
+ } catch (err) {
94
+ setTriggering(false)
95
+ setDeployError(err instanceof Error ? err.message : 'Deploy failed')
96
+ }
97
+ }, [target.url, fetchDeployments])
98
+
99
+ const cancel = useCallback(async () => {
100
+ if (!latest?.uid) return
101
+ setCanceling(true)
102
+ try {
103
+ await cancelDeployment({ deploymentId: latest.uid, token, teamId: target.teamId })
104
+ await fetchDeployments()
105
+ } catch (err) {
106
+ console.error('deploy-vercel-from-sanity: cancel error', err)
107
+ } finally {
108
+ setCanceling(false)
109
+ }
110
+ }, [latest?.uid, token, target.teamId, fetchDeployments])
111
+
112
+ // ── Derived display values ────────────────────────────────────────────────
113
+ const branch = latest?.meta?.githubCommitRef
114
+ const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
115
+ const sha = shortSha(latest?.meta?.githubCommitSha)
116
+ const creator = latest?.creator?.username
117
+ const deployedAt = latest?.created ? timeAgo(latest.created) : null
118
+
119
+ return (
120
+ <>
121
+ <Card padding={4} radius={2} shadow={1} tone="default">
122
+ <Stack space={4}>
123
+ {/* ── Header ──────────────────────────────────────────────── */}
124
+ <Flex align="flex-start" justify="space-between" gap={3}>
125
+ <Stack space={2} flex={1}>
126
+ <Text size={2} weight="semibold">{target.name}</Text>
127
+ <Flex gap={2} wrap="wrap">
128
+ <Text size={0} muted>
129
+ {projectId ? `${projectId.slice(0, 18)}…` : '—'}
130
+ </Text>
131
+ <Text size={0} muted>·</Text>
132
+ <Text size={0} muted>Hook: {hookId || '—'}</Text>
133
+ {target.teamId && (
134
+ <>
135
+ <Text size={0} muted>·</Text>
136
+ <Text size={0} muted>Team: {target.teamId}</Text>
137
+ </>
138
+ )}
139
+ </Flex>
140
+ </Stack>
141
+
142
+ {/* Action menu */}
143
+ <MenuButton
144
+ button={
145
+ <Button mode="ghost" icon={EllipsisVerticalIcon} />
146
+ }
147
+ id={`menu-${target._id}`}
148
+ menu={
149
+ <Menu>
150
+ <MenuItem
151
+ text="History"
152
+ icon={HistoryIcon}
153
+ onClick={() => setShowHistory(true)}
154
+ />
155
+ {safeHref(latest?.inspectorUrl) && (
156
+ <MenuItem
157
+ text="Build logs"
158
+ icon={LaunchIcon}
159
+ as="a"
160
+ href={safeHref(latest?.inspectorUrl)}
161
+ target="_blank"
162
+ rel="noreferrer"
163
+ />
164
+ )}
165
+ {!target.disableDeleteAction && (
166
+ <MenuItem
167
+ text="Delete"
168
+ icon={TrashIcon}
169
+ tone="critical"
170
+ onClick={() => onDelete(target)}
171
+ />
172
+ )}
173
+ </Menu>
174
+ }
175
+ popover={{ placement: 'bottom-end' }}
176
+ />
177
+ </Flex>
178
+
179
+ {/* ── Status row ──────────────────────────────────────────── */}
180
+ {loadingInitial ? (
181
+ <Flex align="center" gap={2}>
182
+ <Spinner muted />
183
+ <Text size={1} muted>Loading…</Text>
184
+ </Flex>
185
+ ) : (
186
+ <Stack space={3}>
187
+ <Flex align="center" gap={3} wrap="wrap">
188
+ {/* Status badge */}
189
+ {triggering ? (
190
+ <Flex align="center" gap={2}>
191
+ <Spinner muted />
192
+ <Badge tone="caution" mode="outline">Triggering…</Badge>
193
+ </Flex>
194
+ ) : (
195
+ <StatusBadge state={latest?.state} showSpinner />
196
+ )}
197
+
198
+ {/* Build timer */}
199
+ {isActive && elapsed > 0 && (
200
+ <Flex align="center" gap={1}>
201
+ <ClockIcon />
202
+ <Text size={1} muted>{formatDuration(elapsed)}</Text>
203
+ </Flex>
204
+ )}
205
+
206
+ {/* Time since deploy */}
207
+ {!isActive && deployedAt && (
208
+ <Text size={1} muted>{deployedAt}</Text>
209
+ )}
210
+
211
+ {/* Branch */}
212
+ {branch && (
213
+ <>
214
+ <Text size={1} muted>·</Text>
215
+ <Badge tone="default" mode="outline">{branch}</Badge>
216
+ </>
217
+ )}
218
+
219
+ {/* Commit */}
220
+ {sha && (
221
+ <Tooltip
222
+ content={
223
+ <Box padding={2}>
224
+ <Text size={1}>{commitMsg ?? sha}</Text>
225
+ </Box>
226
+ }
227
+ portal
228
+ >
229
+ <Text size={1} muted style={{ cursor: 'default' }}>
230
+ {sha}
231
+ </Text>
232
+ </Tooltip>
233
+ )}
234
+
235
+ {/* Creator */}
236
+ {creator && (
237
+ <Text size={1} muted>by {creator}</Text>
238
+ )}
239
+
240
+ {/* Preview link */}
241
+ {latest?.url && latest.state === 'READY' && (
242
+ <a
243
+ href={`https://${latest.url}`}
244
+ target="_blank"
245
+ rel="noreferrer"
246
+ style={{ color: 'inherit' }}
247
+ >
248
+ <Flex align="center" gap={1}>
249
+ <LaunchIcon />
250
+ <Text size={1}>Preview</Text>
251
+ </Flex>
252
+ </a>
253
+ )}
254
+ </Flex>
255
+
256
+ {/* Error message from commit meta */}
257
+ {deployError && (
258
+ <Card tone="critical" padding={2} radius={2}>
259
+ <Text size={1}>{deployError}</Text>
260
+ </Card>
261
+ )}
262
+ </Stack>
263
+ )}
264
+
265
+ {/* ── Action buttons ──────────────────────────────────────── */}
266
+ <Flex align="center" justify="flex-end" gap={2}>
267
+ {isActiveState(latest?.state) && (
268
+ <Button
269
+ text="Cancel"
270
+ mode="ghost"
271
+ tone="critical"
272
+ loading={canceling}
273
+ disabled={canceling}
274
+ onClick={cancel}
275
+ />
276
+ )}
277
+ <Button
278
+ text="Deploy"
279
+ tone="primary"
280
+ icon={RocketIcon}
281
+ loading={triggering}
282
+ disabled={isActive}
283
+ onClick={deploy}
284
+ />
285
+ </Flex>
286
+ </Stack>
287
+ </Card>
288
+
289
+ {showHistory && (
290
+ <DeployHistory
291
+ target={target}
292
+ token={token}
293
+ onClose={() => setShowHistory(false)}
294
+ />
295
+ )}
296
+ </>
297
+ )
298
+ }
@@ -0,0 +1,184 @@
1
+ // Main deploy tool — fetches targets + token, renders per-project cards
2
+ import { useState, useEffect, useCallback } from 'react'
3
+ import { useClient } from 'sanity'
4
+ import {
5
+ Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Dialog, useToast,
6
+ } from '@sanity/ui'
7
+ import { RocketIcon, KeyIcon, TrashIcon, WarningOutlineIcon } from '@sanity/icons'
8
+ import { DeployItem } from './DeployItem'
9
+ import { TokenSetup } from './TokenSetup'
10
+ import type { DeployTarget } from '../types'
11
+
12
+ const TOKEN_QUERY = `*[_id == "secrets.vercelDeploy"][0].accessToken`
13
+ const TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`
14
+
15
+ export function DeployTool() {
16
+ const client = useClient({ apiVersion: '2025-01-01' })
17
+ const toast = useToast()
18
+
19
+ const [token, setToken] = useState<string | null>(null)
20
+ const [targets, setTargets] = useState<DeployTarget[]>([])
21
+ const [loading, setLoading] = useState(true)
22
+ const [showTokenSetup, setShowTokenSetup] = useState(false)
23
+ const [pendingDelete, setPendingDelete] = useState<DeployTarget | null>(null)
24
+ const [deleting, setDeleting] = useState(false)
25
+
26
+ // ── Fetch token + targets ─────────────────────────────────────────────────
27
+ const load = useCallback(async () => {
28
+ setLoading(true)
29
+ try {
30
+ const [fetchedToken, fetchedTargets] = await Promise.all([
31
+ client.fetch<string | null>(TOKEN_QUERY),
32
+ client.fetch<DeployTarget[]>(TARGETS_QUERY),
33
+ ])
34
+ setToken(fetchedToken ?? null)
35
+ setTargets(fetchedTargets)
36
+ } catch (err) {
37
+ console.error('deploy-vercel-from-sanity: load error', err)
38
+ } finally {
39
+ setLoading(false)
40
+ }
41
+ }, [client])
42
+
43
+ useEffect(() => { load() }, [load])
44
+
45
+ // Live subscription — update targets when documents change
46
+ useEffect(() => {
47
+ const sub = client
48
+ .listen<DeployTarget>(TARGETS_QUERY)
49
+ .subscribe(() => {
50
+ client.fetch<DeployTarget[]>(TARGETS_QUERY).then(setTargets).catch(err => {
51
+ console.error('deploy-vercel-from-sanity: subscription refresh error', err)
52
+ })
53
+ })
54
+ return () => sub.unsubscribe()
55
+ }, [client])
56
+
57
+ // ── Delete target ─────────────────────────────────────────────────────────
58
+ const confirmDelete = useCallback(async () => {
59
+ if (!pendingDelete) return
60
+ setDeleting(true)
61
+ try {
62
+ await client.delete(pendingDelete._id)
63
+ setTargets(prev => prev.filter(t => t._id !== pendingDelete._id))
64
+ toast.push({ status: 'success', title: `Deleted "${pendingDelete.name}"` })
65
+ } catch (err) {
66
+ toast.push({ status: 'error', title: 'Delete failed', description: String(err) })
67
+ } finally {
68
+ setDeleting(false)
69
+ setPendingDelete(null)
70
+ }
71
+ }, [client, pendingDelete, toast])
72
+
73
+ // ── Render ────────────────────────────────────────────────────────────────
74
+ if (loading) {
75
+ return (
76
+ <Card height="fill" tone="transparent">
77
+ <Flex align="center" justify="center" height="fill">
78
+ <Spinner muted />
79
+ </Flex>
80
+ </Card>
81
+ )
82
+ }
83
+
84
+ if (!token && !showTokenSetup) {
85
+ return <TokenSetup onSaved={() => { setShowTokenSetup(false); load() }} />
86
+ }
87
+
88
+ if (showTokenSetup) {
89
+ return (
90
+ <TokenSetup
91
+ onSaved={() => {
92
+ setShowTokenSetup(false)
93
+ load()
94
+ }}
95
+ />
96
+ )
97
+ }
98
+
99
+ return (
100
+ <Card height="fill" tone="transparent">
101
+ <Box padding={5}>
102
+ <Stack space={5}>
103
+ {/* ── Header ──────────────────────────────────────────────── */}
104
+ <Flex align="center" justify="space-between">
105
+ <Flex align="center" gap={3}>
106
+ <RocketIcon />
107
+ <Heading size={2}>Deploy</Heading>
108
+ </Flex>
109
+ <Button
110
+ text="API Token"
111
+ mode="ghost"
112
+ icon={KeyIcon}
113
+ fontSize={1}
114
+ onClick={() => setShowTokenSetup(true)}
115
+ />
116
+ </Flex>
117
+
118
+ {/* ── No targets ──────────────────────────────────────────── */}
119
+ {targets.length === 0 && (
120
+ <Card padding={5} radius={2} tone="transparent" shadow={1}>
121
+ <Stack space={3} style={{ textAlign: 'center' }}>
122
+ <Text size={2} weight="semibold">No deploy targets configured</Text>
123
+ <Text size={1} muted>
124
+ Create a <code>vercel_deploy</code> document in the dataset with a Vercel deploy
125
+ hook URL, or add one via the Sanity CLI.
126
+ </Text>
127
+ </Stack>
128
+ </Card>
129
+ )}
130
+
131
+ {/* ── Deploy targets ──────────────────────────────────────── */}
132
+ {token && targets.map(target => (
133
+ <DeployItem
134
+ key={target._id}
135
+ target={target}
136
+ token={token}
137
+ onDelete={setPendingDelete}
138
+ />
139
+ ))}
140
+ </Stack>
141
+ </Box>
142
+
143
+ {/* ── Delete confirmation ─────────────────────────────────────────── */}
144
+ {pendingDelete && (
145
+ <Dialog
146
+ header="Delete deploy target?"
147
+ id="confirm-delete"
148
+ onClose={() => setPendingDelete(null)}
149
+ width={1}
150
+ footer={
151
+ <Flex padding={3} gap={2} justify="flex-end">
152
+ <Button
153
+ text="Cancel"
154
+ mode="ghost"
155
+ onClick={() => setPendingDelete(null)}
156
+ />
157
+ <Button
158
+ text="Delete"
159
+ tone="critical"
160
+ icon={TrashIcon}
161
+ loading={deleting}
162
+ disabled={deleting}
163
+ onClick={confirmDelete}
164
+ />
165
+ </Flex>
166
+ }
167
+ >
168
+ <Box padding={4}>
169
+ <Stack space={3}>
170
+ <Flex align="center" gap={2}>
171
+ <WarningOutlineIcon />
172
+ <Text size={2} weight="semibold">{pendingDelete.name}</Text>
173
+ </Flex>
174
+ <Text size={1} muted>
175
+ This removes the deploy target from the dataset. The Vercel deploy hook itself is
176
+ not affected.
177
+ </Text>
178
+ </Stack>
179
+ </Box>
180
+ </Dialog>
181
+ )}
182
+ </Card>
183
+ )
184
+ }