@xk2800/nextjs-template 0.7.1-beta.1 → 0.7.1-beta.11

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 (127) 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 +3 -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 +3 -3
  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.js +51 -0
  52. package/dist/auth/helpers.js.map +1 -1
  53. package/dist/auth/helpers.mjs +9 -64
  54. package/dist/auth/helpers.mjs.map +1 -1
  55. package/dist/auth/index.js +51 -0
  56. package/dist/auth/index.js.map +1 -1
  57. package/dist/auth/index.mjs +8 -7
  58. package/dist/{chunk-KAEA35OC.mjs → chunk-6MDQRT6U.mjs} +4 -4
  59. package/dist/{chunk-KAEA35OC.mjs.map → chunk-6MDQRT6U.mjs.map} +1 -1
  60. package/dist/{chunk-N6ECRCHY.mjs → chunk-6OAHCEDJ.mjs} +4 -3
  61. package/dist/chunk-6OAHCEDJ.mjs.map +1 -0
  62. package/dist/chunk-6Z3WUSDJ.mjs +62 -0
  63. package/dist/chunk-6Z3WUSDJ.mjs.map +1 -0
  64. package/dist/{chunk-VQ5B4MA7.mjs → chunk-7F2POZM6.mjs} +4 -4
  65. package/dist/{chunk-VQ5B4MA7.mjs.map → chunk-7F2POZM6.mjs.map} +1 -1
  66. package/dist/{chunk-XWVAJXAG.mjs → chunk-7QVIOQ47.mjs} +23 -19
  67. package/dist/chunk-7QVIOQ47.mjs.map +1 -0
  68. package/dist/chunk-C6RGN6QA.mjs +60 -0
  69. package/dist/chunk-C6RGN6QA.mjs.map +1 -0
  70. package/dist/chunk-OVKVD2HH.mjs +25 -0
  71. package/dist/chunk-OVKVD2HH.mjs.map +1 -0
  72. package/dist/{chunk-3MGEAGHU.mjs → chunk-P2G23JMY.mjs} +11 -6
  73. package/dist/chunk-P2G23JMY.mjs.map +1 -0
  74. package/dist/{chunk-TMJYAOC2.mjs → chunk-TKWNEKK6.mjs} +8 -2
  75. package/dist/chunk-TKWNEKK6.mjs.map +1 -0
  76. package/dist/{chunk-ZQCALTKB.mjs → chunk-TR5XFBTD.mjs} +34 -4
  77. package/dist/chunk-TR5XFBTD.mjs.map +1 -0
  78. package/dist/{chunk-WZDAMCUX.mjs → chunk-ZJMFE5B2.mjs} +4 -4
  79. package/dist/{chunk-WZDAMCUX.mjs.map → chunk-ZJMFE5B2.mjs.map} +1 -1
  80. package/dist/config/env.d.mts +2 -0
  81. package/dist/config/env.d.ts +2 -0
  82. package/dist/config/env.js +6 -0
  83. package/dist/config/env.js.map +1 -1
  84. package/dist/config/env.mjs +1 -1
  85. package/dist/db/index.d.mts +1 -1
  86. package/dist/db/index.d.ts +1 -1
  87. package/dist/db/index.js +36 -0
  88. package/dist/db/index.js.map +1 -1
  89. package/dist/db/index.mjs +3 -3
  90. package/dist/db/schema.d.mts +1 -1
  91. package/dist/db/schema.d.ts +1 -1
  92. package/dist/db/schema.js +30 -0
  93. package/dist/db/schema.js.map +1 -1
  94. package/dist/db/schema.mjs +1 -1
  95. package/dist/errors.js +36 -0
  96. package/dist/errors.js.map +1 -1
  97. package/dist/errors.mjs +4 -4
  98. package/dist/marketing/index.d.mts +35 -0
  99. package/dist/marketing/index.d.ts +35 -0
  100. package/dist/marketing/index.js +125 -0
  101. package/dist/marketing/index.js.map +1 -0
  102. package/dist/marketing/index.mjs +6 -0
  103. package/dist/marketing/index.mjs.map +1 -0
  104. package/dist/{schema-UuqxoOzS.d.mts → schema-CN_Y6EXv.d.mts} +148 -2
  105. package/dist/{schema-UuqxoOzS.d.ts → schema-CN_Y6EXv.d.ts} +148 -2
  106. package/dist/sessions/queries.js +36 -0
  107. package/dist/sessions/queries.js.map +1 -1
  108. package/dist/sessions/queries.mjs +3 -3
  109. package/dist/settings/queries.d.mts +4 -3
  110. package/dist/settings/queries.d.ts +4 -3
  111. package/dist/settings/queries.js +42 -0
  112. package/dist/settings/queries.js.map +1 -1
  113. package/dist/settings/queries.mjs +4 -4
  114. package/dist/users/queries.js +36 -0
  115. package/dist/users/queries.js.map +1 -1
  116. package/dist/users/queries.mjs +3 -3
  117. package/lib/cookie-consent.ts +72 -20
  118. package/lib/cookie-names.ts +47 -0
  119. package/lib/device-fingerprint.ts +66 -10
  120. package/lib/recalculate-columns.ts +2 -0
  121. package/package.json +28 -3
  122. package/styles/theme.css +23 -3
  123. package/dist/chunk-3MGEAGHU.mjs.map +0 -1
  124. package/dist/chunk-N6ECRCHY.mjs.map +0 -1
  125. package/dist/chunk-TMJYAOC2.mjs.map +0 -1
  126. package/dist/chunk-XWVAJXAG.mjs.map +0 -1
  127. package/dist/chunk-ZQCALTKB.mjs.map +0 -1
