@xk2800/nextjs-template 0.7.1-beta.7 → 0.7.1-beta.8
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/components/dashboard/admin/broadcastActions.tsx +148 -0
- package/components/dashboard/admin/confirmSendDialog.tsx +44 -0
- package/components/dashboard/admin/marketingEmailForm.tsx +115 -0
- package/components/dashboard/admin/marketingSection.tsx +67 -0
- package/components/dashboard/admin/subscribersCard.tsx +62 -0
- package/components/dashboard/marketingUpdatesCard.tsx +62 -0
- package/components/dashboard/marketingUpdatesSection.tsx +10 -0
- package/dist/api/feedback.mjs +2 -1
- package/dist/api/feedback.mjs.map +1 -1
- package/dist/api/marketing.d.mts +36 -0
- package/dist/api/marketing.d.ts +36 -0
- package/dist/api/marketing.js +1357 -0
- package/dist/api/marketing.js.map +1 -0
- package/dist/api/marketing.mjs +170 -0
- package/dist/api/marketing.mjs.map +1 -0
- package/dist/auth/helpers.mjs +5 -60
- package/dist/auth/helpers.mjs.map +1 -1
- package/dist/auth/index.mjs +2 -1
- package/dist/chunk-6Z3WUSDJ.mjs +62 -0
- package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
- package/dist/{chunk-6B45N7ZB.mjs → chunk-7QVIOQ47.mjs} +4 -13
- package/dist/chunk-7QVIOQ47.mjs.map +1 -0
- package/dist/chunk-C6RGN6QA.mjs +60 -0
- package/dist/chunk-C6RGN6QA.mjs.map +1 -0
- package/dist/chunk-OVKVD2HH.mjs +25 -0
- package/dist/chunk-OVKVD2HH.mjs.map +1 -0
- package/dist/marketing/index.d.mts +35 -0
- package/dist/marketing/index.d.ts +35 -0
- package/dist/marketing/index.js +125 -0
- package/dist/marketing/index.js.map +1 -0
- package/dist/marketing/index.mjs +6 -0
- package/dist/marketing/index.mjs.map +1 -0
- package/package.json +22 -2
- package/dist/chunk-6B45N7ZB.mjs.map +0 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { toast } from "sonner"
|
|
6
|
+
import { Button } from "../../ui/button"
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "../../ui/dialog"
|
|
14
|
+
import ConfirmSendDialog from "./confirmSendDialog"
|
|
15
|
+
|
|
16
|
+
interface Preview {
|
|
17
|
+
subject: string | null
|
|
18
|
+
from: string | null
|
|
19
|
+
previewText: string | null
|
|
20
|
+
html: string | null
|
|
21
|
+
text: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function BroadcastActions({ id, name, status }: { id: string; name: string; status: string }) {
|
|
25
|
+
const router = useRouter()
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
const [preview, setPreview] = useState<Preview | null>(null)
|
|
28
|
+
const [loading, setLoading] = useState(false)
|
|
29
|
+
const [sending, setSending] = useState(false)
|
|
30
|
+
const [confirming, setConfirming] = useState(false)
|
|
31
|
+
|
|
32
|
+
const openPreview = async () => {
|
|
33
|
+
setOpen(true)
|
|
34
|
+
if (preview) return
|
|
35
|
+
setLoading(true)
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(`/api/admin/marketing/${id}`)
|
|
38
|
+
const data = await response.json()
|
|
39
|
+
if (!response.ok) throw new Error(data.error || "Failed to load preview")
|
|
40
|
+
setPreview(data)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
toast.error(error instanceof Error ? error.message : "Failed to load preview")
|
|
43
|
+
setOpen(false)
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const send = async () => {
|
|
50
|
+
setSending(true)
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch(`/api/admin/marketing/${id}`, { method: "POST" })
|
|
53
|
+
const data = await response.json()
|
|
54
|
+
if (!response.ok) throw new Error(data.error || "Failed to send broadcast")
|
|
55
|
+
toast.success("Broadcast sent")
|
|
56
|
+
setOpen(false)
|
|
57
|
+
router.refresh()
|
|
58
|
+
} catch (error) {
|
|
59
|
+
toast.error(error instanceof Error ? error.message : "Failed to send broadcast")
|
|
60
|
+
} finally {
|
|
61
|
+
setSending(false)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resend has no push for broadcast status, so poll while it's queued and
|
|
66
|
+
// refresh the list once it moves on (e.g. to "sent").
|
|
67
|
+
// ponytail: stops after ~5 min; a broadcast scheduled for later stays
|
|
68
|
+
// "queued" until it goes out, so it's not worth polling that long.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (status !== "queued") return
|
|
71
|
+
let tries = 0
|
|
72
|
+
const timer = setInterval(async () => {
|
|
73
|
+
if (++tries > 100) return clearInterval(timer)
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(`/api/admin/marketing/${id}`)
|
|
76
|
+
if (!response.ok) return
|
|
77
|
+
const data = await response.json()
|
|
78
|
+
if (data.status !== "queued") {
|
|
79
|
+
clearInterval(timer)
|
|
80
|
+
router.refresh()
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// network blip, try again next tick
|
|
84
|
+
}
|
|
85
|
+
}, 3000)
|
|
86
|
+
return () => clearInterval(timer)
|
|
87
|
+
}, [id, status, router])
|
|
88
|
+
|
|
89
|
+
const isDraft = status === "draft"
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
{status === "queued" && (
|
|
94
|
+
<span className="flex items-center gap-1.5 text-xs">
|
|
95
|
+
<span className="size-2 animate-pulse rounded-full bg-amber-500" />
|
|
96
|
+
Sending…
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
<Button size="sm" variant="outline" onClick={openPreview}>Preview</Button>
|
|
100
|
+
{isDraft && (
|
|
101
|
+
<Button size="sm" disabled={sending} onClick={() => setConfirming(true)}>
|
|
102
|
+
{sending ? "Sending…" : "Send"}
|
|
103
|
+
</Button>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
107
|
+
<DialogContent className="sm:max-w-2xl">
|
|
108
|
+
<DialogHeader>
|
|
109
|
+
<DialogTitle>{preview?.subject || name}</DialogTitle>
|
|
110
|
+
<DialogDescription>
|
|
111
|
+
{preview ? `From ${preview.from ?? "—"}${preview.previewText ? ` · ${preview.previewText}` : ""}` : "Loading…"}
|
|
112
|
+
</DialogDescription>
|
|
113
|
+
</DialogHeader>
|
|
114
|
+
{loading ? (
|
|
115
|
+
<div className="h-[60vh] animate-pulse rounded-md bg-muted" />
|
|
116
|
+
) : preview?.html ? (
|
|
117
|
+
// sandbox="" — no scripts, no navigation out of the frame
|
|
118
|
+
<iframe
|
|
119
|
+
title="Email preview"
|
|
120
|
+
sandbox=""
|
|
121
|
+
srcDoc={preview.html}
|
|
122
|
+
className="h-[60vh] w-full rounded-md border bg-white"
|
|
123
|
+
/>
|
|
124
|
+
) : (
|
|
125
|
+
<pre className="h-[60vh] overflow-auto whitespace-pre-wrap rounded-md border p-4 text-sm">
|
|
126
|
+
{preview?.text ?? ""}
|
|
127
|
+
</pre>
|
|
128
|
+
)}
|
|
129
|
+
{isDraft && (
|
|
130
|
+
<div className="flex justify-end">
|
|
131
|
+
<Button disabled={sending || loading} onClick={() => setConfirming(true)}>
|
|
132
|
+
{sending ? "Sending…" : "Send now"}
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
|
|
139
|
+
<ConfirmSendDialog
|
|
140
|
+
open={confirming}
|
|
141
|
+
onOpenChange={setConfirming}
|
|
142
|
+
subject={preview?.subject || name}
|
|
143
|
+
audience="its segment"
|
|
144
|
+
onConfirm={send}
|
|
145
|
+
/>
|
|
146
|
+
</>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogAction,
|
|
6
|
+
AlertDialogCancel,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogDescription,
|
|
9
|
+
AlertDialogFooter,
|
|
10
|
+
AlertDialogHeader,
|
|
11
|
+
AlertDialogTitle,
|
|
12
|
+
} from "../../ui/alert-dialog"
|
|
13
|
+
|
|
14
|
+
export default function ConfirmSendDialog({
|
|
15
|
+
open,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
subject,
|
|
18
|
+
audience,
|
|
19
|
+
onConfirm,
|
|
20
|
+
}: {
|
|
21
|
+
open: boolean
|
|
22
|
+
onOpenChange: (open: boolean) => void
|
|
23
|
+
subject: string
|
|
24
|
+
audience: string
|
|
25
|
+
onConfirm: () => void
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
29
|
+
<AlertDialogContent>
|
|
30
|
+
<AlertDialogHeader>
|
|
31
|
+
<AlertDialogTitle>Send this broadcast?</AlertDialogTitle>
|
|
32
|
+
<AlertDialogDescription>
|
|
33
|
+
“{subject}” goes to every subscribed contact in {audience} right away.
|
|
34
|
+
This can't be undone.
|
|
35
|
+
</AlertDialogDescription>
|
|
36
|
+
</AlertDialogHeader>
|
|
37
|
+
<AlertDialogFooter>
|
|
38
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
39
|
+
<AlertDialogAction onClick={onConfirm}>Send now</AlertDialogAction>
|
|
40
|
+
</AlertDialogFooter>
|
|
41
|
+
</AlertDialogContent>
|
|
42
|
+
</AlertDialog>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { toast } from "sonner"
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
7
|
+
import { Input } from "../../ui/input"
|
|
8
|
+
import { Label } from "../../ui/label"
|
|
9
|
+
import { Textarea } from "../../ui/textarea"
|
|
10
|
+
import { Button } from "../../ui/button"
|
|
11
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"
|
|
12
|
+
import ConfirmSendDialog from "./confirmSendDialog"
|
|
13
|
+
|
|
14
|
+
export default function MarketingEmailForm({ segments }: { segments: { id: string; name: string }[] }) {
|
|
15
|
+
const router = useRouter()
|
|
16
|
+
const [segmentId, setSegmentId] = useState(segments[0]?.id ?? "")
|
|
17
|
+
const [subject, setSubject] = useState("")
|
|
18
|
+
const [previewText, setPreviewText] = useState("")
|
|
19
|
+
const [body, setBody] = useState("")
|
|
20
|
+
const [pending, setPending] = useState<"draft" | "send" | null>(null)
|
|
21
|
+
|
|
22
|
+
const [confirming, setConfirming] = useState(false)
|
|
23
|
+
const segmentName = segments.find((s) => s.id === segmentId)?.name ?? "this segment"
|
|
24
|
+
|
|
25
|
+
const submit = async (send: boolean) => {
|
|
26
|
+
setPending(send ? "send" : "draft")
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch("/api/admin/marketing", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ segmentId, subject, previewText, body, send }),
|
|
32
|
+
})
|
|
33
|
+
const data = await response.json()
|
|
34
|
+
if (!response.ok) throw new Error(data.error || "Failed to create broadcast")
|
|
35
|
+
|
|
36
|
+
toast.success(send ? "Broadcast sent" : "Draft saved in Resend")
|
|
37
|
+
setSubject("")
|
|
38
|
+
setPreviewText("")
|
|
39
|
+
setBody("")
|
|
40
|
+
router.refresh()
|
|
41
|
+
} catch (error) {
|
|
42
|
+
toast.error(error instanceof Error ? error.message : "Failed to create broadcast")
|
|
43
|
+
} finally {
|
|
44
|
+
setPending(null)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (segments.length === 0) {
|
|
49
|
+
return (
|
|
50
|
+
<Card>
|
|
51
|
+
<CardHeader>
|
|
52
|
+
<CardTitle>No segments yet</CardTitle>
|
|
53
|
+
<CardDescription>
|
|
54
|
+
Create a segment and add contacts in the Resend dashboard (Audience), then reload this page.
|
|
55
|
+
</CardDescription>
|
|
56
|
+
</CardHeader>
|
|
57
|
+
</Card>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const canSubmit = !!segmentId && !!subject.trim() && !!body.trim() && !pending
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Card>
|
|
65
|
+
<CardHeader>
|
|
66
|
+
<CardTitle>New broadcast</CardTitle>
|
|
67
|
+
<CardDescription>
|
|
68
|
+
Plain text. Blank lines start a new paragraph. An unsubscribe link is added automatically.
|
|
69
|
+
</CardDescription>
|
|
70
|
+
</CardHeader>
|
|
71
|
+
<CardContent className="space-y-4">
|
|
72
|
+
<div className="space-y-2">
|
|
73
|
+
<Label htmlFor="segment">Segment</Label>
|
|
74
|
+
<Select value={segmentId} onValueChange={setSegmentId}>
|
|
75
|
+
<SelectTrigger id="segment" className="w-full">
|
|
76
|
+
<SelectValue placeholder="Choose a segment" />
|
|
77
|
+
</SelectTrigger>
|
|
78
|
+
<SelectContent>
|
|
79
|
+
{segments.map((s) => (
|
|
80
|
+
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
|
81
|
+
))}
|
|
82
|
+
</SelectContent>
|
|
83
|
+
</Select>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="space-y-2">
|
|
86
|
+
<Label htmlFor="subject">Subject</Label>
|
|
87
|
+
<Input id="subject" value={subject} maxLength={200} onChange={(e) => setSubject(e.target.value)} />
|
|
88
|
+
</div>
|
|
89
|
+
<div className="space-y-2">
|
|
90
|
+
<Label htmlFor="preview">Preview text (optional)</Label>
|
|
91
|
+
<Input id="preview" value={previewText} maxLength={200} onChange={(e) => setPreviewText(e.target.value)} />
|
|
92
|
+
</div>
|
|
93
|
+
<div className="space-y-2">
|
|
94
|
+
<Label htmlFor="body">Message</Label>
|
|
95
|
+
<Textarea id="body" rows={12} value={body} onChange={(e) => setBody(e.target.value)} />
|
|
96
|
+
</div>
|
|
97
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
98
|
+
<Button variant="outline" disabled={!canSubmit} onClick={() => submit(false)}>
|
|
99
|
+
{pending === "draft" ? "Saving…" : "Save as draft"}
|
|
100
|
+
</Button>
|
|
101
|
+
<Button disabled={!canSubmit} onClick={() => setConfirming(true)}>
|
|
102
|
+
{pending === "send" ? "Sending…" : "Send now"}
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
<ConfirmSendDialog
|
|
106
|
+
open={confirming}
|
|
107
|
+
onOpenChange={setConfirming}
|
|
108
|
+
subject={subject}
|
|
109
|
+
audience={`"${segmentName}"`}
|
|
110
|
+
onConfirm={() => submit(true)}
|
|
111
|
+
/>
|
|
112
|
+
</CardContent>
|
|
113
|
+
</Card>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Suspense } from "react"
|
|
2
|
+
import { getMarketingOverview } from "@xk2800/nextjs-template/marketing"
|
|
3
|
+
import { formatDateTime } from "@xk2800/nextjs-template/lib/formatters"
|
|
4
|
+
import { Skeleton } from "../../ui/skeleton"
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
6
|
+
import { Badge } from "../../ui/badge"
|
|
7
|
+
import MarketingEmailForm from "./marketingEmailForm"
|
|
8
|
+
import BroadcastActions from "./broadcastActions"
|
|
9
|
+
import SubscribersCard from "./subscribersCard"
|
|
10
|
+
|
|
11
|
+
// Admin marketing email page body: compose form, recent broadcasts and
|
|
12
|
+
// subscribers. `segmentId` picks the Subscribers segment (e.g. from
|
|
13
|
+
// ?segment=); unknown or missing falls back to the first segment.
|
|
14
|
+
export default async function MarketingSection({ segmentId }: { segmentId?: string }) {
|
|
15
|
+
const { segments, broadcasts, error } = await getMarketingOverview()
|
|
16
|
+
|
|
17
|
+
if (error) {
|
|
18
|
+
return (
|
|
19
|
+
<Card>
|
|
20
|
+
<CardHeader>
|
|
21
|
+
<CardTitle>Resend isn't reachable</CardTitle>
|
|
22
|
+
<CardDescription>
|
|
23
|
+
{error} Broadcasts need a <strong>full access</strong> API key (a sending-only key can't list segments).
|
|
24
|
+
</CardDescription>
|
|
25
|
+
</CardHeader>
|
|
26
|
+
</Card>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="space-y-6">
|
|
32
|
+
<MarketingEmailForm segments={segments} />
|
|
33
|
+
|
|
34
|
+
{broadcasts.length > 0 && (
|
|
35
|
+
<Card>
|
|
36
|
+
<CardHeader>
|
|
37
|
+
<CardTitle>Recent broadcasts</CardTitle>
|
|
38
|
+
<CardDescription>Open, click and unsubscribe stats are in the Resend dashboard</CardDescription>
|
|
39
|
+
</CardHeader>
|
|
40
|
+
<CardContent>
|
|
41
|
+
<ul className="divide-y">
|
|
42
|
+
{broadcasts.map((b) => (
|
|
43
|
+
<li key={b.id} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-2 text-sm">
|
|
44
|
+
<span className="truncate">{b.name || "(untitled)"}</span>
|
|
45
|
+
<span className="flex shrink-0 items-center gap-2 text-muted-foreground">
|
|
46
|
+
{formatDateTime(b.sent_at ?? b.created_at)}
|
|
47
|
+
<Badge variant={b.status === "sent" ? "default" : "secondary"}>{b.status}</Badge>
|
|
48
|
+
<BroadcastActions id={b.id} name={b.name || "(untitled)"} status={b.status} />
|
|
49
|
+
</span>
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</CardContent>
|
|
54
|
+
</Card>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{segments.length > 0 && (
|
|
58
|
+
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
|
59
|
+
<SubscribersCard
|
|
60
|
+
segments={segments}
|
|
61
|
+
segmentId={segments.some((s) => s.id === segmentId) ? segmentId! : segments[0].id}
|
|
62
|
+
/>
|
|
63
|
+
</Suspense>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { listSegmentContacts, SEGMENT_CONTACTS_LIMIT as LIMIT, type MarketingSegment } from "@xk2800/nextjs-template/marketing"
|
|
3
|
+
import { formatDateTime } from "@xk2800/nextjs-template/lib/formatters"
|
|
4
|
+
import { Button } from "../../ui/button"
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
6
|
+
import { Badge } from "../../ui/badge"
|
|
7
|
+
|
|
8
|
+
export default async function SubscribersCard({
|
|
9
|
+
segments,
|
|
10
|
+
segmentId,
|
|
11
|
+
}: {
|
|
12
|
+
segments: MarketingSegment[]
|
|
13
|
+
segmentId: string
|
|
14
|
+
}) {
|
|
15
|
+
const { contacts, hasMore, error } = await listSegmentContacts(segmentId)
|
|
16
|
+
const subscribed = contacts.filter((c) => !c.unsubscribed).length
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Card>
|
|
20
|
+
<CardHeader>
|
|
21
|
+
<CardTitle>Subscribers</CardTitle>
|
|
22
|
+
<CardDescription>
|
|
23
|
+
{error
|
|
24
|
+
? `Couldn't load contacts: ${error}`
|
|
25
|
+
: `${subscribed} subscribed, ${contacts.length - subscribed} unsubscribed${hasMore ?` (first ${LIMIT} shown)` : ""}. Add or import contacts in Resend → Audience.`}
|
|
26
|
+
</CardDescription>
|
|
27
|
+
{segments.length > 1 && (
|
|
28
|
+
<div className="flex flex-wrap gap-2 pt-2">
|
|
29
|
+
{segments.map((s) => (
|
|
30
|
+
<Button key={s.id} asChild size="sm" variant={s.id === segmentId ? "default" : "outline"}>
|
|
31
|
+
<Link href={`?segment=${s.id}`} scroll={false}>{s.name}</Link>
|
|
32
|
+
</Button>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
</CardHeader>
|
|
37
|
+
{contacts.length > 0 && (
|
|
38
|
+
<CardContent>
|
|
39
|
+
<ul className="divide-y">
|
|
40
|
+
{contacts.map((c) => {
|
|
41
|
+
const name = [c.first_name, c.last_name].filter(Boolean).join(" ")
|
|
42
|
+
return (
|
|
43
|
+
<li key={c.id} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-2 text-sm">
|
|
44
|
+
<span className="min-w-0 truncate">
|
|
45
|
+
{c.email}
|
|
46
|
+
{name && <span className="text-muted-foreground"> · {name}</span>}
|
|
47
|
+
</span>
|
|
48
|
+
<span className="flex shrink-0 items-center gap-2 text-muted-foreground">
|
|
49
|
+
{formatDateTime(c.created_at)}
|
|
50
|
+
<Badge variant={c.unsubscribed ? "secondary" : "default"}>
|
|
51
|
+
{c.unsubscribed ? "unsubscribed" : "subscribed"}
|
|
52
|
+
</Badge>
|
|
53
|
+
</span>
|
|
54
|
+
</li>
|
|
55
|
+
)
|
|
56
|
+
})}
|
|
57
|
+
</ul>
|
|
58
|
+
</CardContent>
|
|
59
|
+
)}
|
|
60
|
+
</Card>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { toast } from "sonner"
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
|
6
|
+
import { Switch } from "../ui/switch"
|
|
7
|
+
import { Label } from "../ui/label"
|
|
8
|
+
|
|
9
|
+
export default function MarketingUpdatesCard({
|
|
10
|
+
initialSubscribed,
|
|
11
|
+
emailVerified,
|
|
12
|
+
}: {
|
|
13
|
+
initialSubscribed: boolean
|
|
14
|
+
emailVerified: boolean
|
|
15
|
+
}) {
|
|
16
|
+
const [subscribed, setSubscribed] = useState(initialSubscribed)
|
|
17
|
+
const [saving, setSaving] = useState(false)
|
|
18
|
+
|
|
19
|
+
const toggle = async (next: boolean) => {
|
|
20
|
+
setSaving(true)
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch("/api/marketing/subscription", {
|
|
23
|
+
method: "PUT",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ subscribed: next }),
|
|
26
|
+
})
|
|
27
|
+
const data = await response.json()
|
|
28
|
+
if (!response.ok) throw new Error(data.error || "Failed to update subscription")
|
|
29
|
+
setSubscribed(next)
|
|
30
|
+
toast.success(next ? "Subscribed to product updates" : "Unsubscribed from product updates")
|
|
31
|
+
} catch (error) {
|
|
32
|
+
toast.error(error instanceof Error ? error.message : "Failed to update subscription")
|
|
33
|
+
} finally {
|
|
34
|
+
setSaving(false)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Card>
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<CardTitle>Email updates</CardTitle>
|
|
42
|
+
<CardDescription>
|
|
43
|
+
Occasional news about new features. You can turn this off here or with the link in any email.
|
|
44
|
+
</CardDescription>
|
|
45
|
+
</CardHeader>
|
|
46
|
+
<CardContent className="space-y-2">
|
|
47
|
+
<div className="flex items-center justify-between gap-4">
|
|
48
|
+
<Label htmlFor="marketing-updates">Send me product updates</Label>
|
|
49
|
+
<Switch
|
|
50
|
+
id="marketing-updates"
|
|
51
|
+
checked={subscribed}
|
|
52
|
+
disabled={saving || (!subscribed && !emailVerified)}
|
|
53
|
+
onCheckedChange={toggle}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
{!subscribed && !emailVerified && (
|
|
57
|
+
<p className="text-sm text-muted-foreground">Verify your email address to subscribe.</p>
|
|
58
|
+
)}
|
|
59
|
+
</CardContent>
|
|
60
|
+
</Card>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isSubscribed, MARKETING_SEGMENT_ID } from "@xk2800/nextjs-template/marketing"
|
|
2
|
+
import MarketingUpdatesCard from "./marketingUpdatesCard"
|
|
3
|
+
|
|
4
|
+
// Dashboard opt-in for product updates. Renders nothing until
|
|
5
|
+
// RESEND_MARKETING_SEGMENT_ID is set.
|
|
6
|
+
export default async function MarketingUpdatesSection({ email, emailVerified }: { email: string; emailVerified: boolean }) {
|
|
7
|
+
if (!MARKETING_SEGMENT_ID) return null
|
|
8
|
+
const subscribed = await isSubscribed(email)
|
|
9
|
+
return <MarketingUpdatesCard initialSubscribed={subscribed} emailVerified={emailVerified} />
|
|
10
|
+
}
|
package/dist/api/feedback.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { lookupErrorDetail, unexpectedErrorMessage } from '../chunk-6MDQRT6U.mjs';
|
|
2
|
-
import { auth
|
|
2
|
+
import { auth } from '../chunk-7QVIOQ47.mjs';
|
|
3
3
|
import '../chunk-7F2POZM6.mjs';
|
|
4
4
|
import '../chunk-6OAHCEDJ.mjs';
|
|
5
5
|
import { getSystemSettings } from '../chunk-P2G23JMY.mjs';
|
|
6
|
+
import { getResend, EMAIL_FROM } from '../chunk-OVKVD2HH.mjs';
|
|
6
7
|
import '../chunk-ZJMFE5B2.mjs';
|
|
7
8
|
import '../chunk-TR5XFBTD.mjs';
|
|
8
9
|
import { config } from '../chunk-TKWNEKK6.mjs';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../lib/feedback-route.ts"],"names":[],"mappings":"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","file":"feedback.mjs","sourcesContent":["import \"server-only\"\n// POST handler behind components/dashboard/feedbackDialog.tsx. Shipped as\n// `@xk2800/nextjs-template/api/feedback` so apps mount it with a one-line\n// app/api/feedback/route.ts: export { POST } from \"@xk2800/nextjs-template/api/feedback\"\nimport { NextRequest, NextResponse } from \"next/server\"\nimport { headers } from \"next/headers\"\nimport { z } from \"zod\"\nimport { auth } from \"../server/auth\"\nimport { config } from \"../config/env\"\nimport { getResend, EMAIL_FROM } from \"./resend\"\nimport { getSystemSettings } from \"./settings-queries\"\nimport { lookupErrorDetail, unexpectedErrorMessage } from \"./errors\"\n\nconst feedbackSchema = z.object({\n type: z.enum([\"bug\", \"feedback\"]),\n // Keep in sync with FEEDBACK_MAX_LENGTH in components/dashboard/feedbackDialog.tsx.\n message: z.string().trim().min(1).max(2000),\n page: z.string().max(300).optional(),\n // The error the user reported from (FeedbackDialog's errorDetails).\n error: z.string().max(2000).optional(),\n})\n\n// const COOLDOWN_MS = 30_000\nconst COOLDOWN_MS = 0\n// ponytail: in-memory, per instance; move to a DB/Redis counter if one user\n// spamming across instances ever matters.\nconst lastSentAt = new Map<string, number>()\n\nexport async function POST(request: NextRequest) {\n const discordUrl = config.FEEDBACK_DISCORD_WEBHOOK_URL\n const emailTo = config.FEEDBACK_EMAIL\n if (!discordUrl && !emailTo) {\n return NextResponse.json({ error: \"Feedback is not configured\" }, { status: 503 })\n }\n\n const session = await auth.api.getSession({ headers: await headers() })\n if (!session?.user) {\n return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 })\n }\n\n const parsed = feedbackSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return NextResponse.json({ error: \"Message must be 1-2000 characters\" }, { status: 400 })\n }\n\n const now = Date.now()\n if (now - (lastSentAt.get(session.user.id) ?? 0) < COOLDOWN_MS) {\n return NextResponse.json(\n { error: \"Please wait a moment before sending more feedback\" },\n { status: 429 }\n )\n }\n lastSentAt.set(session.user.id, now)\n\n const { type, message, page } = parsed.data\n // Admin-controlled (System Settings → Feedback); off drops it entirely.\n const errorDetails =\n parsed.data.error && (await getSystemSettings()).feedbackAttachErrors\n ? parsed.data.error\n : undefined\n // Technical detail stored under the error's ref (lib/errors.ts). Goes\n // server → Discord/email only; the browser never sees it.\n const technicalDetail = errorDetails ? await lookupErrorDetail(errorDetails) : null\n const { user } = session\n const title = type === \"bug\" ? \"🐞 Bug report\" : \"💬 Feedback\"\n const userAgent = request.headers.get(\"user-agent\") || \"unknown\"\n\n const sends: Promise<unknown>[] = []\n\n if (discordUrl) {\n sends.push(\n fetch(discordUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n username: config.APP_NAME,\n // User text must never ping @everyone/@here/roles in the channel.\n allowed_mentions: { parse: [] },\n embeds: [{\n title,\n description: message,\n color: type === \"bug\" ? 0xef4444 : 0x3b82f6,\n fields: [\n { name: \"User\", value: `${user.name} (${user.email})`.slice(0, 1024) },\n { name: \"Page\", value: page || \"unknown\", inline: true },\n ...(errorDetails ? [{ name: \"Error\", value: errorDetails.slice(0, 1024) }] : []),\n ...(technicalDetail\n ? [{ name: \"Technical details\", value: \"```\\n\" + technicalDetail.slice(0, 1000) + \"\\n```\" }]\n : []),\n { name: \"User agent\", value: userAgent.slice(0, 1024) },\n ],\n timestamp: new Date(now).toISOString(),\n }],\n }),\n }).then((res) => {\n if (!res.ok) throw new Error(`Discord webhook responded ${res.status}`)\n })\n )\n }\n\n if (emailTo) {\n sends.push(\n getResend()\n .then((resend) => resend.emails.send({\n from: EMAIL_FROM,\n to: emailTo.split(\",\").map((s) => s.trim()),\n replyTo: user.email,\n subject: `[${config.APP_NAME}] ${title} from ${user.name}`,\n text: `${message}\\n\\n---\\nUser: ${user.name} <${user.email}>\\nPage: ${page || \"unknown\"}${errorDetails ? `\\nError: ${errorDetails}` : \"\"}${technicalDetail ? `\\n\\nTechnical details:\\n${technicalDetail}` : \"\"}\\nUser agent: ${userAgent}`,\n }))\n .then(({ error }) => {\n if (error) throw new Error(error.message)\n })\n )\n }\n\n const results = await Promise.allSettled(sends)\n results.forEach((r) => {\n if (r.status === \"rejected\") console.error(\"Feedback delivery failed:\", r.reason)\n })\n\n if (!results.some((r) => r.status === \"fulfilled\")) {\n lastSentAt.delete(user.id)\n return NextResponse.json({ error: \"Failed to send feedback\" }, { status: 502 })\n }\n return NextResponse.json({ success: true })\n}\n\n/**\n * Development-only endpoint behind FeedbackTestForm, for testing the\n * report-from-error flow. \"!error\" answers with a safe, user-facing error;\n * \"!crash\" simulates an unexpected failure, so the user gets a generic message\n * with an error ref and the technical detail only reaches the server log.\n * 404 outside development.\n * Mount with app/api/feedback/test/route.ts:\n * export { testErrorPOST as POST } from \"@xk2800/nextjs-template/api/feedback\"\n */\nexport async function testErrorPOST(request: NextRequest) {\n if (process.env.NODE_ENV !== \"development\") {\n return NextResponse.json({ error: \"Not found\" }, { status: 404 })\n }\n const session = await auth.api.getSession({ headers: await headers() })\n if (!session?.user) {\n return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 })\n }\n const body = await request.json().catch(() => null)\n if (typeof body?.text === \"string\" && body.text.trim() === \"!error\") {\n return NextResponse.json({ error: \"Test error from the feedback test form\" }, { status: 400 })\n }\n if (typeof body?.text === \"string\" && body.text.trim() === \"!crash\") {\n const crash = new Error(\"Test crash: DB connection to postgres://admin:hunter2@db.internal failed\")\n return NextResponse.json({ error: unexpectedErrorMessage(crash) }, { status: 500 })\n }\n return NextResponse.json({ success: true })\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../lib/feedback-route.ts"],"names":[],"mappings":"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","file":"feedback.mjs","sourcesContent":["import \"server-only\"\n// POST handler behind components/dashboard/feedbackDialog.tsx. Shipped as\n// `@xk2800/nextjs-template/api/feedback` so apps mount it with a one-line\n// app/api/feedback/route.ts: export { POST } from \"@xk2800/nextjs-template/api/feedback\"\nimport { NextRequest, NextResponse } from \"next/server\"\nimport { headers } from \"next/headers\"\nimport { z } from \"zod\"\nimport { auth } from \"../server/auth\"\nimport { config } from \"../config/env\"\nimport { getResend, EMAIL_FROM } from \"./resend\"\nimport { getSystemSettings } from \"./settings-queries\"\nimport { lookupErrorDetail, unexpectedErrorMessage } from \"./errors\"\n\nconst feedbackSchema = z.object({\n type: z.enum([\"bug\", \"feedback\"]),\n // Keep in sync with FEEDBACK_MAX_LENGTH in components/dashboard/feedbackDialog.tsx.\n message: z.string().trim().min(1).max(2000),\n page: z.string().max(300).optional(),\n // The error the user reported from (FeedbackDialog's errorDetails).\n error: z.string().max(2000).optional(),\n})\n\n// const COOLDOWN_MS = 30_000\nconst COOLDOWN_MS = 0\n// ponytail: in-memory, per instance; move to a DB/Redis counter if one user\n// spamming across instances ever matters.\nconst lastSentAt = new Map<string, number>()\n\nexport async function POST(request: NextRequest) {\n const discordUrl = config.FEEDBACK_DISCORD_WEBHOOK_URL\n const emailTo = config.FEEDBACK_EMAIL\n if (!discordUrl && !emailTo) {\n return NextResponse.json({ error: \"Feedback is not configured\" }, { status: 503 })\n }\n\n const session = await auth.api.getSession({ headers: await headers() })\n if (!session?.user) {\n return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 })\n }\n\n const parsed = feedbackSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return NextResponse.json({ error: \"Message must be 1-2000 characters\" }, { status: 400 })\n }\n\n const now = Date.now()\n if (now - (lastSentAt.get(session.user.id) ?? 0) < COOLDOWN_MS) {\n return NextResponse.json(\n { error: \"Please wait a moment before sending more feedback\" },\n { status: 429 }\n )\n }\n lastSentAt.set(session.user.id, now)\n\n const { type, message, page } = parsed.data\n // Admin-controlled (System Settings → Feedback); off drops it entirely.\n const errorDetails =\n parsed.data.error && (await getSystemSettings()).feedbackAttachErrors\n ? parsed.data.error\n : undefined\n // Technical detail stored under the error's ref (lib/errors.ts). Goes\n // server → Discord/email only; the browser never sees it.\n const technicalDetail = errorDetails ? await lookupErrorDetail(errorDetails) : null\n const { user } = session\n const title = type === \"bug\" ? \"🐞 Bug report\" : \"💬 Feedback\"\n const userAgent = request.headers.get(\"user-agent\") || \"unknown\"\n\n const sends: Promise<unknown>[] = []\n\n if (discordUrl) {\n sends.push(\n fetch(discordUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n username: config.APP_NAME,\n // User text must never ping @everyone/@here/roles in the channel.\n allowed_mentions: { parse: [] },\n embeds: [{\n title,\n description: message,\n color: type === \"bug\" ? 0xef4444 : 0x3b82f6,\n fields: [\n { name: \"User\", value: `${user.name} (${user.email})`.slice(0, 1024) },\n { name: \"Page\", value: page || \"unknown\", inline: true },\n ...(errorDetails ? [{ name: \"Error\", value: errorDetails.slice(0, 1024) }] : []),\n ...(technicalDetail\n ? [{ name: \"Technical details\", value: \"```\\n\" + technicalDetail.slice(0, 1000) + \"\\n```\" }]\n : []),\n { name: \"User agent\", value: userAgent.slice(0, 1024) },\n ],\n timestamp: new Date(now).toISOString(),\n }],\n }),\n }).then((res) => {\n if (!res.ok) throw new Error(`Discord webhook responded ${res.status}`)\n })\n )\n }\n\n if (emailTo) {\n sends.push(\n getResend()\n .then((resend) => resend.emails.send({\n from: EMAIL_FROM,\n to: emailTo.split(\",\").map((s) => s.trim()),\n replyTo: user.email,\n subject: `[${config.APP_NAME}] ${title} from ${user.name}`,\n text: `${message}\\n\\n---\\nUser: ${user.name} <${user.email}>\\nPage: ${page || \"unknown\"}${errorDetails ? `\\nError: ${errorDetails}` : \"\"}${technicalDetail ? `\\n\\nTechnical details:\\n${technicalDetail}` : \"\"}\\nUser agent: ${userAgent}`,\n }))\n .then(({ error }) => {\n if (error) throw new Error(error.message)\n })\n )\n }\n\n const results = await Promise.allSettled(sends)\n results.forEach((r) => {\n if (r.status === \"rejected\") console.error(\"Feedback delivery failed:\", r.reason)\n })\n\n if (!results.some((r) => r.status === \"fulfilled\")) {\n lastSentAt.delete(user.id)\n return NextResponse.json({ error: \"Failed to send feedback\" }, { status: 502 })\n }\n return NextResponse.json({ success: true })\n}\n\n/**\n * Development-only endpoint behind FeedbackTestForm, for testing the\n * report-from-error flow. \"!error\" answers with a safe, user-facing error;\n * \"!crash\" simulates an unexpected failure, so the user gets a generic message\n * with an error ref and the technical detail only reaches the server log.\n * 404 outside development.\n * Mount with app/api/feedback/test/route.ts:\n * export { testErrorPOST as POST } from \"@xk2800/nextjs-template/api/feedback\"\n */\nexport async function testErrorPOST(request: NextRequest) {\n if (process.env.NODE_ENV !== \"development\") {\n return NextResponse.json({ error: \"Not found\" }, { status: 404 })\n }\n const session = await auth.api.getSession({ headers: await headers() })\n if (!session?.user) {\n return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 })\n }\n const body = await request.json().catch(() => null)\n if (typeof body?.text === \"string\" && body.text.trim() === \"!error\") {\n return NextResponse.json({ error: \"Test error from the feedback test form\" }, { status: 400 })\n }\n if (typeof body?.text === \"string\" && body.text.trim() === \"!crash\") {\n const crash = new Error(\"Test crash: DB connection to postgres://admin:hunter2@db.internal failed\")\n return NextResponse.json({ error: unexpectedErrorMessage(crash) }, { status: 500 })\n }\n return NextResponse.json({ success: true })\n}\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
type IdParams = {
|
|
4
|
+
params: Promise<{
|
|
5
|
+
id: string;
|
|
6
|
+
}>;
|
|
7
|
+
};
|
|
8
|
+
declare function createBroadcastPOST(request: NextRequest): Promise<NextResponse<{
|
|
9
|
+
error: string;
|
|
10
|
+
}> | NextResponse<{
|
|
11
|
+
success: boolean;
|
|
12
|
+
id: string;
|
|
13
|
+
sent: boolean;
|
|
14
|
+
}>>;
|
|
15
|
+
declare function getBroadcastGET(_request: NextRequest, { params }: IdParams): Promise<NextResponse<{
|
|
16
|
+
error: string;
|
|
17
|
+
}> | NextResponse<{
|
|
18
|
+
subject: string | null;
|
|
19
|
+
from: string | null;
|
|
20
|
+
previewText: string | null;
|
|
21
|
+
html: string | null;
|
|
22
|
+
text: string | null;
|
|
23
|
+
status: "draft" | "sent" | "queued";
|
|
24
|
+
}>>;
|
|
25
|
+
declare function sendBroadcastPOST(_request: NextRequest, { params }: IdParams): Promise<NextResponse<{
|
|
26
|
+
error: string;
|
|
27
|
+
}> | NextResponse<{
|
|
28
|
+
success: boolean;
|
|
29
|
+
}>>;
|
|
30
|
+
declare function updateSubscriptionPUT(request: NextRequest): Promise<NextResponse<{
|
|
31
|
+
error: string;
|
|
32
|
+
}> | NextResponse<{
|
|
33
|
+
subscribed: boolean;
|
|
34
|
+
}>>;
|
|
35
|
+
|
|
36
|
+
export { createBroadcastPOST, getBroadcastGET, sendBroadcastPOST, updateSubscriptionPUT };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
type IdParams = {
|
|
4
|
+
params: Promise<{
|
|
5
|
+
id: string;
|
|
6
|
+
}>;
|
|
7
|
+
};
|
|
8
|
+
declare function createBroadcastPOST(request: NextRequest): Promise<NextResponse<{
|
|
9
|
+
error: string;
|
|
10
|
+
}> | NextResponse<{
|
|
11
|
+
success: boolean;
|
|
12
|
+
id: string;
|
|
13
|
+
sent: boolean;
|
|
14
|
+
}>>;
|
|
15
|
+
declare function getBroadcastGET(_request: NextRequest, { params }: IdParams): Promise<NextResponse<{
|
|
16
|
+
error: string;
|
|
17
|
+
}> | NextResponse<{
|
|
18
|
+
subject: string | null;
|
|
19
|
+
from: string | null;
|
|
20
|
+
previewText: string | null;
|
|
21
|
+
html: string | null;
|
|
22
|
+
text: string | null;
|
|
23
|
+
status: "draft" | "sent" | "queued";
|
|
24
|
+
}>>;
|
|
25
|
+
declare function sendBroadcastPOST(_request: NextRequest, { params }: IdParams): Promise<NextResponse<{
|
|
26
|
+
error: string;
|
|
27
|
+
}> | NextResponse<{
|
|
28
|
+
success: boolean;
|
|
29
|
+
}>>;
|
|
30
|
+
declare function updateSubscriptionPUT(request: NextRequest): Promise<NextResponse<{
|
|
31
|
+
error: string;
|
|
32
|
+
}> | NextResponse<{
|
|
33
|
+
subscribed: boolean;
|
|
34
|
+
}>>;
|
|
35
|
+
|
|
36
|
+
export { createBroadcastPOST, getBroadcastGET, sendBroadcastPOST, updateSubscriptionPUT };
|