@xk2800/nextjs-template 0.7.1-beta.7 → 0.7.1-beta.9
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/components/ui/alert-dialog.tsx +1 -1
- package/components/ui/badge.tsx +1 -1
- package/components/ui/card.tsx +1 -1
- package/components/ui/dialog.tsx +1 -1
- package/components/ui/dropdown-menu.tsx +2 -2
- package/components/ui/select.tsx +1 -1
- package/components/ui/switch.tsx +2 -2
- 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.d.mts +3 -3
- package/dist/auth/helpers.d.ts +3 -3
- package/dist/auth/helpers.mjs +5 -60
- package/dist/auth/helpers.mjs.map +1 -1
- package/dist/auth/index.mjs +2 -1
- package/dist/auth-client/index.d.mts +16 -16
- package/dist/auth-client/index.d.ts +16 -16
- 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/styles/theme.css +17 -3
- 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
|
+
}
|
|
@@ -47,7 +47,7 @@ function AlertDialogContent({
|
|
|
47
47
|
<AlertDialogOverlay />
|
|
48
48
|
<AlertDialogPrimitive.Content
|
|
49
49
|
className={cn(
|
|
50
|
-
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-
|
|
50
|
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border-(length:--surface-border-width) border-(--surface-border-color) p-6 shadow-(--dialog-shadow) duration-200 sm:max-w-lg",
|
|
51
51
|
className
|
|
52
52
|
)}
|
|
53
53
|
{...props}
|
package/components/ui/badge.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
5
5
|
import { cn } from "../../lib/utils"
|
|
6
6
|
|
|
7
7
|
const badgeVariants = cva(
|
|
8
|
-
"inline-flex items-center justify-center rounded-
|
|
8
|
+
"inline-flex items-center justify-center rounded-(--pill-radius) border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
|
9
9
|
{
|
|
10
10
|
variants: {
|
|
11
11
|
variant: {
|
package/components/ui/card.tsx
CHANGED
|
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
7
7
|
<div
|
|
8
8
|
data-slot="card"
|
|
9
9
|
className={cn(
|
|
10
|
-
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-
|
|
10
|
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border-(length:--surface-border-width) border-(--surface-border-color) py-6 shadow-(--card-shadow)",
|
|
11
11
|
className
|
|
12
12
|
)}
|
|
13
13
|
{...props}
|
package/components/ui/dialog.tsx
CHANGED
|
@@ -60,7 +60,7 @@ function DialogContent({
|
|
|
60
60
|
<DialogPrimitive.Content
|
|
61
61
|
data-slot="dialog-content"
|
|
62
62
|
className={cn(
|
|
63
|
-
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-
|
|
63
|
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border-(length:--surface-border-width) border-(--surface-border-color) p-6 shadow-(--dialog-shadow) duration-200 sm:max-w-lg",
|
|
64
64
|
className
|
|
65
65
|
)}
|
|
66
66
|
{...props}
|
|
@@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|
|
47
47
|
<DropdownMenuPrimitive.SubContent
|
|
48
48
|
ref={ref}
|
|
49
49
|
className={cn(
|
|
50
|
-
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-
|
|
50
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border-(length:--surface-border-width) border-(--surface-border-color) bg-popover p-1 text-popover-foreground shadow-(--popover-shadow) data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
51
51
|
className
|
|
52
52
|
)}
|
|
53
53
|
{...props}
|
|
@@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
|
|
|
65
65
|
ref={ref}
|
|
66
66
|
sideOffset={sideOffset}
|
|
67
67
|
className={cn(
|
|
68
|
-
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-
|
|
68
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border-(length:--surface-border-width) border-(--surface-border-color) bg-popover p-1 text-popover-foreground shadow-(--popover-shadow) data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
69
69
|
className
|
|
70
70
|
)}
|
|
71
71
|
{...props}
|
package/components/ui/select.tsx
CHANGED
|
@@ -61,7 +61,7 @@ function SelectContent({
|
|
|
61
61
|
<SelectPrimitive.Content
|
|
62
62
|
data-slot="select-content"
|
|
63
63
|
className={cn(
|
|
64
|
-
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-
|
|
64
|
+
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border-(length:--surface-border-width) border-(--surface-border-color) bg-popover text-popover-foreground shadow-(--popover-shadow) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
65
65
|
position === "popper" &&
|
|
66
66
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
67
67
|
className
|
package/components/ui/switch.tsx
CHANGED
|
@@ -17,7 +17,7 @@ function Switch({
|
|
|
17
17
|
data-slot="switch"
|
|
18
18
|
data-size={size}
|
|
19
19
|
className={cn(
|
|
20
|
-
"peer group/switch inline-flex shrink-0 items-center rounded-
|
|
20
|
+
"peer group/switch inline-flex shrink-0 items-center rounded-(--pill-radius) border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
|
21
21
|
className
|
|
22
22
|
)}
|
|
23
23
|
{...props}
|
|
@@ -25,7 +25,7 @@ function Switch({
|
|
|
25
25
|
<SwitchPrimitive.Thumb
|
|
26
26
|
data-slot="switch-thumb"
|
|
27
27
|
className={cn(
|
|
28
|
-
"pointer-events-none block rounded-
|
|
28
|
+
"pointer-events-none block rounded-(--pill-radius) bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
|
29
29
|
)}
|
|
30
30
|
/>
|
|
31
31
|
</SwitchPrimitive.Root>
|
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';
|