@@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
47
47
  <DropdownMenuPrimitive.SubContent
48
48
  ref={ref}
49
49
  className={cn(
50
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
50
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border-(length:--surface-border-width) border-(--surface-border-color) bg-popover p-1 text-popover-foreground shadow-(--popover-shadow) data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
51
51
  className
52
52
  )}
53
53
  {...props}
@@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
65
65
  ref={ref}
66
66
  sideOffset={sideOffset}
67
67
  className={cn(
68
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
68
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border-(length:--surface-border-width) border-(--surface-border-color) bg-popover p-1 text-popover-foreground shadow-(--popover-shadow) data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
69
69
  className
70
70
  )}
71
71
  {...props}
@@ -4,6 +4,8 @@ import * as React from "react"
4
4
  import { Camera, ImagePlus, Upload, X } from "lucide-react"
5
5
 
6
6
  import { Button } from "./button"
7
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./dialog"
8
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet"
7
9
  import { cn } from "../../lib/utils"
8
10
 
9
11
  type FileUploadProps = {
@@ -13,6 +15,13 @@ type FileUploadProps = {
13
15
  gallery?: boolean
14
16
  /** Drop files onto the component. */
15
17
  dragDrop?: boolean
18
+ /**
19
+ * Pressing the drop zone opens the picker instead of showing separate buttons. With both
20
+ * `camera` and `gallery` the user is asked which one; with one, it opens directly. Needs `dragDrop`.
21
+ */
22
+ clickable?: boolean
23
+ /** How a touch-screen user is asked "camera or gallery" with `clickable`: a bottom sheet or a centered modal. */
24
+ chooser?: "sheet" | "modal"
16
25
  /** Same format as `<input accept>`. Also enforced on dropped files. */
17
26
  accept?: string
18
27
  multiple?: boolean
@@ -23,6 +32,14 @@ type FileUploadProps = {
23
32
  icon?: React.ReactNode
24
33
  /** Drop zone text. Defaults to "Drag and drop files here" (or "a file" when `multiple` is false). */
25
34
  label?: React.ReactNode
35
+ /** Smaller sub text under the drop zone label. */
36
+ description?: React.ReactNode
37
+ /** Extra classes for the dashed drop zone box. */
38
+ dropzoneClassName?: string
39
+ /** Extra classes for the label text. */
40
+ labelClassName?: string
41
+ /** Extra classes for the description text. */
42
+ descriptionClassName?: string
26
43
  onFilesChange?: (files: File[]) => void
27
44
  className?: string
28
45
  }
@@ -41,6 +58,8 @@ function FileUpload({
41
58
  camera = false,
42
59
  gallery = false,
43
60
  dragDrop = false,
61
+ clickable = false,
62
+ chooser = "sheet",
44
63
  accept = "image/*",
45
64
  multiple = true,
46
65
  maxFiles,
@@ -48,6 +67,10 @@ function FileUpload({
48
67
  disabled = false,
49
68
  icon = <Upload className="size-6" />,
50
69
  label,
70
+ description,
71
+ dropzoneClassName,
72
+ labelClassName,
73
+ descriptionClassName,
51
74
  onFilesChange,
52
75
  className,
53
76
  }: FileUploadProps) {
@@ -56,6 +79,8 @@ function FileUpload({
56
79
  const [dragging, setDragging] = React.useState(false)
57
80
  const cameraRef = React.useRef<HTMLInputElement>(null)
58
81
  const galleryRef = React.useRef<HTMLInputElement>(null)
82
+ const [askOpen, setAskOpen] = React.useState(false)
83
+ const pointerType = React.useRef("")
59
84
 
60
85
  // Created and revoked in the same effect: with useMemo, Strict Mode's
61
86
  // simulated unmount revoked the URLs and the remount reused the dead ones.
@@ -100,6 +125,37 @@ function FileUpload({
100
125
 
101
126
  if (!camera && !gallery && !dragDrop) return null
102
127
 
128
+ // Pressing the drop zone only does something if there's a picker to open.
129
+ const pickable = clickable && !disabled && (camera || gallery)
130
+ // The camera only means something on touch devices, so mouse and keyboard skip the
131
+ // question and open the gallery (or the only source there is).
132
+ const openDefault = () => (gallery ? galleryRef : cameraRef).current?.click()
133
+ const units = multiple ? "files" : "a file"
134
+
135
+ const askTitle = `Add ${multiple ? "photos" : "a photo"}`
136
+ const askDescription = "Take a new photo or pick from your gallery."
137
+ const askButtons = (
138
+ <div className="grid gap-2">
139
+ {[
140
+ { ref: cameraRef, Icon: Camera, text: "Take photo" },
141
+ { ref: galleryRef, Icon: ImagePlus, text: "Choose from gallery" },
142
+ ].map(({ ref, Icon, text }) => (
143
+ <Button
144
+ key={text}
145
+ type="button"
146
+ variant="outline"
147
+ className="h-12 justify-start"
148
+ onClick={() => {
149
+ setAskOpen(false)
150
+ ref.current?.click()
151
+ }}
152
+ >
153
+ <Icon /> {text}
154
+ </Button>
155
+ ))}
156
+ </div>
157
+ )
158
+
103
159
  const buttons = (
104
160
  <div className="flex flex-wrap justify-center gap-2">
105
161
  {camera && (
@@ -126,6 +182,28 @@ function FileUpload({
126
182
 
127
183
  {dragDrop ? (
128
184
  <div
185
+ role={pickable ? "button" : undefined}
186
+ tabIndex={pickable ? 0 : undefined}
187
+ onPointerDown={pickable ? (e) => (pointerType.current = e.pointerType) : undefined}
188
+ onClick={
189
+ pickable
190
+ ? () => {
191
+ if (camera && gallery && pointerType.current !== "mouse") setAskOpen(true)
192
+ else openDefault()
193
+ pointerType.current = ""
194
+ }
195
+ : undefined
196
+ }
197
+ onKeyDown={
198
+ pickable
199
+ ? (e) => {
200
+ if (e.key === "Enter" || e.key === " ") {
201
+ e.preventDefault()
202
+ openDefault()
203
+ }
204
+ }
205
+ : undefined
206
+ }
129
207
  onDragOver={(e) => {
130
208
  e.preventDefault()
131
209
  if (!disabled) setDragging(true)
@@ -139,18 +217,58 @@ function FileUpload({
139
217
  aria-disabled={disabled}
140
218
  className={cn(
141
219
  "flex flex-col items-center gap-3 rounded-lg border-2 border-dashed p-6 text-center text-sm text-muted-foreground transition-colors",
220
+ pickable && "cursor-pointer outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring",
221
+ // Before the state classes so a custom border can't hide the drag highlight.
222
+ dropzoneClassName,
142
223
  dragging && "border-primary bg-primary/5",
143
224
  disabled && "opacity-50"
144
225
  )}
145
226
  >
146
227
  {icon}
147
- <p>{label ?? `Drag and drop ${multiple ? "files" : "a file"} here`}</p>
148
- {buttons}
228
+ <div className="space-y-1">
229
+ <p className={labelClassName}>
230
+ {label ??
231
+ (pickable ? (
232
+ <>
233
+ <span className="pointer-coarse:hidden">Drag and drop {units} here, or click to browse</span>
234
+ <span className="hidden pointer-coarse:inline">Tap to add {units}</span>
235
+ </>
236
+ ) : (
237
+ `Drag and drop ${units} here`
238
+ ))}
239
+ </p>
240
+ {description && <p className={cn("text-xs", descriptionClassName)}>{description}</p>}
241
+ </div>
242
+ {!pickable && buttons}
149
243
  </div>
150
244
  ) : (
151
245
  buttons
152
246
  )}
153
247
 
248
+ {pickable && camera && gallery && (
249
+ chooser === "modal" ? (
250
+ <Dialog open={askOpen} onOpenChange={setAskOpen}>
251
+ <DialogContent className="sm:max-w-sm">
252
+ <DialogHeader>
253
+ <DialogTitle>{askTitle}</DialogTitle>
254
+ <DialogDescription>{askDescription}</DialogDescription>
255
+ </DialogHeader>
256
+ {askButtons}
257
+ </DialogContent>
258
+ </Dialog>
259
+ ) : (
260
+ <Sheet open={askOpen} onOpenChange={setAskOpen}>
261
+ <SheetContent side="bottom" className="mx-auto max-w-lg rounded-t-xl pb-[max(1rem,env(safe-area-inset-bottom))]">
262
+ <SheetHeader className="pb-0">
263
+ <SheetTitle>{askTitle}</SheetTitle>
264
+ <SheetDescription>{askDescription}</SheetDescription>
265
+ </SheetHeader>
266
+ <div className="px-4">{askButtons}</div>
267
+ </SheetContent>
268
+ </Sheet>
269
+ )
270
+ )}
271
+
154
272
  {error && (
155
273
  <p role="alert" className="text-sm text-destructive">
156
274
  {error}
@@ -0,0 +1,189 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../lib/utils"
5
+ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6
+ import { Select as SelectPrimitive } from "radix-ui"
7
+
8
+ function Select({
9
+ ...props
10
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
11
+ return <SelectPrimitive.Root data-slot="select" {...props} />
12
+ }
13
+
14
+ function SelectGroup({
15
+ ...props
16
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
17
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
18
+ }
19
+
20
+ function SelectValue({
21
+ ...props
22
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
23
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
24
+ }
25
+
26
+ function SelectTrigger({
27
+ className,
28
+ size = "default",
29
+ children,
30
+ ...props
31
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
32
+ size?: "sm" | "default"
33
+ }) {
34
+ return (
35
+ <SelectPrimitive.Trigger
36
+ data-slot="select-trigger"
37
+ data-size={size}
38
+ className={cn(
39
+ "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
40
+ className
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ <SelectPrimitive.Icon asChild>
46
+ <ChevronDownIcon className="size-4 opacity-50" />
47
+ </SelectPrimitive.Icon>
48
+ </SelectPrimitive.Trigger>
49
+ )
50
+ }
51
+
52
+ function SelectContent({
53
+ className,
54
+ children,
55
+ position = "item-aligned",
56
+ align = "center",
57
+ ...props
58
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
59
+ return (
60
+ <SelectPrimitive.Portal>
61
+ <SelectPrimitive.Content
62
+ data-slot="select-content"
63
+ className={cn(
64
+ "relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border-(length:--surface-border-width) border-(--surface-border-color) bg-popover text-popover-foreground shadow-(--popover-shadow) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
65
+ position === "popper" &&
66
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
67
+ className
68
+ )}
69
+ position={position}
70
+ align={align}
71
+ {...props}
72
+ >
73
+ <SelectScrollUpButton />
74
+ <SelectPrimitive.Viewport
75
+ className={cn(
76
+ "p-1",
77
+ position === "popper" &&
78
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
79
+ )}
80
+ >
81
+ {children}
82
+ </SelectPrimitive.Viewport>
83
+ <SelectScrollDownButton />
84
+ </SelectPrimitive.Content>
85
+ </SelectPrimitive.Portal>
86
+ )
87
+ }
88
+
89
+ function SelectLabel({
90
+ className,
91
+ ...props
92
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
93
+ return (
94
+ <SelectPrimitive.Label
95
+ data-slot="select-label"
96
+ className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
97
+ {...props}
98
+ />
99
+ )
100
+ }
101
+
102
+ function SelectItem({
103
+ className,
104
+ children,
105
+ ...props
106
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
107
+ return (
108
+ <SelectPrimitive.Item
109
+ data-slot="select-item"
110
+ className={cn(
111
+ "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
112
+ className
113
+ )}
114
+ {...props}
115
+ >
116
+ <span
117
+ data-slot="select-item-indicator"
118
+ className="absolute right-2 flex size-3.5 items-center justify-center"
119
+ >
120
+ <SelectPrimitive.ItemIndicator>
121
+ <CheckIcon className="size-4" />
122
+ </SelectPrimitive.ItemIndicator>
123
+ </span>
124
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
125
+ </SelectPrimitive.Item>
126
+ )
127
+ }
128
+
129
+ function SelectSeparator({
130
+ className,
131
+ ...props
132
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
133
+ return (
134
+ <SelectPrimitive.Separator
135
+ data-slot="select-separator"
136
+ className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
137
+ {...props}
138
+ />
139
+ )
140
+ }
141
+
142
+ function SelectScrollUpButton({
143
+ className,
144
+ ...props
145
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
146
+ return (
147
+ <SelectPrimitive.ScrollUpButton
148
+ data-slot="select-scroll-up-button"
149
+ className={cn(
150
+ "flex cursor-default items-center justify-center py-1",
151
+ className
152
+ )}
153
+ {...props}
154
+ >
155
+ <ChevronUpIcon className="size-4" />
156
+ </SelectPrimitive.ScrollUpButton>
157
+ )
158
+ }
159
+
160
+ function SelectScrollDownButton({
161
+ className,
162
+ ...props
163
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
164
+ return (
165
+ <SelectPrimitive.ScrollDownButton
166
+ data-slot="select-scroll-down-button"
167
+ className={cn(
168
+ "flex cursor-default items-center justify-center py-1",
169
+ className
170
+ )}
171
+ {...props}
172
+ >
173
+ <ChevronDownIcon className="size-4" />
174
+ </SelectPrimitive.ScrollDownButton>
175
+ )
176
+ }
177
+
178
+ export {
179
+ Select,
180
+ SelectContent,
181
+ SelectGroup,
182
+ SelectItem,
183
+ SelectLabel,
184
+ SelectScrollDownButton,
185
+ SelectScrollUpButton,
186
+ SelectSeparator,
187
+ SelectTrigger,
188
+ SelectValue,
189
+ }
@@ -10,14 +10,14 @@ function Switch({
10
10
  size = "default",
11
11
  ...props
12
12
  }: React.ComponentProps<typeof SwitchPrimitive.Root> & {
13
- size?: "sm" | "default"
13
+ size?: "sm" | "default" | "md" | "lg" | "xl" | "2xl"
14
14
  }) {
15
15
  return (
16
16
  <SwitchPrimitive.Root
17
17
  data-slot="switch"
18
18
  data-size={size}
19
19
  className={cn(
20
- "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
20
+ "peer group/switch inline-flex shrink-0 items-center rounded-(--pill-radius) border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[size=md]:h-[1.375rem] data-[size=md]:w-10 data-[size=lg]:h-[1.625rem] data-[size=lg]:w-12 data-[size=xl]:h-[1.875rem] data-[size=xl]:w-14 data-[size=2xl]:h-[2.125rem] data-[size=2xl]:w-16 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
21
21
  className
22
22
  )}
23
23
  {...props}
@@ -25,7 +25,7 @@ function Switch({
25
25
  <SwitchPrimitive.Thumb
26
26
  data-slot="switch-thumb"
27
27
  className={cn(
28
- "pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
28
+ "pointer-events-none block rounded-(--pill-radius) bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=md]/switch:size-5 group-data-[size=lg]/switch:size-6 group-data-[size=xl]/switch:size-7 group-data-[size=2xl]/switch:size-8 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
29
29
  )}
30
30
  />
31
31
  </SwitchPrimitive.Root>
@@ -24,6 +24,7 @@ __export(schema_exports, {
24
24
  accounts: () => accounts,
25
25
  activityLogs: () => activityLogs,
26
26
  authThrottle: () => authThrottle,
27
+ consentLog: () => consentLog,
27
28
  deviceFingerprints: () => deviceFingerprints,
28
29
  errorRefs: () => errorRefs,
29
30
  passkeys: () => passkeys,
@@ -178,10 +179,32 @@ var deviceFingerprints = pgCore.pgTable("device_fingerprint", {
178
179
  userAgent: pgCore.text("userAgent"),
179
180
  country: pgCore.text("country"),
180
181
  city: pgCore.text("city"),
182
+ // Latest FingerprintJS signals for this device (lib/device-fingerprint.ts
183
+ // getDeviceSignals(): accessibility signals dropped, large values hashed),
184
+ // shown on the admin user page. Null for rows from before this column.
185
+ components: pgCore.jsonb("components").$type(),
181
186
  createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
182
187
  }, (table) => [
183
188
  pgCore.unique("device_fingerprint_user_visitor").on(table.userId, table.visitorId)
184
189
  ]);
190
+ var consentLog = pgCore.pgTable("consent_log", {
191
+ id: pgCore.text("id").notNull().primaryKey().$defaultFn(() => cuid2.createId()),
192
+ userId: pgCore.text("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
193
+ // The cookie_consent value: "all", "necessary" or a comma list of purposes.
194
+ choice: pgCore.text("choice").notNull(),
195
+ // CONSENT_VERSION (lib/cookie-names.ts) at the time — which banner/policy
196
+ // wording the user agreed to.
197
+ version: pgCore.integer("version").notNull(),
198
+ // id of the user's record this one follows ("" for their first). Unique per
199
+ // user, so concurrent posts from several tabs that all saw the same latest
200
+ // record can't each insert a duplicate — one wins, the rest conflict. Works
201
+ // without transactions (neon-http has none). Null on rows from before it.
202
+ previousId: pgCore.text("previousId"),
203
+ createdAt: pgCore.timestamp("createdAt").notNull().defaultNow()
204
+ }, (table) => [
205
+ pgCore.index("consent_log_user_idx").on(table.userId, table.createdAt),
206
+ pgCore.unique("consent_log_chain").on(table.userId, table.previousId)
207
+ ]);
185
208
  var authThrottle = pgCore.pgTable("auth_throttle", {
186
209
  fingerprint: pgCore.text("fingerprint").notNull().primaryKey(),
187
210
  count: pgCore.integer("count").notNull().default(0),
@@ -211,6 +234,13 @@ var systemSettings = pgCore.pgTable("system_settings", {
211
234
  // Attach the error a user hit to feedback they send from it (see
212
235
  // lib/feedback-route.ts). Off = only what the user typed is sent.
213
236
  feedbackAttachErrors: pgCore.boolean("feedbackAttachErrors").notNull().default(true),
237
+ // GDPR/ePrivacy consent mode. On: cookie banner, FingerprintJS and One Tap
238
+ // only after "Accept all", device record erased on withdrawal. Off (no EU/UK
239
+ // users): no banner, treated as consented. Null = follow
240
+ // NEXT_PUBLIC_COOKIE_BANNER (nullable so migrating never flips a deployed
241
+ // banner on or off). proxy.ts mirrors the effective value into the
242
+ // consent_required cookie for the client (lib/cookie-consent.ts).
243
+ consentRequired: pgCore.boolean("consentRequired"),
214
244
  updatedAt: pgCore.timestamp("updatedAt").notNull().defaultNow(),
215
245
  // Who last changed a setting. This row doesn't "belong" to that admin the
216
246
  // way sessions/activityLogs belong to their user, so deleting that admin's
@@ -232,6 +262,12 @@ var envSchema = zod.z.object({
232
262
  PORT: zod.z.string().default("3000").transform(Number),
233
263
  DATABASE_URL: zod.z.string().default(""),
234
264
  RESEND_API_KEY: zod.z.string().default(""),
265
+ // Full access key for marketing broadcasts (admin Marketing email page).
266
+ // Blank = use RESEND_API_KEY.
267
+ RESEND_BROADCAST_API_KEY: zod.z.string().default(""),
268
+ // Resend segment users join from the dashboard "Email updates" toggle.
269
+ // Blank = toggle hidden.
270
+ RESEND_MARKETING_SEGMENT_ID: zod.z.string().default(""),
235
271
  // Sender for every transactional email. Must be on a domain verified in
236
272
  // your Resend account.
237
273
  EMAIL_FROM: zod.z.string().default("Acme <onboarding@xavierkhew.xyz>"),