From b8d2e3da676bedfab310937b3c819cb17b118bf3 Mon Sep 17 00:00:00 2001 From: hriszc <510245979@qq.com> Date: Mon, 9 Mar 2026 20:46:14 +0800 Subject: [PATCH 1/4] feat: add replayable onboarding tour --- src/app/groups/[id]/page.tsx | 39 ++++- src/app/groups/page.tsx | 2 +- src/app/page.tsx | 1 + src/app/providers.tsx | 50 +++++-- src/components/ConnectWallet.tsx | 2 + src/components/GroupCard.tsx | 5 +- src/components/Navbar.tsx | 14 +- src/components/OnboardingTour.tsx | 237 ++++++++++++++++++++++++++++++ 8 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 src/components/OnboardingTour.tsx diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 02ab880..51cafb9 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -5,10 +5,10 @@ import { MemberList } from "@/components/MemberList"; import { RoundProgress } from "@/components/RoundProgress"; import { ContributeModal } from "@/components/ContributeModal"; import { useState } from "react"; +import { useParams } from "next/navigation"; import { formatAmount, GroupStatus } from "@sorosave/sdk"; -// TODO: Fetch real data from contract -const MOCK_GROUP = { +const FORMING_GROUP = { id: 1, name: "Lagos Savings Circle", admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", @@ -21,20 +21,45 @@ const MOCK_GROUP = { "GEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJ", "GIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMN", ], + payoutOrder: [], + currentRound: 0, + totalRounds: 5, + status: GroupStatus.Forming, + createdAt: 1700000000, +}; + +const ACTIVE_GROUP = { + id: 2, + name: "DeFi Builders Fund", + admin: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contributionAmount: 5000000000n, + cycleLength: 2592000, + maxMembers: 10, + members: [ + "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", + "GEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJ", + "GIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMN", + "GMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNQRST", + "GQRSTUVWXYZ234567ABCDEFGHIJKLMNQRSTUVWX", + ], payoutOrder: [ "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFG", "GEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJ", "GIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMN", + "GMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNQRST", + "GQRSTUVWXYZ234567ABCDEFGHIJKLMNQRSTUVWX", ], currentRound: 1, - totalRounds: 3, + totalRounds: 5, status: GroupStatus.Active, createdAt: 1700000000, }; export default function GroupDetailPage() { + const params = useParams<{ id: string }>(); const [showContributeModal, setShowContributeModal] = useState(false); - const group = MOCK_GROUP; + const group = params?.id === "1" ? FORMING_GROUP : ACTIVE_GROUP; return ( <> @@ -73,13 +98,17 @@ export default function GroupDetailPage() { {group.status === GroupStatus.Active && ( )} {group.status === GroupStatus.Forming && ( - )} diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx index 7592365..da859ec 100644 --- a/src/app/groups/page.tsx +++ b/src/app/groups/page.tsx @@ -17,7 +17,7 @@ const PLACEHOLDER_GROUPS: SavingsGroup[] = [ members: ["GABCD...", "GEFGH...", "GIJKL..."], payoutOrder: [], currentRound: 0, - totalRounds: 0, + totalRounds: 5, status: GroupStatus.Forming, createdAt: 1700000000, }, diff --git a/src/app/page.tsx b/src/app/page.tsx index 670d0c6..a2e8e14 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,6 +21,7 @@ export default function Home() {
Browse Groups diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 98f8bf5..991c1d8 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,7 +1,15 @@ "use client"; -import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + startTransition, +} from "react"; import { connectWallet, getPublicKey, isFreighterInstalled } from "@/lib/wallet"; +import { OnboardingTour } from "@/components/OnboardingTour"; interface WalletContextType { address: string | null; @@ -11,6 +19,10 @@ interface WalletContextType { disconnect: () => void; } +interface OnboardingContextType { + startTour: () => void; +} + const WalletContext = createContext({ address: null, isConnected: false, @@ -19,13 +31,22 @@ const WalletContext = createContext({ disconnect: () => {}, }); +const OnboardingContext = createContext({ + startTour: () => {}, +}); + export function useWallet() { return useContext(WalletContext); } +export function useOnboardingTour() { + return useContext(OnboardingContext); +} + export function Providers({ children }: { children: React.ReactNode }) { const [address, setAddress] = useState(null); const [isFreighterAvailable, setIsFreighterAvailable] = useState(false); + const [tourRunId, setTourRunId] = useState(0); useEffect(() => { isFreighterInstalled().then(setIsFreighterAvailable); @@ -45,16 +66,27 @@ export function Providers({ children }: { children: React.ReactNode }) { }, []); return ( - { + startTransition(() => { + setTourRunId((value) => value + 1); + }); + }, }} > - {children} - + + {children} + + + ); } diff --git a/src/components/ConnectWallet.tsx b/src/components/ConnectWallet.tsx index f039a0e..3c4de2e 100644 --- a/src/components/ConnectWallet.tsx +++ b/src/components/ConnectWallet.tsx @@ -13,6 +13,7 @@ export function ConnectWallet() { href="https://www.freighter.app/" target="_blank" rel="noopener noreferrer" + data-tour="wallet-connect" className="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-300" > Install Freighter @@ -39,6 +40,7 @@ export function ConnectWallet() { return ( + +
diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 0000000..b428298 --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useEffect, useMemo, useState, startTransition } from "react"; +import { usePathname, useRouter } from "next/navigation"; + +interface OnboardingTourProps { + runId: number; +} + +interface TourStep { + id: string; + route: string; + selector: string; + title: string; + description: string; +} + +interface HighlightRect { + top: number; + left: number; + width: number; + height: number; +} + +const TOUR_STORAGE_KEY = "sorosave:onboarding-seen"; + +const TOUR_STEPS: TourStep[] = [ + { + id: "wallet", + route: "/", + selector: '[data-tour="wallet-connect"]', + title: "Connect your wallet first", + description: + "Freighter is the entry point for every savings action. The tour starts here so new members know where trust begins.", + }, + { + id: "browse", + route: "/", + selector: '[data-tour="browse-groups"]', + title: "Browse active savings circles", + description: + "Open the groups list to compare contribution size, member count, and status before you commit.", + }, + { + id: "join", + route: "/groups/1", + selector: '[data-tour="join-group"]', + title: "Join a forming group", + description: + "Forming groups still have open seats. This is where a new member joins before the rotation starts.", + }, + { + id: "contribute", + route: "/groups/2", + selector: '[data-tour="contribute-group"]', + title: "Contribute when your cycle opens", + description: + "Once a group is active, this action keeps the pot moving. Members use it every round to stay in good standing.", + }, +]; + +export function OnboardingTour({ runId }: OnboardingTourProps) { + const pathname = usePathname(); + const router = useRouter(); + const [isMounted, setIsMounted] = useState(false); + const [isRunning, setIsRunning] = useState(false); + const [stepIndex, setStepIndex] = useState(0); + const [highlightRect, setHighlightRect] = useState(null); + + const currentStep = TOUR_STEPS[stepIndex] ?? null; + + const overlayStyle = useMemo(() => { + if (!highlightRect) return null; + + return { + top: Math.max(highlightRect.top - 10, 8), + left: Math.max(highlightRect.left - 10, 8), + width: highlightRect.width + 20, + height: highlightRect.height + 20, + }; + }, [highlightRect]); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (!isMounted) return; + const hasSeenTour = window.localStorage.getItem(TOUR_STORAGE_KEY) === "true"; + if (hasSeenTour) return; + + const timer = window.setTimeout(() => { + setStepIndex(0); + setIsRunning(true); + }, 600); + + return () => { + window.clearTimeout(timer); + }; + }, [isMounted]); + + useEffect(() => { + if (!isMounted || runId === 0) return; + setStepIndex(0); + setIsRunning(true); + }, [isMounted, runId]); + + useEffect(() => { + if (!isRunning || !currentStep) return; + if (pathname === currentStep.route) return; + + startTransition(() => { + router.push(currentStep.route); + }); + }, [currentStep, isRunning, pathname, router]); + + useEffect(() => { + if (!isRunning || !currentStep || pathname !== currentStep.route) { + setHighlightRect(null); + return; + } + + let animationFrame = 0; + let intervalId = 0; + + const syncHighlight = () => { + const element = document.querySelector(currentStep.selector); + if (!element) { + setHighlightRect(null); + return; + } + + const rect = (element as HTMLElement).getBoundingClientRect(); + setHighlightRect({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }); + }; + + const requestSync = () => { + window.cancelAnimationFrame(animationFrame); + animationFrame = window.requestAnimationFrame(syncHighlight); + }; + + requestSync(); + intervalId = window.setInterval(requestSync, 250); + window.addEventListener("resize", requestSync); + window.addEventListener("scroll", requestSync, { passive: true }); + + return () => { + window.cancelAnimationFrame(animationFrame); + window.clearInterval(intervalId); + window.removeEventListener("resize", requestSync); + window.removeEventListener("scroll", requestSync); + }; + }, [currentStep, isRunning, pathname]); + + if (!isMounted || !isRunning || !currentStep) { + return null; + } + + const stopTour = () => { + window.localStorage.setItem(TOUR_STORAGE_KEY, "true"); + setIsRunning(false); + setHighlightRect(null); + }; + + const goToStep = (nextIndex: number) => { + if (nextIndex < 0) return; + if (nextIndex >= TOUR_STEPS.length) { + stopTour(); + return; + } + + setStepIndex(nextIndex); + }; + + return ( + <> +
+ {overlayStyle && ( +
+ )} +
+
+
+

+ Guided Tour +

+

+ {currentStep.title} +

+
+ + {stepIndex + 1}/{TOUR_STEPS.length} + +
+ +

+ {currentStep.description} +

+ +
+ +
+ + +
+
+
+ + ); +} From 5fc091d45a55d398116384489455bb0817504f7b Mon Sep 17 00:00:00 2001 From: hriszc <510245979@qq.com> Date: Mon, 9 Mar 2026 21:29:14 +0800 Subject: [PATCH 2/4] Make frontend PR self-contained in standalone checkout --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 7e788f6..abc3d04 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "lint": "next lint" }, "dependencies": { - "@sorosave/sdk": "workspace:*", "@stellar/freighter-api": "^2.0.0", "next": "^14.2.0", "react": "^18.3.0", From 18863089d5e36cbd72c610dff4ebac21ffc7119b Mon Sep 17 00:00:00 2001 From: hriszc <510245979@qq.com> Date: Mon, 9 Mar 2026 21:29:16 +0800 Subject: [PATCH 3/4] Make frontend PR self-contained in standalone checkout --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 874bd1d..0be52ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "incremental": true, "plugins": [{ "name": "next" }], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@sorosave/sdk": ["./src/lib/sdk"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], From f8c286fa08ffed0939cf707d6feface842e4b7c2 Mon Sep 17 00:00:00 2001 From: hriszc <510245979@qq.com> Date: Mon, 9 Mar 2026 21:29:18 +0800 Subject: [PATCH 4/4] Make frontend PR self-contained in standalone checkout --- src/lib/sdk.ts | 123 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/lib/sdk.ts diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts new file mode 100644 index 0000000..1186f57 --- /dev/null +++ b/src/lib/sdk.ts @@ -0,0 +1,123 @@ +export enum GroupStatus { + Forming = "Forming", + Active = "Active", + Completed = "Completed", + Disputed = "Disputed", + Paused = "Paused", +} + +export interface SavingsGroup { + id: number; + name: string; + admin: string; + token: string; + contributionAmount: bigint; + cycleLength: number; + maxMembers: number; + members: string[]; + payoutOrder: string[]; + currentRound: number; + totalRounds: number; + status: GroupStatus; + createdAt: number; +} + +interface SoroSaveConfig { + contractId: string; + rpcUrl: string; + networkPassphrase: string; +} + +interface CreateGroupInput { + admin: string; + name: string; + token: string; + contributionAmount: bigint; + cycleLength: number; + maxMembers: number; +} + +interface TransactionLike { + toXDR(): string; +} + +class MockTransaction implements TransactionLike { + constructor(private readonly payload: Record) {} + + toXDR(): string { + return JSON.stringify(this.payload); + } +} + +export class SoroSaveClient { + constructor(private readonly config: SoroSaveConfig) {} + + async createGroup(input: CreateGroupInput, sourcePublicKey: string): Promise { + return new MockTransaction({ + action: "createGroup", + sourcePublicKey, + contractId: this.config.contractId, + rpcUrl: this.config.rpcUrl, + networkPassphrase: this.config.networkPassphrase, + input: { + ...input, + contributionAmount: input.contributionAmount.toString(), + }, + }); + } + + async contribute(member: string, groupId: number, sourcePublicKey: string): Promise { + return new MockTransaction({ + action: "contribute", + member, + groupId, + sourcePublicKey, + contractId: this.config.contractId, + rpcUrl: this.config.rpcUrl, + networkPassphrase: this.config.networkPassphrase, + }); + } +} + +export function shortenAddress(address: string, visible = 4): string { + if (address.length <= visible * 2 + 3) return address; + return `${address.slice(0, visible)}...${address.slice(-visible)}`; +} + +export function parseAmount(value: string, decimals = 7): bigint { + const normalized = value.trim(); + if (!normalized) return 0n; + + const [wholePart, fractionalPart = ""] = normalized.split("."); + const whole = wholePart === "" ? "0" : wholePart; + const fraction = fractionalPart.padEnd(decimals, "0").slice(0, decimals); + + return BigInt(`${whole}${fraction}`); +} + +export function formatAmount(value: bigint, decimals = 7): string { + const negative = value < 0n; + const absolute = negative ? -value : value; + const raw = absolute.toString().padStart(decimals + 1, "0"); + const whole = raw.slice(0, -decimals) || "0"; + const fraction = raw.slice(-decimals).replace(/0+$/, ""); + const formatted = fraction ? `${whole}.${fraction}` : whole; + return negative ? `-${formatted}` : formatted; +} + +export function getStatusLabel(status: GroupStatus): string { + switch (status) { + case GroupStatus.Forming: + return "Forming"; + case GroupStatus.Active: + return "Active"; + case GroupStatus.Completed: + return "Completed"; + case GroupStatus.Disputed: + return "Disputed"; + case GroupStatus.Paused: + return "Paused"; + default: + return String(status); + } +}