Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -4,11 +4,14 @@ import {
RichTextArea,
RichTextProvider,
RichTextToolbar,
Sparkle3,
Tooltip,
Trophy,
useMediaQuery,
} from "@dub/ui";
import { Lock } from "@dub/ui/icons";
import { cn } from "@dub/utils";
import { RotateCcw } from "lucide-react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { toast } from "sonner";

Expand All @@ -18,21 +21,36 @@ export type EmailContent = {
body: string;
};

const INVITE_GENERATION_STEPS = ["Analyzing profile", "Constructing invite"];

export function InviteEmailPreview({
emailContent,
defaultEmailContent,
fromAddress,
onSave,
onGenerate,
onReset,
onEditingChange,
isSaving = false,
isGenerating = false,
showReset = false,
generateDisabledTooltip,
generationAvatar,
}: {
emailContent: EmailContent;
defaultEmailContent: EmailContent;
fromAddress: string;
// Persists the sanitized content; returning false keeps the edit mode open
onSave: (content: EmailContent) => Promise<boolean> | boolean;
onGenerate?: () => Promise<void>;
onReset?: () => void;
onEditingChange?: (isEditing: boolean) => void;
isSaving?: boolean;
isGenerating?: boolean;
showReset?: boolean;
// When set, disables the generate button and explains why
generateDisabledTooltip?: string;
generationAvatar?: ReactNode;
}) {
const { isMobile } = useMediaQuery();
const richTextRef = useRef<{ setContent: (content: any) => void }>(null);
Expand Down Expand Up @@ -116,13 +134,42 @@ export function InviteEmailPreview({
/>
</>
) : (
<Button
type="button"
variant="secondary"
text="Edit"
className="h-7 w-fit rounded-lg px-2.5 text-sm"
onClick={handleStartEditing}
/>
<>
{onGenerate && (
<Button
type="button"
variant="secondary"
text="Personalize"
icon={<Sparkle3 className="size-3.5" />}
className="h-7 w-fit rounded-lg px-2.5 text-sm"
onClick={onGenerate}
loading={isGenerating}
disabled={isGenerating}
disabledTooltip={generateDisabledTooltip}
/>
)}
{showReset && onReset && (
<Tooltip content="Reset to the default invite">
<button
type="button"
aria-label="Reset to the default invite"
className="flex size-7 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onReset}
disabled={isGenerating}
>
<RotateCcw className="size-3.5" />
</button>
</Tooltip>
)}
<Button
type="button"
variant="secondary"
text="Edit"
className="h-7 w-fit rounded-lg px-2.5 text-sm"
onClick={handleStartEditing}
disabled={isGenerating}
/>
</>
)}
</div>
</div>
Expand Down Expand Up @@ -248,23 +295,29 @@ export function InviteEmailPreview({
</p>
</div>
<div className="grid grid-cols-1 gap-3 p-4 pb-6">
<h3 className="font-medium text-neutral-900">
{displayContent.title}
</h3>
<div className="prose prose-sm prose-neutral max-w-none text-neutral-500">
<RichTextProvider
key={`preview-${displayContent.body}`}
features={["bold", "italic", "links"]}
style="condensed"
markdown
editable={false}
initialValue={displayContent.body}
editorClassName="text-sm leading-6 text-neutral-500 [&_a]:font-semibold [&_a]:text-neutral-800 [&_a]:underline [&_a]:underline-offset-2 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:marker:text-neutral-400"
>
<RichTextArea />
</RichTextProvider>
</div>
<InvitePreviewProgramDetails />
{isGenerating ? (
<InviteGenerationProgress avatar={generationAvatar} />
) : (
<>
<h3 className="font-medium text-neutral-900">
{displayContent.title}
</h3>
<div className="prose prose-sm prose-neutral max-w-none text-neutral-500">
<RichTextProvider
key={`preview-${displayContent.body}`}
features={["bold", "italic", "links"]}
style="condensed"
markdown
editable={false}
initialValue={displayContent.body}
editorClassName="text-sm leading-6 text-neutral-500 [&_a]:font-semibold [&_a]:text-neutral-800 [&_a]:underline [&_a]:underline-offset-2 [&_p]:my-0 [&_p:not(:last-child)]:mb-4 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:marker:text-neutral-400"
>
<RichTextArea />
</RichTextProvider>
</div>
<InvitePreviewProgramDetails />
</>
)}
</div>
</>
)}
Expand All @@ -273,6 +326,51 @@ export function InviteEmailPreview({
);
}

function InviteGenerationProgress({ avatar }: { avatar?: ReactNode }) {
const [stepIndex, setStepIndex] = useState(0);

useEffect(() => {
const intervalId = window.setInterval(() => {
setStepIndex((current) =>
Math.min(current + 1, INVITE_GENERATION_STEPS.length - 1),
);
}, 1600);

return () => window.clearInterval(intervalId);
}, []);

return (
<div className="flex min-h-80 items-center justify-center rounded-lg border border-neutral-200 bg-white px-6 py-12">
<div className="flex flex-col items-center">
{avatar ? (
<div className="overflow-hidden rounded-full shadow-md">{avatar}</div>
) : (
<div className="flex size-14 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 shadow-sm">
<Sparkle3 className="size-5 animate-pulse text-neutral-500" />
</div>
)}

<div
key={INVITE_GENERATION_STEPS[stepIndex]}
className="animate-text-appear mt-6 flex items-center gap-2 text-sm font-semibold text-neutral-900"
>
<Sparkle3 className="size-3.5 shrink-0 animate-pulse text-neutral-400" />
<span
className="animate-gradient-move bg-clip-text text-transparent"
style={{
backgroundImage:
"linear-gradient(90deg, #171717 0%, #171717 35%, #a3a3a3 50%, #171717 65%, #171717 100%)",
backgroundSize: "200% 100%",
}}
>
{INVITE_GENERATION_STEPS[stepIndex]}
</span>
</div>
</div>
</div>
);
}

function InvitePreviewProgramDetails() {
return (
<div className="mt-4 rounded-[10px] border border-blue-200 bg-blue-50 p-4 pt-3">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { generatePartnerNetworkInviteEmailAction } from "@/lib/actions/partners/generate-partner-network-invite-email";
import { invitePartnerFromNetworkAction } from "@/lib/actions/partners/invite-partner-from-network";
import { getProgramNetworkInviteEmailDefaults } from "@/lib/network/get-program-network-invite-email-defaults";
import useProgram from "@/lib/swr/use-program";
Expand All @@ -24,7 +25,7 @@ interface InviteNetworkPartnerSheetProps {
// Customized email content is owned by the parent so it survives the sheet
// being closed and reopened (it's never persisted to the server)
emailContent?: EmailContent | null;
onEmailContentChange?: (content: EmailContent) => void;
onEmailContentChange?: (content: EmailContent | null) => void;
onSuccess: () => void;
onInviteLimitError?: () => void;
}
Expand All @@ -44,7 +45,11 @@ function InviteNetworkPartnerSheetContent({
onInviteLimitError,
}: InviteNetworkPartnerSheetProps) {
const { program } = useProgram();
const { id: workspaceId } = useWorkspace();
const {
exceededAI,
id: workspaceId,
mutate: mutateWorkspace,
} = useWorkspace();

const defaultEmailContent = useMemo<EmailContent>(() => {
return getProgramNetworkInviteEmailDefaults({
Expand All @@ -57,6 +62,9 @@ function InviteNetworkPartnerSheetContent({
const [emailContent, setEmailContent] = useState<EmailContent | null>(
initialEmailContent ?? null,
);
const [showResetEmail, setShowResetEmail] = useState(
Boolean(initialEmailContent),
);

const {
register,
Expand Down Expand Up @@ -94,6 +102,15 @@ function InviteNetworkPartnerSheetContent({
},
);

const {
executeAsync: generatePartnerNetworkInviteEmail,
isPending: isGeneratingEmail,
} = useAction(generatePartnerNetworkInviteEmailAction, {
onError({ error }) {
toast.error(error.serverError || "Failed to personalize invite.");
},
});

const onSubmit = async (data: InviteNetworkPartnerFormData) => {
if (!workspaceId || !program?.id) {
return;
Expand All @@ -120,10 +137,43 @@ function InviteNetworkPartnerSheetContent({
// to the parent) instead of being persisted to the server
const handleSaveEmail = (content: EmailContent) => {
setEmailContent(content);
setShowResetEmail(true);
onEmailContentChange?.(content);
return true;
};

const handleResetEmail = () => {
setEmailContent(null);
setShowResetEmail(false);
onEmailContentChange?.(null);
};

const handleGenerateEmail = async () => {
if (!workspaceId || !program?.id) {
return;
}

// Failures (thrown or returned) are toasted by the action's onError handler
const result = await generatePartnerNetworkInviteEmail({
workspaceId,
partnerId: partner.id,
groupId: watch("groupId") || null,
}).catch(() => null);

if (!result?.data) {
return;
}

setEmailContent(result.data);
setShowResetEmail(true);
onEmailContentChange?.(result.data);

// Refresh AI usage so exceededAI reflects the credit we just spent
mutateWorkspace();

toast.success("Invite personalized.");
};

return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
<InviteSheetHeader />
Expand Down Expand Up @@ -153,6 +203,9 @@ function InviteNetworkPartnerSheetContent({
shouldDirty: true,
});
}}
// The generated email is group-specific, so prevent switching
// groups while a personalization is in flight
disabled={isGeneratingEmail}
/>
</div>

Expand All @@ -168,7 +221,17 @@ function InviteNetworkPartnerSheetContent({
// Network invites are always sent from the default address
fromAddress="notifications@mail.dub.co"
onSave={handleSaveEmail}
onGenerate={handleGenerateEmail}
onReset={handleResetEmail}
onEditingChange={setIsEditingEmail}
isGenerating={isGeneratingEmail}
showReset={showResetEmail}
generationAvatar={
<PartnerAvatar partner={partner} className="size-16 shrink-0" />
}
generateDisabledTooltip={
exceededAI ? "You've exceeded your AI usage limit." : undefined
}
/>
</div>
</div>
Expand All @@ -177,7 +240,7 @@ function InviteNetworkPartnerSheetContent({
onCancel={() => setIsOpen(false)}
isPending={isPending}
isSubmitting={isSubmitting}
isSubmitDisabled={isEditingEmail}
isSubmitDisabled={isEditingEmail || isGeneratingEmail}
/>
</form>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ export function GroupField({
optional,
selectedGroupId,
setSelectedGroupId,
disabled,
}: {
optional?: boolean;
selectedGroupId: string | null;
setSelectedGroupId: (groupId: string) => void;
disabled?: boolean;
}) {
return (
<>
Expand All @@ -116,6 +118,7 @@ export function GroupField({
<GroupSelector
selectedGroupId={selectedGroupId}
setSelectedGroupId={setSelectedGroupId}
disabled={disabled}
/>
</div>
</>
Expand Down
Loading
Loading