Skip to content

Commit

Permalink
Implement EditProfile container (#1427)
Browse files Browse the repository at this point in the history
* feat: add delete profile hook

* feat: add profile editing

* fix: update Header import

* chore: remove sfx from confetti

* fix: update jotai type

* chore: add comment to assertion

* chore: add ts path alias for @app dir

* refactor: apply write-check

* feat: remove profile atom and add swr hooks

* feat: add ssr to page

* feat: add profile owner guard

* feat: add ssr with swr fallback

* feat: add ssr with swr fallback for guild and role

* chore: add note why use constant api url

* chore: resolve schema errors in profile update
  • Loading branch information
dominik-stumpf authored Aug 8, 2024
1 parent 4010f41 commit eb7978e
Show file tree
Hide file tree
Showing 26 changed files with 476 additions and 300 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@emotion/styled": "^11.11.0",
"@fuels/connectors": "^0.5.0",
"@fuels/react": "^0.20.0",
"@guildxyz/types": "^1.9.30",
"@guildxyz/types": "^1.9.33",
"@hcaptcha/react-hcaptcha": "^1.4.4",
"@hookform/resolvers": "^3.3.4",
"@lexical/code": "^0.12.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,11 @@ export const StartProfile: OnboardingChain = () => {
{method === undefined ? (
<>
<ConnectFarcasterButton
className="ml-0 w-full gap-2"
className="ml-0 flex w-full items-center gap-2"
size="md"
disabled={!!farcasterProfile}
>
<div className="size-6">
<FarcasterImage />
</div>
<FarcasterImage />
Connect farcaster
</ConnectFarcasterButton>
<Button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useConfetti } from "@/components/Confetti"
import { useToast } from "@/components/ui/hooks/useToast"
import { profileSchema } from "@/lib/validations/profileSchema"
import { Schemas } from "@guildxyz/types"
import { SignedValidation, useSubmitWithSign } from "hooks/useSubmit"
import { useRouter } from "next/navigation"
import fetcher from "utils/fetcher"
Expand All @@ -17,14 +18,13 @@ export const useCreateProfile = () => {
...signedValidation,
})

const submitWithSign = useSubmitWithSign<unknown>(createProfile, {
const submitWithSign = useSubmitWithSign<Schemas["Profile"]>(createProfile, {
onSuccess: (response) => {
toast({
variant: "success",
title: "Successfully created profile",
})
confettiPlayer.current("Confetti from left and right")
// @ts-ignore: TODO: either acquire types from backend, or type them here
router.replace(`/profile/${response.username}`)
},
onError: (response) => {
Expand Down
File renamed without changes.
5 changes: 0 additions & 5 deletions src/app/(marketing)/profile/[username]/atoms.ts

This file was deleted.

258 changes: 99 additions & 159 deletions src/app/(marketing)/profile/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
"use client"

import { CheckMark } from "@/components/CheckMark"
import { Header } from "@/components/Header"
import {
Layout,
Expand All @@ -9,170 +6,113 @@ import {
LayoutHero,
LayoutMain,
} from "@/components/Layout"
import { SWRProvider } from "@/components/SWRProvider"
import { Anchor } from "@/components/ui/Anchor"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/Avatar"
import { AvatarGroup } from "@/components/ui/AvatarGroup"
import { Separator } from "@/components/ui/Separator"
import { Skeleton } from "@/components/ui/Skeleton"
import { useUserPublic } from "@/hooks/useUserPublic"
import { Schemas } from "@guildxyz/types"
import { Guild, Role, Schemas } from "@guildxyz/types"
import { ArrowRight } from "@phosphor-icons/react/dist/ssr"
import { useAtom } from "jotai"
import { useEffect } from "react"
import useSWR from "swr"
import { fetcherForSWR } from "utils/fetcher"
import { ContributionCard } from "../_components/ContributionCard"
import { EditContributions } from "../_components/EditContributions"
import { EditProfile } from "../_components/EditProfile"
import { OperatedGuildCard } from "../_components/OperatedGuildCard"
import { ProfileSkeleton } from "../_components/ProfileSkeleton"
import { RecentActivity } from "../_components/RecentActivity"
import { useContribution } from "../_hooks/useContribution"
import { profileAtom } from "./atoms"

// async function getProfileData(username: string) {
// const req = `https://api.guild.xyz/v2/profiles/${username}`
// try {
// const res = await fetch(req)
// if (!res.ok) {
// throw new Error("Failed to fetch profile data")
// }
// return res.json() as Promise<Schemas["Profile"]>
// } catch (e) {
// // mocking for the time being if fetch fails
// console.error(e)
// const res = {
// id: 4,
// userId: 6027190,
// username: "durumm",
// name: "durum",
// bio: null,
// profileImageUrl:
// "https://guild-xyz.mypinata.cloud/ipfs/QmWGdo6FkjSz22oDZFMJysx3hGKoVqtzTWVMx9tTSP7jvi",
// backgroundImageUrl: null,
// createdAt: "2024-07-25T10:04:20.781Z",
// updatedAt: "2024-07-25T10:04:20.781Z",
// }
// return res
// }
// }
import { Profile } from "../_components/Profile"

const Page = ({
params: { username },
}: {
params: { username: string }
}) => {
const { data: fetchedProfile, isLoading } = useSWR<Schemas["Profile"]>(
`/v2/profiles/${username}`,
fetcherForSWR
)
const [profile, setProfile] = useAtom(profileAtom)
const contributions = useContribution()
// TODO: use env var for this url when it is changed to this value.
// next-server throws fetch error if we modify the env var in memory
const api = "https://api.guild.xyz"

const { id: publicUserId } = useUserPublic()
const isProfileOwner = !!profile?.userId && publicUserId === profile.userId
useEffect(() => {
setProfile(fetchedProfile)
}, [fetchedProfile, setProfile])
async function ssrFetcher<T>(...args: Parameters<typeof fetch>) {
return (await fetch(...args)).json() as T
}

if (!profile || isLoading) {
return <ProfileSkeleton />
const fetchPublicProfileData = async ({ username }: { username: string }) => {
const contributionsRequest = new URL(`v2/profiles/${username}/contributions`, api)
const profileRequest = new URL(`v2/profiles/${username}`, api)
const contributions = await ssrFetcher<Schemas["Contribution"][]>(
contributionsRequest,
{
next: {
tags: ["contributions"],
revalidate: 600,
},
}
)
const profile = await ssrFetcher<Schemas["Profile"]>(profileRequest, {
next: {
tags: ["profile"],
revalidate: 600,
},
})
const roleRequests = contributions.map(
({ roleId, guildId }) => new URL(`v2/guilds/${guildId}/roles/${roleId}`, api)
)
const guildRequests = contributions.map(
({ guildId }) => new URL(`v2/guilds/${guildId}`, api)
)
const guilds = await Promise.all(
guildRequests.map((req) =>
ssrFetcher<Guild>(req, {
next: {
revalidate: 3600,
},
})
)
)
const roles = await Promise.all(
roleRequests.map((req) =>
ssrFetcher<Role>(req, {
next: {
revalidate: 3600,
},
})
)
)
const guildsZipped = Object.fromEntries(
guildRequests.map(({ pathname }, i) => [pathname, guilds[i]])
)
const rolesZipped = Object.fromEntries(
roleRequests.map(({ pathname }, i) => [pathname, roles[i]])
)
return {
fallback: {
[profileRequest.pathname]: profile,
[contributionsRequest.pathname]: contributions,
...guildsZipped,
...rolesZipped,
},
}
}

const Page = async ({ params: { username } }: { params: { username: string } }) => {
const { fallback } = await fetchPublicProfileData({ username })
return (
<Layout>
<LayoutHero>
<Header />
<LayoutBanner className="-bottom-[500px]">
<div className="absolute inset-0 bg-[url('/banner.svg')] opacity-10" />
<div className="absolute inset-0 bg-gradient-to-t from-background" />
</LayoutBanner>
</LayoutHero>
<LayoutMain>
<div className="mt-24">
<div className="relative mb-24 flex flex-col items-center">
<EditProfile
profileImageUrl={profile.profileImageUrl}
name={profile.name}
bio={profile.bio ?? undefined}
backgroundImageUrl={profile.backgroundImageUrl ?? undefined}
username={profile.username}
/>
<div className="relative mb-12 flex items-center justify-center">
<Avatar className="size-48">
<AvatarImage
src={profile.profileImageUrl ?? ""}
alt="profile"
width={192}
height={192}
/>
<AvatarFallback>
<Skeleton className="size-full" />
</AvatarFallback>
</Avatar>
</div>
<h1 className="text-center font-bold text-4xl leading-normal tracking-tight">
{profile.name}
<CheckMark className="ml-2 inline size-6 fill-yellow-500" />
</h1>
<div className="text-lg text-muted-foreground">@{profile.username}</div>
<p className="mt-6 max-w-md text-pretty text-center text-lg text-muted-foreground">
{profile.bio}
</p>
<div className="mt-8 grid grid-cols-[repeat(3,auto)] gap-x-8 gap-y-6 sm:grid-cols-[repeat(5,auto)]">
<div className="flex flex-col items-center leading-tight">
<div className="font-bold text-lg">3232</div>
<div className="text-muted-foreground">Guildmates</div>
</div>
<Separator orientation="vertical" className="h-12" />
<div className="flex flex-col items-center leading-tight">
<div className="font-bold text-lg">0</div>
<div className="text-muted-foreground">Followers</div>
</div>
<Separator orientation="vertical" className="hidden h-12 sm:block" />
<div className="col-span-3 flex items-center gap-2 place-self-center sm:col-span-1">
<AvatarGroup imageUrls={["", ""]} count={8} />
<div className="text-muted-foreground leading-tight">
Followed by <span className="font-bold">Hoho</span>,<br />
<span className="font-bold">Hihi</span> and 22 others
</div>
</div>
</div>
</div>
<h2 className="mb-3 font-bold text-lg">Operated guilds</h2>
<OperatedGuildCard />
<div className="mt-8 mb-3 flex items-center justify-between">
<h2 className="font-bold text-lg">Top contributions</h2>
{isProfileOwner && <EditContributions />}
</div>
<div className="grid grid-cols-1 gap-3">
{contributions.data?.map((contribution) => (
<ContributionCard contribution={contribution} key={contribution.id} />
))}
</div>
<div className="mt-8">
<h2 className="mb-3 font-bold text-lg">Recent activity</h2>
<RecentActivity />
<p className="mt-2 font-semibold text-muted-foreground">
&hellip; only last 20 actions are shown
</p>
</div>
</div>
</LayoutMain>
<LayoutFooter>
<p className="mb-12 text-center font-medium text-muted-foreground">
Guild Profiles are currently in invite only early access, only available to{" "}
<Anchor
href={"#"}
className="inline-flex items-center gap-1"
variant="muted"
>
Subscribers
<ArrowRight />
</Anchor>
</p>
</LayoutFooter>
</Layout>
<SWRProvider
value={{
fallback,
}}
>
<Layout>
<LayoutHero>
<Header />
<LayoutBanner className="-bottom-[500px]">
<div className="absolute inset-0 bg-[url('/banner.svg')] opacity-10" />
<div className="absolute inset-0 bg-gradient-to-t from-background" />
</LayoutBanner>
</LayoutHero>
<LayoutMain>
<Profile />
</LayoutMain>
<LayoutFooter>
<p className="mb-12 text-center font-medium text-muted-foreground">
Guild Profiles are currently in invite only early access, only available
to{" "}
<Anchor
href={"#"}
className="inline-flex items-center gap-1"
variant="muted"
>
Subscribers
<ArrowRight />
</Anchor>
</p>
</LayoutFooter>
</Layout>
</SWRProvider>
)
}

Expand Down
10 changes: 6 additions & 4 deletions src/app/(marketing)/profile/_components/ContributionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"use client"

import { Guild, Role, Schemas } from "@guildxyz/types"
import useSWR from "swr"
import useSWRImmutable from "swr/immutable"
import fetcher from "utils/fetcher"
import { ContributionCardView } from "./ContributionCardView"

export const ContributionCard = ({
contribution,
}: { contribution: Schemas["ProfileContribution"] }) => {
const guild = useSWR<Guild>(`/v2/guilds/${contribution.guildId}`, fetcher)
const role = useSWR<Role>(
}: { contribution: Schemas["Contribution"] }) => {
const guild = useSWRImmutable<Guild>(`/v2/guilds/${contribution.guildId}`, fetcher)
const role = useSWRImmutable<Role>(
`/v2/guilds/${contribution.guildId}/roles/${contribution.roleId}`,
fetcher
)
Expand Down
Loading

0 comments on commit eb7978e

Please sign in to comment.