Skip to content

Commit 60a1f8b

Browse files
UX polish
1 parent 2a27aa7 commit 60a1f8b

File tree

8 files changed

+184
-97
lines changed

8 files changed

+184
-97
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Org" ADD COLUMN "imageUrl" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,22 +59,22 @@ model Repo {
5959
}
6060

6161
model Connection {
62-
id Int @id @default(autoincrement())
63-
name String
64-
config Json
65-
createdAt DateTime @default(now())
66-
updatedAt DateTime @updatedAt
67-
syncedAt DateTime?
68-
repos RepoToConnection[]
69-
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
70-
syncStatusMetadata Json?
62+
id Int @id @default(autoincrement())
63+
name String
64+
config Json
65+
createdAt DateTime @default(now())
66+
updatedAt DateTime @updatedAt
67+
syncedAt DateTime?
68+
repos RepoToConnection[]
69+
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
70+
syncStatusMetadata Json?
7171
7272
// The type of connection (e.g., github, gitlab, etc.)
73-
connectionType String
73+
connectionType String
7474
7575
// The organization that owns this connection
76-
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
77-
orgId Int
76+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
77+
orgId Int
7878
}
7979

8080
model RepoToConnection {
@@ -121,6 +121,7 @@ model Org {
121121
repos Repo[]
122122
secrets Secret[]
123123
isOnboarded Boolean @default(false)
124+
imageUrl String?
124125
125126
stripeCustomerId String?
126127
stripeSubscriptionStatus StripeSubscriptionStatus?

packages/web/src/actions.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,9 +668,16 @@ export const getInviteInfo = async (inviteId: string) =>
668668
return {
669669
id: invite.id,
670670
orgName: invite.org.name,
671-
invitedBy: {
672-
name: invite.host.name,
671+
orgImageUrl: invite.org.imageUrl ?? undefined,
672+
orgDomain: invite.org.domain,
673+
host: {
674+
name: invite.host.name ?? undefined,
673675
email: invite.host.email!,
676+
avatarUrl: invite.host.image ?? undefined,
677+
},
678+
recipient: {
679+
name: user.name ?? undefined,
680+
email: user.email!,
674681
}
675682
}
676683
});

packages/web/src/app/onboard/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default async function Onboarding() {
1212
}
1313

1414
return (
15-
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary fade-in-20 relative">
15+
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary relative">
1616
<OnboardHeader
1717
title="Setup your organization"
1818
description="Create a organization for your team to search and share code across your repositories."

packages/web/src/app/redeem/components/acceptInviteButton.tsx

Lines changed: 0 additions & 53 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client';
2+
3+
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4+
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
5+
import Link from "next/link";
6+
import { Avatar, AvatarImage } from "@/components/ui/avatar";
7+
import placeholderAvatar from "@/public/placeholder_avatar.png";
8+
import { ArrowRight, Loader2 } from "lucide-react";
9+
import { Button } from "@/components/ui/button";
10+
import { useCallback, useState } from "react";
11+
import { redeemInvite } from "@/actions";
12+
import { useRouter } from "next/navigation";
13+
import { useToast } from "@/components/hooks/use-toast";
14+
import { isServiceError } from "@/lib/utils";
15+
16+
interface AcceptInviteCardProps {
17+
inviteId: string;
18+
orgName: string;
19+
orgDomain: string;
20+
orgImageUrl?: string;
21+
host: {
22+
name?: string;
23+
email: string;
24+
avatarUrl?: string;
25+
};
26+
recipient: {
27+
name?: string;
28+
email: string;
29+
};
30+
}
31+
32+
export const AcceptInviteCard = ({ inviteId, orgName, orgDomain, orgImageUrl, host, recipient }: AcceptInviteCardProps) => {
33+
const [isLoading, setIsLoading] = useState(false);
34+
const router = useRouter();
35+
const { toast } = useToast();
36+
37+
const onRedeemInvite = useCallback(() => {
38+
setIsLoading(true);
39+
redeemInvite(inviteId)
40+
.then((response) => {
41+
if (isServiceError(response)) {
42+
toast({
43+
description: `Failed to redeem invite with error: ${response.message}`,
44+
variant: "destructive",
45+
});
46+
} else {
47+
toast({
48+
description: `✅ You are now a member of the ${orgName} organization.`,
49+
});
50+
router.push(`/${orgDomain}`);
51+
}
52+
})
53+
.finally(() => {
54+
setIsLoading(false);
55+
});
56+
}, [inviteId, orgDomain, orgName, router, toast]);
57+
58+
return (
59+
<Card className="p-12 max-w-lg">
60+
<CardHeader className="text-center">
61+
<SourcebotLogo
62+
className="h-16 w-auto mx-auto mb-2"
63+
size="small"
64+
/>
65+
<CardTitle className="font-medium">
66+
Join <strong>{orgName}</strong>
67+
</CardTitle>
68+
</CardHeader>
69+
<CardContent className="mt-3">
70+
<p>
71+
Hello {recipient.name?.split(' ')[0] ?? recipient.email},
72+
</p>
73+
<p className="mt-5">
74+
<InvitedByText email={host.email} name={host.name} /> invited you to join the <strong>{orgName}</strong> organization.
75+
</p>
76+
<div className="flex fex-row items-center justify-center gap-2 mt-12">
77+
<Avatar className="w-14 h-14">
78+
<AvatarImage src={host.avatarUrl ?? placeholderAvatar.src} />
79+
</Avatar>
80+
<ArrowRight className="w-4 h-4 text-muted-foreground" />
81+
<Avatar className="w-14 h-14">
82+
<AvatarImage src={orgImageUrl ?? placeholderAvatar.src} />
83+
</Avatar>
84+
</div>
85+
<Button
86+
className="mt-12 mx-auto w-full"
87+
disabled={isLoading}
88+
onClick={onRedeemInvite}
89+
>
90+
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
91+
Accept Invite
92+
</Button>
93+
</CardContent>
94+
</Card>
95+
)
96+
}
97+
98+
const InvitedByText = ({ email, name }: { email: string, name?: string }) => {
99+
const emailElement = <Link href={`mailto:${email}`} className="text-blue-500 hover:text-blue-600">
100+
{email}
101+
</Link>;
102+
103+
if (name) {
104+
const firstName = name.split(' ')[0];
105+
return <span><strong>{firstName}</strong> ({emailElement})</span>;
106+
}
107+
108+
return emailElement;
109+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
2+
import { Avatar, AvatarImage } from "@/components/ui/avatar";
3+
import placeholderAvatar from "@/public/placeholder_avatar.png";
4+
import { auth } from "@/auth";
5+
import { Card } from "@/components/ui/card";
6+
7+
8+
export const InviteNotFoundCard = async () => {
9+
const session = await auth();
10+
11+
return (
12+
<Card className="flex flex-col items-center justify-center max-w-md text-center p-12">
13+
<SourcebotLogo
14+
className="h-16 w-auto mx-auto mb-2"
15+
size="small"
16+
/>
17+
<h2 className="text-2xl font-bold">Invite not found</h2>
18+
<p className="mt-5">
19+
The invite you are trying to redeem has already been used, expired, or does not exist.
20+
</p>
21+
<div className="flex flex-col items-center gap-2 mt-8">
22+
<Avatar className="h-12 w-12">
23+
<AvatarImage src={session?.user.image ?? placeholderAvatar.src} />
24+
</Avatar>
25+
<p className="text-sm text-muted-foreground">
26+
Logged in as <strong>{session?.user?.email}</strong>
27+
</p>
28+
</div>
29+
</Card>
30+
);
31+
}

packages/web/src/app/redeem/page.tsx

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,17 @@
11
import { notFound, redirect } from 'next/navigation';
22
import { auth } from "@/auth";
3-
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
43
import { getInviteInfo } from "@/actions";
54
import { isServiceError } from "@/lib/utils";
5+
import { AcceptInviteCard } from './components/acceptInviteCard';
6+
import { LogoutEscapeHatch } from '../components/logoutEscapeHatch';
7+
import { InviteNotFoundCard } from './components/inviteNotFoundCard';
8+
69
interface RedeemPageProps {
710
searchParams: {
811
invite_id?: string;
912
};
1013
}
1114

12-
interface ErrorLayoutProps {
13-
title: string;
14-
}
15-
16-
function ErrorLayout({ title }: ErrorLayoutProps) {
17-
return (
18-
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
19-
<div className="max-h-44 w-auto mb-4">
20-
<SourcebotLogo
21-
className="h-18 md:h-40"
22-
size="large"
23-
/>
24-
</div>
25-
<div className="flex justify-center items-center">
26-
<h1>{title}</h1>
27-
</div>
28-
</div>
29-
);
30-
}
31-
3215
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
3316
const inviteId = searchParams.invite_id;
3417
if (!inviteId) {
@@ -41,15 +24,22 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
4124
}
4225

4326
const inviteInfo = await getInviteInfo(inviteId);
44-
if (isServiceError(inviteInfo)) {
45-
return (
46-
<p>Invite not found</p>
47-
);
48-
}
4927

5028
return (
51-
<p>
52-
hello
53-
</p>
29+
<div className="flex flex-col items-center min-h-screen py-24 bg-backgroundSecondary relative">
30+
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
31+
{isServiceError(inviteInfo) ? (
32+
<InviteNotFoundCard />
33+
) : (
34+
<AcceptInviteCard
35+
inviteId={inviteId}
36+
orgName={inviteInfo.orgName}
37+
orgDomain={inviteInfo.orgDomain}
38+
host={inviteInfo.host}
39+
recipient={inviteInfo.recipient}
40+
orgImageUrl={inviteInfo.orgImageUrl}
41+
/>
42+
)}
43+
</div>
5444
);
5545
}

0 commit comments

Comments
 (0)