From 16d2baf044c160faf46e92370f9c7bb4adb1dc64 Mon Sep 17 00:00:00 2001 From: Eiman Date: Tue, 10 Feb 2026 14:47:55 -0600 Subject: [PATCH 1/3] [Dashboard] feat: add partner logo upload to ecosystem partner forms Add optional logo upload field to partner create/update forms so ecosystem partners can have distinct branding in OTP emails. - Add imageUrl to Partner type - Add imageUrl to add/update partner mutation hooks - Add ImageUpload component to partner form with existing logo preview - Upload logo via useDashboardStorageUpload before sending URL to API - Add logo File field to partner form Zod schema Co-Authored-By: Claude Opus 4.6 --- apps/dashboard/src/@/api/team/ecosystems.ts | 1 + .../client/add-partner-form.client.tsx | 21 +++++++- .../components/client/partner-form.client.tsx | 48 +++++++++++++++++++ .../client/update-partner-form.client.tsx | 21 +++++++- .../(active)/configuration/constants.ts | 8 ++++ .../configuration/hooks/use-add-partner.ts | 2 + .../configuration/hooks/use-update-partner.ts | 2 + 7 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/@/api/team/ecosystems.ts b/apps/dashboard/src/@/api/team/ecosystems.ts index c0aed4fd47e..acb1adeff95 100644 --- a/apps/dashboard/src/@/api/team/ecosystems.ts +++ b/apps/dashboard/src/@/api/team/ecosystems.ts @@ -105,6 +105,7 @@ type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1"; export type Partner = { id: string; name: string; + imageUrl?: string; allowlistedDomains: string[]; allowlistedBundleIds: string[]; permissions: [PartnerPermission]; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx index 9d3b97588d6..fbe748d8ef1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx @@ -3,6 +3,7 @@ import { useParams } from "next/navigation"; import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; import type { Ecosystem, Partner } from "@/api/team/ecosystems"; +import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { useAddPartner } from "../../hooks/use-add-partner"; import { PartnerForm, type PartnerFormValues } from "./partner-form.client"; @@ -23,6 +24,8 @@ export function AddPartnerForm({ const teamSlug = params.team_slug as string; const ecosystemSlug = params.slug as string; + const storageUpload = useDashboardStorageUpload({ client }); + const { mutateAsync: addPartner, isPending } = useAddPartner( { authToken, @@ -48,10 +51,23 @@ export function AddPartnerForm({ }, ); - const handleSubmit = ( + const isUploading = storageUpload.isPending; + + const handleSubmit = async ( values: PartnerFormValues, finalAccessControl: Partner["accessControl"] | null, ) => { + let imageUrl: string | undefined; + if (values.logo) { + try { + const [uri] = await storageUpload.mutateAsync([values.logo]); + imageUrl = uri; + } catch { + toast.error("Failed to upload logo"); + return; + } + } + addPartner({ accessControl: finalAccessControl, allowlistedBundleIds: values.bundleIds @@ -61,6 +77,7 @@ export function AddPartnerForm({ .split(/,| /) .filter((d) => d.length > 0), ecosystem, + imageUrl, name: values.name, }); }; @@ -68,7 +85,7 @@ export function AddPartnerForm({ return ( diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx index c4a60b80412..94d185a8564 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx @@ -7,6 +7,7 @@ import { useFieldArray, useForm } from "react-hook-form"; import type { ThirdwebClient } from "thirdweb"; import type { z } from "zod"; import type { Partner } from "@/api/team/ecosystems"; +import { Img } from "@/components/blocks/Img"; import { Button } from "@/components/ui/button"; import { Form, @@ -17,11 +18,13 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { ImageUpload } from "@/components/ui/image-upload"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/Spinner"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; import { partnerFormSchema } from "../../constants"; import { AllowedOperationsSection } from "./allowed-operations-section"; @@ -150,6 +153,51 @@ export function PartnerForm({ )} /> + { + const existingImageUrl = partner?.imageUrl + ? resolveSchemeWithErrorHandler({ + client, + uri: partner.imageUrl, + }) + : undefined; + + return ( + + Partner Logo + +
+ {existingImageUrl && !form.getValues("logo") && ( + {partner?.name + )} + { + if (files[0]) { + form.setValue("logo", files[0], { + shouldValidate: true, + }); + } + }} + /> +
+
+ + Optional logo for this partner. Used in OTP emails sent to + users authenticating through this partner. + + +
+ ); + }} + /> { + let imageUrl: string | undefined; + if (values.logo) { + try { + const [uri] = await storageUpload.mutateAsync([values.logo]); + imageUrl = uri; + } catch { + toast.error("Failed to upload logo"); + return; + } + } + updatePartner({ accessControl: finalAccessControl, allowlistedBundleIds: values.bundleIds @@ -63,6 +79,7 @@ export function UpdatePartnerForm({ .split(/,| /) .filter((d) => d.length > 0), ecosystem, + imageUrl, name: values.name, partnerId: partner.id, }); @@ -71,7 +88,7 @@ export function UpdatePartnerForm({ return ( file.size <= 500 * 1024, { + message: "Logo size must be less than 500KB", + }) + .optional(), accessControl: z .object({ allowedOperations: z diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts index e00ee16b2c6..adefa0ef0a0 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts @@ -8,6 +8,7 @@ import type { Ecosystem, Partner } from "@/api/team/ecosystems"; type AddPartnerParams = { ecosystem: Ecosystem; name: string; + imageUrl?: string; allowlistedDomains: string[]; allowlistedBundleIds: string[]; accessControl?: Partner["accessControl"] | null; @@ -37,6 +38,7 @@ export function useAddPartner( accessControl: params.accessControl ?? undefined, allowlistedBundleIds: params.allowlistedBundleIds, allowlistedDomains: params.allowlistedDomains, + imageUrl: params.imageUrl, name: params.name, // TODO - remove the requirement for permissions in API endpoint permissions: ["FULL_CONTROL_V1"], diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts index ba7b1b98373..f32c6455aa3 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts @@ -9,6 +9,7 @@ type UpdatePartnerParams = { partnerId: string; ecosystem: Ecosystem; name: string; + imageUrl?: string | null; allowlistedDomains: string[]; allowlistedBundleIds: string[]; accessControl?: { @@ -43,6 +44,7 @@ export function useUpdatePartner( accessControl: params.accessControl, allowlistedBundleIds: params.allowlistedBundleIds, allowlistedDomains: params.allowlistedDomains, + imageUrl: params.imageUrl, name: params.name, }), From a93f2515aefac8561e1025f9bb1a6faebf999665 Mon Sep 17 00:00:00 2001 From: Eiman Date: Tue, 10 Feb 2026 15:05:38 -0600 Subject: [PATCH 2/3] [Dashboard] fix: improve partner logo handling with removal support and validation - Add file type validation (PNG, JPG, WEBP only) to logo upload schema - Add removeLogo boolean to form schema for explicit logo removal - Add X button on existing logo preview to allow clearing the logo - Fix update form to handle 3 states: new upload, explicit removal, preserve existing - Reset removeLogo when a new file is uploaded Co-Authored-By: Claude Opus 4.6 --- .../components/client/partner-form.client.tsx | 45 +++++++++++++------ .../client/update-partner-form.client.tsx | 10 ++++- .../(active)/configuration/constants.ts | 7 +++ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx index 94d185a8564..90eb532fff9 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx @@ -1,7 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon, Trash2Icon } from "lucide-react"; +import { PlusIcon, Trash2Icon, XIcon } from "lucide-react"; import { useId } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import type { ThirdwebClient } from "thirdweb"; @@ -157,24 +157,42 @@ export function PartnerForm({ control={form.control} name="logo" render={() => { - const existingImageUrl = partner?.imageUrl - ? resolveSchemeWithErrorHandler({ - client, - uri: partner.imageUrl, - }) - : undefined; + const removeLogo = form.watch("removeLogo"); + const hasNewFile = !!form.getValues("logo"); + const existingImageUrl = + partner?.imageUrl && !removeLogo + ? resolveSchemeWithErrorHandler({ + client, + uri: partner.imageUrl, + }) + : undefined; + const showExistingLogo = !!existingImageUrl && !hasNewFile; return ( Partner Logo
- {existingImageUrl && !form.getValues("logo") && ( - {partner?.name + {showExistingLogo && ( +
+ {partner?.name + +
)} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx index ccc07b99583..b118215f92d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx @@ -59,7 +59,11 @@ export function UpdatePartnerForm({ values: PartnerFormValues, finalAccessControl: Partner["accessControl"] | null, ) => { - let imageUrl: string | undefined; + // Determine imageUrl based on three states: + // 1. New file uploaded → upload and use new URI + // 2. Explicit removal → send null to clear + // 3. No change → preserve existing partner imageUrl + let imageUrl: string | null | undefined; if (values.logo) { try { const [uri] = await storageUpload.mutateAsync([values.logo]); @@ -68,6 +72,10 @@ export function UpdatePartnerForm({ toast.error("Failed to upload logo"); return; } + } else if (values.removeLogo) { + imageUrl = null; + } else { + imageUrl = partner.imageUrl; } updatePartner({ diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts index 94aa7515c7f..87e1d89125b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts @@ -158,10 +158,17 @@ export const partnerFormSchema = z .instanceof(File, { message: "Please select an image file", }) + .refine( + (file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type), + { + message: "Only PNG, JPG or WEBP images are allowed", + }, + ) .refine((file) => file.size <= 500 * 1024, { message: "Logo size must be less than 500KB", }) .optional(), + removeLogo: z.boolean().default(false), accessControl: z .object({ allowedOperations: z From f64d114856212e41f4718f1a349ba4824e988417 Mon Sep 17 00:00:00 2001 From: Eiman Date: Tue, 10 Feb 2026 16:38:32 -0600 Subject: [PATCH 3/3] [Dashboard] fix: hide drag-and-drop when logo exists, add pencil to change - Show drag-and-drop only when no logo is set - When logo exists, show image with X (remove) and pencil (change) overlays - X button uses ghost variant with bg-background instead of destructive red - New file upload shows preview immediately via URL.createObjectURL Co-Authored-By: Claude Opus 4.6 --- .../components/client/partner-form.client.tsx | 76 +++++++++++++------ 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx index 90eb532fff9..73308c8d362 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon, Trash2Icon, XIcon } from "lucide-react"; -import { useId } from "react"; +import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; +import { useId, useRef } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import type { ThirdwebClient } from "thirdweb"; import type { z } from "zod"; @@ -121,6 +121,7 @@ export function PartnerForm({ const accessControlId = useId(); const serverVerifierId = useId(); + const fileInputRef = useRef(null); return (
@@ -158,7 +159,10 @@ export function PartnerForm({ name="logo" render={() => { const removeLogo = form.watch("removeLogo"); - const hasNewFile = !!form.getValues("logo"); + const logoFile = form.watch("logo"); + const newFilePreview = logoFile + ? URL.createObjectURL(logoFile) + : undefined; const existingImageUrl = partner?.imageUrl && !removeLogo ? resolveSchemeWithErrorHandler({ @@ -166,46 +170,74 @@ export function PartnerForm({ uri: partner.imageUrl, }) : undefined; - const showExistingLogo = !!existingImageUrl && !hasNewFile; + const displayUrl = newFilePreview || existingImageUrl; return ( Partner Logo -
- {showExistingLogo && ( -
+
+ { + const file = e.target.files?.[0]; + if (file) { + form.setValue("logo", file, { + shouldValidate: true, + }); + form.setValue("removeLogo", false); + } + e.target.value = ""; + }} + type="file" + /> + {displayUrl ? ( +
{partner?.name +
+ ) : ( + { + if (files[0]) { + form.setValue("logo", files[0], { + shouldValidate: true, + }); + form.setValue("removeLogo", false); + } + }} + /> )} - { - if (files[0]) { - form.setValue("logo", files[0], { - shouldValidate: true, - }); - form.setValue("removeLogo", false); - } - }} - />