Improve onboarding screen

This commit is contained in:
Arnaud 2024-10-21 13:04:50 +02:00
parent ed932e1baf
commit d4667c5ec7
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
13 changed files with 440 additions and 107 deletions

View File

@ -1,14 +1,10 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CodexSdk } from "../../sdk/codex";
import { import {
NetworkIndicator, NetworkIndicator,
Toast, Toast,
} from "@codex-storage/marketplace-ui-components"; } from "@codex-storage/marketplace-ui-components";
import { Promises } from "../../utils/promises"; import { useCodexConnection } from "../../hooks/useCodexConnection";
const report = false;
export let isCodexOnline: boolean | null = null;
export function NodeIndicator() { export function NodeIndicator() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -16,44 +12,19 @@ export function NodeIndicator() {
time: 0, time: 0,
message: "", message: "",
}); });
const codex = useCodexConnection();
const { data, isError } = useQuery({
queryKey: ["spr"],
queryFn: async () => {
return CodexSdk.node()
.spr()
.then((data) => Promises.rejectOnError(data, report));
},
refetchInterval: 5000,
// No need to retry because we defined a refetch interval
retry: false,
// The client node should be local, so display the cache value while
// making a background request looks good.
staleTime: 0,
// Refreshing when focus returns can be useful if a user comes back
// to the UI after performing an operation in the terminal.
refetchOnWindowFocus: true,
// Cache is not useful for the spr endpoint
gcTime: 0,
});
isCodexOnline = !isError && !!data;
useEffect(() => { useEffect(() => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
type: "active", type: "active",
refetchType: "all", refetchType: "all",
}); });
}, [queryClient, isCodexOnline]); }, [queryClient, codex.enabled]);
return ( return (
<> <>
<Toast message={toast.message} time={toast.time} variant="success" /> <Toast message={toast.message} time={toast.time} variant="success" />
<NetworkIndicator online={isCodexOnline} text="Codex node" /> <NetworkIndicator online={codex.enabled} text="Codex node" />
</> </>
); );
} }

View File

