@betterstart/cli 0.1.68 → 0.1.70
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/dist/chunk-E4HZYXQ2.js +36 -0
- package/dist/chunk-E4HZYXQ2.js.map +1 -0
- package/dist/cli.js +580 -4443
- package/dist/cli.js.map +1 -1
- package/dist/reader-2T45D7JZ.js +7 -0
- package/package.json +3 -2
- package/templates/init/api/auth-route.ts +3 -0
- package/templates/init/api/upload-route.ts +74 -0
- package/templates/init/cms-globals.css +200 -0
- package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
- package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
- package/templates/init/components/data-table/data-table.tsx +188 -0
- package/templates/init/components/layout/cms-header.tsx +32 -0
- package/templates/init/components/layout/cms-nav-link.tsx +25 -0
- package/templates/init/components/layout/cms-providers.tsx +33 -0
- package/templates/init/components/layout/cms-search.tsx +25 -0
- package/templates/init/components/layout/cms-sidebar.tsx +192 -0
- package/templates/init/components/layout/cms-sign-out.tsx +30 -0
- package/templates/init/components/shared/delete-dialog.tsx +75 -0
- package/templates/init/components/shared/page-header.tsx +23 -0
- package/templates/init/components/shared/status-badge.tsx +43 -0
- package/templates/init/data/navigation.ts +39 -0
- package/templates/init/db/client.ts +8 -0
- package/templates/init/db/schema.ts +88 -0
- package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
- package/templates/init/hooks/use-cms-theme.tsx +78 -0
- package/templates/init/hooks/use-editor-image-upload.ts +82 -0
- package/templates/init/hooks/use-local-storage.ts +46 -0
- package/templates/init/hooks/use-mobile.ts +19 -0
- package/templates/init/hooks/use-upload.ts +177 -0
- package/templates/init/hooks/use-users.ts +13 -0
- package/templates/init/lib/actions/form-settings.ts +126 -0
- package/templates/init/lib/actions/profile.ts +62 -0
- package/templates/init/lib/actions/upload.ts +153 -0
- package/templates/init/lib/actions/users.ts +145 -0
- package/templates/init/lib/auth/auth-client.ts +12 -0
- package/templates/init/lib/auth/auth.ts +43 -0
- package/templates/init/lib/auth/middleware.ts +44 -0
- package/templates/init/lib/markdown/cached.ts +7 -0
- package/templates/init/lib/markdown/format.ts +55 -0
- package/templates/init/lib/markdown/render.ts +182 -0
- package/templates/init/lib/r2.ts +55 -0
- package/templates/init/pages/account-layout.tsx +63 -0
- package/templates/init/pages/authenticated-layout.tsx +26 -0
- package/templates/init/pages/cms-layout.tsx +16 -0
- package/templates/init/pages/dashboard-page.tsx +91 -0
- package/templates/init/pages/login-form.tsx +117 -0
- package/templates/init/pages/login-page.tsx +17 -0
- package/templates/init/pages/profile/profile-form.tsx +361 -0
- package/templates/init/pages/profile/profile-page.tsx +34 -0
- package/templates/init/pages/users/columns.tsx +241 -0
- package/templates/init/pages/users/create-user-dialog.tsx +116 -0
- package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
- package/templates/init/pages/users/users-page-content.tsx +29 -0
- package/templates/init/pages/users/users-page.tsx +19 -0
- package/templates/init/pages/users/users-table.tsx +219 -0
- package/templates/init/types/auth.ts +78 -0
- package/templates/init/types/index.ts +79 -0
- package/templates/init/types/table-meta.ts +16 -0
- package/templates/init/utils/cn.ts +6 -0
- package/templates/init/utils/mailchimp.ts +39 -0
- package/templates/init/utils/seo.ts +90 -0
- package/templates/init/utils/validation.ts +105 -0
- package/templates/init/utils/webhook.ts +28 -0
- package/templates/ui/alert-dialog.tsx +46 -28
- package/templates/ui/avatar.tsx +37 -20
- package/templates/ui/button.tsx +3 -3
- package/templates/ui/card.tsx +30 -18
- package/templates/ui/dialog.tsx +46 -22
- package/templates/ui/dropdown-menu.tsx +1 -1
- package/templates/ui/input.tsx +1 -1
- package/templates/ui/select.tsx +42 -34
- package/templates/ui/sidebar.tsx +13 -13
- package/templates/ui/table.tsx +2 -2
- package/dist/chunk-6JCWMKSY.js.map +0 -1
- package/dist/drizzle-config-EDKOEZ6G.js +0 -7
- /package/dist/{drizzle-config-EDKOEZ6G.js.map → reader-2T45D7JZ.js.map} +0 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { authClient } from '@cms/auth/client'
|
|
4
|
+
import { Button } from '@cms/components/ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from '@cms/components/ui/select'
|
|
12
|
+
import {
|
|
13
|
+
Table,
|
|
14
|
+
TableBody,
|
|
15
|
+
TableCell,
|
|
16
|
+
TableHead,
|
|
17
|
+
TableHeader,
|
|
18
|
+
TableRow,
|
|
19
|
+
} from '@cms/components/ui/table'
|
|
20
|
+
import { useUsers } from '@cms/hooks/use-users'
|
|
21
|
+
import type { UserData } from '@cms/types/auth'
|
|
22
|
+
import {
|
|
23
|
+
type ColumnDef,
|
|
24
|
+
type ColumnFiltersState,
|
|
25
|
+
flexRender,
|
|
26
|
+
getCoreRowModel,
|
|
27
|
+
getFilteredRowModel,
|
|
28
|
+
getPaginationRowModel,
|
|
29
|
+
getSortedRowModel,
|
|
30
|
+
type SortingState,
|
|
31
|
+
useReactTable,
|
|
32
|
+
type VisibilityState,
|
|
33
|
+
} from '@tanstack/react-table'
|
|
34
|
+
import { parseAsInteger, useQueryState } from 'nuqs'
|
|
35
|
+
import * as React from 'react'
|
|
36
|
+
|
|
37
|
+
const PAGE_SIZE_OPTIONS = [
|
|
38
|
+
{ value: '10', label: '10' },
|
|
39
|
+
{ value: '20', label: '20' },
|
|
40
|
+
{ value: '50', label: '50' },
|
|
41
|
+
{ value: '100', label: '100' },
|
|
42
|
+
{ value: 'all', label: 'All' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
interface UsersTableProps<TValue> {
|
|
46
|
+
columns: ColumnDef<UserData, TValue>[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function UsersTable<TValue>({ columns }: UsersTableProps<TValue>) {
|
|
50
|
+
const { data: session } = authClient.useSession()
|
|
51
|
+
const { data, error, isPending } = useUsers()
|
|
52
|
+
const [sorting, setSorting] = React.useState<SortingState>([])
|
|
53
|
+
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
|
54
|
+
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
|
55
|
+
const [pageIndex, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0))
|
|
56
|
+
const [pageSize, setPageSize] = useQueryState('size', parseAsInteger.withDefault(20))
|
|
57
|
+
|
|
58
|
+
const effectivePageSize = pageSize === -1 ? Number.MAX_SAFE_INTEGER : pageSize
|
|
59
|
+
|
|
60
|
+
const handlePageSizeChange = React.useCallback(
|
|
61
|
+
(value: string) => {
|
|
62
|
+
React.startTransition(() => {
|
|
63
|
+
if (value === 'all') {
|
|
64
|
+
setPageSize(-1)
|
|
65
|
+
} else {
|
|
66
|
+
setPageSize(Number(value))
|
|
67
|
+
}
|
|
68
|
+
setPageIndex(0)
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
[setPageSize, setPageIndex],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const handlePaginationChange = React.useCallback(
|
|
75
|
+
(
|
|
76
|
+
updater:
|
|
77
|
+
| { pageIndex: number; pageSize: number }
|
|
78
|
+
| ((old: { pageIndex: number; pageSize: number }) => {
|
|
79
|
+
pageIndex: number
|
|
80
|
+
pageSize: number
|
|
81
|
+
}),
|
|
82
|
+
) => {
|
|
83
|
+
const currentPagination = { pageIndex, pageSize: effectivePageSize }
|
|
84
|
+
const newPagination = typeof updater === 'function' ? updater(currentPagination) : updater
|
|
85
|
+
React.startTransition(() => {
|
|
86
|
+
setPageIndex(newPagination.pageIndex)
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
[pageIndex, effectivePageSize, setPageIndex],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const table = useReactTable({
|
|
93
|
+
data: data?.users ?? [],
|
|
94
|
+
columns,
|
|
95
|
+
getCoreRowModel: getCoreRowModel(),
|
|
96
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
97
|
+
onSortingChange: setSorting,
|
|
98
|
+
getSortedRowModel: getSortedRowModel(),
|
|
99
|
+
onColumnFiltersChange: setColumnFilters,
|
|
100
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
101
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
102
|
+
onPaginationChange: handlePaginationChange,
|
|
103
|
+
meta: {
|
|
104
|
+
currentUser: session?.user
|
|
105
|
+
? {
|
|
106
|
+
id: session.user.id,
|
|
107
|
+
email: session.user.email,
|
|
108
|
+
name: session.user.name,
|
|
109
|
+
image: session.user.image,
|
|
110
|
+
role: (session.user as { role?: string }).role || 'member',
|
|
111
|
+
}
|
|
112
|
+
: null,
|
|
113
|
+
},
|
|
114
|
+
state: {
|
|
115
|
+
sorting,
|
|
116
|
+
columnFilters,
|
|
117
|
+
columnVisibility,
|
|
118
|
+
pagination: {
|
|
119
|
+
pageIndex,
|
|
120
|
+
pageSize: effectivePageSize,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="space-y-6">
|
|
127
|
+
<div className="bg-card border overflow-hidden rounded-lg">
|
|
128
|
+
<Table>
|
|
129
|
+
<TableHeader className="bg-secondary">
|
|
130
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
131
|
+
<TableRow key={headerGroup.id}>
|
|
132
|
+
{headerGroup.headers.map((header) => (
|
|
133
|
+
<TableHead key={header.id}>
|
|
134
|
+
{header.isPlaceholder
|
|
135
|
+
? null
|
|
136
|
+
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
137
|
+
</TableHead>
|
|
138
|
+
))}
|
|
139
|
+
</TableRow>
|
|
140
|
+
))}
|
|
141
|
+
</TableHeader>
|
|
142
|
+
<TableBody>
|
|
143
|
+
{isPending ? (
|
|
144
|
+
<TableRow>
|
|
145
|
+
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
146
|
+
<div className="text-muted-foreground">Loading users...</div>
|
|
147
|
+
</TableCell>
|
|
148
|
+
</TableRow>
|
|
149
|
+
) : error ? (
|
|
150
|
+
<TableRow>
|
|
151
|
+
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
152
|
+
<div className="text-destructive">Error: {error.message}</div>
|
|
153
|
+
</TableCell>
|
|
154
|
+
</TableRow>
|
|
155
|
+
) : table.getRowModel().rows?.length ? (
|
|
156
|
+
table.getRowModel().rows.map((row) => (
|
|
157
|
+
<TableRow key={row.id}>
|
|
158
|
+
{row.getVisibleCells().map((cell) => (
|
|
159
|
+
<TableCell key={cell.id}>
|
|
160
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
161
|
+
</TableCell>
|
|
162
|
+
))}
|
|
163
|
+
</TableRow>
|
|
164
|
+
))
|
|
165
|
+
) : (
|
|
166
|
+
<TableRow>
|
|
167
|
+
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
168
|
+
No users found.
|
|
169
|
+
</TableCell>
|
|
170
|
+
</TableRow>
|
|
171
|
+
)}
|
|
172
|
+
</TableBody>
|
|
173
|
+
</Table>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="flex items-center justify-between">
|
|
177
|
+
<div className="flex items-center gap-2">
|
|
178
|
+
<span className="text-sm text-muted-foreground">Rows per page</span>
|
|
179
|
+
<Select
|
|
180
|
+
value={pageSize === -1 ? 'all' : String(pageSize)}
|
|
181
|
+
onValueChange={handlePageSizeChange}
|
|
182
|
+
>
|
|
183
|
+
<SelectTrigger className="w-[100px] h-8">
|
|
184
|
+
<SelectValue />
|
|
185
|
+
</SelectTrigger>
|
|
186
|
+
<SelectContent>
|
|
187
|
+
{PAGE_SIZE_OPTIONS.map((option) => (
|
|
188
|
+
<SelectItem key={option.value} value={option.value}>
|
|
189
|
+
{option.label}
|
|
190
|
+
</SelectItem>
|
|
191
|
+
))}
|
|
192
|
+
</SelectContent>
|
|
193
|
+
</Select>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="flex items-center space-x-2">
|
|
196
|
+
<Button
|
|
197
|
+
variant="outline"
|
|
198
|
+
size="sm"
|
|
199
|
+
onClick={() => table.previousPage()}
|
|
200
|
+
disabled={!table.getCanPreviousPage()}
|
|
201
|
+
>
|
|
202
|
+
Previous
|
|
203
|
+
</Button>
|
|
204
|
+
<div className="text-sm text-muted-foreground">
|
|
205
|
+
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1}
|
|
206
|
+
</div>
|
|
207
|
+
<Button
|
|
208
|
+
variant="outline"
|
|
209
|
+
size="sm"
|
|
210
|
+
onClick={() => table.nextPage()}
|
|
211
|
+
disabled={!table.getCanNextPage()}
|
|
212
|
+
>
|
|
213
|
+
Next
|
|
214
|
+
</Button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface AuthUser {
|
|
2
|
+
id: string
|
|
3
|
+
email: string
|
|
4
|
+
name: string
|
|
5
|
+
emailVerified: boolean
|
|
6
|
+
image: string | null
|
|
7
|
+
role: string
|
|
8
|
+
createdAt: Date
|
|
9
|
+
updatedAt: Date
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AuthSession {
|
|
13
|
+
user: AuthUser
|
|
14
|
+
isAuthenticated: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export enum UserRole {
|
|
18
|
+
ADMIN = 'admin',
|
|
19
|
+
EDITOR = 'editor',
|
|
20
|
+
MEMBER = 'member',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UserWithRole extends AuthUser {
|
|
24
|
+
role: UserRole
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Type guard to check if a value is a valid UserRole */
|
|
28
|
+
export function isUserRole(value: unknown): value is UserRole {
|
|
29
|
+
return typeof value === 'string' && Object.values(UserRole).includes(value as UserRole)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Check if user has one of the allowed roles */
|
|
33
|
+
export function hasRequiredRole(userRole: UserRole, allowedRoles: UserRole[]): boolean {
|
|
34
|
+
return allowedRoles.includes(userRole)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuthState {
|
|
38
|
+
user: AuthUser | null
|
|
39
|
+
loading: boolean
|
|
40
|
+
error: Error | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UserData {
|
|
44
|
+
id: string
|
|
45
|
+
email: string
|
|
46
|
+
name: string
|
|
47
|
+
emailVerified: boolean
|
|
48
|
+
createdAt: string
|
|
49
|
+
updatedAt: string
|
|
50
|
+
role: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UsersResponse {
|
|
54
|
+
users: UserData[]
|
|
55
|
+
total: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CreateUserInput {
|
|
59
|
+
email: string
|
|
60
|
+
name: string
|
|
61
|
+
password: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CreateUserResult {
|
|
65
|
+
success: boolean
|
|
66
|
+
error?: string
|
|
67
|
+
user?: UserData
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface UpdateUserRoleInput {
|
|
71
|
+
userId: string
|
|
72
|
+
role: UserRole
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface UpdateUserRoleResult {
|
|
76
|
+
success: boolean
|
|
77
|
+
error?: string
|
|
78
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export * from './auth'
|
|
2
|
+
|
|
3
|
+
/** Markdown editor component types */
|
|
4
|
+
export interface MDXComponent {
|
|
5
|
+
name: string
|
|
6
|
+
snippet: string
|
|
7
|
+
category: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MarkdownEditorProps {
|
|
11
|
+
value?: string
|
|
12
|
+
onChange?: (value: string) => void
|
|
13
|
+
placeholder?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
className?: string
|
|
16
|
+
componentSnippets: Record<string, MDXComponent[]>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Upload types */
|
|
20
|
+
export interface UploadedFile {
|
|
21
|
+
key: string
|
|
22
|
+
url: string
|
|
23
|
+
filename: string
|
|
24
|
+
size: number
|
|
25
|
+
contentType: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UploadFileResult {
|
|
29
|
+
success: boolean
|
|
30
|
+
error?: string
|
|
31
|
+
files?: UploadedFile[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UploadProgress {
|
|
35
|
+
filename: string
|
|
36
|
+
progress: number
|
|
37
|
+
loaded: number
|
|
38
|
+
total: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** API response types */
|
|
42
|
+
export interface ApiResponse<T = unknown> {
|
|
43
|
+
success: boolean
|
|
44
|
+
data?: T
|
|
45
|
+
error?: string
|
|
46
|
+
message?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Database response types */
|
|
50
|
+
export interface PaginationParams {
|
|
51
|
+
page: number
|
|
52
|
+
limit: number
|
|
53
|
+
sortBy?: string
|
|
54
|
+
sortOrder?: 'asc' | 'desc'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PaginatedResponse<T> {
|
|
58
|
+
data: T[]
|
|
59
|
+
total: number
|
|
60
|
+
page: number
|
|
61
|
+
limit: number
|
|
62
|
+
totalPages: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** TanStack Table meta augmentation */
|
|
66
|
+
declare module '@tanstack/react-table' {
|
|
67
|
+
// biome-ignore lint/correctness/noUnusedVariables: augmenting module
|
|
68
|
+
interface TableMeta<TData extends import('@tanstack/react-table').RowData> {
|
|
69
|
+
reorderMode?: boolean
|
|
70
|
+
onMoveRow?: (id: number, direction: 'up' | 'down') => void
|
|
71
|
+
currentUser?: {
|
|
72
|
+
id: string
|
|
73
|
+
email: string
|
|
74
|
+
name: string
|
|
75
|
+
image?: string | null
|
|
76
|
+
role: string
|
|
77
|
+
} | null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RowData } from '@tanstack/react-table'
|
|
2
|
+
|
|
3
|
+
declare module '@tanstack/react-table' {
|
|
4
|
+
// biome-ignore lint/correctness/noUnusedVariables: augmenting module
|
|
5
|
+
interface TableMeta<TData extends RowData> {
|
|
6
|
+
reorderMode?: boolean
|
|
7
|
+
onMoveRow?: (id: number, direction: 'up' | 'down') => void
|
|
8
|
+
currentUser?: {
|
|
9
|
+
id: string
|
|
10
|
+
email: string
|
|
11
|
+
name: string
|
|
12
|
+
image?: string | null
|
|
13
|
+
role: string
|
|
14
|
+
} | null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import mailchimp from '@mailchimp/mailchimp_marketing'
|
|
2
|
+
|
|
3
|
+
if (process.env.BETTERSTART_MAILCHIMP_API_KEY && process.env.BETTERSTART_MAILCHIMP_SERVER_PREFIX) {
|
|
4
|
+
mailchimp.setConfig({
|
|
5
|
+
apiKey: process.env.BETTERSTART_MAILCHIMP_API_KEY,
|
|
6
|
+
server: process.env.BETTERSTART_MAILCHIMP_SERVER_PREFIX
|
|
7
|
+
})
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Add an email address to the Mailchimp audience (fire-and-forget, non-blocking)
|
|
12
|
+
* Follows the same pattern as sendWebhook() in utils/webhook.ts
|
|
13
|
+
*/
|
|
14
|
+
export function addToMailchimpAudience(email: string): void {
|
|
15
|
+
if (
|
|
16
|
+
!process.env.BETTERSTART_MAILCHIMP_API_KEY ||
|
|
17
|
+
!process.env.BETTERSTART_MAILCHIMP_SERVER_PREFIX ||
|
|
18
|
+
!process.env.BETTERSTART_MAILCHIMP_AUDIENCE_ID
|
|
19
|
+
)
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
;(async () => {
|
|
23
|
+
try {
|
|
24
|
+
await mailchimp.lists.addListMember(process.env.BETTERSTART_MAILCHIMP_AUDIENCE_ID!, {
|
|
25
|
+
email_address: email,
|
|
26
|
+
status: 'subscribed' as const,
|
|
27
|
+
merge_fields: {}
|
|
28
|
+
})
|
|
29
|
+
} catch (error: unknown) {
|
|
30
|
+
// Silently ignore "Member Exists" (user re-submitting form)
|
|
31
|
+
if (
|
|
32
|
+
(error as { response?: { body?: { title?: string } } })?.response?.body?.title ===
|
|
33
|
+
'Member Exists'
|
|
34
|
+
)
|
|
35
|
+
return
|
|
36
|
+
console.error('Failed to add to Mailchimp audience:', error)
|
|
37
|
+
}
|
|
38
|
+
})()
|
|
39
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
|
|
3
|
+
interface CreateMetadataOptions {
|
|
4
|
+
title: string
|
|
5
|
+
description: string
|
|
6
|
+
path?: string
|
|
7
|
+
ogImage?: string
|
|
8
|
+
noIndex?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create consistent Next.js metadata for CMS-generated pages.
|
|
13
|
+
* Use this in page.tsx files for entities that have public-facing pages.
|
|
14
|
+
*/
|
|
15
|
+
export function createMetadata({
|
|
16
|
+
title,
|
|
17
|
+
description,
|
|
18
|
+
path,
|
|
19
|
+
ogImage,
|
|
20
|
+
noIndex = false,
|
|
21
|
+
}: CreateMetadataOptions): Metadata {
|
|
22
|
+
const metadata: Metadata = {
|
|
23
|
+
title,
|
|
24
|
+
description,
|
|
25
|
+
openGraph: {
|
|
26
|
+
title,
|
|
27
|
+
description,
|
|
28
|
+
type: 'website',
|
|
29
|
+
...(ogImage && { images: [{ url: ogImage }] }),
|
|
30
|
+
},
|
|
31
|
+
twitter: {
|
|
32
|
+
card: ogImage ? 'summary_large_image' : 'summary',
|
|
33
|
+
title,
|
|
34
|
+
description,
|
|
35
|
+
...(ogImage && { images: [ogImage] }),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (path) {
|
|
40
|
+
metadata.alternates = { canonical: path }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (noIndex) {
|
|
44
|
+
metadata.robots = { index: false, follow: false }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return metadata
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate JSON-LD structured data for a blog post / article.
|
|
52
|
+
*/
|
|
53
|
+
export function generateArticleSchema({
|
|
54
|
+
title,
|
|
55
|
+
description,
|
|
56
|
+
url,
|
|
57
|
+
imageUrl,
|
|
58
|
+
datePublished,
|
|
59
|
+
dateModified,
|
|
60
|
+
authorName,
|
|
61
|
+
}: {
|
|
62
|
+
title: string
|
|
63
|
+
description: string
|
|
64
|
+
url: string
|
|
65
|
+
imageUrl?: string
|
|
66
|
+
datePublished: string
|
|
67
|
+
dateModified?: string
|
|
68
|
+
authorName?: string
|
|
69
|
+
}) {
|
|
70
|
+
return {
|
|
71
|
+
'@context': 'https://schema.org',
|
|
72
|
+
'@type': 'BlogPosting',
|
|
73
|
+
headline: title,
|
|
74
|
+
description,
|
|
75
|
+
url,
|
|
76
|
+
...(imageUrl && { image: imageUrl }),
|
|
77
|
+
datePublished,
|
|
78
|
+
...(dateModified && { dateModified }),
|
|
79
|
+
...(authorName && {
|
|
80
|
+
author: { '@type': 'Person', name: authorName },
|
|
81
|
+
}),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Serialize a JSON-LD schema object to a string for embedding in a script tag.
|
|
87
|
+
*/
|
|
88
|
+
export function schemaToJson(schema: Record<string, unknown>): string {
|
|
89
|
+
return JSON.stringify(schema)
|
|
90
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export interface FileValidationConfig {
|
|
2
|
+
maxSizeInBytes?: number
|
|
3
|
+
allowedTypes?: string[]
|
|
4
|
+
maxFiles?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface FileValidationError {
|
|
8
|
+
filename: string
|
|
9
|
+
error: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FileValidationResult {
|
|
13
|
+
valid: boolean
|
|
14
|
+
errors: FileValidationError[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_SIZE = 10 * 1024 * 1024 // 10MB
|
|
18
|
+
const DEFAULT_MAX_FILES = 10
|
|
19
|
+
|
|
20
|
+
function isFileTypeAllowed(file: File, allowedTypes: string[]): boolean {
|
|
21
|
+
for (const type of allowedTypes) {
|
|
22
|
+
if (type.endsWith('/*')) {
|
|
23
|
+
const prefix = type.slice(0, -1)
|
|
24
|
+
if (file.type.startsWith(prefix)) return true
|
|
25
|
+
} else if (type.startsWith('.')) {
|
|
26
|
+
if (file.name.toLowerCase().endsWith(type.toLowerCase())) return true
|
|
27
|
+
} else {
|
|
28
|
+
if (file.type === type) return true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate an array of files against size, type, and count constraints.
|
|
36
|
+
*/
|
|
37
|
+
export function validateFiles(
|
|
38
|
+
files: File[],
|
|
39
|
+
config: FileValidationConfig = {},
|
|
40
|
+
): FileValidationResult {
|
|
41
|
+
const { maxSizeInBytes = DEFAULT_MAX_SIZE, allowedTypes, maxFiles = DEFAULT_MAX_FILES } = config
|
|
42
|
+
|
|
43
|
+
const errors: FileValidationError[] = []
|
|
44
|
+
|
|
45
|
+
if (files.length > maxFiles) {
|
|
46
|
+
errors.push({
|
|
47
|
+
filename: '',
|
|
48
|
+
error: `Too many files. Maximum is ${maxFiles}.`,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
if (file.size > maxSizeInBytes) {
|
|
54
|
+
const maxMB = Math.round(maxSizeInBytes / (1024 * 1024))
|
|
55
|
+
errors.push({
|
|
56
|
+
filename: file.name,
|
|
57
|
+
error: `File exceeds maximum size of ${maxMB}MB.`,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (allowedTypes && allowedTypes.length > 0 && !isFileTypeAllowed(file, allowedTypes)) {
|
|
62
|
+
errors.push({
|
|
63
|
+
filename: file.name,
|
|
64
|
+
error: `File type "${file.type || 'unknown'}" is not allowed.`,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { valid: errors.length === 0, errors }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Format bytes to a human-readable string.
|
|
74
|
+
*/
|
|
75
|
+
export function formatFileSize(bytes: number): string {
|
|
76
|
+
if (bytes === 0) return '0 B'
|
|
77
|
+
const units = ['B', 'KB', 'MB', 'GB']
|
|
78
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
79
|
+
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a string is a valid URL.
|
|
84
|
+
*/
|
|
85
|
+
export function isValidUrl(str: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
new URL(str)
|
|
88
|
+
return true
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Slugify a string for use in URLs.
|
|
96
|
+
*/
|
|
97
|
+
export function slugify(text: string): string {
|
|
98
|
+
return text
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.trim()
|
|
101
|
+
.replace(/[^\w\s-]/g, '')
|
|
102
|
+
.replace(/[\s_]+/g, '-')
|
|
103
|
+
.replace(/-+/g, '-')
|
|
104
|
+
.replace(/^-|-$/g, '')
|
|
105
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send payload to webhook URL (fire-and-forget, non-blocking)
|
|
3
|
+
* @param webhookUrl - The webhook URL to send data to
|
|
4
|
+
* @param payload - The form data to send as URL-encoded
|
|
5
|
+
*/
|
|
6
|
+
export function sendWebhook(
|
|
7
|
+
webhookUrl: string | null | undefined,
|
|
8
|
+
payload: Record<string, unknown>,
|
|
9
|
+
): void {
|
|
10
|
+
if (!webhookUrl) return // Fire-and-forget: runs in background, doesn't block
|
|
11
|
+
;(async () => {
|
|
12
|
+
try {
|
|
13
|
+
const formData = new URLSearchParams()
|
|
14
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
15
|
+
if (value === null || value === undefined) continue
|
|
16
|
+
const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
17
|
+
formData.append(key, stringValue)
|
|
18
|
+
}
|
|
19
|
+
await fetch(webhookUrl, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
22
|
+
body: formData.toString(),
|
|
23
|
+
})
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error('Webhook failed:', error)
|
|
26
|
+
}
|
|
27
|
+
})()
|
|
28
|
+
}
|