Skip to content

Commit 1eb0cd9

Browse files
eabdelmoneimclaude
andauthored
Feat/partner logo branding (#8668)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7dfde2 commit 1eb0cd9

7 files changed

Lines changed: 167 additions & 6 deletions

File tree

apps/dashboard/src/@/api/team/ecosystems.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
105105
export type Partner = {
106106
id: string;
107107
name: string;
108+
imageUrl?: string;
108109
allowlistedDomains: string[];
109110
allowlistedBundleIds: string[];
110111
permissions: [PartnerPermission];

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
33
import { toast } from "sonner";
44
import type { ThirdwebClient } from "thirdweb";
55
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
6+
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
67
import { useDashboardRouter } from "@/lib/DashboardRouter";
78
import { useAddPartner } from "../../hooks/use-add-partner";
89
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
@@ -23,6 +24,8 @@ export function AddPartnerForm({
2324
const teamSlug = params.team_slug as string;
2425
const ecosystemSlug = params.slug as string;
2526

27+
const storageUpload = useDashboardStorageUpload({ client });
28+
2629
const { mutateAsync: addPartner, isPending } = useAddPartner(
2730
{
2831
authToken,
@@ -48,10 +51,23 @@ export function AddPartnerForm({
4851
},
4952
);
5053

51-
const handleSubmit = (
54+
const isUploading = storageUpload.isPending;
55+
56+
const handleSubmit = async (
5257
values: PartnerFormValues,
5358
finalAccessControl: Partner["accessControl"] | null,
5459
) => {
60+
let imageUrl: string | undefined;
61+
if (values.logo) {
62+
try {
63+
const [uri] = await storageUpload.mutateAsync([values.logo]);
64+
imageUrl = uri;
65+
} catch {
66+
toast.error("Failed to upload logo");
67+
return;
68+
}
69+
}
70+
5571
addPartner({
5672
accessControl: finalAccessControl,
5773
allowlistedBundleIds: values.bundleIds
@@ -61,14 +77,15 @@ export function AddPartnerForm({
6177
.split(/,| /)
6278
.filter((d) => d.length > 0),
6379
ecosystem,
80+
imageUrl,
6481
name: values.name,
6582
});
6683
};
6784

6885
return (
6986
<PartnerForm
7087
client={client}
71-
isSubmitting={isPending}
88+
isSubmitting={isPending || isUploading}
7289
onSubmit={handleSubmit}
7390
submitLabel="Add"
7491
/>

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"use client";
22

33
import { zodResolver } from "@hookform/resolvers/zod";
4-
import { PlusIcon, Trash2Icon } from "lucide-react";
5-
import { useId } from "react";
4+
import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
5+
import { useId, useRef } from "react";
66
import { useFieldArray, useForm } from "react-hook-form";
77
import type { ThirdwebClient } from "thirdweb";
88
import type { z } from "zod";
99
import type { Partner } from "@/api/team/ecosystems";
10+
import { Img } from "@/components/blocks/Img";
1011
import { Button } from "@/components/ui/button";
1112
import {
1213
Form,
@@ -17,11 +18,13 @@ import {
1718
FormLabel,
1819
FormMessage,
1920
} from "@/components/ui/form";
21+
import { ImageUpload } from "@/components/ui/image-upload";
2022
import { Input } from "@/components/ui/input";
2123
import { Label } from "@/components/ui/label";
2224
import { Spinner } from "@/components/ui/Spinner";
2325
import { Switch } from "@/components/ui/switch";
2426
import { cn } from "@/lib/utils";
27+
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
2528
import { partnerFormSchema } from "../../constants";
2629
import { AllowedOperationsSection } from "./allowed-operations-section";
2730

@@ -118,6 +121,7 @@ export function PartnerForm({
118121

119122
const accessControlId = useId();
120123
const serverVerifierId = useId();
124+
const fileInputRef = useRef<HTMLInputElement>(null);
121125

122126
return (
123127
<Form {...form}>
@@ -150,6 +154,101 @@ export function PartnerForm({
150154
</FormItem>
151155
)}
152156
/>
157+
<FormField
158+
control={form.control}
159+
name="logo"
160+
render={() => {
161+
const removeLogo = form.watch("removeLogo");
162+
const logoFile = form.watch("logo");
163+
const newFilePreview = logoFile
164+
? URL.createObjectURL(logoFile)
165+
: undefined;
166+
const existingImageUrl =
167+
partner?.imageUrl && !removeLogo
168+
? resolveSchemeWithErrorHandler({
169+
client,
170+
uri: partner.imageUrl,
171+
})
172+
: undefined;
173+
const displayUrl = newFilePreview || existingImageUrl;
174+
175+
return (
176+
<FormItem>
177+
<FormLabel>Partner Logo</FormLabel>
178+
<FormControl>
179+
<div>
180+
<input
181+
ref={fileInputRef}
182+
accept="image/png,image/jpeg,image/webp"
183+
className="hidden"
184+
onChange={(e) => {
185+
const file = e.target.files?.[0];
186+
if (file) {
187+
form.setValue("logo", file, {
188+
shouldValidate: true,
189+
});
190+
form.setValue("removeLogo", false);
191+
}
192+
e.target.value = "";
193+
}}
194+
type="file"
195+
/>
196+
{displayUrl ? (
197+
<div className="relative inline-block">
198+
<Img
199+
alt={partner?.name ?? "Partner logo"}
200+
className="size-20 rounded-md border object-contain object-center"
201+
src={displayUrl}
202+
/>
203+
<Button
204+
aria-label="Remove logo"
205+
className="absolute -top-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
206+
onClick={() => {
207+
form.setValue("logo", undefined);
208+
form.setValue("removeLogo", true);
209+
}}
210+
size="icon"
211+
type="button"
212+
variant="ghost"
213+
>
214+
<XIcon className="size-3" />
215+
</Button>
216+
<Button
217+
aria-label="Change logo"
218+
className="absolute -bottom-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
219+
onClick={() => fileInputRef.current?.click()}
220+
size="icon"
221+
type="button"
222+
variant="ghost"
223+
>
224+
<PencilIcon className="size-3" />
225+
</Button>
226+
</div>
227+
) : (
228+
<ImageUpload
229+
accept="image/png, image/jpeg, image/webp"
230+
className="bg-background"
231+
onUpload={(files) => {
232+
if (files[0]) {
233+
form.setValue("logo", files[0], {
234+
shouldValidate: true,
235+
});
236+
form.setValue("removeLogo", false);
237+
}
238+
}}
239+
/>
240+
)}
241+
</div>
242+
</FormControl>
243+
<FormDescription>
244+
Optional logo for this partner. Used in OTP emails sent to
245+
users authenticating through this partner.
246+
</FormDescription>
247+
<FormMessage />
248+
</FormItem>
249+
);
250+
}}
251+
/>
153252
<FormField
154253
control={form.control}
155254
defaultValue=""

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
33
import { toast } from "sonner";
44
import type { ThirdwebClient } from "thirdweb";
55
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
6+
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
67
import { useDashboardRouter } from "@/lib/DashboardRouter";
78
import { useUpdatePartner } from "../../hooks/use-update-partner";
89
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
@@ -25,6 +26,8 @@ export function UpdatePartnerForm({
2526
const teamSlug = params.team_slug as string;
2627
const ecosystemSlug = params.slug as string;
2728

29+
const storageUpload = useDashboardStorageUpload({ client });
30+
2831
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
2932
{
3033
authToken,
@@ -50,10 +53,31 @@ export function UpdatePartnerForm({
5053
},
5154
);
5255

53-
const handleSubmit = (
56+
const isUploading = storageUpload.isPending;
57+
58+
const handleSubmit = async (
5459
values: PartnerFormValues,
5560
finalAccessControl: Partner["accessControl"] | null,
5661
) => {
62+
// Determine imageUrl based on three states:
63+
// 1. New file uploaded → upload and use new URI
64+
// 2. Explicit removal → send null to clear
65+
// 3. No change → preserve existing partner imageUrl
66+
let imageUrl: string | null | undefined;
67+
if (values.logo) {
68+
try {
69+
const [uri] = await storageUpload.mutateAsync([values.logo]);
70+
imageUrl = uri;
71+
} catch {
72+
toast.error("Failed to upload logo");
73+
return;
74+
}
75+
} else if (values.removeLogo) {
76+
imageUrl = null;
77+
} else {
78+
imageUrl = partner.imageUrl;
79+
}
80+
5781
updatePartner({
5882
accessControl: finalAccessControl,
5983
allowlistedBundleIds: values.bundleIds
@@ -63,6 +87,7 @@ export function UpdatePartnerForm({
6387
.split(/,| /)
6488
.filter((d) => d.length > 0),
6589
ecosystem,
90+
imageUrl,
6691
name: values.name,
6792
partnerId: partner.id,
6893
});
@@ -71,7 +96,7 @@ export function UpdatePartnerForm({
7196
return (
7297
<PartnerForm
7398
client={client}
74-
isSubmitting={isPending}
99+
isSubmitting={isPending || isUploading}
75100
onSubmit={handleSubmit}
76101
partner={partner}
77102
submitLabel="Update"

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,21 @@ const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
154154

155155
export const partnerFormSchema = z
156156
.object({
157+
logo: z
158+
.instanceof(File, {
159+
message: "Please select an image file",
160+
})
161+
.refine(
162+
(file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type),
163+
{
164+
message: "Only PNG, JPG or WEBP images are allowed",
165+
},
166+
)
167+
.refine((file) => file.size <= 500 * 1024, {
168+
message: "Logo size must be less than 500KB",
169+
})
170+
.optional(),
171+
removeLogo: z.boolean().default(false),
157172
accessControl: z
158173
.object({
159174
allowedOperations: z

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Ecosystem, Partner } from "@/api/team/ecosystems";
88
type AddPartnerParams = {
99
ecosystem: Ecosystem;
1010
name: string;
11+
imageUrl?: string;
1112
allowlistedDomains: string[];
1213
allowlistedBundleIds: string[];
1314
accessControl?: Partner["accessControl"] | null;
@@ -37,6 +38,7 @@ export function useAddPartner(
3738
accessControl: params.accessControl ?? undefined,
3839
allowlistedBundleIds: params.allowlistedBundleIds,
3940
allowlistedDomains: params.allowlistedDomains,
41+
imageUrl: params.imageUrl,
4042
name: params.name,
4143
// TODO - remove the requirement for permissions in API endpoint
4244
permissions: ["FULL_CONTROL_V1"],

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type UpdatePartnerParams = {
99
partnerId: string;
1010
ecosystem: Ecosystem;
1111
name: string;
12+
imageUrl?: string | null;
1213
allowlistedDomains: string[];
1314
allowlistedBundleIds: string[];
1415
accessControl?: {
@@ -43,6 +44,7 @@ export function useUpdatePartner(
4344
accessControl: params.accessControl,
4445
allowlistedBundleIds: params.allowlistedBundleIds,
4546
allowlistedDomains: params.allowlistedDomains,
47+
imageUrl: params.imageUrl,
4648
name: params.name,
4749
}),
4850

0 commit comments

Comments
 (0)