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
2 changes: 2 additions & 0 deletions packages/widget/src/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { AnimatedActivityPage } from "./pages/details/activity-page/activity.pag
import { Details } from "./pages/details/details-page/details.page";
import { AnimatedEarnPage } from "./pages/details/earn-page/earn.page";
import { AnimatedPositionsPage } from "./pages/details/positions-page/positions.page";
import { KycGatePage } from "./pages/kyc/kyc-gate.page";
import { PositionDetailsPage } from "./pages/position-details";
import { StakeReviewPage } from "./pages/review";
import { ActionReviewPage } from "./pages/review/pages/action-review.page";
Expand Down Expand Up @@ -133,6 +134,7 @@ export const Widget = () => {

{/* Stake flow */}
<Route>
<Route path="kyc" element={<KycGatePage />} />
<Route path="review" element={<StakeReviewPage />} />
<Route path="steps" element={<StakeStepsPage />} />
<Route path="complete" element={<StakeCompletePage />} />
Expand Down
34 changes: 34 additions & 0 deletions packages/widget/src/components/atoms/icons/shield-check.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { vars } from "../../../styles/theme/contract.css";

export const ShieldCheckIcon = ({
width,
height,
color = "text",
}: {
width?: number;
height?: number;
color?: Exclude<keyof (typeof vars)["color"], "connectKit">;
}) => (
<svg
width={width ?? 48}
height={height ?? 48}
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
d="M12 2 4 5v6c0 5 3.4 8.27 8 10 4.6-1.73 8-5 8-10V5l-8-3Z"
stroke={vars.color[color]}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m8.75 12 2.25 2.25L15.5 9.75"
stroke={vars.color[color]}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Content, Overlay, Portal, Root, Title } from "@radix-ui/react-dialog";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { SettingsContext } from "../../../providers/settings";
import { id } from "../../../styles/theme/ids";
import { Box } from "../../atoms/box";
import { XIcon } from "../../atoms/icons/x-icon";
import { Text } from "../../atoms/typography/text";
import { container, content, iframe, overlay } from "./styles.css";

type KycIframeModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
url: string;
title: string;
};

