Skip to content
Open
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
4 changes: 4 additions & 0 deletions app/RootLayoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const SearchOverlay = dynamic(() => import("@/components/shared/SearchOverlay"),
const AirdropModal = dynamic(() => import("@/components/airdrop/AirdropModal"), { ssr: false });
const ReportModal = dynamic(() => import("@/components/report/ReportModal"), { ssr: false });
const AccountLinkingDetector = dynamic(() => import("@/components/layout/AccountLinkingDetector"), { ssr: false });
const OnboardingDetector = dynamic(() => import("@/components/onboarding/OnboardingDetector"), { ssr: false });
const CommunityToasts = dynamic(() => import("@/components/homepage/CommunityToasts"), { ssr: false });
const IOSAppBanner = dynamic(() => import("@/components/shared/IOSAppBanner"), { ssr: false });
const HZCEasterEgg = dynamic(() => import("@/components/shared/HZCEasterEgg"), { ssr: false });
Expand Down Expand Up @@ -235,6 +236,9 @@ function InnerLayout({
{/* Account Linking Detector - auto-prompts when wallets are connected */}
<AccountLinkingDetector />

{/* Onboarding Detector - guides new users through profile setup */}
<OnboardingDetector />

{/* HZC Easter Egg */}
<HZCEasterEgg onTrigger={() => searchProps?.setIsSearchOpen(false)} />

Expand Down
4 changes: 4 additions & 0 deletions app/api/userbase/auth/magic-link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ function createTransport() {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
// Local development only: bypass TLS verification for self-signed/proxy certs.
...(process.env.NODE_ENV === 'development' && {
tls: { rejectUnauthorized: false },
}),
});
}

Expand Down
4 changes: 2 additions & 2 deletions app/api/userbase/auth/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export async function GET(request: NextRequest) {

const { data: sessionRows, error: sessionError } = await supabase
.from('userbase_sessions')
.select('id, user_id, expires_at, revoked_at, userbase_users(id, handle, display_name, avatar_url, status, onboarding_step)')
.select('id, user_id, expires_at, revoked_at, userbase_users(id, handle, display_name, avatar_url, bio, status, onboarding_step, created_at)')
.eq('refresh_token_hash', refreshTokenHash)
.is('revoked_at', null)
.limit(1);
Expand Down Expand Up @@ -342,7 +342,7 @@ export async function GET(request: NextRequest) {
.from("userbase_users")
.update(updates)
.eq("id", user.id)
.select("id, handle, display_name, avatar_url, status, onboarding_step")
.select("id, handle, display_name, avatar_url, bio, status, onboarding_step, created_at")
.single();
if (updatedUser) {
user = updatedUser;
Expand Down
7 changes: 7 additions & 0 deletions app/api/userbase/hive/comment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,13 @@ export async function POST(request: NextRequest) {
});
}

// DRY RUN — set HIVE_DRY_RUN=true in .env.local to skip broadcast during local dev.
// Guarded by NODE_ENV=development to prevent accidental use in staging/CI.
if (process.env.NODE_ENV === "development" && process.env.HIVE_DRY_RUN === "true") {
console.log("[dry-run] Hive broadcast skipped:", { author, permlink, ops });
return NextResponse.json({ success: true, author, permlink, dry_run: true });
}

const privateKey = PrivateKey.fromString(postingKey as string);
await HiveClient.broadcast.sendOperations(ops, privateKey);

Expand Down
16 changes: 14 additions & 2 deletions app/api/userbase/profile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ export async function PATCH(request: NextRequest) {

// Parse request body
const body = await request.json();
const { display_name, avatar_url, cover_url, bio, location, handle } = body;
const { display_name, avatar_url, cover_url, bio, location, handle, onboarding_step_flag } = body;

// Build update object with only provided fields
const updateData: Record<string, string | null> = {};
const updateData: Record<string, string | number | null> = {};
if (display_name !== undefined) updateData.display_name = display_name;
if (avatar_url !== undefined) updateData.avatar_url = avatar_url;
if (cover_url !== undefined) updateData.cover_url = cover_url;
Expand All @@ -262,6 +262,18 @@ export async function PATCH(request: NextRequest) {
updateData.handle = normalizedHandle;
}

// onboarding_step_flag: bitmask OR — only adds bits, never removes them
// bit 0 (1) = photo, bit 1 (2) = bio, bit 2 (4) = intro post
if (typeof onboarding_step_flag === "number" && onboarding_step_flag > 0) {
const { data: currentUser } = await supabase
.from("userbase_users")
.select("onboarding_step")
.eq("id", userId)
.single();
const currentStep = currentUser?.onboarding_step ?? 0;
updateData.onboarding_step = currentStep | onboarding_step_flag;
}
Comment on lines +265 to +275
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition: read-then-write on onboarding_step can drop flag bits under concurrency.

This does a non-atomic read → OR → write. Concurrent PATCHes (which the client can easily produce — e.g., OnboardingModal.savePhoto and the silent-sync effect at OnboardingModal.tsx lines 117‑131 firing at nearly the same time) both read the same currentStep, each OR in their own flag, and the second write clobbers the first. Net result: one of the bits is lost and the user stays stuck on onboarding.

Perform the OR atomically at the database level via a Postgres RPC so the read/OR/write happens in a single transaction:

🛡️ Suggested fix — atomic bitwise OR via RPC
-- migration
create or replace function set_onboarding_flag(p_user_id uuid, p_flag int)
returns int
language sql
as $$
  update userbase_users
     set onboarding_step = coalesce(onboarding_step, 0) | p_flag
   where id = p_user_id
  returning onboarding_step;
$$;
-    if (typeof onboarding_step_flag === "number" && onboarding_step_flag > 0) {
-      const { data: currentUser } = await supabase
-        .from("userbase_users")
-        .select("onboarding_step")
-        .eq("id", userId)
-        .single();
-      const currentStep = currentUser?.onboarding_step ?? 0;
-      updateData.onboarding_step = currentStep | onboarding_step_flag;
-    }
+    if (typeof onboarding_step_flag === "number" && onboarding_step_flag > 0) {
+      const { data: newStep, error: flagErr } = await supabase.rpc(
+        "set_onboarding_flag",
+        { p_user_id: userId, p_flag: onboarding_step_flag }
+      );
+      if (flagErr) {
+        console.error("Failed to OR onboarding_step_flag:", flagErr);
+        return NextResponse.json(
+          { error: "Failed to update onboarding flag" },
+          { status: 500 }
+        );
+      }
+      // Skip adding onboarding_step to updateData — the RPC already persisted it.
+      // (Keep other fields in updateData; below block will still return updatedUser.)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/userbase/profile/route.ts` around lines 265 - 275, The current
read-then-write on onboarding_step (the block using onboarding_step_flag,
fetching userbase_users.onboarding_step into currentStep and setting
updateData.onboarding_step = currentStep | onboarding_step_flag) can lose bits
under concurrency; replace that logic with an atomic DB-side bitwise OR via a
Postgres RPC: add a migration that creates a function like
set_onboarding_flag(p_user_id uuid, p_flag int) returning the updated
onboarding_step, then call it from route.ts with
supabase.rpc('set_onboarding_flag', { p_user_id: userId, p_flag:
onboarding_step_flag }) and use the returned onboarding_step (or rely on the RPC
result) instead of doing the local read/OR/write so the OR happens inside the DB
in one transaction.


if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: "No fields to update" },
Expand Down
226 changes: 226 additions & 0 deletions components/onboarding/OnboardingDetector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"use client";

/**
* OnboardingDetector
*
* - Auto-opens the OnboardingModal on first login (once per browser session).
* - Shows a persistent floating card until all 3 steps are done.
* - Bitmask: photo=1, bio=2, intro post=4 — all done when onboarding_step === 7.
*/

import { useEffect, useRef, useState } from "react";
import {
Box,
Flex,
Text,
Icon,
IconButton,
VStack,
HStack,
Button,
useTheme,
} from "@chakra-ui/react";
import { FiCamera, FiFileText, FiEdit3, FiCheck, FiX } from "react-icons/fi";
import dynamic from "next/dynamic";
import { useUserbaseAuth } from "@/contexts/UserbaseAuthContext";
import {
ONBOARDING_FLAG_PHOTO,
ONBOARDING_FLAG_BIO,
ONBOARDING_FLAG_POST,
ONBOARDING_ALL_DONE,
DICEBEAR_URL_PATTERN,
} from "./OnboardingModal";

const OnboardingModal = dynamic(() => import("./OnboardingModal"), { ssr: false });

// Accounts created before this date are excluded from onboarding.
const ONBOARDING_LAUNCH_DATE = new Date("2026-04-22");

// sessionStorage keys
const SS_SEEN = "onboarding_modal_seen"; // set after first auto-open
const SS_DONE = "onboarding_done"; // set immediately when modal finishes

function isLocallyDone() {
return typeof window !== "undefined" && sessionStorage.getItem(SS_DONE) === "true";
}

export default function OnboardingDetector() {
const { user } = useUserbaseAuth();
const theme = useTheme();

const [isModalOpen, setIsModalOpen] = useState(false);
const [isCardDismissed, setIsCardDismissed] = useState(false);
const hasAutoOpened = useRef(false);

// ── Auto-open once per browser session for new users ─────────────────────
useEffect(() => {
if (!user) return;
if (hasAutoOpened.current) return;

const isDone = ((user.onboarding_step ?? 0) & ONBOARDING_ALL_DONE) === ONBOARDING_ALL_DONE || isLocallyDone();
if (isDone) return;

const alreadySeen = typeof window !== "undefined"
? sessionStorage.getItem(SS_SEEN) === "true"
: false;

if (!alreadySeen) {
const timeout = setTimeout(() => {
setIsModalOpen(true);
sessionStorage.setItem(SS_SEEN, "true");
hasAutoOpened.current = true;
}, 1200);
return () => clearTimeout(timeout);
}

hasAutoOpened.current = true;
}, [user]);

// ── Clean up SS_DONE once the server confirms onboarding_step === 7 ───────
useEffect(() => {
if (!user) return;
if (((user.onboarding_step ?? 0) & ONBOARDING_ALL_DONE) === ONBOARDING_ALL_DONE) {
sessionStorage.removeItem(SS_DONE);
}
}, [user]);

// ── Reset on logout ───────────────────────────────────────────────────────
useEffect(() => {
if (!user) {
hasAutoOpened.current = false;
setIsCardDismissed(false);
sessionStorage.removeItem(SS_SEEN);
sessionStorage.removeItem(SS_DONE);
}
}, [user]);

// ── Nothing to show ───────────────────────────────────────────────────────
// isLoading intentionally excluded: background refreshes (focus/visibility
// events) set isLoading=true while user stays non-null, which would unmount
// the modal mid-flow and reset its state.
if (!user) return null;

// Only show onboarding for accounts created after the feature launch date.
// Existing users are excluded without any database migration.
const createdAt = new Date(user.created_at);
if (isNaN(createdAt.getTime()) || createdAt < ONBOARDING_LAUNCH_DATE) return null;

const step = user.onboarding_step ?? 0;
const hasCustomAvatar = !!user.avatar_url && !user.avatar_url.includes(DICEBEAR_URL_PATTERN);
const hasBio = !!user.bio?.trim();
const effectiveStep = (hasCustomAvatar ? ONBOARDING_FLAG_PHOTO : 0)
| (hasBio ? ONBOARDING_FLAG_BIO : 0)
| step;
const isDone = (effectiveStep & ONBOARDING_ALL_DONE) === ONBOARDING_ALL_DONE || isLocallyDone();
if (isDone) return null;

const items = [
...(!hasCustomAvatar ? [{ flag: ONBOARDING_FLAG_PHOTO, icon: FiCamera, label: "Add a photo" }] : []),
...(!hasBio ? [{ flag: ONBOARDING_FLAG_BIO, icon: FiFileText, label: "Write your bio" }] : []),
{ flag: ONBOARDING_FLAG_POST, icon: FiEdit3, label: "Intro post" },
];

const pendingItems = items.filter(({ flag }) => !(effectiveStep & flag));
const ctaLabel = pendingItems.length === 1 ? pendingItems[0].label : "finish setup";
Comment thread
mtlouzada marked this conversation as resolved.

const bgColor = theme.colors.panel || theme.colors.background;
const borderColor = theme.colors.border;
const dimColor = theme.colors.dim;

return (
<>
{/* Modal */}
<OnboardingModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>

{/* Floating card — hidden when dismissed for session OR when modal is open */}
{!isCardDismissed && !isModalOpen && (
<Box
position="fixed"
bottom={{ base: "70px", md: "24px" }}
right="16px"
zIndex={1400}
bg={bgColor}
border="1px solid"
borderColor={borderColor}
borderRadius="md"
boxShadow="lg"
w="200px"
overflow="hidden"
>
{/* Header */}
<Flex
align="center"
justify="space-between"
px={3}
py={1.5}
bg={bgColor}
borderBottom="1px solid"
borderColor={borderColor}
>
<Text fontSize="2xs" color={dimColor} fontFamily="mono">
profile.sh
</Text>
<IconButton
aria-label="Dismiss"
icon={<Icon as={FiX} boxSize={3} />}
size="xs"
variant="ghost"
minW="auto"
h="auto"
p={0}
color={dimColor}
onClick={() => setIsCardDismissed(true)}
/>
</Flex>

{/* Steps checklist */}
<VStack align="stretch" spacing={0} px={3} py={2}>
{items.map(({ flag, icon, label }) => {
const done = Boolean(effectiveStep & flag);
return (
<HStack key={flag} spacing={2} py={1}>
<Icon
as={done ? FiCheck : icon}
boxSize={3.5}
color={done ? "green.400" : "orange.400"}
flexShrink={0}
/>
<Text
fontSize="xs"
fontFamily="mono"
color={done ? "green.400" : "text"}
textDecoration={done ? "line-through" : "none"}
opacity={done ? 0.6 : 1}
>
{label}
</Text>
{!done && (
<Text fontSize="2xs" color="orange.400" fontFamily="mono" ml="auto">
pending
</Text>
)}
</HStack>
);
})}
</VStack>

{/* CTA */}
<Box px={3} pb={3}>
<Button
size="xs"
w="full"
colorScheme="orange"
fontFamily="mono"
onClick={() => setIsModalOpen(true)}
>
{ctaLabel}
</Button>
</Box>
</Box>
)}
</>
);
}
Loading