@xk2800/nextjs-template 0.7.1-beta.6 → 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.
Files changed (86) hide show
  1. package/README.md +31 -29
  2. package/components/dashboard/admin/broadcastActions.tsx +148 -0
  3. package/components/dashboard/admin/confirmSendDialog.tsx +44 -0
  4. package/components/dashboard/admin/marketingEmailForm.tsx +115 -0
  5. package/components/dashboard/admin/marketingSection.tsx +67 -0
  6. package/components/dashboard/admin/subscribersCard.tsx +62 -0
  7. package/components/dashboard/marketingUpdatesCard.tsx +62 -0
  8. package/components/dashboard/marketingUpdatesSection.tsx +10 -0
  9. package/components/ui/select.tsx +189 -0
  10. package/dist/activity/logger.js +6 -0
  11. package/dist/activity/logger.js.map +1 -1
  12. package/dist/activity/logger.mjs +3 -3
  13. package/dist/activity/queries.js +6 -0
  14. package/dist/activity/queries.js.map +1 -1
  15. package/dist/activity/queries.mjs +2 -2
  16. package/dist/admin/queries.js +6 -0
  17. package/dist/admin/queries.js.map +1 -1
  18. package/dist/admin/queries.mjs +2 -2
  19. package/dist/api/feedback.js +7 -0
  20. package/dist/api/feedback.js.map +1 -1
  21. package/dist/api/feedback.mjs +7 -6
  22. package/dist/api/feedback.mjs.map +1 -1
  23. package/dist/api/marketing.d.mts +36 -0
  24. package/dist/api/marketing.d.ts +36 -0
  25. package/dist/api/marketing.js +1357 -0
  26. package/dist/api/marketing.js.map +1 -0
  27. package/dist/api/marketing.mjs +170 -0
  28. package/dist/api/marketing.mjs.map +1 -0
  29. package/dist/auth/helpers.d.mts +3 -3
  30. package/dist/auth/helpers.d.ts +3 -3
  31. package/dist/auth/helpers.js +7 -0
  32. package/dist/auth/helpers.js.map +1 -1
  33. package/dist/auth/helpers.mjs +8 -63
  34. package/dist/auth/helpers.mjs.map +1 -1
  35. package/dist/auth/index.js +7 -0
  36. package/dist/auth/index.js.map +1 -1
  37. package/dist/auth/index.mjs +6 -5
  38. package/dist/auth-client/index.d.mts +16 -16
  39. package/dist/auth-client/index.d.ts +16 -16
  40. package/dist/{chunk-6KTSULA5.mjs → chunk-6MDQRT6U.mjs} +3 -3
  41. package/dist/{chunk-6KTSULA5.mjs.map → chunk-6MDQRT6U.mjs.map} +1 -1
  42. package/dist/chunk-6Z3WUSDJ.mjs +62 -0
  43. package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
  44. package/dist/{chunk-FVLMSLTX.mjs → chunk-7F2POZM6.mjs} +3 -3
  45. package/dist/{chunk-FVLMSLTX.mjs.map → chunk-7F2POZM6.mjs.map} +1 -1
  46. package/dist/{chunk-4GGLYS5R.mjs → chunk-7QVIOQ47.mjs} +8 -16
  47. package/dist/chunk-7QVIOQ47.mjs.map +1 -0
  48. package/dist/chunk-C6RGN6QA.mjs +60 -0
  49. package/dist/chunk-C6RGN6QA.mjs.map +1 -0
  50. package/dist/chunk-OVKVD2HH.mjs +25 -0
  51. package/dist/chunk-OVKVD2HH.mjs.map +1 -0
  52. package/dist/{chunk-F776DQAM.mjs → chunk-P2G23JMY.mjs} +4 -4
  53. package/dist/{chunk-F776DQAM.mjs.map → chunk-P2G23JMY.mjs.map} +1 -1
  54. package/dist/{chunk-TMJYAOC2.mjs → chunk-TKWNEKK6.mjs} +8 -2
  55. package/dist/chunk-TKWNEKK6.mjs.map +1 -0
  56. package/dist/{chunk-F7CG5CA3.mjs → chunk-ZJMFE5B2.mjs} +3 -3
  57. package/dist/{chunk-F7CG5CA3.mjs.map → chunk-ZJMFE5B2.mjs.map} +1 -1
  58. package/dist/config/env.d.mts +2 -0
  59. package/dist/config/env.d.ts +2 -0
  60. package/dist/config/env.js +6 -0
  61. package/dist/config/env.js.map +1 -1
  62. package/dist/config/env.mjs +1 -1
  63. package/dist/db/index.js +6 -0
  64. package/dist/db/index.js.map +1 -1
  65. package/dist/db/index.mjs +2 -2
  66. package/dist/errors.js +6 -0
  67. package/dist/errors.js.map +1 -1
  68. package/dist/errors.mjs +3 -3
  69. package/dist/marketing/index.d.mts +35 -0
  70. package/dist/marketing/index.d.ts +35 -0
  71. package/dist/marketing/index.js +125 -0
  72. package/dist/marketing/index.js.map +1 -0
  73. package/dist/marketing/index.mjs +6 -0
  74. package/dist/marketing/index.mjs.map +1 -0
  75. package/dist/sessions/queries.js +6 -0
  76. package/dist/sessions/queries.js.map +1 -1
  77. package/dist/sessions/queries.mjs +2 -2
  78. package/dist/settings/queries.js +6 -0
  79. package/dist/settings/queries.js.map +1 -1
  80. package/dist/settings/queries.mjs +3 -3
  81. package/dist/users/queries.js +6 -0
  82. package/dist/users/queries.js.map +1 -1
  83. package/dist/users/queries.mjs +2 -2
  84. package/package.json +23 -3
  85. package/dist/chunk-4GGLYS5R.mjs.map +0 -1
  86. package/dist/chunk-TMJYAOC2.mjs.map +0 -1
