chore: organize ts types, fix eslint errors

This commit is contained in:
Vasily Zubarev
2025-04-09 12:45:56 +02:00
parent 707a030a0a
commit 416c45d08c
29 changed files with 277 additions and 84 deletions

View File

@@ -1,5 +1,6 @@
"use server"
import { ActionState } from "@/lib/actions"
import OpenAI from "openai"
import { AnalyzeAttachment } from "./attachments"
@@ -8,7 +9,7 @@ export async function analyzeTransaction(
schema: Record<string, unknown>,
attachments: AnalyzeAttachment[],
apiKey: string
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
): Promise<ActionState<Record<string, string>>> {
const openai = new OpenAI({
apiKey,
})

View File

@@ -38,7 +38,7 @@ export async function GET(request: Request) {
// CSV rows
for (const transaction of transactions) {
const row: Record<string, any> = {}
const row: Record<string, unknown> = {}
for (const field of existingFields) {
let value
if (field.isExtra) {

View File

@@ -1,5 +1,6 @@
"use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files"
import { createFile } from "@/models/files"
@@ -8,7 +9,7 @@ import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
export async function uploadFilesAction(prevState: any, formData: FormData) {
export async function uploadFilesAction(formData: FormData): Promise<ActionState<null>> {
const user = await getCurrentUser()
const files = formData.getAll("files")

View File

@@ -1,12 +1,17 @@
"use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import"
import { createTransaction } from "@/models/transactions"
import { parse } from "@fast-csv/parse"
import { Transaction } from "@prisma/client"
import { revalidatePath } from "next/cache"
export async function parseCSVAction(prevState: any, formData: FormData) {
export async function parseCSVAction(
_prevState: ActionState<string[][]> | null,
formData: FormData
): Promise<ActionState<string[][]>> {
const file = formData.get("file") as File
if (!file) {
return { success: false, error: "No file uploaded" }
@@ -38,7 +43,10 @@ export async function parseCSVAction(prevState: any, formData: FormData) {
}
}
export async function saveTransactionsAction(prevState: any, formData: FormData) {
export async function saveTransactionsAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
const user = await getCurrentUser()
try {
const rows = JSON.parse(formData.get("rows") as string) as Record<string, unknown>[]

View File

@@ -8,19 +8,23 @@ import {
settingsFormSchema,
} from "@/forms/settings"
import { userFormSchema } from "@/forms/users"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { codeFromName, randomHexColor } from "@/lib/utils"
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
import { createField, deleteField, updateField } from "@/models/fields"
import { createProject, deleteProject, updateProject } from "@/models/projects"
import { updateSettings } from "@/models/settings"
import { SettingsMap, updateSettings } from "@/models/settings"
import { updateUser } from "@/models/users"
import { Prisma } from "@prisma/client"
import { Prisma, User } from "@prisma/client"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function saveSettingsAction(prevState: any, formData: FormData) {
export async function saveSettingsAction(
_prevState: ActionState<SettingsMap> | null,
formData: FormData
): Promise<ActionState<SettingsMap>> {
const user = await getCurrentUser()
const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData))
@@ -29,7 +33,10 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
}
for (const key in validatedForm.data) {
await updateSettings(user.id, key, validatedForm.data[key as keyof typeof validatedForm.data])
const value = validatedForm.data[key as keyof typeof validatedForm.data]
if (value !== undefined) {
await updateSettings(user.id, key, value)
}
}
revalidatePath("/settings")
@@ -37,7 +44,10 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
// return { success: true }
}
export async function saveProfileAction(prevState: any, formData: FormData) {
export async function saveProfileAction(
_prevState: ActionState<User> | null,
formData: FormData
): Promise<ActionState<User>> {
const user = await getCurrentUser()
const validatedForm = userFormSchema.safeParse(Object.fromEntries(formData))

View File

@@ -1,5 +1,6 @@
"use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db"
import { getUserUploadsDirectory } from "@/lib/files"
@@ -12,7 +13,14 @@ const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
const REMOVE_EXISTING_DATA = true
const MAX_BACKUP_SIZE = 256 * 1024 * 1024 // 256MB
export async function restoreBackupAction(prevState: any, formData: FormData) {
type BackupRestoreResult = {
counters: Record<string, number>
}
export async function restoreBackupAction(
_prevState: ActionState<BackupRestoreResult> | null,
formData: FormData
): Promise<ActionState<BackupRestoreResult>> {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
const file = formData.get("file") as File
@@ -32,7 +40,7 @@ export async function restoreBackupAction(prevState: any, formData: FormData) {
const fileData = Buffer.from(fileBuffer)
zip = await JSZip.loadAsync(fileData)
} catch (error) {
return { success: false, error: "Bad zip archive" }
return { success: false, error: "Bad zip archive: " + (error as Error).message }
}
// Check metadata and start restoring
@@ -133,7 +141,7 @@ export async function restoreBackupAction(prevState: any, formData: FormData) {
}
}
return { success: true, message: "Restore completed successfully", counters }
return { success: true, data: { counters } }
} catch (error) {
console.error("Error restoring from backup:", error)
return {

View File

@@ -9,7 +9,7 @@ import path from "path"
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
const BACKUP_VERSION = "1.0"
export async function GET(request: Request) {
export async function GET() {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
@@ -87,7 +87,7 @@ export async function GET(request: Request) {
}
async function getAllFilePaths(dirPath: string): Promise<string[]> {
let filePaths: string[] = []
const filePaths: string[] = []
async function readDirectoryRecursively(currentPath: string) {
const isDirExists = await fileExists(currentPath)

View File

@@ -62,7 +62,7 @@ export default function BackupSettingsPage() {
<h2 className="text-xl font-semibold">Backup restored successfully</h2>
<p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p>
<ul className="list-disc list-inside">
{Object.entries(restoreState.counters || {}).map(([key, value]) => (
{Object.entries(restoreState.data?.counters || {}).map(([key, value]) => (
<li key={key}>
<span className="font-bold">{key}</span>: {value} items
</li>

View File

@@ -17,8 +17,9 @@ export default async function FieldsSettingsPage() {
<div className="container">
<h1 className="text-2xl font-bold mb-2">Custom Fields</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
You can add new fields to your transactions. Standard fields can't be removed but you can tweak their prompts or
hide them. If you don't want a field to be analyzed by AI but filled in by hand, leave the "LLM prompt" empty.
You can add new fields to your transactions. Standard fields can&apos;t be removed but you can tweak their
prompts or hide them. If you don&apos;t want a field to be analyzed by AI but filled in by hand, leave the
&quot;LLM prompt&quot; empty.
</p>
<CrudTable
items={fieldsWithActions}

View File

@@ -1,6 +1,7 @@
"use server"
import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { updateField } from "@/models/fields"
@@ -13,12 +14,16 @@ import {
updateTransaction,
updateTransactionFiles,
} from "@/models/transactions"
import { Transaction } from "@prisma/client"
import { randomUUID } from "crypto"
import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
export async function createTransactionAction(prevState: any, formData: FormData) {
export async function createTransactionAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
try {
const user = await getCurrentUser()
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
@@ -30,14 +35,17 @@ export async function createTransactionAction(prevState: any, formData: FormData
const transaction = await createTransaction(user.id, validatedForm.data)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
return { success: true, data: transaction }
} catch (error) {
console.error("Failed to create transaction:", error)
return { success: false, error: "Failed to create transaction" }
}
}
export async function saveTransactionAction(prevState: any, formData: FormData) {
export async function saveTransactionAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
try {
const user = await getCurrentUser()
const transactionId = formData.get("transactionId") as string
@@ -50,14 +58,17 @@ export async function saveTransactionAction(prevState: any, formData: FormData)
const transaction = await updateTransaction(transactionId, user.id, validatedForm.data)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
return { success: true, data: transaction }
} catch (error) {
console.error("Failed to update transaction:", error)
return { success: false, error: "Failed to save transaction" }
}
}
export async function deleteTransactionAction(prevState: any, transactionId: string) {
export async function deleteTransactionAction(
_prevState: ActionState<Transaction> | null,
transactionId: string
): Promise<ActionState<Transaction>> {
try {
const user = await getCurrentUser()
const transaction = await getTransactionById(transactionId, user.id)
@@ -67,7 +78,7 @@ export async function deleteTransactionAction(prevState: any, transactionId: str
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
return { success: true, data: transaction }
} catch (error) {
console.error("Failed to delete transaction:", error)
return { success: false, error: "Failed to delete transaction" }
@@ -77,7 +88,7 @@ export async function deleteTransactionAction(prevState: any, transactionId: str
export async function deleteTransactionFileAction(
transactionId: string,
fileId: string
): Promise<{ success: boolean; error?: string }> {
): Promise<ActionState<Transaction>> {
if (!fileId || !transactionId) {
return { success: false, error: "File ID and transaction ID are required" }
}
@@ -96,10 +107,10 @@ export async function deleteTransactionFileAction(
await deleteFile(fileId, user.id)
revalidatePath(`/transactions/${transactionId}`)
return { success: true }
return { success: true, data: transaction }
}
export async function uploadTransactionFilesAction(formData: FormData): Promise<{ success: boolean; error?: string }> {
export async function uploadTransactionFilesAction(formData: FormData): Promise<ActionState<Transaction>> {
try {
const transactionId = formData.get("transactionId") as string
const files = formData.getAll("files") as File[]

View File

@@ -5,13 +5,14 @@ import { AnalyzeAttachment, loadAttachmentsForAI } from "@/ai/attachments"
import { buildLLMPrompt } from "@/ai/prompt"
import { fieldsToJsonSchema } from "@/ai/schema"
import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import config from "@/lib/config"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
import { deleteFile, getFileById, updateFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
import { Category, Field, File, Project } from "@prisma/client"
import { Category, Field, File, Project, Transaction } from "@prisma/client"
import { mkdir, rename } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
@@ -22,7 +23,7 @@ export async function analyzeFileAction(
fields: Field[],
categories: Category[],
projects: Project[]
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
): Promise<ActionState<Record<string, string>>> {
const user = await getCurrentUser()
if (!file || file.userId !== user.id) {
@@ -58,7 +59,10 @@ export async function analyzeFileAction(
return results
}
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) {
export async function saveFileAsTransactionAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
try {
const user = await getCurrentUser()
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
@@ -97,14 +101,17 @@ export async function saveFileAsTransactionAction(prevState: any, formData: Form
revalidatePath("/unsorted")
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
return { success: true, data: transaction }
} catch (error) {
console.error("Failed to save transaction:", error)
return { success: false, error: `Failed to save transaction: ${error}` }
}
}
export async function deleteUnsortedFileAction(prevState: any, fileId: string) {
export async function deleteUnsortedFileAction(
_prevState: ActionState<Transaction> | null,
fileId: string
): Promise<ActionState<Transaction>> {
try {
const user = await getCurrentUser()
await deleteFile(fileId, user.id)

View File

@@ -15,12 +15,12 @@ export async function selfHostedGetStartedAction(formData: FormData) {
const openaiApiKey = formData.get("openai_api_key")
if (openaiApiKey) {
await updateSettings(user.id, "openai_api_key", openaiApiKey)
await updateSettings(user.id, "openai_api_key", openaiApiKey as string)
}
const defaultCurrency = formData.get("default_currency")
if (defaultCurrency) {
await updateSettings(user.id, "default_currency", defaultCurrency)
await updateSettings(user.id, "default_currency", defaultCurrency as string)
}
revalidatePath("/dashboard")

View File

@@ -2,6 +2,7 @@ import { LoginForm } from "@/components/auth/login-form"
import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text"
import config from "@/lib/config"
import Image from "next/image"
import { redirect } from "next/navigation"
export default async function LoginPage() {
@@ -11,7 +12,7 @@ export default async function LoginPage() {
return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
<Image src="/logo/512.png" alt="Logo" width={144} height={144} className="w-36 h-36" />
<CardTitle className="text-3xl font-bold ">
<ColoredText>TaxHacker: Cloud Edition</ColoredText>
</CardTitle>

View File

@@ -1,16 +1,17 @@
import { X } from "lucide-react"
import Link from "next/link"
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-900 flex flex-col relative">
<a
<Link
href="/"
className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 transition-colors"
>
<span className="text-gray-300 font-bold text-xl">
<X />
</span>
</a>
</Link>
<div className="flex-grow flex flex-col justify-center items-center py-12 px-4 sm:px-6 lg:px-8">{children}</div>
</div>
)

View File

@@ -7,6 +7,7 @@ import config from "@/lib/config"
import { DEFAULT_CURRENCIES, DEFAULT_SETTINGS } from "@/models/defaults"
import { getSelfHostedUser } from "@/models/users"
import { ShieldAlert } from "lucide-react"
import Image from "next/image"
import { redirect } from "next/navigation"
import { selfHostedGetStartedAction } from "../actions"
@@ -36,12 +37,12 @@ export default async function SelfHostedWelcomePage() {
return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
<Image src="/logo/512.png" alt="Logo" width={144} height={144} className="w-36 h-36" />
<CardTitle className="text-3xl font-bold ">
<ColoredText>TaxHacker: Self-Hosted Edition</ColoredText>
</CardTitle>
<CardDescription className="flex flex-col gap-4 text-center text-lg">
<p>Welcome to your own instance of TaxHacker. Let's set up a couple of settings to get started.</p>
<p>Welcome to your own instance of TaxHacker. Let&apos;s set up a couple of settings to get started.</p>
<form action={selfHostedGetStartedAction} className="flex flex-col gap-8 pt-8">
<div>

View File

@@ -4,7 +4,7 @@ import { getSelfHostedUser } from "@/models/users"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function GET(request: Request) {
export async function GET() {
if (!config.selfHosted.isEnabled) {
redirect(config.auth.loginUrl)
}

View File

@@ -8,10 +8,10 @@ export default function LandingPage() {
<div className="min-h-screen flex flex-col bg-[#FAFAFA]">
<header className="py-6 px-8 bg-white/80 backdrop-blur-md shadow-sm fixed w-full z-10">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<a href="/" className="flex items-center gap-2">
<img src="/logo/256.png" alt="Logo" className="h-8" />
<Link href="/" className="flex items-center gap-2">
<Image src="/logo/256.png" alt="Logo" width={32} height={32} className="h-8" />
<ColoredText className="text-2xl font-bold">TaxHacker</ColoredText>
</a>
</Link>
<div className="flex gap-4">
<Link
href="#start"
@@ -43,12 +43,12 @@ export default function LandingPage() {
>
Get Started
</Link>
<a
<Link
href="mailto:me@vas3k.ru"
className="px-8 py-3 border border-gray-200 text-gray-700 font-medium rounded-full hover:bg-gray-50 transition-all"
>
Contact Us
</a>
</Link>
</div>
</div>
<div className="relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
@@ -323,7 +323,7 @@ export default function LandingPage() {
Upcoming Features
</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
We're a small, indie project constantly improving. Here's what we're working on next.
We&apos;re a small, indie project constantly improving. Here&apos;s what we&apos;re working on next.
</p>
</div>
@@ -429,9 +429,9 @@ export default function LandingPage() {
<footer className="py-8 px-8 bg-white">
<div className="max-w-7xl mx-auto text-center text-sm text-gray-500">
Made with in Berlin by{" "}
<a href="https://github.com/vas3k" className="underline">
<Link href="https://github.com/vas3k" className="underline">
vas3k
</a>
</Link>
</div>
</footer>
</div>

View File

@@ -27,7 +27,7 @@ export default function DashboardDropZoneWidget() {
// Submit the files using the server action
startTransition(async () => {
const result = await uploadFilesAction(null, formData)
const result = await uploadFilesAction(formData)
if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)

View File

@@ -65,7 +65,7 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
startTransition(async () => {
const result = transactionId
? await uploadTransactionFilesAction(formData)
: await uploadFilesAction(null, formData)
: await uploadFilesAction(formData)
if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" })
@@ -88,18 +88,18 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
// Add event listeners to document body
useEffect(() => {
document.body.addEventListener("dragenter", handleDragEnter as any)
document.body.addEventListener("dragover", handleDragOver as any)
document.body.addEventListener("dragleave", handleDragLeave as any)
document.body.addEventListener("drop", handleDrop as any)
document.body.addEventListener("dragenter", handleDragEnter as unknown as EventListener)
document.body.addEventListener("dragover", handleDragOver as unknown as EventListener)
document.body.addEventListener("dragleave", handleDragLeave as unknown as EventListener)
document.body.addEventListener("drop", handleDrop as unknown as EventListener)
return () => {
document.body.removeEventListener("dragenter", handleDragEnter as any)
document.body.removeEventListener("dragover", handleDragOver as any)
document.body.removeEventListener("dragleave", handleDragLeave as any)
document.body.removeEventListener("drop", handleDrop as any)
document.body.removeEventListener("dragenter", handleDragEnter as unknown as EventListener)
document.body.removeEventListener("dragover", handleDragOver as unknown as EventListener)
document.body.removeEventListener("dragleave", handleDragLeave as unknown as EventListener)
document.body.removeEventListener("drop", handleDrop as unknown as EventListener)
}
}, [isDragging])
}, [isDragging, handleDrop])
return (
<div className="relative min-h-screen w-full">

View File

@@ -28,7 +28,7 @@ export function UploadButton({ children, ...props }: { children: React.ReactNode
// Submit the files using the server action
startTransition(async () => {
const result = await uploadFilesAction(null, formData)
const result = await uploadFilesAction(formData)
if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)

View File

@@ -16,16 +16,8 @@ export const FormConvertCurrency = ({
date?: Date | undefined
onChange?: (value: number) => void
}) => {
if (
originalTotal === 0 ||
!originalCurrencyCode ||
!targetCurrencyCode ||
originalCurrencyCode === targetCurrencyCode
) {
return <></>
}
const normalizedDate = startOfDay(date || new Date(Date.now() - 24 * 60 * 60 * 1000))
const normalizedDateString = format(normalizedDate, "yyyy-MM-dd")
const [exchangeRate, setExchangeRate] = useState(0)
const [isLoading, setIsLoading] = useState(false)
@@ -46,7 +38,11 @@ export const FormConvertCurrency = ({
}
fetchData()
}, [originalCurrencyCode, targetCurrencyCode, format(normalizedDate, "LLLL-mm-dd")])
}, [originalCurrencyCode, targetCurrencyCode, normalizedDateString, originalTotal])
if (!originalTotal || !originalCurrencyCode || !targetCurrencyCode || originalCurrencyCode === targetCurrencyCode) {
return <></>
}
return (
<div className="flex flex-row gap-2 items-center text-muted-foreground">

View File

@@ -117,7 +117,7 @@ export const FormDate = ({
if (!isNaN(newDate.getTime())) {
setDate(newDate)
}
} catch (error) {}
} catch (_) {}
}
return (

View File

@@ -23,10 +23,11 @@ export function ImportCSVTable({ fields }: { fields: Field[] }) {
useEffect(() => {
if (parseState?.success && parseState.data) {
setCSVData(parseState.data)
if (parseState.data.length > 0) {
const parsedData = parseState.data as string[][]
setCSVData(parsedData)
if (parsedData.length > 0) {
setColumnMappings(
parseState.data[0].map((value) => {
parsedData[0].map((value) => {
const field = fields.find((field) => field.code === value || field.name === value)
return field?.code || ""
})

View File

@@ -44,8 +44,8 @@ export default function TransactionCreateForm({
})
useEffect(() => {
if (createState?.success) {
router.push(`/transactions/${createState.transactionId}`)
if (createState?.success && createState.data) {
router.push(`/transactions/${createState.data.id}`)
}
}, [createState, router])

View File

@@ -8,7 +8,7 @@ export const settingsFormSchema = z.object({
default_project: z.string().optional(),
openai_api_key: z.string().optional(),
prompt_analyse_new_file: z.string().optional(),
is_welcome_message_hidden: z.boolean().optional(),
is_welcome_message_hidden: z.string().optional(),
})
export const currencyFormSchema = z.object({

5
lib/actions.ts Normal file
View File

@@ -0,0 +1,5 @@
export type ActionState<T> = {
success: boolean
error?: string | null
data?: T | null
}

View File

@@ -13,7 +13,7 @@ export const getSettings = cache(async (userId: string): Promise<SettingsMap> =>
}, {} as SettingsMap)
})
export const updateSettings = cache(async (userId: string, code: string, value: any) => {
export const updateSettings = cache(async (userId: string, code: string, value: string | undefined) => {
return await prisma.setting.upsert({
where: { userId_code: { code, userId } },
update: { value },

151
package-lock.json generated
View File

@@ -20,7 +20,7 @@
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^9.11.0",
"@types/sharp": "^0.31.1",
@@ -2625,6 +2625,24 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
@@ -2691,6 +2709,24 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -2883,6 +2919,24 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
@@ -2920,6 +2974,24 @@
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
@@ -3023,6 +3095,24 @@
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
@@ -3097,6 +3187,24 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
@@ -3121,12 +3229,12 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
@@ -3138,6 +3246,21 @@
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",
@@ -3172,6 +3295,24 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",

View File

@@ -22,7 +22,7 @@
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^9.11.0",
"@types/sharp": "^0.31.1",