Add commissions

This commit is contained in:
2025-12-24 01:08:17 +01:00
parent 296e8a1787
commit e285e7b9af
15 changed files with 964 additions and 1 deletions

View File

@ -6,12 +6,15 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.954.0", "@aws-sdk/client-s3": "^3.954.0",
"@aws-sdk/s3-request-presigner": "^3.954.0", "@aws-sdk/s3-request-presigner": "^3.954.0",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
@ -26,8 +29,10 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"react-hook-form": "^7.69.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.2.1",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",
@ -174,6 +179,8 @@
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="], "@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@ -312,6 +319,8 @@
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
@ -458,6 +467,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@ -682,6 +693,8 @@
"react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="],
"react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
@ -756,6 +769,8 @@
"zeptomatch": ["zeptomatch@2.0.2", "", { "dependencies": { "grammex": "^3.1.10" } }, "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g=="], "zeptomatch": ["zeptomatch@2.0.2", "", { "dependencies": { "grammex": "^3.1.10" } }, "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g=="],
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@ -772,6 +787,8 @@
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],

View File

@ -12,12 +12,15 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.954.0", "@aws-sdk/client-s3": "^3.954.0",
"@aws-sdk/s3-request-presigner": "^3.954.0", "@aws-sdk/s3-request-presigner": "^3.954.0",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
@ -32,8 +35,10 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"react-hook-form": "^7.69.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",

View File

@ -222,3 +222,127 @@ model FileVariant {
@@unique([artworkId, type]) @@unique([artworkId, type])
} }
model Commission {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
}
model CommissionType {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
options CommissionTypeOption[]
extras CommissionTypeExtra[]
customInputs CommissionTypeCustomInput[]
}
model CommissionOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
types CommissionTypeOption[]
}
model CommissionTypeOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
optionId String
priceRange String?
pricePercent Float?
price Float?
type CommissionType @relation(fields: [typeId], references: [id])
option CommissionOption @relation(fields: [optionId], references: [id])
@@unique([typeId, optionId])
}
model CommissionExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
types CommissionTypeExtra[]
}
model CommissionTypeExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
extraId String
priceRange String?
pricePercent Float?
price Float?
type CommissionType @relation(fields: [typeId], references: [id])
extra CommissionExtra @relation(fields: [extraId], references: [id])
@@unique([typeId, extraId])
}
model CommissionCustomInput {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
fieldId String
types CommissionTypeCustomInput[]
}
model CommissionTypeCustomInput {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
customInputId String
inputType String
label String
required Boolean @default(false)
type CommissionType @relation(fields: [typeId], references: [id])
customInput CommissionCustomInput @relation(fields: [customInputId], references: [id])
@@unique([typeId, customInputId])
}
model CommissionRequest {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
}

View File