package/README.md CHANGED
@@ -34,24 +34,24 @@ UPDATE "user" SET roles = 'admin' WHERE email = 'you@example.com';
34
34
 
35
35
  - **Auth:** email/password, Google OAuth, Google One Tap, passkeys, TOTP 2FA with backup codes, password reset, email verification
36
36
  - **Security:** new-device email alerts, per-device sign-in/sign-up throttle, active-session revocation, self-serve account deletion, cookie-consent banner with an admin consent-mode switch
37
- - **Admin:** user management (roles, bans, bulk actions, CSV export), impersonation, system-wide activity log, device-security cards, live auth toggles, maintenance mode
37
+ - **Admin:** user management (roles, bans, bulk actions, CSV export), impersonation, system-wide activity log, device-security cards, live auth toggles, maintenance mode, marketing email via Resend Broadcasts with user self opt-in (Beta)
38
38
  - **Ops:** `bun run doctor`, Docker image, Doppler support, separate env files for dev, prod and test
39
39
 
40
40
  ## Docs
41
41
 
42
- | Topic | |
43
- |---|---|
44
- | Every env var, env vs. admin settings, Doppler | [Configuration](docs/content/configuration.mdx) |
45
- | Folder layout, request flow, adding protected pages/routes | [Architecture](docs/content/architecture.mdx) |
46
- | Tables, column gotchas, migrations | [Database](docs/content/database.mdx) |
47
- | Providers, sessions, helpers, client usage | [Authentication](docs/content/authentication.mdx) |
48
- | 2FA, passkeys, device alerts, throttle, cookie consent | [Security Features](docs/content/security.mdx) |
49
- | Admin dashboard, impersonation, maintenance mode | [Admin & Maintenance](docs/content/admin.mdx) |
50
- | Every route, body and guard | [API Reference](docs/content/api.mdx) |
51
- | UI, auth, dashboard and email components | [Components](docs/content/components.mdx) |
52
- | Installing the npm package | [Using as a Package](docs/content/npm-package.mdx) |
53
- | Production checklist, migrations, Docker | [Deployment](docs/content/deployment.mdx) |
54
- | Common errors and fixes | [Troubleshooting](docs/content/troubleshooting.mdx) |
42
+ | Topic | |
43
+ | ---------------------------------------------------------- | --------------------------------------------------- |
44
+ | Every env var, env vs. admin settings, Doppler | [Configuration](docs/content/configuration.mdx) |
45
+ | Folder layout, request flow, adding protected pages/routes | [Architecture](docs/content/architecture.mdx) |
46
+ | Tables, column gotchas, migrations | [Database](docs/content/database.mdx) |
47
+ | Providers, sessions, helpers, client usage | [Authentication](docs/content/authentication.mdx) |
48
+ | 2FA, passkeys, device alerts, throttle, cookie consent | [Security Features](docs/content/security.mdx) |
49
+ | Admin dashboard, impersonation, maintenance mode | [Admin & Maintenance](docs/content/admin.mdx) |
50
+ | Every route, body and guard | [API Reference](docs/content/api.mdx) |
51
+ | UI, auth, dashboard and email components | [Components](docs/content/components.mdx) |
52
+ | Installing the npm package | [Using as a Package](docs/content/npm-package.mdx) |
53
+ | Production checklist, migrations, Docker | [Deployment](docs/content/deployment.mdx) |
54
+ | Common errors and fixes | [Troubleshooting](docs/content/troubleshooting.mdx) |
55
55
 