@ -0,0 +1,100 @@
import { AlphaIcon } from "./AlphaIcon";
import { AlphaText } from "../AlphaText/AlphaText";
import { Modal, SimpleText } from "@codex-storage/marketplace-ui-components";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
type Props = {
onNextStep: () => void;
};
export function OnBoardingStepOne({ onNextStep }: Props) {
const [modal, setModal] = useState(false);
const onLegalDisclaimerOpen = () => setModal(true);
const onLegalDisclaimerClose = () => setModal(false);
return (
<>
<div className="index-column-section">
<div>
<AlphaIcon />
</div>
<div className="index-alphaText">
<p>
<AlphaText></AlphaText>
</p>
<p>
<SimpleText className="index-version" variant="normal">
{import.meta.env.PACKAGE_VERSION}
</SimpleText>
</p>
<p>
<SimpleText
className="index-disclaimer"
variant="error"
onClick={onLegalDisclaimerOpen}>
<a className="index-link">Legal Disclaimer</a>
</SimpleText>
</p>
</div>
</div>
<div className="index-column-section">
<h3 className="index-mainTitle">
Hello,
<br /> Welcome to <span className="index-codex">Codex</span>{" "}
<span className="index-vault">Vault</span>
</h3>
<p className="index-description">
<SimpleText variant="light">
Codex is a durable, decentralised data storage protocol, created so
the world community can preserve its most important knowledge
without risk of censorship.
</SimpleText>
</p>
</div>
<div className="index-column-section ">
<SimpleText variant="primary">
<a onClick={onNextStep} className="index-link index-getStarted">
Lets get started <ArrowRight></ArrowRight>
</a>
</SimpleText>
<Modal onClose={onLegalDisclaimerClose} open={modal}>
<h1 className="disclaimer-title" style={{ marginTop: 0 }}>
Disclaimer
</h1>
<p className="disclaimer-text">
The website and the content herein is not intended for public use
and is for informational and demonstration purposes only.
</p>
<br />
<p className="disclaimer-text">
The website and any associated functionalities are provided on an
as is basis without any guarantees, warranties, or representations
of any kind, either express or implied. The website and any
associated functionalities may not reflect the final version of the
project and is subject to changes, updates, or removal at any time
and without notice.
</p>
<br />
<p className="disclaimer-text">
By accessing and using this website, you agree that we, Logos
Collective Association and its affiliates, will not be liable for
any direct, indirect, incidental, or consequential damages arising
from the use of, or inability to use, this website. Any data,
content, or interactions on this site are non-binding and should not
be considered final or actionable. Your use of this website is at
your sole risk.
</p>
</Modal>
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
.onboarding-check {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.onboarding-check-icon--valid {
color: var(--codex-color-primary);
}
.onboarding-check-icon--invalid {
color: rgb(var(--codex-color-error));
}
.onboarding-check-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.onboarding-check-refresh {
cursor: pointer;
}
.onboarding-check-refresh--fetching {
animation: rotateAnimation 2s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg); /* Start at 0 degrees */
}
to {
transform: rotate(360deg); /* End at 360 degrees */
}
}

View File

@ -0,0 +1,114 @@
import { CheckIcon, RefreshCcw, X } from "lucide-react";
import { classnames } from "../../utils/classnames";
import "./OnBoardingStepThree.css";
import { usePortForwarding } from "../../hooks/usePortForwarding";
import { useCodexConnection } from "../../hooks/useCodexConnection";
import { SimpleText } from "@codex-storage/marketplace-ui-components";
import { useEffect } from "react";
type Props = {
online: boolean;
onStepValid: (valid: boolean) => void;
};
export function OnBoardingStepThree({ online, onStepValid }: Props) {
const portForwarding = usePortForwarding(online);
const codex = useCodexConnection();
useEffect(() => {
onStepValid(online && portForwarding.enabled && codex.enabled);
}, [portForwarding.enabled, codex.enabled, onStepValid, online]);
const InternetIcon = online ? CheckIcon : X;
const PortForWarningIcon = portForwarding.enabled ? CheckIcon : X;
const CodexIcon = codex.enabled ? CheckIcon : X;
return (
<div className="index-column-section">
<div
className={classnames(
["onboarding-check"],
["onboarding-check--valid", online]
)}>
<InternetIcon
className={classnames(
["onboarding-check-icon--valid", online],
["onboarding-check-icon--invalid", !online]
)}
/>
<div>
<p>Internet connection</p>
<SimpleText variant="light">
Status indicator for the Internet.
</SimpleText>
</div>
</div>
<div
className={classnames(
["onboarding-check"],
["onboarding-check--valid", portForwarding.enabled]
)}>
<PortForWarningIcon
className={classnames(
["onboarding-check-icon--valid", portForwarding.enabled],
["onboarding-check-icon--invalid", !portForwarding.enabled]
)}
/>
<div className="onboarding-check-line">
<div>
<p>Port forwarding</p>
<SimpleText variant="light">
Status indicator for port forwarding activation.
</SimpleText>
</div>
{!portForwarding.enabled && (
<a
className="onboarding-check-refresh"
onClick={() => portForwarding.refetch()}>
<RefreshCcw
size={"1.25rem"}
className={classnames([
"onboarding-check-refresh--fetching",
portForwarding.isFetching,
])}
/>
</a>
)}
</div>
</div>
<div
className={classnames(
["onboarding-check"],
["onboarding-check--valid", codex.enabled]
)}>
<CodexIcon
className={classnames(
["onboarding-check-icon--valid", codex.enabled],
["onboarding-check-icon--invalid", !codex.enabled]
)}
/>
<div className="onboarding-check-line">
<div>
<p>Codex connection</p>
<SimpleText variant="light">
Status indicator for the Codex network.
</SimpleText>
</div>
{!codex.enabled && (
<a
className="onboarding-check-refresh"
onClick={() => codex.refetch()}>
<RefreshCcw
size={"1.25rem"}
className={classnames([
"onboarding-check-refresh--fetching",
codex.isFetching,
])}
/>
</a>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { Input } from "@codex-storage/marketplace-ui-components";
import { ChangeEvent, useState } from "react";
type Props = {
onStepValid: (valid: boolean) => void;
};
export function OnBoardingStepTwo({ onStepValid }: Props) {
const [displayName, setDisplayName] = useState("");
const onDisplayNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.currentTarget.value);
onStepValid(!!e.currentTarget.value);
};
return (
<>
<div className="index-column-section">
<Input
onChange={onDisplayNameChange}
label="Display name"
id="displayName"
value={displayName}></Input>
</div>
</>
);
}

View File

@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { CodexSdk } from "../sdk/codex";
import { Promises } from "../utils/promises";
const report = false;
export function useCodexConnection() {
const { data, isError, isFetching, refetch } = useQuery({
queryKey: ["spr"],
queryFn: async () => {
return CodexSdk.node()
.spr()
.then((data) => Promises.rejectOnError(data, report));
},
refetchInterval: 5000,
// No need to retry because we defined a refetch interval
retry: false,
// The client node should be local, so display the cache value while
// making a background request looks good.
staleTime: 0,
// Refreshing when focus returns can be useful if a user comes back
// to the UI after performing an operation in the terminal.
refetchOnWindowFocus: true,
// Cache is not useful for the spr endpoint
gcTime: 0,
});
return { enabled: !isError && !!data, isFetching, refetch };
}

View File

@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import { Errors } from "../utils/errors";
type PortForwardingResponse = { reachable: boolean };
export function usePortForwarding(online: boolean) {
const { data, isFetching, refetch } = useQuery({
queryFn: (): Promise<PortForwardingResponse> =>
fetch(import.meta.env.VITE_ECHO_URL + "/port/8070")
.then((res) => res.json())
.catch((e) => Errors.report(e)),
queryKey: ["port-forwarding"],
initialData: { reachable: false },
// Enable only when the use has an internet connection
enabled: !!online,
// No need to retry because we provide a retry button
retry: false,
// The data should not be cached
staleTime: 0,
// The user may try to change the port forwarding and go back
// to the tab
refetchOnWindowFocus: true,
});
return { enabled: data.reachable, isFetching, refetch };
}

View File

@ -52,7 +52,22 @@
font-size: var(--codex-font-size); font-size: var(--codex-font-size);
color-scheme: dark; color-scheme: dark;
color: var(--codex-color); color: var(--codex-color);
background: linear-gradient(246.02deg, #000000 30.36%, #222222 91.05%); background: #000000; /* Fallback color */
background: -webkit-linear-gradient(
246.02deg,
#000000 30.36%,
#222222 91.05%
); /* For Safari and older Chrome */
background: -moz-linear-gradient(
246.02deg,
#000000 30.36%,
#222222 91.05%
); /* For older Firefox */
background: linear-gradient(
246.02deg,
#000000 30.36%,
#222222 91.05%
); /* Standard syntax */
} }
::selection { ::selection {
@ -140,6 +155,12 @@ pre {
a { a {
text-decoration: inherit; text-decoration: inherit;
color: var(--codex-color); color: var(--codex-color);
transition: opacity 0.35s;
}
a[aria-disabled] {
opacity: 0.6;
cursor: not-allowed;
} }
.root { .root {

View File

@ -77,7 +77,7 @@
filter: brightness(1); filter: brightness(1);
-webkit-filter: brightness(1); -webkit-filter: brightness(1);
} }
50% { 30% {
filter: brightness(0.7); filter: brightness(0.7);
-webkit-filter: brightness(0.7); -webkit-filter: brightness(0.7);
} }
@ -167,7 +167,6 @@
display: inline-block; display: inline-block;
border-radius: 50%; border-radius: 50%;
transition: opacity 0.35s; transition: opacity 0.35s;
cursor: pointer;
} }
.index-dot:not(.index-dot--active) { .index-dot:not(.index-dot--active) {
@ -175,10 +174,24 @@
} }
.index-dot:hover { .index-dot:hover {
opacity: 0.8; animation-name: pulse;
animation-duration: 2.5s;
animation-iteration-count: infinite;
} }
.index-dot--active { .index-dot--active {
box-shadow: 0px 0px 13px 2px #fff; box-shadow: 0px 0px 12px 0px #fff;
opacity: 1; opacity: 1;
} }
@keyframes pulse {
0% {
opacity: 0.4;
}
30% {
opacity: 0.8;
}
100% {
opacity: 0.4;
}
}

View File

@ -1,14 +1,16 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import "./index.css"; import "./index.css";
import { AlphaIcon } from "../components/AlphaIcon/AlphaIcon";
import { AlphaText } from "../components/AlphaText/AlphaText";
import { SimpleText } from "@codex-storage/marketplace-ui-components";
import { ArrowRight } from "lucide-react";
import { CodexLogo } from "../components/CodexLogo/CodexLogo";
import { ArrowRightCircle } from "../components/ArrowRightCircle/ArrowRightCircle"; import { ArrowRightCircle } from "../components/ArrowRightCircle/ArrowRightCircle";
import { useNetwork } from "../network/useNetwork"; import { useNetwork } from "../network/useNetwork";
import { NetworkIcon } from "../components/NetworkIcon/NetworkIcon"; import { NetworkIcon } from "../components/NetworkIcon/NetworkIcon";
import { Logotype } from "../components/Logotype/Logotype"; import { Logotype } from "../components/Logotype/Logotype";
import { useState } from "react";
import { OnBoardingStepOne } from "../components/OnBoarding/OnBoardingStepOne";
import { OnBoardingStepTwo } from "../components/OnBoarding/OnBoardingStepTwo";
import { classnames } from "../utils/classnames";
import { OnBoardingStepThree } from "../components/OnBoarding/OnBoardingStepThree";
import { attributes } from "../utils/attributes";
import { CodexLogo } from "../components/CodexLogo/CodexLogo";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: Index, component: Index,
@ -20,7 +22,31 @@ export const Route = createFileRoute("/")({
}); });
function Index() { function Index() {
const [isStepValid, setIsStepValid] = useState(true);
const [step, setStep] = useState(0);
const online = useNetwork(); const online = useNetwork();
const navigate = useNavigate({ from: "/" });
const onStepValid = (valid: boolean) => setIsStepValid(valid);
const onNextStep = () => {
if (!isStepValid) {
return;
}
if (step === 2) {
navigate({ to: "/dashboard" });
return;
}
setStep(step + 1);
setIsStepValid(false);
};
const components = [
<OnBoardingStepOne onNextStep={onNextStep} />,
<OnBoardingStepTwo onStepValid={onStepValid} />,
<OnBoardingStepThree online={online} onStepValid={onStepValid} />,
];
const text = online ? "Network connected" : "Network disconnected"; const text = online ? "Network connected" : "Network disconnected";
@ -32,51 +58,25 @@ function Index() {
<Logotype /> <Logotype />
</div> </div>
<div className="index-column-section"> {components[step]}
<div>
<AlphaIcon />
</div>
<div className="index-alphaText">
<p>
<AlphaText></AlphaText>
</p>
<p>
<SimpleText className="index-version" variant="normal">
{import.meta.env.PACKAGE_VERSION}
</SimpleText>
</p>
<p>
<SimpleText className="index-disclaimer" variant="error">
<a className="index-link">Legal Disclaimer</a>
</SimpleText>
</p>
</div>
</div>
<div className="index-column-section">
<h3 className="index-mainTitle">
Hello,
<br /> Welcome to <span className="index-codex">Codex</span>{" "}
<span className="index-vault">Vault</span>
</h3>
<p className="index-description">
<SimpleText variant="light">
Codex is a durable, decentralised data storage protocol, created
so the world community can preserve its most important knowledge
without risk of censorship.
</SimpleText>
</p>
</div>
<div className="index-column-section ">
<SimpleText variant="primary">
<a href="/dashboard" className="index-link index-getStarted">
Lets get started <ArrowRight></ArrowRight>
</a>
</SimpleText>
<div className=" ">
<div className="index-dots"> <div className="index-dots">
<span className="index-dot index-dot--active"></span> <span
<span className="index-dot"></span> className={classnames(
<span className="index-dot"></span> ["index-dot"],
["index-dot--active", step === 0]
)}></span>
<span
className={classnames(
["index-dot"],
["index-dot--active", step === 1]
)}></span>
<span
className={classnames(
["index-dot"],
["index-dot--active", step === 2]
)}></span>
</div> </div>
</div> </div>
</div> </div>
@ -88,7 +88,10 @@ function Index() {
</div> </div>
<CodexLogo></CodexLogo> <CodexLogo></CodexLogo>
</div> </div>
<a href="/dashboard" className="index-link2"> <a
className="index-link2"
{...attributes({ "aria-disabled": !isStepValid })}
onClick={onNextStep}>
<ArrowRightCircle></ArrowRightCircle> <ArrowRightCircle></ArrowRightCircle>
</a> </a>
</div> </div>

View File

@ -1,23 +1,6 @@
import * as Sentry from "@sentry/browser"; import * as Sentry from "@sentry/browser";
import { isCodexOnline } from "../components/NodeIndicator/NodeIndicator";
import { CodexError } from "@codex-storage/sdk-js"; import { CodexError } from "@codex-storage/sdk-js";
// It would be preferable to completely ignore the error
// when the node is not connected. However, during the
// initial load, we lack this information until the
// SPR response is completed. In the meantime, other
// requests may be initiated, so if the node is not
// connected, we should set the level to 'log'.
const getLogLevel = () => {
switch (isCodexOnline) {
case true:
return "error";
case null:
return "info";
case false:
return "log";
}
};
export const Errors = { export const Errors = {
report(safe: { error: true, data: CodexError }) { report(safe: { error: true, data: CodexError }) {
@ -26,7 +9,6 @@ export const Errors = {
code: safe.data.code, code: safe.data.code,
errors: safe.data.errors, errors: safe.data.errors,
sourceStack: safe.data.sourceStack, sourceStack: safe.data.sourceStack,
level: getLogLevel(),
}, },
}); });
} }

1
src/vite-env.d.ts vendored
View File

@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
VITE_CODEX_API_URL: string; VITE_CODEX_API_URL: string;
VITE_ECHO_URL: string;
} }
interface ImportMeta { interface ImportMeta {