@xk2800/nextjs-template 0.6.1-beta.3 → 0.7.0

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 (105) hide show
  1. package/README.md +56 -321
  2. package/components/dashboard/admin/adminUsersTable.tsx +36 -2
  3. package/components/dashboard/admin/feedbackTestForm.tsx +55 -0
  4. package/components/dashboard/admin/systemSettingsForm.tsx +28 -0
  5. package/components/dashboard/appSidebar.tsx +4 -4
  6. package/components/dashboard/deleteAccountCard.tsx +137 -0
  7. package/components/dashboard/errorFeedback.tsx +65 -0
  8. package/components/dashboard/feedbackDialog.tsx +160 -0
  9. package/components/dashboard/installAppCard.tsx +187 -0
  10. package/components/site/cookie-banner.tsx +57 -0
  11. package/components/ui/button.tsx +1 -1
  12. package/components/ui/file-upload.tsx +178 -0
  13. package/components/ui/input.tsx +1 -1
  14. package/components/ui/separator.tsx +1 -1
  15. package/components/ui/sheet.tsx +1 -1
  16. package/components/ui/sidebar.tsx +8 -8
  17. package/components/ui/skeleton.tsx +1 -1
  18. package/components/ui/tooltip.tsx +1 -1
  19. package/dist/activity/logger.js +21 -1
  20. package/dist/activity/logger.js.map +1 -1
  21. package/dist/activity/logger.mjs +4 -4
  22. package/dist/activity/queries.d.mts +1 -1
  23. package/dist/activity/queries.d.ts +1 -1
  24. package/dist/activity/queries.js +21 -1
  25. package/dist/activity/queries.js.map +1 -1
  26. package/dist/activity/queries.mjs +3 -3
  27. package/dist/admin/queries.js +21 -1
  28. package/dist/admin/queries.js.map +1 -1
  29. package/dist/admin/queries.mjs +3 -3
  30. package/dist/api/feedback.d.mts +23 -0
  31. package/dist/api/feedback.d.ts +23 -0
  32. package/dist/api/feedback.js +1224 -0
  33. package/dist/api/feedback.js.map +1 -0
  34. package/dist/api/feedback.mjs +136 -0
  35. package/dist/api/feedback.mjs.map +1 -0
  36. package/dist/auth/helpers.d.mts +3 -3
  37. package/dist/auth/helpers.d.ts +3 -3
  38. package/dist/auth/helpers.js +45 -4
  39. package/dist/auth/helpers.js.map +1 -1
  40. package/dist/auth/helpers.mjs +7 -7
  41. package/dist/auth/helpers.mjs.map +1 -1
  42. package/dist/auth/index.d.mts +8 -2
  43. package/dist/auth/index.d.ts +8 -2
  44. package/dist/auth/index.js +44 -3
  45. package/dist/auth/index.js.map +1 -1
  46. package/dist/auth/index.mjs +6 -6
  47. package/dist/auth-client/index.d.mts +16 -16
  48. package/dist/auth-client/index.d.ts +16 -16
  49. package/dist/{chunk-IF3JZRSZ.mjs → chunk-3MGEAGHU.mjs} +6 -5
  50. package/dist/chunk-3MGEAGHU.mjs.map +1 -0
  51. package/dist/chunk-KAEA35OC.mjs +76 -0
  52. package/dist/chunk-KAEA35OC.mjs.map +1 -0
  53. package/dist/{chunk-GTPTVNY2.mjs → chunk-TMJYAOC2.mjs} +10 -2
  54. package/dist/chunk-TMJYAOC2.mjs.map +1 -0
  55. package/dist/{chunk-OHAUKE2Q.mjs → chunk-VQ5B4MA7.mjs} +4 -4
  56. package/dist/{chunk-OHAUKE2Q.mjs.map → chunk-VQ5B4MA7.mjs.map} +1 -1
  57. package/dist/{chunk-5PYDAD4P.mjs → chunk-WZDAMCUX.mjs} +4 -4
  58. package/dist/{chunk-5PYDAD4P.mjs.map → chunk-WZDAMCUX.mjs.map} +1 -1
  59. package/dist/{chunk-V72CFFU2.mjs → chunk-XWVAJXAG.mjs} +31 -11
  60. package/dist/chunk-XWVAJXAG.mjs.map +1 -0
  61. package/dist/{chunk-4B7MJOLH.mjs → chunk-ZQCALTKB.mjs} +17 -5
  62. package/dist/chunk-ZQCALTKB.mjs.map +1 -0
  63. package/dist/config/env.d.mts +3 -0
  64. package/dist/config/env.d.ts +3 -0
  65. package/dist/config/env.js +8 -0
  66. package/dist/config/env.js.map +1 -1
  67. package/dist/config/env.mjs +1 -1
  68. package/dist/db/index.d.mts +1 -1
  69. package/dist/db/index.d.ts +1 -1
  70. package/dist/db/index.js +21 -1
  71. package/dist/db/index.js.map +1 -1
  72. package/dist/db/index.mjs +3 -3
  73. package/dist/db/schema.d.mts +1 -1
  74. package/dist/db/schema.d.ts +1 -1
  75. package/dist/db/schema.js +13 -1
  76. package/dist/db/schema.js.map +1 -1
  77. package/dist/db/schema.mjs +1 -1
  78. package/dist/errors.d.mts +29 -0
  79. package/dist/errors.d.ts +29 -0
  80. package/dist/errors.js +365 -0
  81. package/dist/errors.js.map +1 -0
  82. package/dist/errors.mjs +7 -0
  83. package/dist/errors.mjs.map +1 -0
  84. package/dist/{schema-BghJ8N4X.d.mts → schema-BNSBWNjK.d.mts} +99 -6
  85. package/dist/{schema-BghJ8N4X.d.ts → schema-BNSBWNjK.d.ts} +99 -6
  86. package/dist/sessions/queries.js +21 -1
  87. package/dist/sessions/queries.js.map +1 -1
  88. package/dist/sessions/queries.mjs +3 -3
  89. package/dist/settings/queries.d.mts +2 -2
  90. package/dist/settings/queries.d.ts +2 -2
  91. package/dist/settings/queries.js +22 -1
  92. package/dist/settings/queries.js.map +1 -1
  93. package/dist/settings/queries.mjs +4 -4
  94. package/dist/users/queries.d.mts +3 -3
  95. package/dist/users/queries.d.ts +3 -3
  96. package/dist/users/queries.js +21 -1
  97. package/dist/users/queries.js.map +1 -1
  98. package/dist/users/queries.mjs +3 -3
  99. package/hooks/use-mobile.ts +19 -0
  100. package/lib/device-fingerprint.ts +2 -3
  101. package/package.json +35 -32
  102. package/dist/chunk-4B7MJOLH.mjs.map +0 -1
  103. package/dist/chunk-GTPTVNY2.mjs.map +0 -1
  104. package/dist/chunk-IF3JZRSZ.mjs.map +0 -1
  105. package/dist/chunk-V72CFFU2.mjs.map +0 -1
