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
115 changes: 115 additions & 0 deletions frontend/app/components/OnboardingWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import React from "react";
import { useOnboarding } from "../context/OnboardingContext";
import { ArrowLeft, ArrowRight, CheckCircle2, Sparkles, X } from "lucide-react";
import { Button } from "./ui/Button";

const ONBOARDING_STEPS = [
{
title: "Welcome to Nestera!",
description: "Your gateway to smart savings and decentralized finance.",
icon: <Sparkles className="w-12 h-12 text-emerald-400" />,
},
{
title: "Connect Your Wallet",
description: "Link your crypto wallet to start using all features.",
icon: <ArrowRight className="w-12 h-12 text-blue-400" />,
},
{
title: "Explore the Dashboard",
description: "Get familiar with your portfolio, goals, and savings pools.",
icon: <CheckCircle2 className="w-12 h-12 text-purple-400" />,
},
{
title: "Create Your First Goal",
description: "Set a savings target and start growing your funds.",
icon: <Sparkles className="w-12 h-12 text-amber-400" />,
},
{
title: "Make Your First Deposit",
description: "Fund your goal and watch your savings grow with yield.",
icon: <CheckCircle2 className="w-12 h-12 text-rose-400" />,
},
{
title: "You're All Set!",
description: "Congratulations! You're ready to start your savings journey.",
icon: <Sparkles className="w-12 h-12 text-emerald-400" />,
},
];

export function OnboardingWizard() {
const {
isOnboardingActive,
currentStep,
nextStep,
prevStep,
completeOnboarding,
skipOnboarding,
} = useOnboarding();

if (!isOnboardingActive) return null;

const step = ONBOARDING_STEPS[currentStep];
const isLastStep = currentStep === ONBOARDING_STEPS.length - 1;
const isFirstStep = currentStep === 0;

return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="w-full max-w-lg rounded-2xl bg-gray-900 p-8 shadow-2xl border border-gray-700">
<div className="flex justify-between items-center mb-6">
<span className="text-sm text-gray-400">
Step {currentStep + 1} of {ONBOARDING_STEPS.length}
</span>
<button
onClick={skipOnboarding}
className="text-sm text-gray-400 hover:text-white transition-colors"
aria-label="Skip onboarding"
>
Skip
</button>
</div>

<div className="w-full h-2 bg-gray-800 rounded-full mb-8 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-400 to-cyan-400 transition-all duration-300"
style={{
width: `${((currentStep + 1) / ONBOARDING_STEPS.length) * 100}%`,
}}
/>
</div>

<div className="flex flex-col items-center text-center mb-8">
<div className="mb-6 p-4 rounded-full bg-gray-800">
{step.icon}
</div>
<h2 className="text-2xl font-bold text-white mb-2">{step.title}</h2>
<p className="text-gray-400">{step.description}</p>
</div>

<div className="flex gap-3">
{!isFirstStep && (
<Button
variant="secondary"
onClick={prevStep}
className="flex-1"
aria-label="Previous step"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Previous
</Button>
)}
<Button
variant="primary"
onClick={isLastStep ? completeOnboarding : nextStep}
className={isFirstStep ? "flex-1" : "flex-1"}
aria-label={isLastStep ? "Complete onboarding" : "Next step"}
>
{isLastStep ? "Get Started" : "Next"}
{!isLastStep && <ArrowRight className="w-4 h-4 ml-2" />}
</Button>
</div>
</div>
</div>
);
}
128 changes: 128 additions & 0 deletions frontend/app/context/OnboardingContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client";

import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";

const ONBOARDING_STORAGE_KEY = "nestera-onboarding-completed";

interface OnboardingContextValue {
isOnboardingCompleted: boolean;
isOnboardingActive: boolean;
currentStep: number;
startOnboarding: () => void;
completeOnboarding: () => void;
resetOnboarding: () => void;
nextStep: () => void;
prevStep: () => void;
goToStep: (step: number) => void;
skipOnboarding: () => void;
}

const OnboardingContext = createContext<OnboardingContextValue | null>(null);

function readInitialOnboardingState(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(ONBOARDING_STORAGE_KEY) === "true";
} catch {
return false;
}
}

