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..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,12 +1,13 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon, Trash2Icon } 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"; 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"; @@ -118,6 +121,7 @@ export function PartnerForm({ const accessControlId = useId(); const serverVerifierId = useId(); + const fileInputRef = useRef(null); return (
@@ -150,6 +154,101 @@ export function PartnerForm({ )} /> + { + const removeLogo = form.watch("removeLogo"); + const logoFile = form.watch("logo"); + const newFilePreview = logoFile + ? URL.createObjectURL(logoFile) + : undefined; + const existingImageUrl = + partner?.imageUrl && !removeLogo + ? resolveSchemeWithErrorHandler({ + client, + uri: partner.imageUrl, + }) + : undefined; + const displayUrl = newFilePreview || existingImageUrl; + + return ( + + Partner Logo + +
+ { + 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); + } + }} + /> + )} +
+
+ + Optional logo for this partner. Used in OTP emails sent to + users authenticating through this partner. + + +
+ ); + }} + /> { + // 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]); + imageUrl = uri; + } catch { + toast.error("Failed to upload logo"); + return; + } + } else if (values.removeLogo) { + imageUrl = null; + } else { + imageUrl = partner.imageUrl; + } + updatePartner({ accessControl: finalAccessControl, allowlistedBundleIds: values.bundleIds @@ -63,6 +87,7 @@ export function UpdatePartnerForm({ .split(/,| /) .filter((d) => d.length > 0), ecosystem, + imageUrl, name: values.name, partnerId: partner.id, }); @@ -71,7 +96,7 @@ export function UpdatePartnerForm({ return ( ["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 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, }),