@xk2800/nextjs-template 0.7.1-beta.0 → 0.7.1-beta.10
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 +32 -30
- package/components/auth/deviceCheck.tsx +47 -6
- package/components/auth/oneTap.tsx +5 -5
- package/components/dashboard/admin/adminUserConsentCard.tsx +79 -0
- package/components/dashboard/admin/adminUserDevicesCard.tsx +80 -0
- 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/admin/systemSettingsForm.tsx +33 -0
- package/components/dashboard/admin/systemSettingsSection.tsx +8 -2
- package/components/dashboard/errorFeedback.tsx +2 -4
- package/components/dashboard/feedbackDialog.tsx +4 -4
- package/components/dashboard/marketingUpdatesCard.tsx +62 -0
- package/components/dashboard/marketingUpdatesSection.tsx +10 -0
- package/components/site/cookie-banner.tsx +109 -7
- 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/data-table.tsx +4 -1
- package/components/ui/dialog.tsx +1 -1
- package/components/ui/dropdown-menu.tsx +2 -2
- package/components/ui/file-upload.tsx +129 -7
- package/components/ui/select.tsx +189 -0
- package/components/ui/switch.tsx +2 -2
- package/dist/activity/logger.js +36 -0
- package/dist/activity/logger.js.map +1 -1
- package/dist/activity/logger.mjs +4 -4
- package/dist/activity/queries.d.mts +1 -1
- package/dist/activity/queries.d.ts +1 -1
- package/dist/activity/queries.js +36 -0
- package/dist/activity/queries.js.map +1 -1
- package/dist/activity/queries.mjs +3 -3
- package/dist/admin/queries.d.mts +19 -1
- package/dist/admin/queries.d.ts +19 -1
- package/dist/admin/queries.js +64 -0
- package/dist/admin/queries.js.map +1 -1
- package/dist/admin/queries.mjs +26 -5
- package/dist/admin/queries.mjs.map +1 -1
- package/dist/api/feedback.js +53 -2
- package/dist/api/feedback.js.map +1 -1
- package/dist/api/feedback.mjs +9 -8
- 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.js +51 -0
- package/dist/auth/helpers.js.map +1 -1
- package/dist/auth/helpers.mjs +9 -64
- package/dist/auth/helpers.mjs.map +1 -1
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.js +51 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/index.mjs +8 -7
- package/dist/{chunk-KAEA35OC.mjs → chunk-6MDQRT6U.mjs} +4 -4
- package/dist/{chunk-KAEA35OC.mjs.map → chunk-6MDQRT6U.mjs.map} +1 -1
- package/dist/{chunk-N6ECRCHY.mjs → chunk-6OAHCEDJ.mjs} +4 -3
- package/dist/chunk-6OAHCEDJ.mjs.map +1 -0
- package/dist/chunk-6Z3WUSDJ.mjs +62 -0
- package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
- package/dist/{chunk-VQ5B4MA7.mjs → chunk-7F2POZM6.mjs} +4 -4
- package/dist/{chunk-VQ5B4MA7.mjs.map → chunk-7F2POZM6.mjs.map} +1 -1
- package/dist/{chunk-XWVAJXAG.mjs → chunk-7QVIOQ47.mjs} +23 -19
- 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-3MGEAGHU.mjs → chunk-P2G23JMY.mjs} +11 -6
- package/dist/chunk-P2G23JMY.mjs.map +1 -0
- package/dist/{chunk-TMJYAOC2.mjs → chunk-TKWNEKK6.mjs} +8 -2
- package/dist/chunk-TKWNEKK6.mjs.map +1 -0
- package/dist/{chunk-ZQCALTKB.mjs → chunk-TR5XFBTD.mjs} +34 -4
- package/dist/chunk-TR5XFBTD.mjs.map +1 -0
- package/dist/{chunk-WZDAMCUX.mjs → chunk-ZJMFE5B2.mjs} +4 -4
- package/dist/{chunk-WZDAMCUX.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.d.mts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +36 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/index.mjs +3 -3
- package/dist/db/schema.d.mts +1 -1
- package/dist/db/schema.d.ts +1 -1
- package/dist/db/schema.js +30 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/db/schema.mjs +1 -1
- package/dist/errors.js +36 -0
- package/dist/errors.js.map +1 -1
- package/dist/errors.mjs +4 -4
- 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/{schema-BNSBWNjK.d.mts → schema-CN_Y6EXv.d.mts} +149 -3
- package/dist/{schema-BNSBWNjK.d.ts → schema-CN_Y6EXv.d.ts} +149 -3
- package/dist/sessions/queries.js +36 -0
- package/dist/sessions/queries.js.map +1 -1
- package/dist/sessions/queries.mjs +3 -3
- package/dist/settings/queries.d.mts +4 -3
- package/dist/settings/queries.d.ts +4 -3
- package/dist/settings/queries.js +42 -0
- package/dist/settings/queries.js.map +1 -1
- package/dist/settings/queries.mjs +4 -4
- package/dist/users/queries.js +36 -0
- package/dist/users/queries.js.map +1 -1
- package/dist/users/queries.mjs +3 -3
- package/lib/cookie-consent.ts +72 -20
- package/lib/cookie-names.ts +47 -0
- package/lib/device-fingerprint.ts +66 -10
- package/lib/recalculate-columns.ts +2 -0
- package/package.json +28 -3
- package/styles/theme.css +17 -3
- package/dist/chunk-3MGEAGHU.mjs.map +0 -1
- package/dist/chunk-N6ECRCHY.mjs.map +0 -1
- package/dist/chunk-TMJYAOC2.mjs.map +0 -1
- package/dist/chunk-XWVAJXAG.mjs.map +0 -1
- package/dist/chunk-ZQCALTKB.mjs.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -52,6 +52,7 @@ export default function SystemSettingsForm({ initialSettings, configuredFlags }:
|
|
|
52
52
|
authEnableOneTap: settings.authEnableOneTap,
|
|
53
53
|
enableSessionRevocation: settings.enableSessionRevocation,
|
|
54
54
|
feedbackAttachErrors: settings.feedbackAttachErrors,
|
|
55
|
+
consentRequired: settings.consentRequired ?? true,
|
|
55
56
|
}),
|
|
56
57
|
})
|
|
57
58
|
|
|
@@ -161,6 +162,38 @@ export default function SystemSettingsForm({ initialSettings, configuredFlags }:
|
|
|
161
162
|
</CardContent>
|
|
162
163
|
</Card>
|
|
163
164
|
|
|
165
|
+
<Card>
|
|
166
|
+
<CardHeader>
|
|
167
|
+
<CardTitle>Privacy & Consent</CardTitle>
|
|
168
|
+
<CardDescription>
|
|
169
|
+
Whether visitors must opt in before device fingerprinting and Google One Tap run
|
|
170
|
+
</CardDescription>
|
|
171
|
+
</CardHeader>
|
|
172
|
+
<CardContent className="space-y-4">
|
|
173
|
+
<div className="flex items-center justify-between gap-4">
|
|
174
|
+
<div>
|
|
175
|
+
<Label htmlFor="consent-toggle">Require consent (GDPR / ePrivacy)</Label>
|
|
176
|
+
<p className="text-sm text-gray-500">
|
|
177
|
+
On: cookie banner where visitors allow fingerprinting and One Tap separately, and
|
|
178
|
+
the device record is erased if fingerprint consent is withdrawn. Off: no banner, everything runs for
|
|
179
|
+
every visitor.
|
|
180
|
+
</p>
|
|
181
|
+
{!settings.consentRequired && (
|
|
182
|
+
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
183
|
+
Only turn this off if none of your users are in the EU, the UK or another region with
|
|
184
|
+
consent rules. Fingerprinting without consent breaks those laws.
|
|
185
|
+
</p>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
<Switch
|
|
189
|
+
id="consent-toggle"
|
|
190
|
+
checked={settings.consentRequired ?? true}
|
|
191
|
+
onCheckedChange={(checked) => setSettings({ ...settings, consentRequired: checked })}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
</CardContent>
|
|
195
|
+
</Card>
|
|
196
|
+
|
|
164
197
|
<Card>
|
|
165
198
|
<CardHeader>
|
|
166
199
|
<CardTitle>Feedback</CardTitle>
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { getSystemSettings, getConfiguredAuthFlags } from "@xk2800/nextjs-template/settings/queries"
|
|
1
|
+
import { getSystemSettings, getConfiguredAuthFlags, isConsentRequired } from "@xk2800/nextjs-template/settings/queries"
|
|
2
2
|
import SystemSettingsForm from "./systemSettingsForm"
|
|
3
3
|
|
|
4
4
|
export default async function SystemSettingsSection() {
|
|
5
5
|
const settings = await getSystemSettings()
|
|
6
6
|
const configuredFlags = getConfiguredAuthFlags()
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// Show the effective consent mode (null = NEXT_PUBLIC_COOKIE_BANNER default).
|
|
9
|
+
return (
|
|
10
|
+
<SystemSettingsForm
|
|
11
|
+
initialSettings={{ ...settings, consentRequired: isConsentRequired(settings) }}
|
|
12
|
+
configuredFlags={configuredFlags}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
9
15
|
}
|
|
@@ -7,7 +7,8 @@ import FeedbackDialog from "./feedbackDialog"
|
|
|
7
7
|
// One hidden feedback dialog for reporting errors. Mount <ErrorFeedbackHost />
|
|
8
8
|
// once in a layout, only when feedback is configured (FEEDBACK_DISCORD_WEBHOOK_URL
|
|
9
9
|
// or FEEDBACK_EMAIL). Error toasts (toastError) and error pages (reportProblem)
|
|
10
|
-
// then open it as a bug report with the error
|
|
10
|
+
// then open it as a bug report with the error attached (sent with the report,
|
|
11
|
+
// not shown in the message box). Where
|
|
11
12
|
// it isn't mounted, toastError is a plain toast.error.
|
|
12
13
|
let openReport: ((errorText: string) => void) | null = null
|
|
13
14
|
const listeners = new Set<() => void>()
|
|
@@ -31,9 +32,6 @@ export function ErrorFeedbackHost() {
|
|
|
31
32
|
onOpenChange={(open) => !open && setErrorText(null)}
|
|
32
33
|
defaultType="bug"
|
|
33
34
|
errorDetails={errorText ?? undefined}
|
|
34
|
-
defaultMessage={
|
|
35
|
-
errorText ? `Error: ${errorText}\n\nWhat were you doing when this happened?\n` : ""
|
|
36
|
-
}
|
|
37
35
|
/>
|
|
38
36
|
)
|
|
39
37
|
}
|
|
@@ -62,13 +62,13 @@ export default function FeedbackDialog({
|
|
|
62
62
|
const [message, setMessage] = useState(defaultMessage)
|
|
63
63
|
const [isSending, setIsSending] = useState(false)
|
|
64
64
|
|
|
65
|
-
// With a pre-filled message,
|
|
66
|
-
// second error report doesn't start from the first one's text.
|
|
67
|
-
// keep whatever the user had typed (a closed dialog keeps its draft).
|
|
65
|
+
// With a pre-filled message or attached error, reset every time the dialog
|
|
66
|
+
// opens so a second error report doesn't start from the first one's text.
|
|
67
|
+
// Otherwise keep whatever the user had typed (a closed dialog keeps its draft).
|
|
68
68
|
const [wasOpen, setWasOpen] = useState(open)
|
|
69
69
|
if (open !== wasOpen) {
|
|
70
70
|
setWasOpen(open)
|
|
71
|
-
if (open && defaultMessage) {
|
|
71
|
+
if (open && (defaultMessage || errorDetails)) {
|
|
72
72
|
setType(defaultType)
|
|
73
73
|
setMessage(defaultMessage)
|
|
74
74
|
}
|
|
@@ -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
|
+
}
|
|
@@ -6,19 +6,90 @@ import { useEffect, useState } from "react"
|
|
|
6
6
|
// Relative imports: this file ships raw in the npm package, where `@/` would
|
|
7
7
|
// resolve against the consuming project instead.
|
|
8
8
|
import { Button } from "../ui/button"
|
|
9
|
-
import {
|
|
9
|
+
import { Checkbox } from "../ui/checkbox"
|
|
10
|
+
import { Label } from "../ui/label"
|
|
11
|
+
import {
|
|
12
|
+
readConsent,
|
|
13
|
+
setCookieConsent,
|
|
14
|
+
useConsentRequired,
|
|
15
|
+
useCookieConsent,
|
|
16
|
+
type ConsentPurpose,
|
|
17
|
+
} from "../../lib/cookie-consent"
|
|
18
|
+
import { CONSENT_VERSION } from "../../lib/cookie-names"
|
|
19
|
+
import { useSession } from "../../lib/auth-client"
|
|
20
|
+
|
|
21
|
+
// Last choice sent to app/api/consent from this browser, as
|
|
22
|
+
// "userId|choice|version", so each choice is recorded once, not on every load.
|
|
23
|
+
const RECORDED_KEY = "consent-recorded"
|
|
24
|
+
|
|
25
|
+
// Proof of consent: once a signed-in user has a choice (made now, or before
|
|
26
|
+
// they signed in), record it server-side. The route is idempotent, so a lost
|
|
27
|
+
// localStorage marker only costs one redundant request.
|
|
28
|
+
function useRecordConsent(consentRequired: boolean) {
|
|
29
|
+
const consent = useCookieConsent()
|
|
30
|
+
const { data: session } = useSession()
|
|
31
|
+
const userId = session?.user?.id
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!consentRequired || !userId || consent === null) return
|
|
35
|
+
const choice = readConsent()
|
|
36
|
+
if (!choice) return
|
|
37
|
+
const marker = `${userId}|${choice}|${CONSENT_VERSION}`
|
|
38
|
+
try {
|
|
39
|
+
if (localStorage.getItem(RECORDED_KEY) === marker) return
|
|
40
|
+
} catch {
|
|
41
|
+
// No localStorage — fall through; the route dedupes.
|
|
42
|
+
}
|
|
43
|
+
fetch("/api/consent", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ choice }),
|
|
47
|
+
})
|
|
48
|
+
.then((res) => {
|
|
49
|
+
if (!res.ok) return
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(RECORDED_KEY, marker)
|
|
52
|
+
} catch {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.catch(() => {})
|
|
57
|
+
}, [consentRequired, userId, consent])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PURPOSES: { id: ConsentPurpose; label: string; description: string }[] = [
|
|
61
|
+
{
|
|
62
|
+
id: "fingerprint",
|
|
63
|
+
label: "Security fingerprint",
|
|
64
|
+
description:
|
|
65
|
+
"A device identifier built from your browser and device characteristics, used to alert you about sign-ins from new devices and to block repeated failed sign-ins.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "onetap",
|
|
69
|
+
label: "Google One Tap",
|
|
70
|
+
description: "Quick sign-in with Google. Loads a Google script that may set Google's own cookies.",
|
|
71
|
+
},
|
|
72
|
+
]
|
|
10
73
|
|
|
11
74
|
// Mount once in the root layout. Shows until the visitor picks an option;
|
|
12
75
|
// "Necessary only" is as prominent as "Accept all" on purpose — GDPR requires
|
|
13
|
-
// rejecting to be as easy as accepting.
|
|
76
|
+
// rejecting to be as easy as accepting. "Customize" gives one choice per
|
|
77
|
+
// purpose, unticked by default (pre-ticked boxes aren't valid consent).
|
|
14
78
|
export function CookieBanner() {
|
|
15
79
|
const consent = useCookieConsent()
|
|
80
|
+
const consentRequired = useConsentRequired()
|
|
81
|
+
const [customizing, setCustomizing] = useState(false)
|
|
82
|
+
const [picked, setPicked] = useState<ConsentPurpose[]>([])
|
|
16
83
|
// useCookieConsent() is null on the server *and* before a choice, so wait
|
|
17
84
|
// for mount to tell them apart — otherwise returning visitors see a flash.
|
|
18
85
|
const [mounted, setMounted] = useState(false)
|
|
19
86
|
useEffect(() => setMounted(true), [])
|
|
87
|
+
useRecordConsent(consentRequired)
|
|
88
|
+
|
|
89
|
+
if (!consentRequired || !mounted || consent) return null
|
|
20
90
|
|
|
21
|
-
|
|
91
|
+
const toggle = (id: ConsentPurpose, on: boolean) =>
|
|
92
|
+
setPicked((prev) => (on ? [...prev, id] : prev.filter((p) => p !== id)))
|
|
22
93
|
|
|
23
94
|
return (
|
|
24
95
|
<div
|
|
@@ -28,16 +99,46 @@ export function CookieBanner() {
|
|
|
28
99
|
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-lg rounded-lg border border-border bg-background p-4 shadow-lg"
|
|
29
100
|
>
|
|
30
101
|
<p className="text-sm text-muted-foreground">
|
|
31
|
-
We use necessary cookies to keep you signed in. With your permission we also use a
|
|
32
|
-
fingerprint
|
|
102
|
+
We use necessary cookies to keep you signed in. With your permission we also use a security
|
|
103
|
+
fingerprint, built from your browser and device characteristics, to detect suspicious
|
|
104
|
+
sign-ins, and Google One Tap sign-in. You can allow each separately.{" "}
|
|
33
105
|
<Link href="/privacy" className="underline underline-offset-4 hover:text-foreground">
|
|
34
106
|
Privacy policy
|
|
35
107
|
</Link>
|
|
36
108
|
</p>
|
|
37
|
-
|
|
109
|
+
|
|
110
|
+
{customizing && (
|
|
111
|
+
<div className="mt-3 space-y-3">
|
|
112
|
+
{PURPOSES.map((p) => (
|
|
113
|
+
<div key={p.id} className="flex items-start gap-3">
|
|
114
|
+
<Checkbox
|
|
115
|
+
id={`consent-${p.id}`}
|
|
116
|
+
checked={picked.includes(p.id)}
|
|
117
|
+
onCheckedChange={(checked) => toggle(p.id, checked === true)}
|
|
118
|
+
className="mt-0.5"
|
|
119
|
+
/>
|
|
120
|
+
<div>
|
|
121
|
+
<Label htmlFor={`consent-${p.id}`}>{p.label}</Label>
|
|
122
|
+
<p className="text-xs text-muted-foreground">{p.description}</p>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
|
38
130
|
<Button variant="outline" size="sm" onClick={() => setCookieConsent("necessary")}>
|
|
39
131
|
Necessary only
|
|
40
132
|
</Button>
|
|
133
|
+
{customizing ? (
|
|
134
|
+
<Button variant="outline" size="sm" onClick={() => setCookieConsent(picked)}>
|
|
135
|
+
Save choices
|
|
136
|
+
</Button>
|
|
137
|
+
) : (
|
|
138
|
+
<Button variant="outline" size="sm" onClick={() => setCustomizing(true)}>
|
|
139
|
+
Customize
|
|
140
|
+
</Button>
|
|
141
|
+
)}
|
|
41
142
|
<Button size="sm" onClick={() => setCookieConsent("all")}>
|
|
42
143
|
Accept all
|
|
43
144
|
</Button>
|
|
@@ -48,7 +149,8 @@ export function CookieBanner() {
|
|
|
48
149
|
|
|
49
150
|
// Withdrawing consent must be as easy as giving it — this reopens the banner.
|
|
50
151
|
export function CookieSettingsButton({ className }: { className?: string }) {
|
|
51
|
-
|
|
152
|
+
const consentRequired = useConsentRequired()
|
|
153
|
+
if (!consentRequired) return null
|
|
52
154
|
return (
|
|
53
155
|
<button type="button" className={className} onClick={() => setCookieConsent(null)}>
|
|
54
156
|
Cookie settings
|
|
@@ -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}
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
export { MIN_CELL_WIDTH, STICKY_BREAKPOINT, recalculateColumns }
|
|
24
24
|
export type { Column }
|
|
25
25
|
|
|
26
|
+
const ALIGN_CLASS = { left: undefined, center: "text-center", right: "text-right" } as const
|
|
27
|
+
|
|
26
28
|
function renderCell<TRow>(col: Column<TRow>, row: TRow): React.ReactNode {
|
|
27
29
|
if (col.renderer) return col.renderer(row)
|
|
28
30
|
const value = (row as Record<string, unknown>)[col.name]
|
|
@@ -241,6 +243,7 @@ export function DataTable<TRow>({
|
|
|
241
243
|
{visible.map((col) => (
|
|
242
244
|
<TableHead
|
|
243
245
|
key={col.name}
|
|
246
|
+
className={ALIGN_CLASS[col.align ?? "left"]}
|
|
244
247
|
style={{ minWidth: col.minWidth ?? minCell, maxWidth: col.maxWidth }}
|
|
245
248
|
>
|
|
246
249
|
{col.title}
|
|
@@ -276,7 +279,7 @@ export function DataTable<TRow>({
|
|
|
276
279
|
{visible.map((col) => (
|
|
277
280
|
<TableCell
|
|
278
281
|
key={col.name}
|
|
279
|
-
className="whitespace-normal break-words align-top"
|
|
282
|
+
className={cn("whitespace-normal break-words align-top", ALIGN_CLASS[col.align ?? "left"])}
|
|
280
283
|
style={{ maxWidth: col.maxWidth }}
|
|
281
284
|
>
|
|
282
285
|
{renderCell(col, row)}
|
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}
|