Skip to content

Redeem UX pass #204

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

Merged
merged 3 commits into from
Feb 21, 2025
Merged
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
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "imageUrl" TEXT;
25 changes: 13 additions & 12 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,22 @@ model Repo {
}

model Connection {
id Int @id @default(autoincrement())
name String
config Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncedAt DateTime?
repos RepoToConnection[]
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
syncStatusMetadata Json?
id Int @id @default(autoincrement())
name String
config Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncedAt DateTime?
repos RepoToConnection[]
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
syncStatusMetadata Json?

// The type of connection (e.g., github, gitlab, etc.)
connectionType String
connectionType String

// The organization that owns this connection
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
}

model RepoToConnection {
Expand Down Expand Up @@ -121,6 +121,7 @@ model Org {
repos Repo[]
secrets Secret[]
isOnboarded Boolean @default(false)
imageUrl String?

stripeCustomerId String?
stripeSubscriptionStatus StripeSubscriptionStatus?
Expand Down
144 changes: 97 additions & 47 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,65 +570,115 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
);


export const redeemInvite = async (invite: Invite, userId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async () => {
try {
const res = await prisma.$transaction(async (tx) => {
const org = await tx.org.findUnique({
where: {
id: invite.orgId,
}
});
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async (session) => {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
include: {
org: true,
}
});

if (!org) {
return notFound();
}

// @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
const subscription = await _fetchSubscriptionForOrg(org.id, tx);
if (subscription) {
if (isServiceError(subscription)) {
return subscription;
}
if (!invite) {
return notFound();
}

const existingSeatCount = subscription.items.data[0].quantity;
const newSeatCount = (existingSeatCount || 1) + 1
const user = await getUser(session.user.id);
if (!user) {
return notFound();
}

const stripe = getStripe();
await stripe.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
}
)
// Check if the user is the recipient of the invite
if (user.email !== invite.recipientEmail) {
return notFound();
}

const res = await prisma.$transaction(async (tx) => {
// @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx);
if (subscription) {
if (isServiceError(subscription)) {
return subscription;
}

await tx.userToOrg.create({
data: {
userId,
orgId: invite.orgId,
role: "MEMBER",
}
});
const existingSeatCount = subscription.items.data[0].quantity;
const newSeatCount = (existingSeatCount || 1) + 1

await tx.invite.delete({
where: {
id: invite.id,
const stripe = getStripe();
await stripe.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
}
});
)
}

await tx.userToOrg.create({
data: {
userId: user.id,
orgId: invite.orgId,
role: "MEMBER",
}
});

if (isServiceError(res)) {
return res;
await tx.invite.delete({
where: {
id: invite.id,
}
});
});

if (isServiceError(res)) {
return res;
}

return {
success: true,
}
});

export const getInviteInfo = async (inviteId: string) =>
withAuth(async (session) => {
const user = await getUser(session.user.id);
if (!user) {
return notFound();
}

const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
include: {
org: true,
host: true,
}
});

return {
success: true,
if (!invite) {
return notFound();
}

if (invite.recipientEmail !== user.email) {
return notFound();
}

return {
id: invite.id,
orgName: invite.org.name,
orgImageUrl: invite.org.imageUrl ?? undefined,
orgDomain: invite.org.domain,
host: {
name: invite.host.name ?? undefined,
email: invite.host.email!,
avatarUrl: invite.host.image ?? undefined,
},
recipient: {
name: user.name ?? undefined,
email: user.email!,
}
} catch (error) {
console.error("Failed to redeem invite:", error);
return unexpectedError("Failed to redeem invite");
}
});

Expand Down
1 change: 0 additions & 1 deletion packages/web/src/app/[domain]/settings/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export default async function BillingPage({
<h3 className="text-lg font-medium">Billing</h3>
<p className="text-sm text-muted-foreground">Manage your subscription and billing information</p>
</div>
<Separator />
<div className="grid gap-6">
{/* Billing Email Card */}
<ChangeBillingEmailCard currentUserRole={currentUserRole} />
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/onboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default async function Onboarding() {
}

return (
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary fade-in-20 relative">
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary relative">
<OnboardHeader
title="Setup your organization"
description="Create a organization for your team to search and share code across your repositories."
Expand Down
53 changes: 0 additions & 53 deletions packages/web/src/app/redeem/components/acceptInviteButton.tsx

This file was deleted.

109 changes: 109 additions & 0 deletions packages/web/src/app/redeem/components/acceptInviteCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use client';

import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import Link from "next/link";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react";
import { redeemInvite } from "@/actions";
import { useRouter } from "next/navigation";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils";

interface AcceptInviteCardProps {
inviteId: string;
orgName: string;
orgDomain: string;
orgImageUrl?: string;
host: {
name?: string;
email: string;
avatarUrl?: string;
};
recipient: {
name?: string;
email: string;
};
}

export const AcceptInviteCard = ({ inviteId, orgName, orgDomain, orgImageUrl, host, recipient }: AcceptInviteCardProps) => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { toast } = useToast();

const onRedeemInvite = useCallback(() => {
setIsLoading(true);
redeemInvite(inviteId)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `Failed to redeem invite with error: ${response.message}`,
variant: "destructive",
});
} else {
toast({
description: `✅ You are now a member of the ${orgName} organization.`,
});
router.push(`/${orgDomain}`);
}
})
.finally(() => {
setIsLoading(false);
});
}, [inviteId, orgDomain, orgName, router, toast]);

return (
<Card className="p-12 max-w-lg">
<CardHeader className="text-center">
<SourcebotLogo
className="h-16 w-auto mx-auto mb-2"
size="large"
/>
<CardTitle className="font-medium text-2xl">
Join <strong>{orgName}</strong>
</CardTitle>
</CardHeader>
<CardContent className="mt-3">
<p>
Hello {recipient.name?.split(' ')[0] ?? recipient.email},
</p>
<p className="mt-5">
<InvitedByText email={host.email} name={host.name} /> invited you to join the <strong>{orgName}</strong> organization.
</p>
<div className="flex fex-row items-center justify-center gap-2 mt-12">
<Avatar className="w-14 h-14">
<AvatarImage src={host.avatarUrl ?? placeholderAvatar.src} />
</Avatar>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
<Avatar className="w-14 h-14">
<AvatarImage src={orgImageUrl ?? placeholderAvatar.src} />
</Avatar>
</div>
<Button
className="mt-12 mx-auto w-full"
disabled={isLoading}
onClick={onRedeemInvite}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Accept Invite
</Button>
</CardContent>
</Card>
)
}

const InvitedByText = ({ email, name }: { email: string, name?: string }) => {
const emailElement = <Link href={`mailto:${email}`} className="text-blue-500 hover:text-blue-600">
{email}
</Link>;

if (name) {
const firstName = name.split(' ')[0];
return <span><strong>{firstName}</strong> ({emailElement})</span>;
}

return emailElement;
}
Loading