export const KycIframeModal = ({
open,
onOpenChange,
url,
title,
}: KycIframeModalProps) => {
const portalContainer = useContext(SettingsContext)?.portalContainer;
const { t } = useTranslation();

return (
<Root open={open} onOpenChange={onOpenChange}>
<Portal container={portalContainer}>
<Box className={container} data-rk={id}>
<Overlay onClick={() => onOpenChange(false)} className={overlay} />

<Content
data-testid="kyc-iframe-modal__container"
className={content}
aria-describedby={undefined}
>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
px="4"
py="3"
>
<Title>
<Text variant={{ weight: "bold", size: "large" }}>{title}</Text>
</Title>

<Box
as="button"
onClick={() => onOpenChange(false)}
aria-label={t("kyc.close")}
>
<XIcon />
</Box>
</Box>

<iframe
data-testid="kyc-iframe-modal__iframe"
title={title}
src={url}
allow="camera; microphone; clipboard-write"
className={iframe}
/>
</Content>
</Box>
</Portal>
</Root>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { keyframes, style } from "@vanilla-extract/css";
import { atoms } from "../../../styles/theme/atoms.css";
import { vars } from "../../../styles/theme/contract.css";
import { breakpoints, minMediaQuery } from "../../../styles/tokens/breakpoints";

const slideUp = keyframes({
"0%": { transform: "translateY(20%)" },
"100%": { transform: "translateY(0)" },
});

const fadeIn = keyframes({
"0%": { opacity: 0 },
"100%": { opacity: 1 },
});

export const container = style({
zIndex: 20,
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "fixed",
top: 0,
bottom: 0,
left: 0,
right: 0,
});

export const overlay = style({
animation: `${fadeIn} 150ms ease`,
willChange: "opacity",
position: "absolute",
inset: 0,
background: vars.color.modalOverlayBackground,
});

export const content = style([
{
"@media": {
[minMediaQuery("tablet")]: {
width: "440px",
height: "640px",
maxHeight: "85vh",
bottom: "auto",
borderBottomLeftRadius: vars.borderRadius.baseContract["2xl"],
borderBottomRightRadius: vars.borderRadius.baseContract["2xl"],
},
},
animation: `${slideUp} 350ms cubic-bezier(.15,1.15,0.6,1.00), ${fadeIn} 150ms ease`,
willChange: "transform, opacity",
},
atoms({
width: "full",
background: "modalBodyBackground",
}),
{
display: "flex",
flexDirection: "column",
borderTopLeftRadius: vars.borderRadius.baseContract["2xl"],
borderTopRightRadius: vars.borderRadius.baseContract["2xl"],
maxWidth: `${breakpoints.tablet}px`,
position: "absolute",
height: "90vh",
bottom: 0,
},
]);

export const iframe = style({
flex: 1,
width: "100%",
border: "none",
});
87 changes: 87 additions & 0 deletions packages/widget/src/domain/types/kyc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Yield } from "./yields";

// kyc contract is not in the generated client yet; mirrored from the api here

export enum KycStatus {
NotRequired = "not_required",
NotStarted = "not_started",
Pending = "pending",
Approved = "approved",
Rejected = "rejected",
}

export enum KycMode {
OauthRedirect = "oauth_redirect",
}

enum KycAccreditation {
Retail = "retail",
QualifiedPurchaser = "qualified_purchaser",
Accredited = "accredited",
}

enum KycSubjectType {
Kyc = "KYC",
Kyb = "KYB",
}

interface YieldKycEligibility {
countries?: string[];
usPersonAllowed?: boolean;
accreditation?: KycAccreditation;
subjectType?: KycSubjectType;
}

export interface YieldKycMetadata {
kycMode: KycMode;
iframeAllowed: boolean;
authorizeUrl?: string;
notes?: string;
eligibility?: YieldKycEligibility;
}

export type KycStatusResult = {
kycStatus: KycStatus;
kycUrl?: string;
};

type RequirementsWithKyc = {
readonly kycRequired?: boolean;
readonly kycUrl?: string;
readonly kyc?: YieldKycMetadata;
};

type YieldKycRequirement = {
required: boolean;
kyc?: YieldKycMetadata;
legacyKycUrl?: string;
};

export const getYieldKycRequirement = (
yieldDto: Yield
): YieldKycRequirement => {
// kyc block is not in the generated requirements type yet
const requirements = yieldDto.mechanics.requirements as
| RequirementsWithKyc
| undefined;

return {
required: !!requirements?.kycRequired || !!requirements?.kyc,
kyc: requirements?.kyc,
legacyKycUrl: requirements?.kycUrl,
};
};

// statuses that block entry
export const kycNeedsVerification = (status: KycStatus): boolean =>
status === KycStatus.NotStarted ||
status === KycStatus.Pending ||
status === KycStatus.Rejected;

// live per-address kycUrl wins over the static issuer page
export const resolveKycUrl = (args: {
statusKycUrl?: string;
kyc?: YieldKycMetadata;
legacyKycUrl?: string;
}): string | undefined =>
args.statusKycUrl ?? args.kyc?.authorizeUrl ?? args.legacyKycUrl;
43 changes: 43 additions & 0 deletions packages/widget/src/hooks/api/use-kyc-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import type { Maybe } from "purify-ts";
import {
getYieldKycRequirement,
type KycStatusResult,
} from "../../domain/types/kyc";
import type { Yield } from "../../domain/types/yields";
import { useApiClient } from "../../providers/api/api-client-provider";
import { useSKWallet } from "../../providers/sk-wallet";

const getKycStatusQueryKey = (yieldId: string, address: string) =>
["kyc-status", yieldId, address] as const;

export const useKycStatus = (
yieldId: string | undefined,
address: string | null | undefined,
opts?: { enabled?: boolean }
) => {
const apiClient = useApiClient();

return useQuery<KycStatusResult>({
queryKey: getKycStatusQueryKey(yieldId ?? "", address ?? ""),
queryFn: ({ signal }) =>
apiClient.getKycStatus(yieldId as string, address as string, { signal }),
// per-address, so short cache
staleTime: 1000 * 30,
enabled: !!yieldId && !!address && (opts?.enabled ?? true),
});
};

// prefetch so the gate is resolved before the cta is clicked
export const useKycStatusPrefetch = (selectedStake: Maybe<Yield>) => {
const { address, isConnected } = useSKWallet();

const yieldDto = selectedStake.extractNullable();
const requiresKyc = yieldDto
? getYieldKycRequirement(yieldDto).required
: false;

return useKycStatus(yieldDto?.id, address, {
enabled: isConnected && requiresKyc,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
stakeTokenSameAsGasToken,
tokenString,
} from "../../../../domain";
import { getYieldKycRequirement } from "../../../../domain/types/kyc";
import { getInitSelectedValidators } from "../../../../domain/types/stake";
import type { TokenBalanceScanResponseDto } from "../../../../domain/types/token-balance";
import type { TronResourceType } from "../../../../domain/types/tron";
Expand All @@ -38,6 +39,7 @@ import {
type Yield,
} from "../../../../domain/types/yields";
import { useDefaultTokens } from "../../../../hooks/api/use-default-tokens";
import { useKycStatusPrefetch } from "../../../../hooks/api/use-kyc-status";
import { useStreamMultiYields } from "../../../../hooks/api/use-multi-yields";
import { useTokenBalancesScan } from "../../../../hooks/api/use-token-balances-scan";
import { useTokensPrices } from "../../../../hooks/api/use-tokens-prices";
Expand Down Expand Up @@ -103,6 +105,9 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => {
const { isConnected, isConnecting, isLedgerLiveAccountPlaceholder, chain } =
useSKWallet();

// prefetch kyc status for the gate
useKycStatusPrefetch(selectedStake);

const yieldType = useYieldType(selectedStake).mapOrDefault(
(y) => y.title,
""
Expand Down Expand Up @@ -485,7 +490,11 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => {
selectedValidators: val.stakeEnterRequestDto.selectedValidators,
},
});
navigate("/review");

const requiresKyc = getYieldKycRequirement(
val.stakeEnterRequestDto.selectedStake
).required;
navigate(requiresKyc ? "/kyc" : "/review");
},
});

Expand Down
Loading