@@ -0,0 +1,137 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card'
6
+ import { Button } from '../ui/button'
7
+ import { Input } from '../ui/input'
8
+ import { PasswordInput } from '../ui/password-input'
9
+ import {
10
+ AlertDialog,
11
+ AlertDialogCancel,
12
+ AlertDialogContent,
13
+ AlertDialogDescription,
14
+ AlertDialogFooter,
15
+ AlertDialogHeader,
16
+ AlertDialogTitle,
17
+ } from '../ui/alert-dialog'
18
+ import { authClient, useSession } from '../../lib/auth-client'
19
+
20
+ export default function DeleteAccountCard() {
21
+ const { data: session } = useSession()
22
+ const email = session?.user.email ?? ''
23
+ // null = still checking. Credential users confirm with their password;
24
+ // OAuth-only users have none, so they type their email instead and the
25
+ // server requires a recent sign-in (better-auth's freshAge check).
26
+ const [hasPassword, setHasPassword] = useState<boolean | null>(null)
27
+ const [open, setOpen] = useState(false)
28
+ const [password, setPassword] = useState('')
29
+ const [typedEmail, setTypedEmail] = useState('')
30
+ const [deleting, setDeleting] = useState(false)
31
+
32
+ useEffect(() => {
33
+ authClient.listAccounts().then(({ data }) => {
34
+ setHasPassword(!!data?.some((a) => a.providerId === 'credential'))
35
+ })
36
+ }, [])
37
+
38
+ const canSubmit = hasPassword ? password.length > 0 : typedEmail.trim().toLowerCase() === email.toLowerCase()
39
+
40
+ const close = (next: boolean) => {
41
+ if (deleting) return
42
+ setOpen(next)
43
+ if (!next) {
44
+ setPassword('')
45
+ setTypedEmail('')
46
+ }
47
+ }
48
+
49
+ const deleteAccount = async (e: React.FormEvent) => {
50
+ e.preventDefault()
51
+ if (!canSubmit) return
52
+ setDeleting(true)
53
+ try {
54
+ const { error } = await authClient.deleteUser(hasPassword ? { password } : {})
55
+ if (error) {
56
+ toast.error(
57
+ error.code === 'SESSION_EXPIRED'
58
+ ? 'For your security, sign out and sign back in, then try again.'
59
+ : error.message || 'Could not delete account',
60
+ )
61
+ return
62
+ }
63
+ // Full reload, not router.push: drops every cached session/client state.
64
+ window.location.href = '/'
65
+ } finally {
66
+ setDeleting(false)
67
+ }
68
+ }
69
+
70
+ return (
71
+ <>
72
+ <Card className="border-destructive/50">
73
+ <CardHeader>
74
+ <CardTitle>Delete account</CardTitle>
75
+ <CardDescription>
76
+ Permanently delete your account, sessions, passkeys, and activity
77
+ history. This can&apos;t be undone.
78
+ </CardDescription>
79
+ </CardHeader>
80
+ <CardContent>
81
+ <Button variant="destructive" onClick={() => setOpen(true)} disabled={hasPassword === null}>
82
+ Delete account
83
+ </Button>
84
+ </CardContent>
85
+ </Card>
86
+
87
+ <AlertDialog open={open} onOpenChange={close}>
88
+ <AlertDialogContent>
89
+ <form onSubmit={deleteAccount} className="space-y-4">
90
+ <AlertDialogHeader>
91
+ <AlertDialogTitle>Delete your account?</AlertDialogTitle>
92
+ <AlertDialogDescription>
93
+ Everything tied to <strong>{email}</strong> is removed immediately
94
+ and you&apos;ll be signed out on every device.
95
+ </AlertDialogDescription>
96
+ </AlertDialogHeader>
97
+
98
+ {hasPassword ? (
99
+ <div className="flex flex-col gap-1">
100
+ <label htmlFor="delete-password" className="text-sm font-medium">
101
+ Enter your password to confirm
102
+ </label>
103
+ <PasswordInput
104
+ id="delete-password"
105
+ autoComplete="current-password"
106
+ value={password}
107
+ onChange={(e) => setPassword(e.target.value)}
108
+ autoFocus
109
+ />
110
+ </div>
111
+ ) : (
112
+ <div className="flex flex-col gap-1">
113
+ <label htmlFor="delete-email" className="text-sm font-medium">
114
+ Type <strong>{email}</strong> to confirm
115
+ </label>
116
+ <Input
117
+ id="delete-email"
118
+ autoComplete="off"
119
+ value={typedEmail}
120
+ onChange={(e) => setTypedEmail(e.target.value)}
121
+ autoFocus
122
+ />
123
+ </div>
124
+ )}
125
+
126
+ <AlertDialogFooter>
127
+ <AlertDialogCancel type="button" disabled={deleting}>Cancel</AlertDialogCancel>
128
+ <Button type="submit" variant="destructive" disabled={!canSubmit || deleting}>
129
+ {deleting ? 'Deleting...' : 'Delete account'}
130
+ </Button>
131
+ </AlertDialogFooter>
132
+ </form>
133
+ </AlertDialogContent>
134
+ </AlertDialog>
135
+ </>
136
+ )
137
+ }
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, useSyncExternalStore } from "react"
4
+ import { toast, type ExternalToast } from "sonner"
5
+ import FeedbackDialog from "./feedbackDialog"
6
+
7
+ // One hidden feedback dialog for reporting errors. Mount <ErrorFeedbackHost />
8
+ // once in a layout, only when feedback is configured (FEEDBACK_DISCORD_WEBHOOK_URL
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
11
+ // it isn't mounted, toastError is a plain toast.error.
12
+ let openReport: ((errorText: string) => void) | null = null
13
+ const listeners = new Set<() => void>()
14
+ function setOpenReport(fn: typeof openReport) {
15
+ openReport = fn
16
+ listeners.forEach((listener) => listener())
17
+ }
18
+
19
+ export function ErrorFeedbackHost() {
20
+ const [errorText, setErrorText] = useState<string | null>(null)
21
+
22
+ useEffect(() => {
23
+ setOpenReport(setErrorText)
24
+ return () => setOpenReport(null)
25
+ }, [])
26
+
27
+ return (
28
+ <FeedbackDialog
29
+ showTrigger={false}
30
+ open={errorText !== null}
31
+ onOpenChange={(open) => !open && setErrorText(null)}
32
+ defaultType="bug"
33
+ errorDetails={errorText ?? undefined}
34
+ defaultMessage={
35
+ errorText ? `Error: ${errorText}\n\nWhat were you doing when this happened?\n` : ""
36
+ }
37
+ />
38
+ )
39
+ }
40
+
41
+ /** Whether a Report button can open the feedback form (host mounted and configured). */
42
+ export function useCanReportProblem() {
43
+ return useSyncExternalStore(
44
+ (listener) => {
45
+ listeners.add(listener)
46
+ return () => listeners.delete(listener)
47
+ },
48
+ () => openReport !== null,
49
+ () => false
50
+ )
51
+ }
52
+
53
+ export function reportProblem(errorText: string) {
54
+ openReport?.(errorText)
55
+ }
56
+
57
+ /** toast.error with a "Report" button that opens the feedback form pre-filled. */
58
+ export function toastError(message: string, options?: ExternalToast) {
59
+ return toast.error(message, {
60
+ ...(openReport && {
61
+ action: { label: "Report", onClick: () => reportProblem(message) },
62
+ }),
63
+ ...options,
64
+ })
65
+ }
@@ -0,0 +1,160 @@
1
+ 'use client'
2
+
3
+ import { useState } from "react"
4
+ import { usePathname } from "next/navigation"
5
+ import { toast } from "sonner"
6
+ import { MessageSquare } from "lucide-react"
7
+ import { Button } from "../ui/button"
8
+ import { Label } from "../ui/label"
9
+ import { Textarea } from "../ui/textarea"
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogTrigger,
18
+ } from "../ui/dialog"
19
+
20
+ // Keep in sync with the cap in app/api/feedback/route.ts.
21
+ const FEEDBACK_MAX_LENGTH = 2000
22
+
23
+ type FeedbackType = "bug" | "feedback"
24
+
25
+ export type FeedbackDialogProps = {
26
+ /** Control the dialog from outside (e.g. a "Report" button on an error). */
27
+ open?: boolean
28
+ onOpenChange?: (open: boolean) => void
29
+ /** Pre-filled each time the dialog opens, e.g. the error the user just hit. */
30
+ defaultType?: FeedbackType
31
+ defaultMessage?: string
32
+ /**
33
+ * The error the user hit, sent alongside their message (not editable). The
34
+ * server only forwards it when "Attach error details" is on in System Settings.
35
+ */
36
+ errorDetails?: string
37
+ /** Render the "Feedback" header button. Default `true`. */
38
+ showTrigger?: boolean
39
+ }
40
+
41
+ /**
42
+ * Header button + dialog that POSTs to /api/feedback. Render it only when a
43
+ * feedback channel is configured (FEEDBACK_DISCORD_WEBHOOK_URL or
44
+ * FEEDBACK_EMAIL); otherwise the route answers 503.
45
+ */
46
+ export default function FeedbackDialog({
47
+ open: openProp,
48
+ onOpenChange,
49
+ defaultType = "feedback",
50
+ defaultMessage = "",
51
+ errorDetails,
52
+ showTrigger = true,
53
+ }: FeedbackDialogProps = {}) {
54
+ const pathname = usePathname()
55
+ const [openState, setOpenState] = useState(false)
56
+ const open = openProp ?? openState
57
+ const setOpen = (next: boolean) => {
58
+ setOpenState(next)
59
+ onOpenChange?.(next)
60
+ }
61
+ const [type, setType] = useState<FeedbackType>(defaultType)
62
+ const [message, setMessage] = useState(defaultMessage)
63
+ const [isSending, setIsSending] = useState(false)
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).
68
+ const [wasOpen, setWasOpen] = useState(open)
69
+ if (open !== wasOpen) {
70
+ setWasOpen(open)
71
+ if (open && defaultMessage) {
72
+ setType(defaultType)
73
+ setMessage(defaultMessage)
74
+ }
75
+ }
76
+
77
+ const handleSubmit = async (e: React.FormEvent) => {
78
+ e.preventDefault()
79
+ setIsSending(true)
80
+ try {
81
+ const response = await fetch("/api/feedback", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ type, message, page: pathname, error: errorDetails }),
85
+ })
86
+ const data = await response.json().catch(() => ({}))
87
+ if (!response.ok) throw new Error(data.error || "Failed to send feedback")
88
+
89
+ toast.success("Thanks! Your feedback was sent.")
90
+ setMessage("")
91
+ setOpen(false)
92
+ } catch (error) {
93
+ toast.error(error instanceof Error ? error.message : "Failed to send feedback")
94
+ } finally {
95
+ setIsSending(false)
96
+ }
97
+ }
98
+
99
+ return (
100
+ <Dialog open={open} onOpenChange={setOpen}>
101
+ {showTrigger && (
102
+ <DialogTrigger asChild>
103
+ <Button variant="ghost" size="sm">
104
+ <MessageSquare />
105
+ Feedback
106
+ </Button>
107
+ </DialogTrigger>
108
+ )}
109
+ <DialogContent>
110
+ <form onSubmit={handleSubmit} className="space-y-4">
111
+ <DialogHeader>
112
+ <DialogTitle>Send feedback</DialogTitle>
113
+ <DialogDescription>
114
+ Report a bug or tell us what could be better.
115
+ </DialogDescription>
116
+ </DialogHeader>
117
+
118
+ <div className="flex gap-2" role="radiogroup" aria-label="Feedback type">
119
+ {(["feedback", "bug"] as const).map((t) => (
120
+ <Button
121
+ key={t}
122
+ type="button"
123
+ role="radio"
124
+ aria-checked={type === t}
125
+ variant={type === t ? "default" : "outline"}
126
+ size="sm"
127
+ onClick={() => setType(t)}
128
+ >
129
+ {t === "bug" ? "Bug report" : "General feedback"}
130
+ </Button>
131
+ ))}
132
+ </div>
133
+
134
+ <div className="space-y-2">
135
+ <Label htmlFor="feedback-message">
136
+ {type === "bug" ? "What happened, and what did you expect?" : "Your feedback"}
137
+ </Label>
138
+ <Textarea
139
+ id="feedback-message"
140
+ value={message}
141
+ onChange={(e) => setMessage(e.target.value)}
142
+ maxLength={FEEDBACK_MAX_LENGTH}
143
+ rows={6}
144
+ required
145
+ />
146
+ <p className="text-xs text-muted-foreground text-right">
147
+ {message.length}/{FEEDBACK_MAX_LENGTH}
148
+ </p>
149
+ </div>
150
+
151
+ <DialogFooter>
152
+ <Button type="submit" disabled={isSending || !message.trim()}>
153
+ {isSending ? "Sending..." : "Send"}
154
+ </Button>
155
+ </DialogFooter>
156
+ </form>
157
+ </DialogContent>
158
+ </Dialog>
159
+ )
160
+ }
@@ -0,0 +1,187 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react'
4
+ import { CircleCheck, Download } from 'lucide-react'
5
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card'
6
+ import { Button } from '../ui/button'
7
+
8
+ // Chrome/Edge's install event; not in the DOM typings yet.
9
+ type InstallPromptEvent = Event & { prompt: () => Promise<{ outcome: 'accepted' | 'dismissed' }> }
10
+
11
+ declare global {
12
+ interface Window {
13
+ // Stashed by app/layout.tsx in case the event fires before this card mounts.
14
+ __pwaInstallPrompt?: InstallPromptEvent
15
+ }
16
+ }
17
+
18
+ export type InstallTarget = {
19
+ os: 'ios' | 'android' | 'desktop'
20
+ browser: 'safari' | 'chrome' | 'edge' | 'firefox' | 'samsung' | 'other'
21
+ }
22
+
23
+ // iPadOS reports a Mac user agent, so touch support tells them apart.
24
+ export function detectInstallTarget(ua: string, maxTouchPoints = 0): InstallTarget {
25
+ const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && maxTouchPoints > 1)
26
+ const os = ios ? 'ios' : /Android/.test(ua) ? 'android' : 'desktop'
27
+ const browser = /SamsungBrowser/.test(ua)
28
+ ? 'samsung'
29
+ : /Edg(e|A|iOS)?\//.test(ua)
30
+ ? 'edge'
31
+ : /Firefox|FxiOS/.test(ua)
32
+ ? 'firefox'
33
+ : /OPR\/|Opera/.test(ua)
34
+ ? 'other'
35
+ : /Chrome|CriOS/.test(ua)
36
+ ? 'chrome'
37
+ : /Safari/.test(ua)
38
+ ? 'safari'
39
+ : 'other'
40
+ return { os, browser }
41
+ }
42
+
43
+ const BROWSER_NAMES: Record<InstallTarget['browser'], string> = {
44
+ safari: 'Safari',
45
+ chrome: 'Chrome',
46
+ edge: 'Edge',
47
+ firefox: 'Firefox',
48
+ samsung: 'Samsung Internet',
49
+ other: 'your browser',
50
+ }
51
+ const OS_NAMES: Record<InstallTarget['os'], string> = {
52
+ ios: 'iPhone / iPad',
53
+ android: 'Android',
54
+ desktop: 'this computer',
55
+ }
56
+
57
+ // Manual steps for each OS/browser, or null when the browser can't install apps.
58
+ function steps({ os, browser }: InstallTarget): ReactNode[] | null {
59
+ if (os === 'ios') {
60
+ return [
61
+ browser === 'safari' ? (
62
+ <>Tap <strong>Share</strong> in the toolbar. It may be inside the <strong>⋯</strong> menu.</>
63
+ ) : (
64
+ <>Tap <strong>Share</strong> in the address bar or menu.</>
65
+ ),
66
+ <>Choose <strong>Add to Home Screen</strong>. Keep &ldquo;Open as Web App&rdquo; on if shown.</>,
67
+ <>Tap <strong>Add</strong>, then open the new icon on your home screen.</>,
68
+ ]
69
+ }
70
+ if (os === 'android') {
71
+ if (browser === 'samsung') {
72
+ return [
73
+ <>Tap the <strong>☰</strong> menu at the bottom right.</>,
74
+ <>Choose <strong>Add page to</strong> → <strong>Home screen</strong>.</>,
75
+ ]
76
+ }
77
+ return [
78
+ <>Tap the <strong>⋮</strong> menu at the top right.</>,
79
+ <>Choose <strong>Install app</strong> or <strong>Add to Home screen</strong>.</>,
80
+ ]
81
+ }
82
+ if (browser === 'chrome') {
83
+ return [
84
+ <>
85
+ Click the install icon at the right of the address bar, or open the <strong>⋮</strong> menu
86
+ and choose <strong>Cast, save and share</strong> → <strong>Install page as app</strong>.
87
+ </>,
88
+ ]
89
+ }
90
+ if (browser === 'edge') {
91
+ return [
92
+ <>
93
+ Click the install icon at the right of the address bar, or open the <strong>⋯</strong> menu
94
+ and choose <strong>Apps</strong> → <strong>Install this site as an app</strong>.
95
+ </>,
96
+ ]
97
+ }
98
+ if (browser === 'safari') {
99
+ return [<>Choose <strong>File</strong> → <strong>Add to Dock</strong> (macOS Sonoma or later).</>]
100
+ }
101
+ return null
102
+ }
103
+
104
+ // Install instructions for the visitor's own OS and browser, plus a one-click
105
+ // Install button where the browser offers one (Chrome, Edge). Shows a
106
+ // confirmation instead once the app runs installed.
107
+ export default function InstallAppCard() {
108
+ const [target, setTarget] = useState<InstallTarget | undefined>()
109
+ const [installed, setInstalled] = useState(false)
110
+ const [prompt, setPrompt] = useState<InstallPromptEvent | undefined>()
111
+
112
+ useEffect(() => {
113
+ setTarget(detectInstallTarget(navigator.userAgent, navigator.maxTouchPoints))
114
+ setInstalled(
115
+ matchMedia('(display-mode: standalone)').matches ||
116
+ (navigator as Navigator & { standalone?: boolean }).standalone === true
117
+ )
118
+ setPrompt(window.__pwaInstallPrompt)
119
+
120
+ const onPrompt = (e: Event) => setPrompt(e as InstallPromptEvent)
121
+ const onInstalled = () => setInstalled(true)
122
+ addEventListener('beforeinstallprompt', onPrompt)
123
+ addEventListener('appinstalled', onInstalled)
124
+ return () => {
125
+ removeEventListener('beforeinstallprompt', onPrompt)
126
+ removeEventListener('appinstalled', onInstalled)
127
+ }
128
+ }, [])
129
+
130
+ async function install() {
131
+ if (!prompt) return
132
+ const { outcome } = await prompt.prompt()
133
+ // The event can only be used once.
134
+ window.__pwaInstallPrompt = undefined
135
+ setPrompt(undefined)
136
+ if (outcome === 'accepted') setInstalled(true)
137
+ }
138
+
139
+ // Rendered after mount only: the server can't see the visitor's device.
140
+ if (!target) return null
141
+ const list = steps(target)
142
+
143
+ return (
144
+ <Card>
145
+ <CardHeader>
146
+ <CardTitle>Install the app</CardTitle>
147
+ <CardDescription>
148
+ Open it from your home screen or dock, in its own window.
149
+ </CardDescription>
150
+ </CardHeader>
151
+ <CardContent className="space-y-4">
152
+ {installed ? (
153
+ <p className="flex items-center gap-2 text-sm">
154
+ <CircleCheck className="size-4 text-green-600" aria-hidden />
155
+ You&apos;re using the installed app on this device.
156
+ </p>
157
+ ) : (
158
+ <>
159
+ <p className="text-sm text-muted-foreground">
160
+ {BROWSER_NAMES[target.browser]} on {OS_NAMES[target.os]}
161
+ </p>
162
+ {prompt && (
163
+ <Button onClick={install}>
164
+ <Download aria-hidden /> Install app
165
+ </Button>
166
+ )}
167
+ {list ? (
168
+ <ol className="list-decimal space-y-2 pl-5 text-sm marker:text-muted-foreground">
169
+ {list.map((step, i) => (
170
+ <li key={i}>{step}</li>
171
+ ))}
172
+ </ol>
173
+ ) : (
174
+ !prompt && (
175
+ <p className="text-sm">
176
+ This browser can&apos;t install apps. Open this page in{' '}
177
+ {target.os === 'desktop' ? 'Chrome, Edge, or Safari on a Mac' : 'Chrome'} to install
178
+ it.
179
+ </p>
180
+ )
181
+ )}
182
+ </>
183
+ )}
184
+ </CardContent>
185
+ </Card>
186
+ )
187
+ }
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import { useEffect, useState } from "react"
5
+
6
+ // Relative imports: this file ships raw in the npm package, where `@/` would
7
+ // resolve against the consuming project instead.
8
+ import { Button } from "../ui/button"
9
+ import { COOKIE_BANNER_ENABLED, setCookieConsent, useCookieConsent } from "../../lib/cookie-consent"
10
+
11
+ // Mount once in the root layout. Shows until the visitor picks an option;
12
+ // "Necessary only" is as prominent as "Accept all" on purpose — GDPR requires
13
+ // rejecting to be as easy as accepting.
14
+ export function CookieBanner() {
15
+ const consent = useCookieConsent()
16
+ // useCookieConsent() is null on the server *and* before a choice, so wait
17
+ // for mount to tell them apart — otherwise returning visitors see a flash.
18
+ const [mounted, setMounted] = useState(false)
19
+ useEffect(() => setMounted(true), [])
20
+
21
+ if (!COOKIE_BANNER_ENABLED || !mounted || consent) return null
22
+
23
+ return (
24
+ <div
25
+ role="dialog"
26
+ aria-live="polite"
27
+ aria-label="Cookie consent"
28
+ 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
+ >
30
+ <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.{" "}
33
+ <Link href="/privacy" className="underline underline-offset-4 hover:text-foreground">
34
+ Privacy policy
35
+ </Link>
36
+ </p>
37
+ <div className="mt-3 flex justify-end gap-2">
38
+ <Button variant="outline" size="sm" onClick={() => setCookieConsent("necessary")}>
39
+ Necessary only
40
+ </Button>
41
+ <Button size="sm" onClick={() => setCookieConsent("all")}>
42
+ Accept all
43
+ </Button>
44
+ </div>
45
+ </div>
46
+ )
47
+ }
48
+
49
+ // Withdrawing consent must be as easy as giving it — this reopens the banner.
50
+ export function CookieSettingsButton({ className }: { className?: string }) {
51
+ if (!COOKIE_BANNER_ENABLED) return null
52
+ return (
53
+ <button type="button" className={className} onClick={() => setCookieConsent(null)}>
54
+ Cookie settings
55
+ </button>
56
+ )
57
+ }
@@ -2,7 +2,7 @@ import * as React from "react"
2
2
  import { cva, type VariantProps } from "class-variance-authority"
3
3
  import { Slot } from "radix-ui"
4
4
 
5
- import { cn } from "@/lib/utils"
5
+ import { cn } from "../../lib/utils"
6
6
 
7
7
  const buttonVariants = cva(
8
8
  "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",