From ce0b39f94379e30cf83e3ad9149b59c818428f62 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Tue, 9 Jun 2026 05:16:17 +0100 Subject: [PATCH 1/2] feat(web): polish auth and profile flows --- .../notification/notification.service.ts | 2 +- .../src/domains/users/auth/auth.controller.ts | 17 +- .../auth/auth.service.registration.spec.ts | 95 +++ .../src/domains/users/auth/auth.service.ts | 28 +- .../src/domains/users/user/user.controller.ts | 24 + .../src/domains/users/user/user.module.ts | 2 + .../src/domains/users/user/user.service.ts | 275 +++++++- apps/web/src/app/(auth)/layout.tsx | 55 +- apps/web/src/app/(auth)/login/page.tsx | 336 ++++++--- apps/web/src/app/(auth)/register/page.tsx | 640 +++++++++++------- apps/web/src/app/(auth)/verify-email/page.tsx | 39 +- .../u/[username]/PublicUserProfileClient.tsx | 394 ++++++++--- .../_components/SocialGraphPageClient.tsx | 475 +++++++++++++ .../(public)/u/[username]/followers/page.tsx | 13 + .../(public)/u/[username]/following/page.tsx | 13 + .../u/[username]/verified_followers/page.tsx | 18 + .../buyer/settings/SettingsClient.tsx | 161 +++-- apps/web/src/components/layout/LeftNav.tsx | 149 ++-- apps/web/src/lib/user.ts | 76 +++ apps/web/src/middleware.ts | 5 +- 20 files changed, 2223 insertions(+), 594 deletions(-) create mode 100644 apps/web/src/app/(public)/u/[username]/_components/SocialGraphPageClient.tsx create mode 100644 apps/web/src/app/(public)/u/[username]/followers/page.tsx create mode 100644 apps/web/src/app/(public)/u/[username]/following/page.tsx create mode 100644 apps/web/src/app/(public)/u/[username]/verified_followers/page.tsx diff --git a/apps/backend/src/domains/social/notification/notification.service.ts b/apps/backend/src/domains/social/notification/notification.service.ts index d0efa4ce..fa38a629 100644 --- a/apps/backend/src/domains/social/notification/notification.service.ts +++ b/apps/backend/src/domains/social/notification/notification.service.ts @@ -322,7 +322,7 @@ export class NotificationService { return { title: "New follower", body: `${username} started following you.`, - url: null, + url: username === "Someone" ? null : `/u/${username}`, }; case NotificationType.TAGGED_PRODUCT_SOLD_VIA_POST: return { diff --git a/apps/backend/src/domains/users/auth/auth.controller.ts b/apps/backend/src/domains/users/auth/auth.controller.ts index 3897cab9..536f3309 100644 --- a/apps/backend/src/domains/users/auth/auth.controller.ts +++ b/apps/backend/src/domains/users/auth/auth.controller.ts @@ -268,8 +268,21 @@ export class AuthController { @Post("email/verify") @HttpCode(HttpStatus.OK) - async verifyEmail(@Body() dto: VerifyEmailDto) { - return this.authService.verifyEmail(dto); + async verifyEmail( + @Body() dto: VerifyEmailDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const result = await this.authService.verifyEmail( + dto, + this.getSessionMetadata(request), + ); + this.setAuthCookies(response, result); + + return this.successEnvelope({ + verified: result.verified, + redirectTo: result.redirectTo, + }); } @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) diff --git a/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts b/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts index 553a5614..a1c6ddba 100644 --- a/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts +++ b/apps/backend/src/domains/users/auth/auth.service.registration.spec.ts @@ -10,9 +10,14 @@ jest.mock("bcrypt", () => ({ describe("AuthService email registration", () => { const prisma = { + $transaction: jest.fn(), user: { findFirst: jest.fn(), findUnique: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + session: { create: jest.fn(), }, storeProfile: { @@ -20,17 +25,29 @@ describe("AuthService email registration", () => { }, oTPCode: { updateMany: jest.fn(), + update: jest.fn(), + findFirst: jest.fn(), create: jest.fn(), }, }; const resendClient = { sendEmail: jest.fn(), }; + const jwtService = { + signAsync: jest.fn(), + }; + const verificationService = { + tryUpgradeTierForUser: jest.fn(), + }; type AuthServiceTestHarness = { prisma: typeof prisma; resendClient: typeof resendClient; + jwtService: typeof jwtService; + verificationService: typeof verificationService; otpSecret: string; + accessTokenSecret: string; + refreshTokenSecret: string; }; const baseDto = { @@ -50,7 +67,11 @@ describe("AuthService email registration", () => { Object.assign(service as unknown as AuthServiceTestHarness, { prisma, resendClient, + jwtService, + verificationService, otpSecret: "otp-secret", + accessTokenSecret: "access-secret", + refreshTokenSecret: "refresh-secret", }); (bcrypt.hash as jest.Mock).mockResolvedValue("hashed-password"); @@ -68,7 +89,16 @@ describe("AuthService email registration", () => { ); prisma.storeProfile.findUnique.mockResolvedValue(null); prisma.oTPCode.updateMany.mockResolvedValue({ count: 0 }); + prisma.oTPCode.update.mockResolvedValue({}); + prisma.oTPCode.findFirst.mockResolvedValue(null); prisma.oTPCode.create.mockResolvedValue({}); + prisma.user.update.mockResolvedValue({}); + prisma.session.create.mockResolvedValue({}); + prisma.$transaction.mockResolvedValue([]); + jwtService.signAsync + .mockResolvedValueOnce("access-token") + .mockResolvedValueOnce("refresh-token"); + verificationService.tryUpgradeTierForUser.mockResolvedValue(undefined); resendClient.sendEmail.mockResolvedValue({}); }); @@ -209,4 +239,69 @@ describe("AuthService email registration", () => { }), }); }); + + it("issues auth tokens after successful email verification", async () => { + const code = "123456"; + const user = { + id: "user-1", + email: baseDto.email, + phone: baseDto.phone, + username: "user123456", + displayName: "Ada", + dateOfBirth: baseDto.dateOfBirth, + firstName: "Ada", + lastName: "Lovelace", + passwordHash: "hashed-password", + role: UserRole.USER, + emailVerified: false, + phoneVerified: false, + isActive: true, + deletedAt: null, + storeProfile: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + const otpSecret = (service as unknown as AuthServiceTestHarness).otpSecret; + const codeHash = await ( + service as unknown as { + hashOtp: ( + code: string, + purpose: OTPPurpose, + identifier: string, + ) => string; + } + ).hashOtp(code, OTPPurpose.EMAIL_VERIFY, baseDto.email); + + prisma.user.findUnique.mockResolvedValueOnce(user); + prisma.oTPCode.findFirst.mockResolvedValueOnce({ + id: "otp-1", + codeHash, + attempts: 0, + }); + + const result = await service.verifyEmail( + { email: baseDto.email, code }, + { userAgent: "jest", ipAddress: "127.0.0.1" }, + ); + + expect(otpSecret).toBe("otp-secret"); + expect(result).toEqual({ + accessToken: "access-token", + refreshToken: "refresh-token", + verified: true, + redirectTo: "/explore", + }); + expect(jwtService.signAsync).toHaveBeenCalledTimes(2); + expect(prisma.session.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: user.id, + refreshTokenHash: expect.any(String), + userAgent: "jest", + ipAddress: "127.0.0.1", + expiresAt: expect.any(Date), + }), + }); + expect(verificationService.tryUpgradeTierForUser).toHaveBeenCalledWith( + user.id, + ); + }); }); diff --git a/apps/backend/src/domains/users/auth/auth.service.ts b/apps/backend/src/domains/users/auth/auth.service.ts index 50195afb..e146b80b 100644 --- a/apps/backend/src/domains/users/auth/auth.service.ts +++ b/apps/backend/src/domains/users/auth/auth.service.ts @@ -467,9 +467,17 @@ export class AuthService { }; } - async verifyEmail(dto: VerifyEmailDto): Promise<{ verified: true }> { + async verifyEmail( + dto: VerifyEmailDto, + metadata: SessionMetadata, + ): Promise { const user = await this.prisma.user.findUnique({ where: { email: dto.email }, + include: { + storeProfile: { + select: { id: true }, + }, + }, }); if (!user) { @@ -525,7 +533,23 @@ export class AuthService { ); }); - return { verified: true }; + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + }, + metadata, + }); + + return { + ...tokens, + verified: true, + redirectTo: user.storeProfile + ? REDIRECT_TARGET.STORE_DASHBOARD + : REDIRECT_TARGET.EXPLORE, + }; } async resendEmailVerification( diff --git a/apps/backend/src/domains/users/user/user.controller.ts b/apps/backend/src/domains/users/user/user.controller.ts index 1ca86af8..41cc114f 100644 --- a/apps/backend/src/domains/users/user/user.controller.ts +++ b/apps/backend/src/domains/users/user/user.controller.ts @@ -78,6 +78,30 @@ export class UserController { return this.userService.unfollowUser(user.id, userId); } + @UseGuards(JwtAuthGuard) + @Get(":userId/follow-status") + getFollowStatus( + @CurrentUser() user: AuthenticatedRequestUser, + @Param("userId") userId: string, + ) { + return this.userService.getFollowStatus(user.id, userId); + } + + @Get(":username/followers") + listFollowers(@Param("username") username: string) { + return this.userService.listFollowers(username); + } + + @Get(":username/following") + listFollowing(@Param("username") username: string) { + return this.userService.listFollowing(username); + } + + @Get(":username/verified-followers") + listVerifiedFollowers(@Param("username") username: string) { + return this.userService.listVerifiedFollowers(username); + } + @Get(":username") getPublicProfile(@Param("username") username: string) { return this.userService.getPublicProfile(username); diff --git a/apps/backend/src/domains/users/user/user.module.ts b/apps/backend/src/domains/users/user/user.module.ts index 6d23460e..25595208 100644 --- a/apps/backend/src/domains/users/user/user.module.ts +++ b/apps/backend/src/domains/users/user/user.module.ts @@ -1,9 +1,11 @@ import { Module } from "@nestjs/common"; +import { NotificationModule } from "../../social/notification/notification.module"; import { UserController } from "./user.controller"; import { UserService } from "./user.service"; @Module({ + imports: [NotificationModule], controllers: [UserController], providers: [UserService], exports: [UserService], diff --git a/apps/backend/src/domains/users/user/user.service.ts b/apps/backend/src/domains/users/user/user.service.ts index 7a0d8b18..7c0b2a80 100644 --- a/apps/backend/src/domains/users/user/user.service.ts +++ b/apps/backend/src/domains/users/user/user.service.ts @@ -7,6 +7,7 @@ import { import { FollowTargetType, ModerationStatus, + NotificationType, PostType, Prisma, ProductStatus, @@ -15,6 +16,7 @@ import { import { randomUUID } from "crypto"; import { PrismaService } from "../../../core/prisma/prisma.service"; +import { NotificationService } from "../../social/notification/notification.service"; import { UpdateAddressesDto } from "./dto/update-addresses.dto"; import { UpdateMeasurementsDto } from "./dto/update-measurements.dto"; import { UpdateUserDto } from "./dto/update-user.dto"; @@ -77,6 +79,23 @@ export interface PublicUserProfile { postCount: number; } +export interface PublicFollowerProfile { + id: string; + type: "USER" | "STORE"; + username: string | null; + displayName: string | null; + bio: string | null; + profilePhotoUrl: string | null; + followerCount: number; + verified: boolean; + href: string | null; +} + +export interface PublicFollowList { + profile: PublicUserProfile; + items: PublicFollowerProfile[]; +} + export interface OwnUserProfile extends PublicUserProfile { email: string; phone: string; @@ -103,7 +122,10 @@ type DeliveryAddress = Prisma.JsonObject & { @Injectable() export class UserService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + ) {} async getMe(userId: string): Promise { const user = await this.findUserProfileById(userId); @@ -247,16 +269,190 @@ export class UserService { }); } - await this.prisma.followRelation.create({ - data: { - followerId, - targetUserId, - targetType: FollowTargetType.USER, + const [, follower] = await this.prisma.$transaction([ + this.prisma.followRelation.create({ + data: { + followerId, + targetUserId, + targetType: FollowTargetType.USER, + }, + select: { id: true }, + }), + this.prisma.user.findUnique({ + where: { id: followerId }, + select: { + id: true, + username: true, + displayName: true, + }, + }), + ]); + + if (follower) { + void this.notificationService + .createInApp(targetUserId, NotificationType.NEW_FOLLOWER, { + followerId: follower.id, + username: follower.username || "Someone", + }) + .catch(() => undefined); + } + + return { following: true }; + } + + async getFollowStatus( + followerId: string, + targetUserId: string, + ): Promise<{ following: boolean }> { + if (followerId === targetUserId) { + return { following: false }; + } + + const existingFollow = await this.prisma.followRelation.findUnique({ + where: { + followerId_targetUserId: { + followerId, + targetUserId, + }, }, select: { id: true }, }); - return { following: true }; + return { following: Boolean(existingFollow) }; + } + + async listFollowers(username: string): Promise { + return this.listUserFollowers(username, false); + } + + async listVerifiedFollowers(username: string): Promise { + return this.listUserFollowers(username, true); + } + + async listFollowing(username: string): Promise { + const user = await this.findPublicUserByUsername(username); + + const rows = await this.prisma.followRelation.findMany({ + where: { + followerId: user.id, + OR: [ + { + targetUser: { + is: { + isActive: true, + deletedAt: null, + }, + }, + }, + { + targetStore: { + is: { + isOpen: true, + }, + }, + }, + ], + }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + targetType: true, + targetUser: { + select: this.socialUserSelect(), + }, + targetStore: { + select: { + id: true, + storeHandle: true, + storeName: true, + businessName: true, + bio: true, + description: true, + logoUrl: true, + profileImage: true, + tier: true, + _count: { + select: { + followers: true, + targetedFollowRelations: true, + }, + }, + }, + }, + }, + }); + + return { + profile: this.toPublicProfile(user), + items: rows + .map((row) => { + if (row.targetType === FollowTargetType.USER && row.targetUser) { + return this.toPublicFollowerUser(row.targetUser); + } + + if (row.targetType === FollowTargetType.STORE && row.targetStore) { + const store = row.targetStore; + const handle = store.storeHandle; + return { + id: store.id, + type: "STORE" as const, + username: handle, + displayName: + store.storeName ?? store.businessName ?? "twizrr store", + bio: store.bio ?? store.description, + profilePhotoUrl: store.logoUrl ?? store.profileImage, + followerCount: + store._count.followers + store._count.targetedFollowRelations, + verified: store.tier !== StoreTier.TIER_0, + href: handle ? `/stores/${handle}` : null, + }; + } + + return null; + }) + .filter((item): item is PublicFollowerProfile => item !== null), + }; + } + + private async listUserFollowers( + username: string, + verifiedOnly: boolean, + ): Promise { + const user = await this.findPublicUserByUsername(username); + + const rows = await this.prisma.followRelation.findMany({ + where: { + targetUserId: user.id, + targetType: FollowTargetType.USER, + follower: { + isActive: true, + deletedAt: null, + ...(verifiedOnly + ? { + storeProfile: { + is: { + tier: { + not: StoreTier.TIER_0, + }, + }, + }, + } + : {}), + }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + follower: { + select: this.socialUserSelect(), + }, + }, + }); + + return { + profile: this.toPublicProfile(user), + items: rows.map(({ follower }) => this.toPublicFollowerUser(follower)), + }; } async unfollowUser( @@ -314,6 +510,31 @@ export class UserService { } } + private async findPublicUserByUsername(username: string): Promise< + Prisma.UserGetPayload<{ + select: ReturnType; + }> + > { + const normalizedUsername = username.trim().replace(/^@+/, "").toLowerCase(); + const user = await this.prisma.user.findFirst({ + where: { + username: normalizedUsername, + isActive: true, + deletedAt: null, + }, + select: this.publicUserProfileSelect(), + }); + + if (!user) { + throw new NotFoundException({ + message: "User not found", + code: "USER_NOT_FOUND", + }); + } + + return user; + } + private async assertUsernameCanChange( user: SelectedUserProfile, username: string, @@ -510,4 +731,44 @@ export class UserService { }, } satisfies Prisma.UserSelect; } + + private socialUserSelect() { + return { + id: true, + username: true, + displayName: true, + bio: true, + profilePhotoUrl: true, + storeProfile: { + select: { + tier: true, + }, + }, + _count: { + select: { + followerRelations: true, + }, + }, + } satisfies Prisma.UserSelect; + } + + private toPublicFollowerUser( + user: Prisma.UserGetPayload<{ + select: ReturnType; + }>, + ): PublicFollowerProfile { + return { + id: user.id, + type: "USER", + username: user.username, + displayName: user.displayName, + bio: user.bio, + profilePhotoUrl: user.profilePhotoUrl, + followerCount: user._count.followerRelations, + verified: Boolean( + user.storeProfile && user.storeProfile.tier !== StoreTier.TIER_0, + ), + href: user.username ? `/u/${user.username}` : null, + }; + } } diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx index 807e9378..13c03335 100644 --- a/apps/web/src/app/(auth)/layout.tsx +++ b/apps/web/src/app/(auth)/layout.tsx @@ -1,28 +1,55 @@ +"use client"; + import Image from "next/image"; import Link from "next/link"; +import { usePathname } from "next/navigation"; export default function AuthLayout({ children, }: { children: React.ReactNode; }) { + const pathname = usePathname(); + const isSplitAuthPage = pathname === "/register" || pathname === "/login"; + const isCenteredAuthPage = pathname === "/verify-email"; + return (
-
- - twizrr - -
+ {!isSplitAuthPage && !isCenteredAuthPage && ( +
+ + twizrr + +
+ )} -
-
{children}
+
+
+ {children} +
); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 349d18a4..068464e2 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -4,9 +4,10 @@ import { Suspense, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { Eye, EyeOff } from "lucide-react"; +import { Eye, EyeOff, ShieldCheck, Truck } from "lucide-react"; import { Input } from "@/components/ui/Input"; import { Button } from "@/components/ui/Button"; import { PageLoadingState } from "@/components/ui/brand-loader"; @@ -26,6 +27,110 @@ type LoginFields = z.infer; const DEFAULT_LOGIN_DESTINATION = "/home"; +function AuthDivider() { + return ( +
+
+ + or + +
+
+ ); +} + +function AuthLegalCopy() { + return ( +

+ By signing in, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +

+ ); +} + +function LoginPreviewPanel() { + return ( + + ); +} + function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); @@ -46,6 +151,7 @@ function LoginForm() { searchParams.get("redirect"), DEFAULT_LOGIN_DESTINATION, ); + router.refresh(); router.push(destination); } catch (err) { setServerError( @@ -66,115 +172,135 @@ function LoginForm() { const visibleError = serverError ?? oauthError; return ( -
-

- Welcome back -

-

- Sign in to your twizrr account. -

- -
- - -

- Google verifies your email. Phone verification may still be required - before checkout. -

- -
-
- - or - -
-
-
- -
- - - setShowPassword((p) => !p)} - aria-label={showPassword ? "Hide password" : "Show password"} - className="pointer-events-auto cursor-pointer p-1 -m-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" - > - {showPassword ? ( -
); } diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx index 618ca231..e27c3554 100644 --- a/apps/web/src/app/(auth)/register/page.tsx +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -4,9 +4,10 @@ import { Suspense, useState } from "react"; import { useForm, type SubmitHandler } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { ArrowLeft, Eye, EyeOff } from "lucide-react"; +import { ArrowLeft, Eye, EyeOff, ShieldCheck, Truck } from "lucide-react"; import { Input } from "@/components/ui/Input"; import { Button } from "@/components/ui/Button"; import { PageLoadingState } from "@/components/ui/brand-loader"; @@ -126,8 +127,112 @@ function StepProgress({ step, total }: { step: number; total: number }) { ); } +function RegisterPreviewPanel() { + return ( + + ); +} + const DEFAULT_REGISTER_DESTINATION = "/explore"; +function AuthDivider() { + return ( +
+
+ + or + +
+
+ ); +} + +function AuthLegalCopy() { + return ( +

+ By creating an account, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +

+ ); +} + function RegisterForm() { const router = useRouter(); const searchParams = useSearchParams(); @@ -230,270 +335,297 @@ function RegisterForm() { const oauthError = getGoogleAuthErrorMessage(searchParams.get("error")); return ( -
- {step > 1 && ( - - )} - - {step > 1 && } - - {step === 1 && ( -
-

- Create your account -

-

- Join twizrr to shop from verified Nigerian stores with protected - payment through twizrr Buyer Protection. -

- -
- -

- Google verifies your email. Phone verification may still be - required before checkout. -

- - - {oauthError && ( -

- {oauthError} -

- )} - -
-
- - or - -
-
- - -
- -

- Already have an account?{" "} - - Sign in - -

-
- )} - - {step === 2 && ( -
-

- Your email -

-

- Use an email you can access. We will send a verification code. -

- -
+
+
+ - + - 1 && ( + + )} + + {step > 1 && } + + {step === 1 && ( +
+
+

+ Create your account +

+

+ Already have an account?{" "} + + Sign in + +

+
+ +
+ - } - {...form2.register("password")} - /> - - - -
- )} - - {step === 3 && ( -
-

- Phone number -

-

- Your Nigerian mobile number is required for account and order - updates. -

- -
- - - -
-
- )} - - {step === 4 && ( -
-

- Date of birth -

-

- Required to confirm account eligibility. -

- -
- + Continue with Google + + + + + {oauthError && ( +

+ {oauthError} +

+ )} + + + + + +

+ Google verifies your email. Phone verification may still be + required before checkout. +

+
+ +
+ +
+
+ )} + + {step === 2 && ( +
+

+ Your email +

+

+ Use an email you can access. We will send a verification code. +

- - -
- )} - - {step === 5 && ( -
-

- Your name -

-

- Enter your first and last name to finish creating your account. -

- -
- + + + + setShowPassword((p) => !p)} + aria-label={ + showPassword ? "Hide password" : "Show password" + } + className="pointer-events-auto cursor-pointer p-1 -m-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" + > + {showPassword ? ( +
+ )} + + {step === 3 && ( +
+

+ Phone number +

+

+ Your Nigerian mobile number is required for account and order + updates. +

- +
+ + + +
+
+ )} + + {step === 4 && ( +
+

+ Date of birth +

+

+ Required to confirm account eligibility. +

- {serverError && ( -

- {serverError} + + + + +

+ )} + + {step === 5 && ( +
+

+ Your name +

+

+ Enter your first and last name to finish creating your account.

- )} - - - + +
+ + + + + {serverError && ( +

+ {serverError} +

+ )} + + +
+
+ )}
- )} +
+ +
); } diff --git a/apps/web/src/app/(auth)/verify-email/page.tsx b/apps/web/src/app/(auth)/verify-email/page.tsx index 702409cf..cf1c3c5a 100644 --- a/apps/web/src/app/(auth)/verify-email/page.tsx +++ b/apps/web/src/app/(auth)/verify-email/page.tsx @@ -1,6 +1,8 @@ "use client"; import { Suspense, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { CheckCircle } from "lucide-react"; import { Input } from "@/components/ui/Input"; @@ -10,6 +12,28 @@ import { api, type ApiError } from "@/lib/api"; type PageState = "idle" | "verifying" | "success"; +function VerifyEmailShell({ children }: { children: React.ReactNode }) { + return ( +
+ + twizrr + + {children} +
+ ); +} + function VerifyEmailForm() { const router = useRouter(); const searchParams = useSearchParams(); @@ -31,9 +55,12 @@ function VerifyEmailForm() { setPageState("verifying"); setError(null); try { - await api.post("/auth/email/verify", { email, code }); + const result = await api.post<{ verified: true; redirectTo?: string }>( + "/auth/email/verify", + { email, code }, + ); setPageState("success"); - setTimeout(() => router.push(next), 1500); + setTimeout(() => router.push(result.redirectTo || next), 1500); } catch (err) { setPageState("idle"); setError( @@ -58,7 +85,7 @@ function VerifyEmailForm() { if (pageState === "success") { return ( -
+
Taking you in…

-
+ ); } return ( -
+

Verify your email

@@ -135,7 +162,7 @@ function VerifyEmailForm() { )}

-
+
); } diff --git a/apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx b/apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx index d9d56fde..a3650bde 100644 --- a/apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx +++ b/apps/web/src/app/(public)/u/[username]/PublicUserProfileClient.tsx @@ -1,19 +1,27 @@ "use client"; +import type { ReactNode } from "react"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; import { + ArrowLeft, CalendarDays, MessageCircle, Pencil, + Share2, UserPlus, UserRound, } from "lucide-react"; + import { AppShell } from "@/components/layout"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { useOwnProfile } from "@/hooks/useOwnProfile"; import { useOwnerStore } from "@/hooks/useOwnerStore"; +import { type ApiError } from "@/lib/api"; +import { fetchUserFollowStatus, followUser, unfollowUser } from "@/lib/user"; import { cn } from "@/lib/utils"; import type { PublicUserProfile } from "./page"; @@ -23,19 +31,30 @@ interface PublicUserProfileClientProps { } type ProfileTab = { + id: "posts" | "replies" | "photos" | "twizz"; label: string; body: string; soon?: boolean; }; const PROFILE_TABS: ProfileTab[] = [ - { label: "Posts", body: "Posts from this shopper will appear here soon." }, { + id: "posts", + label: "Posts", + body: "Posts from this shopper will appear here soon.", + }, + { + id: "replies", label: "Replies", body: "Replies will appear here once feed replies ship.", }, - { label: "Photos", body: "Photo posts will appear here soon." }, { + id: "photos", + label: "Photos", + body: "Photo posts will appear here soon.", + }, + { + id: "twizz", label: "Twizz", body: "Short videos are coming in a later release.", soon: true, @@ -46,8 +65,17 @@ export function PublicUserProfileClient({ user, requestedUsername, }: PublicUserProfileClientProps) { + const router = useRouter(); const { profile, isLoading: profileLoading } = useOwnProfile(); const { hasStore, isLoading: ownerStoreLoading } = useOwnerStore(); + const [isFollowing, setIsFollowing] = useState(false); + const [isFollowPending, setIsFollowPending] = useState(false); + const [followerCount, setFollowerCount] = useState(user.followerCount); + const [followError, setFollowError] = useState(null); + const [activeTab, setActiveTab] = useState("posts"); + const [shareStatus, setShareStatus] = useState<"idle" | "copied" | "failed">( + "idle", + ); const displayName = user.displayName || user.username || "twizrr user"; const username = user.username ?? requestedUsername; @@ -58,6 +86,107 @@ export function PublicUserProfileClient({ Boolean(profileUsername) && viewerUsername === profileUsername; + useEffect(() => { + setFollowerCount(user.followerCount); + setIsFollowing(false); + setFollowError(null); + }, [user.id, user.followerCount]); + + useEffect(() => { + let active = true; + + async function loadFollowStatus() { + if (!profile || isOwnProfile) { + return; + } + + try { + const result = await fetchUserFollowStatus(user.id); + if (active) { + setIsFollowing(result.following); + } + } catch { + if (active) { + setIsFollowing(false); + } + } + } + + void loadFollowStatus(); + + return () => { + active = false; + }; + }, [isOwnProfile, profile, user.id]); + + async function handleFollowToggle() { + if (profileLoading || isOwnProfile || isFollowPending) { + return; + } + + if (!profile) { + router.push( + `/login?redirect=${encodeURIComponent(`/u/${requestedUsername}`)}`, + ); + return; + } + + setIsFollowPending(true); + setFollowError(null); + + try { + if (isFollowing) { + await unfollowUser(user.id); + setIsFollowing(false); + setFollowerCount((count) => Math.max(0, count - 1)); + } else { + await followUser(user.id); + setIsFollowing(true); + setFollowerCount((count) => count + 1); + } + } catch (err) { + const apiError = err as ApiError; + if (apiError.code === "FOLLOW_ALREADY_EXISTS") { + setIsFollowing(true); + return; + } + + if (apiError.status === 401) { + router.push( + `/login?redirect=${encodeURIComponent(`/u/${requestedUsername}`)}`, + ); + return; + } + + setFollowError( + apiError.message ?? "Could not follow this profile. Please try again.", + ); + } finally { + setIsFollowPending(false); + } + } + + async function handleShareProfile() { + const path = `/u/${username ?? requestedUsername}`; + const href = + typeof window === "undefined" + ? path + : new URL(path, window.location.origin).toString(); + + try { + await navigator.clipboard.writeText(href); + setShareStatus("copied"); + } catch { + setShareStatus("failed"); + } finally { + window.setTimeout(() => setShareStatus("idle"), 2000); + } + } + + function handleBackNavigation() { + router.push("/home"); + } + return (
-

- Profile -

-

- {displayName} -

+
+ +
+

+ {displayName} +

+

+ {formatCount(user.postCount, "post")} +

+
+
@@ -110,65 +251,66 @@ export function PublicUserProfileClient({ {username}

) : null} - - {user.bio ? ( -

- {user.bio} -

- ) : ( -

- This profile does not have a bio yet. -

- )}
-
- - - -
+
+ {user.bio ? ( +

+ {user.bio} +

+ ) : ( +

+ This profile does not have a bio yet. +

+ )} -
-

About

-
- {user.memberSince ? ( -

-

- ) : null} - {username ? ( -

- Profile URL{" "} - - /u/{username} - -

- ) : null} -
-
+

+

+
+ +
+ + +
- {PROFILE_TABS.map((tab, index) => ( + {PROFILE_TABS.map((tab) => (
@@ -195,9 +338,21 @@ export function PublicUserProfileClient({ function ProfileActions({ isOwnProfile, isResolvingViewer, + isFollowing, + isFollowPending, + followError, + shareStatus, + onFollowToggle, + onShareProfile, }: { isOwnProfile: boolean; isResolvingViewer: boolean; + isFollowing: boolean; + isFollowPending: boolean; + followError: string | null; + shareStatus: "idle" | "copied" | "failed"; + onFollowToggle: () => void; + onShareProfile: () => void; }) { if (isResolvingViewer) { return ( @@ -210,59 +365,144 @@ function ProfileActions({ if (isOwnProfile) { return ( - -