export function OnboardingProvider({ children }: { children: React.ReactNode }) {
const [isOnboardingCompleted, setIsOnboardingCompleted] = useState<boolean>(() =>
readInitialOnboardingState()
);
const [isOnboardingActive, setIsOnboardingActive] = useState<boolean>(false);
const [currentStep, setCurrentStep] = useState<number>(0);

useEffect(() => {
if (!isOnboardingCompleted) {
setIsOnboardingActive(true);
}
}, [isOnboardingCompleted]);

const startOnboarding = useCallback(() => {
setCurrentStep(0);
setIsOnboardingActive(true);
}, []);

const completeOnboarding = useCallback(() => {
setIsOnboardingActive(false);
setIsOnboardingCompleted(true);
try {
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, "true");
} catch {}
}, []);

const resetOnboarding = useCallback(() => {
setIsOnboardingCompleted(false);
try {
window.localStorage.removeItem(ONBOARDING_STORAGE_KEY);
} catch {}
startOnboarding();
}, [startOnboarding]);

const nextStep = useCallback(() => {
setCurrentStep((prev) => prev + 1);
}, []);

const prevStep = useCallback(() => {
setCurrentStep((prev) => Math.max(0, prev - 1));
}, []);

const goToStep = useCallback((step: number) => {
setCurrentStep(step);
}, []);

const skipOnboarding = useCallback(() => {
completeOnboarding();
}, [completeOnboarding]);

const value = useMemo(
() => ({
isOnboardingCompleted,
isOnboardingActive,
currentStep,
startOnboarding,
completeOnboarding,
resetOnboarding,
nextStep,
prevStep,
goToStep,
skipOnboarding,
}),
[
isOnboardingCompleted,
isOnboardingActive,
currentStep,
startOnboarding,
completeOnboarding,
resetOnboarding,
nextStep,
prevStep,
goToStep,
skipOnboarding,
]
);

return (
<OnboardingContext.Provider value={value}>
{children}
</OnboardingContext.Provider>
);
}

export function useOnboarding() {
const context = useContext(OnboardingContext);
if (!context) {
throw new Error("useOnboarding must be used within OnboardingProvider");
}
return context;
}
28 changes: 23 additions & 5 deletions frontend/app/dashboard/DashboardProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

import React from "react";
import { FeatureFlagProvider } from "../context/FeatureFlagContext";
import { WalletProvider } from "../context/WalletContext";
import { OnboardingProvider } from "../context/OnboardingContext";
import { OnboardingWizard } from "../components/OnboardingWizard";
import { useWallet } from "../context/WalletContext";

/**
* Client-side providers for the dashboard.
* Bridges server layout → client context.
*/
export default function DashboardProviders({
function InnerDashboardProviders({
children,
}: {
children: React.ReactNode;
Expand All @@ -18,6 +17,25 @@ export default function DashboardProviders({
return (
<FeatureFlagProvider userContext={{ address, network }}>
{children}
<OnboardingWizard />
</FeatureFlagProvider>
);
}

/**
* Client-side providers for the dashboard.
* Bridges server layout → client context.
*/
export default function DashboardProviders({
children,
}: {
children: React.ReactNode;
}) {
return (
<WalletProvider>
<OnboardingProvider>
<InnerDashboardProviders>{children}</InnerDashboardProviders>
</OnboardingProvider>
</WalletProvider>
);
}
21 changes: 21 additions & 0 deletions frontend/app/dashboard/settings/SettingsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React from "react";
import { useForm } from "react-hook-form";
import { Settings } from "lucide-react";
import { useTranslations } from "next-intl";
import { useOnboarding } from "../../context/OnboardingContext";

type Prefs = {
emailNotifications?: boolean;
Expand All @@ -16,6 +17,7 @@ type Prefs = {

export default function SettingsClient() {
const t = useTranslations("Settings");
const { resetOnboarding } = useOnboarding();
const [optimisticPrefs, setOptimisticPrefs] = React.useState<Prefs>({
emailNotifications: false,
inAppNotifications: false,
Expand Down Expand Up @@ -211,6 +213,25 @@ export default function SettingsClient() {
</p>
)}
</form>

<div className="mt-8 pt-6 border-t border-[rgba(8,120,120,0.06)]">
<h2 className="text-lg font-semibold text-white mb-4">Tutorial</h2>
<div className="flex items-center justify-between">
<div>
<div className="text-white font-medium">Show onboarding tutorial again</div>
<div className="text-sm text-[#5e8c96]">
Replay the interactive guide to Nestera
</div>
</div>
<button
type="button"
onClick={resetOnboarding}
className="px-4 py-2 rounded border border-[#06b6b6] text-[#06b6b6] hover:bg-[#06b6b6] hover:text-black transition-colors"
>
Restart Tutorial
</button>
</div>
</div>
</div>
</div>
);
Expand Down