From 43e5cc95afd7f7e60ef4f77e43c09efb19f627a2 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 7 Jul 2026 19:11:25 +0800 Subject: [PATCH] feat(profile): add profile page and styles, update route access --- src/app/profile/page.tsx | 5 + src/app/profile/profile-screen.module.css | 418 ++++++++++++++++++++++ src/app/profile/profile-screen.tsx | 164 +++++++++ src/router/__tests__/route-meta.test.ts | 9 +- src/router/route-meta.ts | 1 + src/router/routes.ts | 2 + 6 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 src/app/profile/page.tsx create mode 100644 src/app/profile/profile-screen.module.css create mode 100644 src/app/profile/profile-screen.tsx diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 00000000..b26ed174 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from "./profile-screen"; + +export default function ProfilePage() { + return ; +} diff --git a/src/app/profile/profile-screen.module.css b/src/app/profile/profile-screen.module.css new file mode 100644 index 00000000..5aeb3a2a --- /dev/null +++ b/src/app/profile/profile-screen.module.css @@ -0,0 +1,418 @@ +.shell { + position: relative; + min-height: var(--app-viewport-height, 100dvh); + padding: + calc(var(--app-safe-top, 0px) + clamp(18px, 4.444vw, 24px)) + calc(var(--app-safe-right, 0px) + clamp(18px, 5.185vw, 28px)) + calc(var(--app-safe-bottom, 0px) + 96px) + calc(var(--app-safe-left, 0px) + clamp(18px, 5.185vw, 28px)); + overflow: hidden auto; + background: + radial-gradient(circle at 12% 10%, rgba(255, 193, 121, 0.32), transparent 30%), + radial-gradient(circle at 90% 4%, rgba(255, 91, 148, 0.22), transparent 28%), + linear-gradient(180deg, #fff7ed 0%, #fff8f6 42%, #ffffff 100%); + color: #21171b; +} + +.backgroundGlowOne, +.backgroundGlowTwo { + position: absolute; + z-index: 0; + pointer-events: none; + border-radius: 999px; + filter: blur(8px); +} + +.backgroundGlowOne { + top: 118px; + right: -82px; + width: 170px; + height: 170px; + background: rgba(255, 93, 149, 0.16); +} + +.backgroundGlowTwo { + bottom: 92px; + left: -92px; + width: 190px; + height: 190px; + background: rgba(255, 188, 92, 0.18); +} + +.hero, +.postsSection, +.bottomNav { + position: relative; + z-index: 1; +} + +.hero { + display: flex; + flex-direction: column; + gap: clamp(14px, 3.333vw, 18px); + padding: clamp(18px, 4.444vw, 24px); + border: 1px solid rgba(255, 116, 159, 0.18); + border-radius: clamp(26px, 6.667vw, 36px); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(255, 236, 223, 0.8)), + #ffffff; + box-shadow: 0 22px 58px rgba(131, 72, 85, 0.13); + animation: riseIn 0.45s ease both; +} + +.heroTopline { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid rgba(255, 116, 159, 0.2); + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + color: #a94c64; + font-size: clamp(11px, 2.593vw, 14px); + font-weight: 780; + letter-spacing: 0.02em; +} + +.liveDot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #ff4f91; + box-shadow: 0 0 0 5px rgba(255, 79, 145, 0.16); +} + +.identity { + display: flex; + align-items: center; + gap: clamp(14px, 3.704vw, 20px); +} + +.avatarFrame { + position: relative; + display: grid; + width: clamp(68px, 16.667vw, 90px); + height: clamp(68px, 16.667vw, 90px); + flex: 0 0 auto; + place-items: center; + border: 3px solid rgba(255, 255, 255, 0.92); + border-radius: 24px; + background: linear-gradient(145deg, #ffb36d, #ff5d95); + box-shadow: 0 16px 34px rgba(255, 95, 128, 0.23); + overflow: hidden; +} + +.avatarFrame img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.identityText { + min-width: 0; +} + +.title { + margin: 0; + color: #201417; + font-size: clamp(24px, 6.667vw, 36px); + font-weight: 920; + letter-spacing: -0.04em; + line-height: 0.95; +} + +.handle { + margin: 7px 0 0; + color: #a56f7b; + font-size: clamp(13px, 3.148vw, 17px); + font-weight: 720; +} + +.bio { + max-width: 360px; + margin: 0; + color: #755f66; + font-size: clamp(14px, 3.333vw, 18px); + font-weight: 620; + line-height: 1.55; +} + +.primaryCta { + display: inline-flex; + min-height: 48px; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: linear-gradient(135deg, #262026, #7f5861); + color: #ffffff; + cursor: pointer; + font: inherit; + font-size: clamp(13px, 3.148vw, 16px); + font-weight: 850; + box-shadow: 0 14px 30px rgba(47, 35, 40, 0.18); + transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; +} + +.primaryCta:hover { + filter: brightness(1.04); + box-shadow: 0 18px 36px rgba(47, 35, 40, 0.23); + transform: translateY(-1px); +} + +.primaryCta:active { + transform: translateY(1px) scale(0.99); +} + +.postsSection { + margin-top: clamp(18px, 4.815vw, 26px); +} + +.sectionHeader { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 14px; + margin-bottom: 14px; +} + +.kicker { + margin: 0; + color: #ff5d95; + font-size: clamp(11px, 2.593vw, 13px); + font-weight: 900; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.sectionTitle { + margin: 4px 0 0; + color: #25191d; + font-size: clamp(21px, 5.185vw, 28px); + font-weight: 900; + letter-spacing: -0.03em; +} + +.postCount { + display: inline-flex; + min-width: 42px; + height: 34px; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 116, 159, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + color: #a94c64; + font-size: 14px; + font-weight: 880; + box-shadow: 0 8px 18px rgba(131, 72, 85, 0.08); +} + +.timeline { + display: flex; + flex-direction: column; + gap: 14px; +} + +.postCard { + padding: clamp(14px, 3.704vw, 20px); + border: 1px solid rgba(44, 29, 34, 0.07); + border-radius: clamp(22px, 5.926vw, 32px); + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 16px 42px rgba(131, 72, 85, 0.1); + backdrop-filter: blur(18px); + animation: riseIn 0.42s ease both; + animation-delay: calc(var(--reveal-index, 0) * 90ms + 80ms); +} + +.postHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.postAuthor { + display: flex; + min-width: 0; + align-items: center; + gap: 11px; +} + +.postAvatar { + flex: 0 0 auto; + border: 2px solid rgba(255, 255, 255, 0.9); + border-radius: 14px; + object-fit: cover; + box-shadow: 0 8px 18px rgba(131, 72, 85, 0.12); +} + +.authorName { + margin: 0; + color: #26191d; + font-size: clamp(14px, 3.333vw, 17px); + font-weight: 820; + line-height: 1.15; +} + +.authorSubline { + margin: 3px 0 0; + color: #a58b92; + font-size: clamp(11px, 2.778vw, 13px); + font-weight: 680; +} + +.postTime { + flex: 0 0 auto; + color: #7b666d; + font-size: clamp(12px, 2.963vw, 15px); + font-weight: 760; +} + +.lockedPreview { + display: grid; + width: 100%; + grid-template-columns: auto 1fr; + align-items: center; + gap: 14px; + margin-top: 14px; + padding: clamp(16px, 4.074vw, 22px); + border: 1px solid rgba(255, 116, 159, 0.24); + border-radius: clamp(20px, 5.185vw, 28px); + background: + linear-gradient(135deg, rgba(255, 246, 239, 0.98), rgba(255, 231, 240, 0.9)), + #ffffff; + color: #21171b; + cursor: pointer; + font: inherit; + text-align: left; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 12px 28px rgba(255, 116, 159, 0.12); + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.lockedPreview:hover { + border-color: rgba(255, 82, 139, 0.45); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 18px 36px rgba(255, 116, 159, 0.18); + transform: translateY(-1px); +} + +.lockedPreview:active { + transform: translateY(1px) scale(0.995); +} + +.lockedPreview:focus-visible, +.primaryCta:focus-visible, +.navButton:focus-visible { + outline: 2px solid #ff5d95; + outline-offset: 3px; +} + +.previewIcon { + display: grid; + width: 46px; + height: 46px; + place-items: center; + border-radius: 17px; + background: linear-gradient(135deg, #ff7aa9, #ffb36d); + color: #ffffff; + box-shadow: 0 12px 24px rgba(255, 104, 146, 0.24); +} + +.previewText { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.previewText strong { + color: #21171b; + font-size: clamp(16px, 4.074vw, 21px); + font-weight: 930; + line-height: 1.05; +} + +.previewText span { + color: #4c3a40; + font-size: clamp(13px, 3.333vw, 17px); + font-weight: 780; +} + +.previewText small { + display: inline-flex; + align-items: center; + gap: 5px; + color: #a94c64; + font-size: clamp(12px, 2.963vw, 15px); + font-weight: 760; +} + +.bottomNav { + position: fixed; + right: max(calc((100vw - var(--app-max-width, 540px)) / 2), 0px); + bottom: 0; + left: max(calc((100vw - var(--app-max-width, 540px)) / 2), 0px); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + box-sizing: border-box; + width: min(100vw, var(--app-max-width, 540px)); + margin: 0 auto; + padding: + 10px + calc(var(--app-safe-right, 0px) + 14px) + calc(var(--app-safe-bottom, 0px) + 10px) + calc(var(--app-safe-left, 0px) + 14px); + border-top: 1px solid rgba(44, 29, 34, 0.08); + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 -18px 48px rgba(53, 34, 40, 0.09); + backdrop-filter: blur(18px); +} + +.navButton { + display: flex; + min-height: 54px; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid transparent; + border-radius: 18px; + background: transparent; + color: #8d777e; + cursor: pointer; + font: inherit; + font-size: clamp(12px, 2.963vw, 14px); + font-weight: 820; + transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease; +} + +.navButton:hover { + background: rgba(255, 116, 159, 0.08); + color: #2d2024; +} + +.navButton:active { + transform: scale(0.98); +} + +.navButtonActive { + border-color: rgba(255, 116, 159, 0.2); + background: linear-gradient(135deg, rgba(255, 244, 236, 0.98), rgba(255, 232, 241, 0.96)); + color: #201417; + box-shadow: 0 10px 24px rgba(255, 116, 159, 0.12); +} + +@keyframes riseIn { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/profile/profile-screen.tsx b/src/app/profile/profile-screen.tsx new file mode 100644 index 00000000..2f24c318 --- /dev/null +++ b/src/app/profile/profile-screen.tsx @@ -0,0 +1,164 @@ +"use client"; + +import Image from "next/image"; +import { Camera, ImageIcon, LockKeyhole, MessageCircle, Sparkles } from "lucide-react"; + +import { MobileShell } from "@/app/_components/core"; +import { useAppNavigator } from "@/router/use-app-navigator"; + +import styles from "./profile-screen.module.css"; + +const PROFILE = { + name: "Elio Silvestri", + handle: "@elio.private", + avatar: "/images/chat/pic-chat-elio.png", + postsCount: 10, + cta: "Join Me, unlock my private zoom", + bio: "Soft moments, private photos, and a little sweetness just for you.", +} as const; + +const POSTS = [ + { + id: "private-zoom-1", + timeAgo: "1 day ago", + price: "XXX coins", + photoCount: "7 photos", + }, + { + id: "private-zoom-2", + timeAgo: "30 days ago", + price: "XXX coins", + photoCount: "7 photos", + }, +] as const; + +export function ProfileScreen() { + const navigator = useAppNavigator(); + + const openVipSubscription = () => { + navigator.openSubscription({ type: "vip" }); + }; + + return ( + +
+
+
+ +
+
+
+ +
+
+ {PROFILE.name} +
+ +
+

+ {PROFILE.name} +

+

{PROFILE.handle}

+
+
+ +

{PROFILE.bio}

+ + +
+ +
+
+
+

Posts

+

+ Private moments +

+
+ {PROFILE.postsCount} +
+ +
+ {POSTS.map((post, index) => ( +
+
+
+ +
+

{PROFILE.name}

+

Private zoom drop

+
+
+ +
+ + +
+ ))} +
+
+ + +
+
+ ); +} diff --git a/src/router/__tests__/route-meta.test.ts b/src/router/__tests__/route-meta.test.ts index ee2e2052..196ef44c 100644 --- a/src/router/__tests__/route-meta.test.ts +++ b/src/router/__tests__/route-meta.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import { getRouteAccess, isInternalRoute } from "../route-meta"; -import { ROUTES } from "../routes"; +import { ALL_STATIC_ROUTES, ROUTES } from "../routes"; describe("route meta", () => { it("classifies static routes by access level", () => { expect(getRouteAccess(ROUTES.splash)).toBe("authOnly"); expect(getRouteAccess(ROUTES.auth)).toBe("authOnly"); - expect(getRouteAccess(ROUTES.chat)).toBe("session"); + expect(getRouteAccess(ROUTES.chat)).toBe("guestEntry"); + expect(getRouteAccess(ROUTES.profile)).toBe("public"); expect(getRouteAccess(ROUTES.sidebar)).toBe("session"); expect(getRouteAccess(ROUTES.subscription)).toBe("realUser"); expect(getRouteAccess(ROUTES.coinsRules)).toBe("public"); @@ -18,6 +19,10 @@ describe("route meta", () => { expect(getRouteAccess("/chat/deviceid/device_1")).toBe("public"); }); + it("includes profile in static routes", () => { + expect(ALL_STATIC_ROUTES).toContain(ROUTES.profile); + }); + it("treats unknown routes as public by default", () => { expect(getRouteAccess("/unknown")).toBe("public"); }); diff --git a/src/router/route-meta.ts b/src/router/route-meta.ts index 127a0425..d535d091 100644 --- a/src/router/route-meta.ts +++ b/src/router/route-meta.ts @@ -12,6 +12,7 @@ const STATIC_ROUTE_ACCESS: Partial> = { [ROUTES.splash]: "authOnly", [ROUTES.auth]: "authOnly", [ROUTES.chat]: "guestEntry", + [ROUTES.profile]: "public", [ROUTES.sidebar]: "session", [ROUTES.subscription]: "realUser", [ROUTES.coinsRules]: "public", diff --git a/src/router/routes.ts b/src/router/routes.ts index f5fd9b28..539ee761 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -21,6 +21,7 @@ export const ROUTES = { root: "/", splash: "/splash", chat: "/chat", + profile: "/profile", auth: "/auth", sidebar: "/sidebar", subscription: "/subscription", @@ -52,6 +53,7 @@ export const ROUTE_BUILDERS = { export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [ ROUTES.splash, ROUTES.chat, + ROUTES.profile, ROUTES.auth, ROUTES.sidebar, ROUTES.coinsRules,