@xk2800/nextjs-template 0.7.1-beta.1 → 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.
Files changed (131) hide show
  1. package/README.md +32 -30
  2. package/components/auth/deviceCheck.tsx +47 -6
  3. package/components/auth/oneTap.tsx +5 -5
  4. package/components/dashboard/admin/adminUserConsentCard.tsx +79 -0
  5. package/components/dashboard/admin/adminUserDevicesCard.tsx +80 -0
  6. package/components/dashboard/admin/broadcastActions.tsx +148 -0
  7. package/components/dashboard/admin/confirmSendDialog.tsx +44 -0
  8. package/components/dashboard/admin/marketingEmailForm.tsx +115 -0
  9. package/components/dashboard/admin/marketingSection.tsx +67 -0
  10. package/components/dashboard/admin/subscribersCard.tsx +62 -0
  11. package/components/dashboard/admin/systemSettingsForm.tsx +33 -0
  12. package/components/dashboard/admin/systemSettingsSection.tsx +8 -2
  13. package/components/dashboard/errorFeedback.tsx +2 -4
  14. package/components/dashboard/feedbackDialog.tsx +4 -4
  15. package/components/dashboard/marketingUpdatesCard.tsx +62 -0
  16. package/components/dashboard/marketingUpdatesSection.tsx +10 -0
  17. package/components/site/cookie-banner.tsx +109 -7
  18. package/components/ui/alert-dialog.tsx +1 -1
  19. package/components/ui/badge.tsx +1 -1
  20. package/components/ui/card.tsx +1 -1
  21. package/components/ui/data-table.tsx +4 -1
  22. package/components/ui/dialog.tsx +1 -1
  23. package/components/ui/dropdown-menu.tsx +2 -2
  24. package/components/ui/file-upload.tsx +120 -2
  25. package/components/ui/select.tsx +189 -0
  26. package/components/ui/switch.tsx +2 -2
  27. package/dist/activity/logger.js +36 -0
  28. package/dist/activity/logger.js.map +1 -1
  29. package/dist/activity/logger.mjs +4 -4
  30. package/dist/activity/queries.d.mts +1 -1
  31. package/dist/activity/queries.d.ts +1 -1
  32. package/dist/activity/queries.js +36 -0
  33. package/dist/activity/queries.js.map +1 -1
  34. package/dist/activity/queries.mjs +3 -3
  35. package/dist/admin/queries.d.mts +19 -1
  36. package/dist/admin/queries.d.ts +19 -1
  37. package/dist/admin/queries.js +64 -0
  38. package/dist/admin/queries.js.map +1 -1
  39. package/dist/admin/queries.mjs +26 -5
  40. package/dist/admin/queries.mjs.map +1 -1
  41. package/dist/api/feedback.js +53 -2
  42. package/dist/api/feedback.js.map +1 -1
  43. package/dist/api/feedback.mjs +9 -8
  44. package/dist/api/feedback.mjs.map +1 -1
  45. package/dist/api/marketing.d.mts +36 -0
  46. package/dist/api/marketing.d.ts +36 -0
  47. package/dist/api/marketing.js +1357 -0
  48. package/dist/api/marketing.js.map +1 -0
  49. package/dist/api/marketing.mjs +170 -0
  50. package/dist/api/marketing.mjs.map +1 -0
  51. package/dist/auth/helpers.d.mts +3 -3
  52. package/dist/auth/helpers.d.ts +3 -3
  53. package/dist/auth/helpers.js +51 -0
  54. package/dist/auth/helpers.js.map +1 -1
  55. package/dist/auth/helpers.mjs +9 -64
  56. package/dist/auth/helpers.mjs.map +1 -1
  57. package/dist/auth/index.js +51 -0
  58. package/dist/auth/index.js.map +1 -1
  59. package/dist/auth/index.mjs +8 -7
  60. package/dist/auth-client/index.d.mts +16 -16
  61. package/dist/auth-client/index.d.ts +16 -16
  62. package/dist/{chunk-KAEA35OC.mjs → chunk-6MDQRT6U.mjs} +4 -4
  63. package/dist/{chunk-KAEA35OC.mjs.map → chunk-6MDQRT6U.mjs.map} +1 -1
  64. package/dist/{chunk-N6ECRCHY.mjs → chunk-6OAHCEDJ.mjs} +4 -3
  65. package/dist/chunk-6OAHCEDJ.mjs.map +1 -0
  66. package/dist/chunk-6Z3WUSDJ.mjs +62 -0
  67. package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
  68. package/dist/{chunk-VQ5B4MA7.mjs → chunk-7F2POZM6.mjs} +4 -4
  69. package/dist/{chunk-VQ5B4MA7.mjs.map → chunk-7F2POZM6.mjs.map} +1 -1
  70. package/dist/{chunk-XWVAJXAG.mjs → chunk-7QVIOQ47.mjs} +23 -19
  71. package/dist/chunk-7QVIOQ47.mjs.map +1 -0
  72. package/dist/chunk-C6RGN6QA.mjs +60 -0
  73. package/dist/chunk-C6RGN6QA.mjs.map +1 -0
  74. package/dist/chunk-OVKVD2HH.mjs +25 -0
  75. package/dist/chunk-OVKVD2HH.mjs.map +1 -0
  76. package/dist/{chunk-3MGEAGHU.mjs → chunk-P2G23JMY.mjs} +11 -6
  77. package/dist/chunk-P2G23JMY.mjs.map +1 -0
  78. package/dist/{chunk-TMJYAOC2.mjs → chunk-TKWNEKK6.mjs} +8 -2
  79. package/dist/chunk-TKWNEKK6.mjs.map +1 -0
  80. package/dist/{chunk-ZQCALTKB.mjs → chunk-TR5XFBTD.mjs} +34 -4
  81. package/dist/chunk-TR5XFBTD.mjs.map +1 -0
  82. package/dist/{chunk-WZDAMCUX.mjs → chunk-ZJMFE5B2.mjs} +4 -4
  83. package/dist/{chunk-WZDAMCUX.mjs.map → chunk-ZJMFE5B2.mjs.map} +1 -1
  84. package/dist/config/env.d.mts +2 -0
  85. package/dist/config/env.d.ts +2 -0
  86. package/dist/config/env.js +6 -0
  87. package/dist/config/env.js.map +1 -1
  88. package/dist/config/env.mjs +1 -1
  89. package/dist/db/index.d.mts +1 -1
  90. package/dist/db/index.d.ts +1 -1
  91. package/dist/db/index.js +36 -0
  92. package/dist/db/index.js.map +1 -1
  93. package/dist/db/index.mjs +3 -3
  94. package/dist/db/schema.d.mts +1 -1
  95. package/dist/db/schema.d.ts +1 -1
  96. package/dist/db/schema.js +30 -0
  97. package/dist/db/schema.js.map +1 -1
  98. package/dist/db/schema.mjs +1 -1
  99. package/dist/errors.js +36 -0
  100. package/dist/errors.js.map +1 -1
  101. package/dist/errors.mjs +4 -4
  102. package/dist/marketing/index.d.mts +35 -0
  103. package/dist/marketing/index.d.ts +35 -0
  104. package/dist/marketing/index.js +125 -0
  105. package/dist/marketing/index.js.map +1 -0
  106. package/dist/marketing/index.mjs +6 -0
  107. package/dist/marketing/index.mjs.map +1 -0
  108. package/dist/{schema-UuqxoOzS.d.mts → schema-CN_Y6EXv.d.mts} +148 -2
  109. package/dist/{schema-UuqxoOzS.d.ts → schema-CN_Y6EXv.d.ts} +148 -2
  110. package/dist/sessions/queries.js +36 -0
  111. package/dist/sessions/queries.js.map +1 -1
  112. package/dist/sessions/queries.mjs +3 -3
  113. package/dist/settings/queries.d.mts +4 -3
  114. package/dist/settings/queries.d.ts +4 -3
  115. package/dist/settings/queries.js +42 -0
  116. package/dist/settings/queries.js.map +1 -1
  117. package/dist/settings/queries.mjs +4 -4
  118. package/dist/users/queries.js +36 -0
  119. package/dist/users/queries.js.map +1 -1
  120. package/dist/users/queries.mjs +3 -3
  121. package/lib/cookie-consent.ts +72 -20
  122. package/lib/cookie-names.ts +47 -0
  123. package/lib/device-fingerprint.ts +66 -10
  124. package/lib/recalculate-columns.ts +2 -0
  125. package/package.json +28 -3
  126. package/styles/theme.css +17 -3
  127. package/dist/chunk-3MGEAGHU.mjs.map +0 -1
  128. package/dist/chunk-N6ECRCHY.mjs.map +0 -1
  129. package/dist/chunk-TMJYAOC2.mjs.map +0 -1
  130. package/dist/chunk-XWVAJXAG.mjs.map +0 -1
  131. 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&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
+ }
@@ -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 &amp; 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
- return <SystemSettingsForm initialSettings={settings} configuredFlags={configuredFlags} />
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 filled in and attached. Where
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, re-apply it every time the dialog opens so a
66
- // second error report doesn't start from the first one's text. Without one,
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 { COOKIE_BANNER_ENABLED, setCookieConsent, useCookieConsent } from "../../lib/cookie-consent"
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
- if (!COOKIE_BANNER_ENABLED || !mounted || consent) return null
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 device
32
- fingerprint to detect suspicious sign-ins, and Google One Tap sign-in.{" "}
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
- <div className="mt-3 flex justify-end gap-2">
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
- if (!COOKIE_BANNER_ENABLED) return null
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-lg duration-200 sm:max-w-lg",
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}
@@ -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-full 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",
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: {
@@ -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-sm",
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)}
@@ -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-lg duration-200 sm:max-w-lg",
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}