Skip to content
Closed
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
75 changes: 73 additions & 2 deletions packages/widget/scripts/generated-api/generate-effect-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ const specs: SpecConfig[] = [
schemaOnly: true,
},
],
// NestJS URI versioning appends `_v1` to Swagger operationIds
// (e.g. `TokenController_getTokens_v1`). Strip the suffix so openapigen
// generates stable client method and DTO names without `V1` suffixes.
prepareSpec: (contents) =>
contents.replace(/operationId:\s*(.+?)_v\d+\b/g, "operationId: $1"),
patches: [
// Upstream currently declares regionCode as an object, but production
// payloads and widget geo-block handling use it as a string.
Expand All @@ -97,8 +102,7 @@ const specs: SpecConfig[] = [
},
{
name: "YieldApi",
url:
process.env.YIELD_API_SPEC_URL ?? "https://api.stg.yield.xyz/docs.yaml",
url: process.env.YIELD_API_SPEC_URL ?? "https://api.yield.xyz/docs.yaml",
specFileName: "yield-api.yaml",
outputs: [
{
Expand All @@ -111,6 +115,11 @@ const specs: SpecConfig[] = [
schemaOnly: true,
},
],
// NestJS URI versioning appends `_v1` to Swagger operationIds
// (e.g. `YieldsController_getYields_v1`). Strip the suffix so openapigen
// generates stable client method and DTO names without `V1` suffixes.
prepareSpec: (contents) =>
contents.replace(/operationId:\s*(.+?)_v\d+\b/g, "operationId: $1"),
patches: [
// These DTO properties have concrete scalar types in the Yield API
// source, but their Swagger decorators omit the explicit property type.
Expand Down Expand Up @@ -171,6 +180,17 @@ const specs: SpecConfig[] = [
description: "Total TVL across the entire provider in USD",
example: "10,200,000",
}),
{
op: "replace",
path: "/components/schemas/ValidatorProviderDto/properties/revshare",
value: {
description: "Revenue sharing details by tier",
oneOf: [
{ $ref: "#/components/schemas/RevShareTiersDto" },
{ type: "null" },
],
},
},
nullableScalarPatch({
schema: "CuratorDto",
property: "name",
Expand Down Expand Up @@ -351,6 +371,25 @@ const specs: SpecConfig[] = [
description: "When the transaction was broadcasted to the network",
},
},
{
op: "replace",
path: "/components/schemas/TransactionDto/properties/unsignedTransaction",
value: {
description:
"The unsigned transaction data to be signed by the wallet",
nullable: true,
oneOf: [
{ type: "string", description: "Serialized transaction data" },
{
type: "object",
description: "Transaction object (for non-EVM chains)",
},
{ type: "null" },
],
example:
"0x02f87082012a022f2f83018000947a250d5630b4cf539739df2c5dacb4c659f2488d880de0b6b3a764000080c080a0ef0de6c7b46fc75dd6cb86dccc3cfd731c2bdf6f3d736557240c3646c6fe01a6a07cd60b58dfe01847249dfdd7950ba0d045dded5bbe410b07a015a0ed34e5e00d",
},
},
{
op: "replace",
path: "/components/schemas/ActionDto/properties/completedAt",
Expand Down Expand Up @@ -448,9 +487,41 @@ const fetchSpec = async (spec: SpecConfig) => {
return response.text();
};

/**
* In Effect rc.115, openapigen emits `Schema.StructWithRest` for OpenAPI objects
* that omit `additionalProperties`. Explicitly closing objects that declare
* `properties` ensures openapigen emits `Schema.Struct`, preserving direct
* `.fields` access across widget domain models.
*/
const closeOpenApiObjectSchemas = (document: unknown): void => {
const isJsonObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

const visit = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isJsonObject(value)) return;

if (
value.type === "object" &&
value.additionalProperties === undefined &&
isJsonObject(value.properties)
) {
value.additionalProperties = false;
}

for (const child of Object.values(value)) visit(child);
};

visit(document);
};

const prepareSpecContents = (spec: SpecConfig, contents: string) => {
const document = parse(spec.prepareSpec?.(contents) ?? contents) as unknown;
normalizeOpenApiUnionObjects(document);
closeOpenApiObjectSchemas(document);

return spec.specFileName.endsWith(".json")
? `${JSON.stringify(document, null, 2)}\n`
Expand Down
9 changes: 6 additions & 3 deletions packages/widget/src/domain/earn/stake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Array as EArray, Option } from "effect";
import { exactDecimal, exactZero } from "../finance/exact";
import type { YieldId } from "../identity/identifiers";
import type { Network } from "../network/network";
import { equalTokens, type Token } from "../token/token";
import { equalTokens, isNativeToken, type Token } from "../token/token";
import type { EarnValidator, EarnYieldWithProvider } from "./models";
import type { ValidatorKey } from "./validator";
import { getYieldActionArg, isBittensorStaking } from "./yield";
Expand All @@ -13,8 +13,11 @@ export const stakeTokenSameAsGasToken = ({
yieldDto,
}: {
stakeToken: Token;
yieldDto: EarnYieldWithProvider;
}) => equalTokens(stakeToken, yieldDto.mechanics.gasFeeToken);
yieldDto: EarnYieldWithProvider | null;
}) =>
isNativeToken(stakeToken) ||
(yieldDto !== null &&
equalTokens(stakeToken, yieldDto.mechanics.gasFeeToken));

export const getMaxAmount = ({
availableAmount,
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/domain/token/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ export const tokenString = (token: TokenLike): TokenString => {

export const equalTokens = (a: TokenLike, b: TokenLike) =>
tokenString(a) === tokenString(b);
export const isNativeToken = (token: TokenLike) => token.address === undefined;
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAtomMount, useAtomSet, useAtomValue } from "@effect/atom-react";
import { createContext, useContext } from "react";
import { Navigate, Outlet, useMatch, useParams } from "react-router";
import { ContentLoaderSquare } from "../../../shared/ui/primitives/content-loader";
import { LoadingSkeleton } from "../../../shared/ui/components/loading-skeleton";
import { YieldActionContinuationSessionRoute } from "../../classic-transaction-flow/views";
import { walletScopeAtom } from "../../wallet/index";
import type { YieldSummaryProvider } from "../../yield-summary/index";
Expand Down Expand Up @@ -83,7 +83,7 @@ const BoundActivityActionRoute = ({
const retry = useAtomSet(retryActivityActionRouteAtom(selectionKey));

if (result.status === "loading") {
return <ContentLoaderSquare heightPx={320} />;
return <LoadingSkeleton />;
}
if (result.status === "failed") {
return <ActivityDetailsFailure onRetry={() => retry(undefined)} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { VirtualList } from "../../../../shared/ui/components/virtual-list";
import { Box } from "../../../../shared/ui/primitives/box";
import { Button } from "../../../../shared/ui/primitives/button";
import { ContentLoaderSquare } from "../../../../shared/ui/primitives/content-loader";
import { Text } from "../../../../shared/ui/primitives/typography/text";
import { FallbackContent } from "../../../widget-shell/views";
import type { ActivityActionItem } from "../../model/activity-action";
Expand All @@ -12,7 +11,10 @@ import type {
ActivityPagePagination,
ActivityPageView,
} from "../../state/page";
import { ActionListItem } from "./components/action-list-item";
import {
ActionListItem,
ActionListItemSkeleton,
} from "./components/action-list-item";
import { ActivityFilters } from "./components/activity-filters";
import { container } from "./style.css";

Expand All @@ -23,11 +25,10 @@ const ActivityPageSkeleton = () => (
aria-hidden="true"
data-rk="activity-page-skeleton"
display="flex"
gap="1"
flexDirection="column"
>
{[...Array(5).keys()].map((item) => (
<ContentLoaderSquare key={item} heightPx={60} />
<ActionListItemSkeleton key={item} />
))}
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useTranslation } from "react-i18next";
import { Box } from "../../../../../../shared/ui/primitives/box";
import { ContentLoaderLine } from "../../../../../../shared/ui/primitives/content-loader";
import { ListItem } from "../../../../../../shared/ui/primitives/list/list-item";
import { Text } from "../../../../../../shared/ui/primitives/typography/text";
import type { ActivityActionItem } from "../../../../model/activity-action";
import type { ActivityStatusLabel } from "../../../../model/activity-action-list-item";
import { useActionListItem } from "../../hooks/use-action-list-item";
import { ActivityIcon } from "../activity-icon";
import { ActivityIcon, type ActivityIconType } from "../activity-icon";
import {
amountNeutral,
amountPositive,
Expand Down Expand Up @@ -33,9 +35,58 @@ export const ActionListItem = ({

if (!listItemView) return null;

const { providersDetails } = listItemView;

const firstProvider = providersDetails?.[0];
const providerLabel = firstProvider
? t("positions.via", {
providerName: firstProvider.name ?? firstProvider.address,
count: Math.max((providersDetails?.length ?? 0) - 1, 1),
})
: null;
return (
<ActionListItemPresentation
view={listItemView}
viaLabel={providerLabel}
isSelected={isSelected}
onSelect={() => onActionSelect(action)}
/>
);
};

export const ActionListItemSkeleton = () => <ActionListItemPresentation />;

type ActionListItemContent = {
readonly canOpenDetails: boolean;
readonly iconType: ActivityIconType;
readonly title: string;
readonly tokenSymbol: string | null;
readonly amount: string | null;
readonly amountSign: "" | "+" | "-";
readonly isPositive: boolean;
readonly timestampAbsolute: string;
readonly timestampRelative: string;
readonly badgeLabel: string | null;
readonly statusLabel: ActivityStatusLabel | null;
};

const ActionListItemPresentation = ({
view,
viaLabel,
isSelected = false,
onSelect,
}: {
readonly view?: ActionListItemContent;
readonly viaLabel?: string | null;
readonly isSelected?: boolean;
readonly onSelect?: () => void;
}) => {
const loading = !view;
const readyDataRk = isSelected
? "activity-list-item-selected"
: "activity-list-item";
const {
canOpenDetails,
providersDetails,
iconType,
title,
tokenSymbol,
Expand All @@ -46,25 +97,14 @@ export const ActionListItem = ({
timestampRelative,
badgeLabel,
statusLabel,
} = listItemView;

const firstProvider = providersDetails?.[0];
const providerLabel = firstProvider
? t("positions.via", {
providerName: firstProvider.name ?? firstProvider.address,
count: Math.max((providersDetails?.length ?? 0) - 1, 1),
})
: null;
const viaLabel = providerLabel;
} = view ?? {};

return (
<Box py="1" width="full">
<Box py="1" width="full" aria-hidden={loading || undefined}>
<ListItem
onClick={canOpenDetails ? () => onActionSelect(action) : undefined}
onClick={canOpenDetails ? onSelect : undefined}
className={listItem}
data-rk={
isSelected ? "activity-list-item-selected" : "activity-list-item"
}
data-rk={loading ? "activity-list-item-skeleton" : readyDataRk}
variant={{
active: isSelected ? "active" : "inactive",
hover: canOpenDetails ? "enabled" : "disabled",
Expand All @@ -88,9 +128,11 @@ export const ActionListItem = ({
<ActivityIcon type={iconType} />

<Box className={infoColumn}>
<Text className={titleText}>{title}</Text>
<Text className={titleText}>
{loading ? <ContentLoaderLine widthPx="14ch" /> : title}
</Text>

{badgeLabel || viaLabel ? (
{loading || badgeLabel || viaLabel ? (
<Box className={metaRow}>
{badgeLabel && statusLabel ? (
<Box
Expand All @@ -112,12 +154,16 @@ export const ActionListItem = ({
</Box>
) : null}

{viaLabel ? (
{loading || viaLabel ? (
<Text
className={viaText}
variant={{ type: "muted", weight: "normal" }}
>
{viaLabel}
{loading ? (
<ContentLoaderLine widthPx="10ch" />
) : (
viaLabel
)}
</Text>
) : null}
</Box>
Expand All @@ -132,23 +178,37 @@ export const ActionListItem = ({
gap="3"
flexShrink={0}
>
{amount ? (
{loading || amount ? (
<Text className={isPositive ? amountPositive : amountNeutral}>
{amountSign}
{tokenSymbol ? `${amount} ${tokenSymbol}` : amount}
{loading ? (
<ContentLoaderLine widthPx="8ch" />
) : (
<>
{amountSign}
{tokenSymbol ? `${amount} ${tokenSymbol}` : amount}
</>
)}
</Text>
) : null}

<Box className={timeColumn}>
<Text
variant={{ type: "muted", weight: "normal", size: "small" }}
>
{timestampAbsolute}
{loading ? (
<ContentLoaderLine widthPx="9ch" />
) : (
timestampAbsolute
)}
</Text>
<Text
variant={{ type: "muted", weight: "normal", size: "small" }}
>
{timestampRelative}
{loading ? (
<ContentLoaderLine widthPx="6ch" />
) : (
timestampRelative
)}
</Text>
</Box>
</Box>
Expand Down
Loading
Loading