Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Program Resources #2149

Merged
merged 28 commits into from
Mar 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
03eba73
WIP program owner Resources tab
TWilson023 Mar 11, 2025
05d1119
Update page-client.tsx
TWilson023 Mar 12, 2025
8829adc
WIP program resources
TWilson023 Mar 12, 2025
b0c81e4
WIP logos management
TWilson023 Mar 12, 2025
0f7530f
Update add-program-resource.ts
TWilson023 Mar 12, 2025
ed7b212
Add resource deletion
TWilson023 Mar 12, 2025
6551a54
Refactor deleting
TWilson023 Mar 12, 2025
8eef9b8
Add loading state
TWilson023 Mar 12, 2025
0bf33fd
Add color management
TWilson023 Mar 12, 2025
f295c79
Add additional file management
TWilson023 Mar 12, 2025
3e1eed6
Update page-client.tsx
TWilson023 Mar 12, 2025
3ac8052
Add downloads
TWilson023 Mar 12, 2025
0ae7867
Add resources to partners dashboard
TWilson023 Mar 13, 2025
6c209ac
Add resources to referral embed
TWilson023 Mar 13, 2025
08465a7
Merge branch 'main' into program-resources
TWilson023 Mar 13, 2025
fe4e349
Update use-copy-to-clipboard.tsx
TWilson023 Mar 13, 2025
d53cc3f
Update client.ts
TWilson023 Mar 13, 2025
f64f424
Dark mode fixes
TWilson023 Mar 13, 2025
79877d9
Merge branch 'main' into program-resources
steven-tey Mar 13, 2025
f202ca6
Merge branch 'main' into program-resources
steven-tey Mar 14, 2025
c8085f2
remove ProgramResourceType enum
steven-tey Mar 14, 2025
26c4f87
Merge branch 'main' into program-resources
steven-tey Mar 14, 2025
787d605
createId for programResource
steven-tey Mar 14, 2025
022b747
download vs open in new tab
steven-tey Mar 14, 2025
7846cdc
Download logos
TWilson023 Mar 14, 2025
73f9105
Update loading state
TWilson023 Mar 14, 2025
ed2307a
Merge branch 'main' into program-resources
steven-tey Mar 14, 2025
f7f080c
Add .zip support
TWilson023 Mar 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { withPartnerProfile } from "@/lib/auth/partner";
import { programResourcesSchema } from "@/lib/zod/schemas/program-resources";
import { NextResponse } from "next/server";

// GET /api/partner-profile/programs/[programId]/resources – get resources for an enrolled program
export const GET = withPartnerProfile(async ({ partner, params }) => {
const { program } = await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId: params.programId,
});

const resources = programResourcesSchema.parse(
program?.resources ?? {
logos: [],
colors: [],
files: [],
},
);

return NextResponse.json(resources);
});
35 changes: 35 additions & 0 deletions apps/web/app/api/programs/[programId]/resources/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
import { withWorkspace } from "@/lib/auth";
import { programResourcesSchema } from "@/lib/zod/schemas/program-resources";
import { prisma } from "@dub/prisma";
import { NextResponse } from "next/server";