56
56
  ## Using as a package
57
57
 
@@ -63,7 +63,7 @@ bun add @xk2800/nextjs-template
63
63
 
64
64
  ```ts
65
65
  // next.config.ts — components ship as raw .tsx
66
- const nextConfig = { transpilePackages: ["@xk2800/nextjs-template"] }
66
+ const nextConfig = { transpilePackages: ["@xk2800/nextjs-template"] };
67
67
  ```
68
68
 
69
69
  ```css
@@ -75,25 +75,27 @@ const nextConfig = { transpilePackages: ["@xk2800/nextjs-template"] }
75
75
 
76
76
  ```ts
77
77
  // app/api/auth/[...all]/route.ts
78
- import { auth } from "@xk2800/nextjs-template/auth"
79
- import { toNextJsHandler } from "better-auth/next-js"
80
- export const { GET, POST } = toNextJsHandler(auth)
78
+ import { auth } from "@xk2800/nextjs-template/auth";
79
+ import { toNextJsHandler } from "better-auth/next-js";
80
+ export const { GET, POST } = toNextJsHandler(auth);
81
81
  ```
82
82
 
83
83
  Peer dependencies, the full import list, migrations, adding providers and One Tap are covered in **[Using as a Package](docs/content/npm-package.mdx)**.
84
84
 
85
85
  ## Common scripts
86
86
 
87
- | | |
88
- |---|---|
89
- | `bun dev` | Dev server |
90
- | `bun run doctor` | Check env, auth secret, DB connection and pending migrations |
91
- | `bun update @xk2800/nextjs-template` | Pull template updates (then `bun run generate`) |
92
- | `bun run generate` | Generate a migration from `server/db/schema.ts` |
93
- | `bun run migrate:dev` / `:prod` / `:test` | Apply migrations |
94
- | `bun run studio:dev` | Drizzle Studio |
95
- | `bun run test` | Unit tests |
96
- | `bun run storybook` | Storybook for every component (mock data, no DB needed) |
97
- | `bun run *:doppler` | Any of the above with secrets from Doppler |
87
+ | | |
88
+ | ----------------------------------------- | ------------------------------------------------------------ |
89
+ | `bun dev` | Dev server |
90
+ | `bun run doctor` | Check env, auth secret, DB connection and pending migrations |
91
+ | `bun update @xk2800/nextjs-template` | Pull template updates (then `bun run generate`) |
92
+ | `bun run generate` | Generate a migration from `server/db/schema.ts` |
93
+ | `bun run migrate:dev` / `:prod` / `:test` | Apply migrations |
94
+ | `bun run studio:dev` | Drizzle Studio |
95
+ | `bun run test` | Unit tests |
96
+ | `bun run storybook` | Storybook for every component (mock data, no DB needed) |
97
+ | `bun run *:doppler` | Any of the above with secrets from Doppler |
98
98
 
99
99
  **Working on the template itself:** clone this repo, copy `.env.development.example` to `.env.development`, then run `bun install`. Publishing steps are in [PUBLISHING.md](PUBLISHING.md).
100
+
101
+ test
@@ -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
+ &ldquo;{subject}&rdquo; goes to every subscribed contact in {audience} right away.
34
+ This can&apos;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&apos;t reachable</CardTitle>
22
+ <CardDescription>
23
+ {error} Broadcasts need a <strong>full access</strong> API key (a sending-only key can&apos;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
+ }