@ -0,0 +1,28 @@
import { CommissionCard } from "@/components/commissions/CommissionCard";
import { CommissionOrderForm } from "@/components/commissions/CommissionOrderForm";
import { prisma } from "@/lib/prisma";
export default async function CommissionsPage() {
const commissions = await prisma.commissionType.findMany({
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
return (
<div className="container py-10 space-y-10">
<h1 className="text-3xl font-bold">Commission Pricing</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
{commissions.map((commission) => (
<CommissionCard key={commission.id} commission={commission} />
))}
</div>
<hr />
<h2 className="text-2xl font-semibold">Request a Commission</h2>
<CommissionOrderForm types={commissions} />
</div>
);
}

View File

@ -0,0 +1,97 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
type CommissionTypeWithItems = CommissionType & {
options: (CommissionTypeOption & {
option: CommissionOption | null
})[]
extras: (CommissionTypeExtra & {
extra: CommissionExtra | null
})[]
}
export function CommissionCard({ commission }: { commission: CommissionTypeWithItems }) {
// const [open, setOpen] = useState(false)
return (
<div className="flex flex-col h-full">
<Card className="flex flex-col flex-1">
<CardHeader>
<CardTitle className="text-xl font-bold">{commission.name}</CardTitle>
<p className="text-muted-foreground text-sm">{commission.description}</p>
</CardHeader>
<CardContent className="flex flex-col justify-start gap-4">
{/* {examples && examples.length > 0 && (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="text-sm underline text-muted-foreground">
{open ? "Hide Examples" : "See Examples"}
</CollapsibleTrigger>
<CollapsibleContent asChild>
<div className="overflow-hidden transition-all data-[state=closed]:max-h-0 data-[state=open]:max-h-[300px]">
<div className="flex gap-2 mt-2 overflow-x-auto">
{examples.map((src, idx) => (
<Image
key={src + idx}
src={src}
width={100}
height={100}
alt={`${type.name} example ${idx + 1}`}
className="h-24 w-auto rounded border"
/>
))}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)} */}
<div>
<h4 className="font-semibold">Options</h4>
<ul className="pl-4 list-disc">
{commission.options.map((option) => (
<li key={option.id}>
{option.option?.name}:{" "}
{option.price
? `${option.price}`
: option.pricePercent
? `+${option.pricePercent}%`
: option.priceRange
? `${option.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-semibold">Extras</h4>
<ul className="pl-4 list-disc">
{commission.extras.map((extra) => (
<li key={extra.id}>
{extra.extra?.name}:{" "}
{extra.price
? `${extra.price}`
: extra.pricePercent
? `+${extra.pricePercent}%`
: extra.priceRange
? `${extra.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
{/* <div className="flex flex-wrap gap-2">
{commission.extras.map((extra) => (
<Badge variant="outline" key={extra.id}>
{extra.extra?.name}
</Badge>
))}
</div> */}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,304 @@
"use client"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
import { commissionOrderSchema } from "@/schemas/commissionOrder"
import { calculatePriceRange } from "@/utils/calculatePrice"
import { zodResolver } from "@hookform/resolvers/zod"
import Link from "next/link"
import { useMemo, useState } from "react"
import { useForm, useWatch } from "react-hook-form"
import * as z from "zod/v4"
import { FileDropzone } from "./FileDropzone"
type CommissionTypeWithRelations = CommissionType & {
options: (CommissionTypeOption & { option: CommissionOption })[]
extras: (CommissionTypeExtra & { extra: CommissionExtra })[]
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[]
}
type Props = {
types: CommissionTypeWithRelations[]
}
export function CommissionOrderForm({ types }: Props) {
const form = useForm<z.infer<typeof commissionOrderSchema>>({
resolver: zodResolver(commissionOrderSchema),
defaultValues: {
typeId: "",
optionId: "",
extraIds: [],
customerName: "",
customerEmail: "",
message: "",
},
})
const [files, setFiles] = useState<File[]>([])
const typeId = useWatch({ control: form.control, name: "typeId" })
const optionId = useWatch({ control: form.control, name: "optionId" })
const extraIds = useWatch({ control: form.control, name: "extraIds" })
const selectedType = useMemo(
() => types.find((t) => t.id === typeId),
[types, typeId]
)
const selectedOption = useMemo(
() => selectedType?.options.find((o) => o.optionId === optionId),
[selectedType, optionId]
)
const selectedExtras = useMemo(
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
[selectedType, extraIds]
)
const [minPrice, maxPrice] = useMemo(() => {
return calculatePriceRange(selectedOption, selectedExtras)
}, [selectedOption, selectedExtras])
async function onSubmit(values: z.infer<typeof commissionOrderSchema>) {
const { customFields, ...rest } = values
console.log("Submit:", { ...rest, customFields, files })
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="typeId"
render={({ field }) => (
<FormItem>
<FormLabel>Choose a commission type</FormLabel>
<FormControl>
<div className="flex flex-wrap gap-2">
{types.map((type) => (
<Button
key={type.id}
type="button"
variant={field.value === type.id ? "default" : "outline"}
onClick={() => field.onChange(type.id)}
>
{type.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{selectedType && (
<>
<FormField
control={form.control}
name="optionId"
render={({ field }) => (
<FormItem>
<FormLabel>Base Option</FormLabel>
<FormControl>
<div className="space-y-1">
{selectedType.options.map((opt) => (
<label key={opt.id} className="flex items-center gap-2">
<input
type="radio"
checked={field.value === opt.optionId}
value={opt.optionId}
onChange={() => field.onChange(opt.optionId)}
/>
{opt.option.name}
</label>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="extraIds"
render={({ field }) => (
<FormItem>
<FormLabel>Extras</FormLabel>
<FormControl>
<div className="space-y-1">
{selectedType?.extras.map((ext) => (
<label key={ext.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={field.value?.includes(ext.extraId) ?? false}
value={ext.extraId}
onChange={(e) => {
const checked = e.target.checked
const newSet = new Set(field.value ?? [])
if (checked) {
newSet.add(ext.extraId)
} else {
newSet.delete(ext.extraId)
}
field.onChange(Array.from(newSet))
}}
/>
{ext.extra.name}
</label>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="customerName"
render={({ field }) => (
<FormItem>
<FormLabel>Your Name</FormLabel>
<FormControl>
<Input placeholder="Jane Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="jane@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Project Details</FormLabel>
<FormControl>
<Textarea
placeholder="Describe what youd like drawn..."
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{selectedType && selectedType.customInputs.length > 0 && (
<div className="space-y-4">
{selectedType.customInputs.map((input) => {
const name = `customFields.${input.customInput.name}`
return (
<FormField
key={input.id}
control={form.control}
name={name as `customFields.${string}`}
render={({ field }) => (
<FormItem>
<FormLabel>{input.label}</FormLabel>
<FormControl>
{input.inputType === "textarea" ? (
<Textarea {...field} rows={3} />
) : input.inputType === "number" ? (
<Input type="number" {...field} />
) : input.inputType === "checkbox" ? (
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
) : input.inputType === "date" ? (
<Input type="date" {...field} />
) : input.inputType === "select" ? (
// Placeholder select populate with options if needed
<select
{...field}
className="border rounded px-2 py-1 w-full"
>
<option value="">Please select</option>
<option value="example1">Example 1</option>
<option value="example2">Example 2</option>
</select>
) : (
<Input {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
})}
</div>
)}
<FormItem>
<FormLabel>Reference Images</FormLabel>
<FormControl>
<div className="space-y-2">
<FileDropzone onFilesSelected={setFiles} />
{files.length > 0 && (
<ul className="list-disc pl-4 text-sm text-muted-foreground">
{files.map((file, i) => (
<li key={i}>{file.name}</li>
))}
</ul>
)}
</div>
</FormControl>
</FormItem>
<div className="text-lg font-semibold">
Estimated Price:{" "}
{minPrice === maxPrice
? `${minPrice.toFixed(2)}`
: `${minPrice.toFixed(2)} ${maxPrice.toFixed(2)}`}
</div>
<div className="text-muted-foreground">
By submitting this form, you agree to our{" "}
<Link href="/tos" className="underline">
Terms of Service
</Link>
.
</div>
<Button type="submit" disabled={!form.formState.isValid}>
Submit Request
</Button>
</form>
</Form>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { cn } from "@/lib/utils"
import { useCallback, useRef, useState } from "react"
export function FileDropzone({
onFilesSelected,
}: {
onFilesSelected: (files: File[]) => void
}) {
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement | null>(null)
const handleFiles = (files: FileList | null) => {
if (files) {
onFilesSelected(Array.from(files))
}
}
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = () => {
setIsDragging(false)
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files)
},
[onFilesSelected]
)
return (
<div
onClick={() => inputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"w-full border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors",
isDragging ? "border-primary bg-muted" : "border-muted-foreground/30"
)}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleChange}
className="hidden"
/>
<p className="text-sm text-muted-foreground">
Drag & drop images here or click to upload
</p>
</div>
)
}

View File

@ -11,6 +11,7 @@ const links = [
{ href: "/", label: "Home" }, { href: "/", label: "Home" },
{ href: "/artworks", label: "Portfolio" }, { href: "/artworks", label: "Portfolio" },
{ href: "/artworks/animalstudies", label: "Animal Studies" }, { href: "/artworks/animalstudies", label: "Animal Studies" },
{ href: "/commissions", label: "Commissions" },
// { href: "/portfolio/artfight", label: "Artfight" }, // { href: "/portfolio/artfight", label: "Artfight" },
// { href: "/portfolio/minis", label: "Miniatures" }, // { href: "/portfolio/minis", label: "Miniatures" },
// { href: "/commissions", label: "Commissions" }, // { href: "/commissions", label: "Commissions" },

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

167
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,11 @@
import * as z from "zod/v4"
export const commissionOrderSchema = z.object({
typeId: z.string().min(1, "Please select a type"),
optionId: z.string().min(1, "Please choose a base option"),
extraIds: z.array(z.string()).optional(),
customFields: z.record(z.string(), z.any()).optional(),
customerName: z.string().min(2, "Enter your name"),
customerEmail: z.email("Invalid email"),
message: z.string().min(5, "Please describe what you want"),
})

View File

@ -0,0 +1,48 @@
type PriceSource = {
price?: number | null
pricePercent?: number | null
priceRange?: string | null
}
export function calculatePrice(source: PriceSource, base: number): number {
if (source.price != null) return source.price
if (source.pricePercent != null) return base * (source.pricePercent / 100)
if (source.priceRange) {
const parts = source.priceRange.split("").map(Number)
const max = Math.max(...parts)
return isNaN(max) ? 0 : max
}
return 0
}
export function calculatePriceRange(
baseSource: PriceSource | undefined,
extras: PriceSource[]
): [number, number] {
if (!baseSource) return [0, 0]
const base = calculatePrice(baseSource, 0)
let minExtra = 0
let maxExtra = 0
for (const extra of extras) {
if (extra.price != null) {
minExtra += extra.price
maxExtra += extra.price
} else if (extra.pricePercent != null) {
const val = base * (extra.pricePercent / 100)
minExtra += val
maxExtra += val
} else if (extra.priceRange) {
const [minStr, maxStr] = extra.priceRange.split("")
const min = Number(minStr)
const max = Number(maxStr)
if (!isNaN(min)) minExtra += min
if (!isNaN(max)) maxExtra += max
}
}
return [base + minExtra, base + maxExtra]
}