// GET /api/programs/[programId]/resources - get resources for a program
export const GET = withWorkspace(async ({ workspace, params }) => {
const { programId } = params;

await getProgramOrThrow({
workspaceId: workspace.id,
programId,
});

const program = await prisma.program.findUnique({
where: {
id: programId,
workspaceId: workspace.id,
},
select: {
resources: true,
},
});

const resources = programResourcesSchema.parse(
program?.resources ?? {
logos: [],
colors: [],
files: [],
},
);

return NextResponse.json(resources);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"use client";

import { addProgramResourceAction } from "@/lib/actions/partners/program-resources/add-program-resource";
import useProgramResources from "@/lib/swr/use-program-resources";
import useWorkspace from "@/lib/swr/use-workspace";
import { Button, Modal } from "@dub/ui";
import { cn } from "@dub/utils";
import { useAction } from "next-safe-action/hooks";
import { useParams } from "next/navigation";
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { HexColorPicker } from "react-colorful";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";

type AddColorModalProps = {
showAddColorModal: boolean;
setShowAddColorModal: Dispatch<SetStateAction<boolean>>;
};

const colorFormSchema = z.object({
name: z.string().min(1, "Name is required"),
color: z.string().min(1, "Color is required"),
});

type ColorFormData = z.infer<typeof colorFormSchema>;

function AddColorModal(props: AddColorModalProps) {
return (
<Modal
showModal={props.showAddColorModal}
setShowModal={props.setShowAddColorModal}
>
<AddColorModalInner {...props} />
</Modal>
);
}

const DEFAULT_COLORS = ["#dc2626", "#84cc16", "#14b8a6", "#0ea5e9", "#d946ef"];

function AddColorModalInner({ setShowAddColorModal }: AddColorModalProps) {
const { programId } = useParams();
const { id: workspaceId } = useWorkspace();
const { mutate } = useProgramResources({
workspaceId: workspaceId!,
programId: programId as string,
});
const [hexInputValue, setHexInputValue] = useState("#000000");

const {
register,
handleSubmit,
setValue,
watch,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<ColorFormData>({
defaultValues: {
name: "",
color: DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)],
},
});

const selectedColor = watch("color");

// Keep hex input in sync with form value
useEffect(() => setHexInputValue(selectedColor), [selectedColor]);

const handleHexInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setHexInputValue(value);

// Only update form value if it's a valid hex color
if (/^#?[0-9A-F]{6}$/i.test(value)) {
setValue("color", value.startsWith("#") ? value : `#${value}`);
}
};

const { executeAsync } = useAction(addProgramResourceAction, {
onSuccess: () => {
mutate();
setShowAddColorModal(false);
toast.success("Color added successfully!");
},
onError({ error }) {
if (error.serverError) {
setError("root.serverError", {
message: error.serverError,
});
toast.error(error.serverError);
} else {
toast.error("Failed to add color");
}
},
});

return (
<>
<div className="space-y-2 border-b border-neutral-200 p-4 sm:p-6">
<h3 className="text-lg font-medium leading-none">Add color</h3>
</div>

<form
onSubmit={handleSubmit(async (data: ColorFormData) => {
await executeAsync({
workspaceId: workspaceId!,
programId: programId as string,
name: data.name,
resourceType: "color",
color: data.color,
});
})}
>
<div className="bg-neutral-50 p-4 sm:p-6">
<div className="space-y-4">
<div>
<span className="mb-1 block text-sm font-medium text-neutral-700">
Color
</span>
<div className="flex justify-center [&_.react-colorful]:h-[180px] [&_.react-colorful]:w-full">
<HexColorPicker
color={selectedColor}
onChange={(color) =>
setValue("color", color, { shouldDirty: true })
}
/>
</div>
</div>

<label className="block">
<span className="mb-1 block text-sm font-medium text-neutral-700">
Hex
</span>
<input
type="text"
value={hexInputValue}
onChange={handleHexInputChange}
className={cn(
"block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm",
!/^#[0-9A-F]{6}$/i.test(hexInputValue) &&
"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500",
)}
placeholder="#000000"
/>
<input
type="hidden"
{...register("color", { required: "Please select a color" })}
/>
{errors.color && (
<p className="mt-1 text-xs text-red-600">
{errors.color.message}
</p>
)}
</label>

<label className="block">
<span className="mb-1 block text-sm font-medium text-neutral-700">
Color name
</span>
<input
id="name"
type="text"
className={cn(
"block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm",
errors.name &&
"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500",
)}
{...register("name", { required: "Color name is required" })}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-600">
{errors.name.message}
</p>
)}
</label>
</div>
</div>

<div className="flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6">
<Button
onClick={() => setShowAddColorModal(false)}
variant="secondary"
text="Cancel"
className="h-8 w-fit px-3"
type="button"
/>
<Button
type="submit"
autoFocus
loading={isSubmitting || isSubmitSuccessful}
text="Add Color"
className="h-8 w-fit px-3"
/>
</div>
</form>
</>
);
}

export function useAddColorModal() {
const [showAddColorModal, setShowAddColorModal] = useState(false);

const AddColorModalCallback = useCallback(() => {
return (
<AddColorModal
showAddColorModal={showAddColorModal}
setShowAddColorModal={setShowAddColorModal}
/>
);
}, [showAddColorModal, setShowAddColorModal]);

return useMemo(
() => ({
setShowAddColorModal,
AddColorModal: AddColorModalCallback,
}),
[setShowAddColorModal, AddColorModalCallback],
);
}
Loading