@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.
- package/README.md +31 -29
- 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/select.tsx +189 -0
- package/dist/activity/logger.js +6 -0
- package/dist/activity/logger.js.map +1 -1
- package/dist/activity/logger.mjs +3 -3
- package/dist/activity/queries.js +6 -0
- package/dist/activity/queries.js.map +1 -1
- package/dist/activity/queries.mjs +2 -2
- package/dist/admin/queries.js +6 -0
- package/dist/admin/queries.js.map +1 -1
- package/dist/admin/queries.mjs +2 -2
- package/dist/api/feedback.js +7 -0
- package/dist/api/feedback.js.map +1 -1
- package/dist/api/feedback.mjs +7 -6
- 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.js +7 -0
- package/dist/auth/helpers.js.map +1 -1
- package/dist/auth/helpers.mjs +8 -63
- package/dist/auth/helpers.mjs.map +1 -1
- package/dist/auth/index.js +7 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/index.mjs +6 -5
- package/dist/auth-client/index.d.mts +16 -16
- package/dist/auth-client/index.d.ts +16 -16
- package/dist/{chunk-6KTSULA5.mjs → chunk-6MDQRT6U.mjs} +3 -3
- package/dist/{chunk-6KTSULA5.mjs.map → chunk-6MDQRT6U.mjs.map} +1 -1
- package/dist/chunk-6Z3WUSDJ.mjs +62 -0
- package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
- package/dist/{chunk-FVLMSLTX.mjs → chunk-7F2POZM6.mjs} +3 -3
- package/dist/{chunk-FVLMSLTX.mjs.map → chunk-7F2POZM6.mjs.map} +1 -1
- package/dist/{chunk-4GGLYS5R.mjs → chunk-7QVIOQ47.mjs} +8 -16
- 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/{chunk-F776DQAM.mjs → chunk-P2G23JMY.mjs} +4 -4
- package/dist/{chunk-F776DQAM.mjs.map → chunk-P2G23JMY.mjs.map} +1 -1
- package/dist/{chunk-TMJYAOC2.mjs → chunk-TKWNEKK6.mjs} +8 -2
- package/dist/chunk-TKWNEKK6.mjs.map +1 -0
- package/dist/{chunk-F7CG5CA3.mjs → chunk-ZJMFE5B2.mjs} +3 -3
- package/dist/{chunk-F7CG5CA3.mjs.map → chunk-ZJMFE5B2.mjs.map} +1 -1
- package/dist/config/env.d.mts +2 -0
- package/dist/config/env.d.ts +2 -0
- package/dist/config/env.js +6 -0
- package/dist/config/env.js.map +1 -1
- package/dist/config/env.mjs +1 -1
- package/dist/db/index.js +6 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/index.mjs +2 -2
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/errors.mjs +3 -3
- 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/dist/sessions/queries.js +6 -0
- package/dist/sessions/queries.js.map +1 -1
- package/dist/sessions/queries.mjs +2 -2
- package/dist/settings/queries.js +6 -0
- package/dist/settings/queries.js.map +1 -1
- package/dist/settings/queries.mjs +3 -3
- package/dist/users/queries.js +6 -0
- package/dist/users/queries.js.map +1 -1
- package/dist/users/queries.mjs +2 -2
- package/package.json +23 -3
- package/dist/chunk-4GGLYS5R.mjs.map +0 -1
- 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
|
|
45
|
-
| Folder layout, request flow, adding protected pages/routes | [Architecture](docs/content/architecture.mdx)
|
|
46
|
-
| Tables, column gotchas, migrations
|
|
47
|
-
| Providers, sessions, helpers, client usage
|
|
48
|
-
| 2FA, passkeys, device alerts, throttle, cookie consent
|
|
49
|
-
| Admin dashboard, impersonation, maintenance mode
|
|
50
|
-
| Every route, body and guard
|
|
51
|
-
| UI, auth, dashboard and email components
|
|
52
|
-
| Installing the npm package
|
|
53
|
-
| Production checklist, migrations, Docker
|
|
54
|
-
| Common errors and fixes
|
|
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`
|
|
90
|
-
| `bun run doctor`
|
|
91
|
-
| `bun update @xk2800/nextjs-template`
|
|
92
|
-
| `bun run generate`
|
|
93
|
-
| `bun run migrate:dev` / `:prod` / `:test` | Apply migrations
|
|
94
|
-
| `bun run studio:dev`
|
|
95
|
-
| `bun run test`
|
|
96
|
-
| `bun run storybook`
|
|
97
|
-
| `bun run *: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
|
+
“{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
|
+
}
|