Kaynağa Gözat

Merge pull request #1868 from cprussin/staking-app-styling

Staking app styling
Connor Prussin 1 yıl önce
ebeveyn
işleme
c252a1c3bf
47 değiştirilmiş dosya ile 2651 ekleme ve 1466 silme
  1. 1 0
      apps/staking/package.json
  2. 16 0
      apps/staking/src/api.ts
  3. 1 1
      apps/staking/src/app/robots.ts
  4. 2 12
      apps/staking/src/components/AccountHistory/index.tsx
  5. BIN
      apps/staking/src/components/AccountSummary/background.png
  6. 206 0
      apps/staking/src/components/AccountSummary/index.tsx
  7. 0 0
      apps/staking/src/components/Amplitude/index.tsx
  8. 17 6
      apps/staking/src/components/Button/index.tsx
  9. 168 166
      apps/staking/src/components/Dashboard/index.tsx
  10. 0 823
      apps/staking/src/components/Dashboard/loaded.tsx
  11. 1 0
      apps/staking/src/components/Footer/discord.svg
  12. 3 0
      apps/staking/src/components/Footer/github.svg
  13. 64 0
      apps/staking/src/components/Footer/index.tsx
  14. 3 0
      apps/staking/src/components/Footer/linkedin.svg
  15. 3 0
      apps/staking/src/components/Footer/telegram.svg
  16. 3 0
      apps/staking/src/components/Footer/x.svg
  17. 3 0
      apps/staking/src/components/Footer/youtube.svg
  18. 41 0
      apps/staking/src/components/Governance/index.tsx
  19. 25 0
      apps/staking/src/components/Header/index.tsx
  20. 4 0
      apps/staking/src/components/Header/logo.svg
  21. 121 32
      apps/staking/src/components/Home/index.tsx
  22. 3 3
      apps/staking/src/components/LoadingSpinner/index.tsx
  23. 3 0
      apps/staking/src/components/MaxWidth/index.tsx
  24. 104 46
      apps/staking/src/components/Modal/index.tsx
  25. 0 60
      apps/staking/src/components/ModalButton/index.tsx
  26. 350 0
      apps/staking/src/components/OracleIntegrityStaking/index.tsx
  27. 232 0
      apps/staking/src/components/PositionFlowchart/index.tsx
  28. 38 0
      apps/staking/src/components/ProgramSection/index.tsx
  29. 1 1
      apps/staking/src/components/ReportAccessibility/index.tsx
  30. 27 17
      apps/staking/src/components/Root/index.tsx
  31. 12 3
      apps/staking/src/components/SparkChart/index.tsx
  32. 32 0
      apps/staking/src/components/StakingTimeline/index.tsx
  33. 5 2
      apps/staking/src/components/Tokens/index.tsx
  34. 2 4
      apps/staking/src/components/Tokens/pyth.svg
  35. 190 60
      apps/staking/src/components/TransferButton/index.tsx
  36. 249 0
      apps/staking/src/components/WalletButton/index.tsx
  37. 0 4
      apps/staking/src/components/WalletProvider/index.tsx
  38. 1 1
      apps/staking/src/config/isomorphic.ts
  39. 0 0
      apps/staking/src/config/server.ts
  40. 1 3
      apps/staking/src/hooks/use-api-context.ts
  41. 0 0
      apps/staking/src/hooks/use-is-mounted.ts
  42. 1 1
      apps/staking/src/hooks/use-logger.tsx
  43. 3 8
      apps/staking/src/hooks/use-primary-domain.ts
  44. 35 21
      apps/staking/src/hooks/use-stake-account.tsx
  45. 4 2
      apps/staking/src/hooks/use-transfer.ts
  46. 1 0
      apps/staking/tailwind.config.ts
  47. 675 190
      pnpm-lock.yaml

+ 1 - 0
apps/staking/package.json

@@ -36,6 +36,7 @@
     "next": "^14.2.5",
     "pino": "^9.3.2",
     "react": "^18.3.1",
+    "react-aria": "^3.34.3",
     "react-dom": "^18.3.1",
     "recharts": "^2.12.7",
     "swr": "^2.2.5",

+ 16 - 0
apps/staking/src/api.ts

@@ -290,6 +290,22 @@ export const calculateApy = (
   );
 };
 
+export const getUpcomingEpoch = (): Date => {
+  const d = new Date();
+  d.setUTCDate(d.getUTCDate() + ((5 + 7 - d.getUTCDay()) % 7 || 7));
+  d.setUTCHours(0);
+  d.setUTCMinutes(0);
+  d.setUTCSeconds(0);
+  d.setUTCMilliseconds(0);
+  return d;
+};
+
+export const getNextFullEpoch = (): Date => {
+  const d = getUpcomingEpoch();
+  d.setUTCDate(d.getUTCDate() + 7);
+  return d;
+};
+
 const MOCK_DELAY = 500;
 
 const MOCK_STAKE_ACCOUNTS: StakeAccount[] = [

+ 1 - 1
apps/staking/src/app/robots.ts

@@ -1,6 +1,6 @@
 import type { MetadataRoute } from "next";
 
-import { IS_PRODUCTION_SERVER } from "../server-config";
+import { IS_PRODUCTION_SERVER } from "../config/server";
 
 const robots = (): MetadataRoute.Robots => ({
   rules: {

+ 2 - 12
apps/staking/src/components/AccountHistoryButton/index.tsx → apps/staking/src/components/AccountHistory/index.tsx

@@ -7,25 +7,15 @@ import {
   StakeType,
   loadAccountHistory,
 } from "../../api";
-import { useApiContext } from "../../use-api-context";
+import { useApiContext } from "../../hooks/use-api-context";
 import { LoadingSpinner } from "../LoadingSpinner";
-import { ModalButton } from "../ModalButton";
 import { Tokens } from "../Tokens";
 
 const ONE_SECOND_IN_MS = 1000;
 const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
 const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS;
 
-export const AccountHistoryButton = () => (
-  <ModalButton
-    title="Account history"
-    description="A history of events that have affected your account balances"
-  >
-    <ModalBody />
-  </ModalButton>
-);
-
-const ModalBody = () => {
+export const AccountHistory = () => {
   const history = useAccountHistoryData();
 
   switch (history.type) {

BIN
apps/staking/src/components/AccountSummary/background.png


+ 206 - 0
apps/staking/src/components/AccountSummary/index.tsx

@@ -0,0 +1,206 @@
+import Image from "next/image";
+import type { ComponentProps, ReactNode } from "react";
+
+import background from "./background.png";
+import { deposit, withdraw, claim } from "../../api";
+import { StateType, useTransfer } from "../../hooks/use-transfer";
+import { Button } from "../Button";
+import { Modal, ModalButton, ModalPanel } from "../Modal";
+import { Tokens } from "../Tokens";
+import { TransferButton } from "../TransferButton";
+
+type Props = {
+  total: bigint;
+  locked: bigint;
+  unlockSchedule: {
+    amount: bigint;
+    date: Date;
+  }[];
+  lastSlash:
+    | {
+        amount: bigint;
+        date: Date;
+      }
+    | undefined;
+  walletAmount: bigint;
+  availableRewards: bigint;
+  expiringRewards: {
+    amount: bigint;
+    expiry: Date;
+  };
+  availableToWithdraw: bigint;
+};
+
+export const AccountSummary = ({
+  locked,
+  unlockSchedule,
+  lastSlash,
+  walletAmount,
+  total,
+  availableToWithdraw,
+  availableRewards,
+  expiringRewards,
+}: Props) => (
+  <section className="relative w-full overflow-hidden border border-neutral-600/50 bg-pythpurple-800">
+    <Image
+      src={background}
+      alt=""
+      className="absolute -right-40 h-full object-right [mask-image:linear-gradient(to_right,_transparent,_black_50%)]"
+    />
+    <div className="relative flex flex-col items-start justify-between gap-16 px-12 py-20 md:flex-row md:items-center">
+      <div>
+        <div className="mb-4 inline-block border border-neutral-600/50 bg-neutral-900 px-4 py-1 text-xs text-neutral-400">
+          Total Balance
+        </div>
+        <div className="flex flex-row items-center gap-8">
+          <span>
+            <Tokens className="text-6xl font-light">{total}</Tokens>
+          </span>
+          {lastSlash && (
+            <p className="max-w-48 text-sm text-red-600">
+              <Tokens>{lastSlash.amount}</Tokens> were slashed on{" "}
+              {lastSlash.date.toLocaleString()}
+            </p>
+          )}
+        </div>
+        <div className="mt-8 flex flex-row items-center gap-4">
+          <TransferButton
+            actionDescription="Add funds to your balance"
+            actionName="Deposit"
+            max={walletAmount}
+            transfer={deposit}
+          />
+        </div>
+        {locked > 0n && (
+          <>
+            <div className="mt-6 flex flex-row items-center gap-1 text-xl text-pythpurple-100/50">
+              <Tokens>{locked}</Tokens>
+              <div>locked</div>
+            </div>
+            <Modal>
+              <ModalButton
+                as="button"
+                className="mt-1 text-sm text-pythpurple-400 hover:underline"
+              >
+                Show Unlock Schedule
+              </ModalButton>
+              <ModalPanel
+                title="Unlock Schedule"
+                description="Your tokens will become available for withdrawal and for participation in Integrity Staking according to this schedule"
+              >
+                <div className="border border-neutral-600/50 bg-pythpurple-100/10 px-8 py-6">
+                  <table>
+                    <thead className="font-medium">
+                      <tr>
+                        <td className="pr-12 text-sm text-neutral-400">Date</td>
+                        <td className="text-sm text-neutral-400">Amount</td>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {unlockSchedule.map((unlock, i) => (
+                        <tr key={i}>
+                          <td className="pr-12 text-sm opacity-80">
+                            {unlock.date.toLocaleString()}
+                          </td>
+                          <td>
+                            <Tokens>{unlock.amount}</Tokens>
+                          </td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+              </ModalPanel>
+            </Modal>
+          </>
+        )}
+      </div>
+      <div className="flex flex-col items-stretch gap-4 xl:flex-row">
+        <BalanceCategory
+          name="Available for Withdrawal"
+          amount={availableToWithdraw}
+          description="The lesser of the amount you have available to stake in governance & integrity staking"
+          action={
+            <TransferButton
+              small
+              secondary
+              actionDescription="Move funds from your account back to your wallet"
+              actionName="Withdraw"
+              max={availableToWithdraw}
+              transfer={withdraw}
+              disabled={availableToWithdraw === 0n}
+            />
+          }
+        />
+        <BalanceCategory
+          name="Available Rewards"
+          amount={availableRewards}
+          description="Rewards you have earned but not yet claimed from the Integrity Staking program"
+          action={<ClaimButton disabled={availableRewards === 0n} />}
+          {...(expiringRewards.amount > 0n && {
+            warning: (
+              <>
+                <Tokens>{expiringRewards.amount}</Tokens> will expire on{" "}
+                {expiringRewards.expiry.toLocaleDateString()}
+              </>
+            ),
+          })}
+        />
+      </div>
+    </div>
+  </section>
+);
+
+type BalanceCategoryProps = {
+  name: string;
+  amount: bigint;
+  description: string;
+  action: ReactNode;
+  warning?: ReactNode | undefined;
+};
+
+const BalanceCategory = ({
+  name,
+  amount,
+  description,
+  action,
+  warning,
+}: BalanceCategoryProps) => (
+  <div className="flex flex-col justify-between border border-neutral-600/50 bg-pythpurple-800/60 p-6 backdrop-blur">
+    <div>
+      <div className="mb-4 inline-block border border-neutral-600/50 bg-neutral-900 px-4 py-1 text-xs text-neutral-400">
+        {name}
+      </div>
+      <div>
+        <Tokens className="text-xl font-light">{amount}</Tokens>
+      </div>
+      <p className="mt-4 max-w-xs text-sm text-neutral-500">{description}</p>
+    </div>
+    <div className="mt-4 flex flex-row items-center gap-4">
+      {action}
+      {warning && <p className="max-w-xs text-xs text-red-600">{warning}</p>}
+    </div>
+  </div>
+);
+
+const ClaimButton = (
+  props: Omit<
+    ComponentProps<typeof Button>,
+    "onClick" | "disabled" | "loading"
+  >,
+) => {
+  const { state, execute } = useTransfer(claim);
+
+  return (
+    <Button
+      small
+      secondary
+      onClick={execute}
+      disabled={state.type !== StateType.Base}
+      loading={state.type === StateType.Submitting}
+      {...props}
+    >
+      Claim
+    </Button>
+  );
+};

+ 0 - 0
apps/staking/src/components/Root/amplitude.tsx → apps/staking/src/components/Amplitude/index.tsx


+ 17 - 6
apps/staking/src/components/Button/index.tsx

@@ -4,19 +4,30 @@ import { Styled } from "../Styled";
 
 type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
   loading?: boolean | undefined;
+  secondary?: boolean | undefined;
+  small?: boolean | undefined;
+  nopad?: boolean | undefined;
 };
 
-const ButtonBase = ({ loading, disabled, children, ...props }: Props) => (
+const ButtonBase = ({
+  loading,
+  secondary,
+  small,
+  nopad,
+  disabled,
+  ...props
+}: Props) => (
   <button
     disabled={loading === true || disabled === true}
-    {...(loading && { "data-loading": true })}
+    {...(loading && { "data-loading": "" })}
+    {...(secondary && { "data-secondary": "" })}
+    {...(small && { "data-small": "" })}
+    {...(nopad && { "data-nopad": "" })}
     {...props}
-  >
-    {children}
-  </button>
+  />
 );
 
 export const Button = Styled(
   ButtonBase,
-  "border border-pythpurple-600 px-2 py-0.5 bg-black/10 disabled:cursor-not-allowed disabled:bg-black/20 disabled:border-black/40 disabled:text-neutral-700 disabled:data-[loading]:cursor-wait",
+  "border border-pythpurple-600 bg-pythpurple-600/50 data-[small]:text-sm data-[small]:px-6 data-[small]:py-1 data-[secondary]:bg-pythpurple-600/20 px-8 py-2 data-[nopad]:px-0 data-[nopad]:py-0 disabled:cursor-not-allowed disabled:bg-neutral-50/10 disabled:border-neutral-50/10 disabled:text-white/60 disabled:data-[loading]:cursor-wait hover:bg-pythpurple-600/60 data-[secondary]:hover:bg-pythpurple-600/60 data-[secondary]:disabled:bg-neutral-50/10 focus-visible:ring-1 focus-visible:ring-pythpurple-400 focus:outline-none justify-center",
 );

+ 168 - 166
apps/staking/src/components/Dashboard/index.tsx

@@ -1,180 +1,182 @@
-"use client";
+import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
+import { type ComponentProps, useMemo } from "react";
 
-import {
-  Listbox,
-  ListboxButton,
-  ListboxOptions,
-  ListboxOption,
-  Field,
-  Label,
-} from "@headlessui/react";
-import { ChevronDownIcon } from "@heroicons/react/24/outline";
-import useSWR from "swr";
+import { AccountSummary } from "../AccountSummary";
+import { Governance } from "../Governance";
+import { OracleIntegrityStaking } from "../OracleIntegrityStaking";
+import { Styled } from "../Styled";
 
-import { DashboardLoaded } from "./loaded";
-import { WalletButton } from "./wallet-button";
-import { type StakeAccount, loadData } from "../../api";
-import { useApiContext } from "../../use-api-context";
-import {
-  StateType,
-  StakeAccountProvider,
-  useStakeAccount,
-} from "../../use-stake-account";
-import { AccountHistoryButton } from "../AccountHistoryButton";
-import { LoadingSpinner } from "../LoadingSpinner";
-
-const ONE_SECOND_IN_MS = 1000;
-const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
-const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS;
-
-export const Dashboard = () => (
-  <StakeAccountProvider>
-    <DashboardHeader />
-    <DashboardBody />
-  </StakeAccountProvider>
-);
+type Props = {
+  total: bigint;
+  lastSlash:
+    | {
+        amount: bigint;
+        date: Date;
+      }
+    | undefined;
+  walletAmount: bigint;
+  availableRewards: bigint;
+  expiringRewards: {
+    amount: bigint;
+    expiry: Date;
+  };
+  locked: bigint;
+  unlockSchedule: {
+    amount: bigint;
+    date: Date;
+  }[];
+  governance: {
+    warmup: bigint;
+    staked: bigint;
+    cooldown: bigint;
+    cooldown2: bigint;
+  };
+  integrityStakingPublishers: ComponentProps<
+    typeof OracleIntegrityStaking
+  >["publishers"];
+};
 
-const DashboardHeader = () => {
-  const stakeAccountState = useStakeAccount();
-  return (
-    <header className="mb-4 flex flex-row items-center justify-end gap-4">
-      {stakeAccountState.type === StateType.Loaded &&
-        stakeAccountState.allAccounts.length > 1 && (
-          <AccountSelector
-            accounts={stakeAccountState.allAccounts}
-            selectedAccount={stakeAccountState.account}
-            setAccount={stakeAccountState.selectAccount}
-          />
-        )}
-      {stakeAccountState.type === StateType.Loaded && <AccountHistoryButton />}
-      <WalletButton />
-    </header>
+export const Dashboard = ({
+  total,
+  lastSlash,
+  walletAmount,
+  availableRewards,
+  expiringRewards,
+  governance,
+  integrityStakingPublishers,
+  locked,
+  unlockSchedule,
+}: Props) => {
+  const availableToStakeGovernance = useMemo(
+    () =>
+      total -
+      governance.warmup -
+      governance.staked -
+      governance.cooldown -
+      governance.cooldown2,
+    [
+      total,
+      governance.warmup,
+      governance.staked,
+      governance.cooldown,
+      governance.cooldown2,
+    ],
   );
-};
 
-type AccountSelectorProps = {
-  selectedAccount: StakeAccount;
-  accounts: [StakeAccount, ...StakeAccount[]];
-  setAccount: (account: StakeAccount) => void;
-};
+  const integrityStakingWarmup = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "warmup",
+  );
+  const integrityStakingStaked = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "staked",
+  );
+  const integrityStakingCooldown = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "cooldown",
+  );
+  const integrityStakingCooldown2 = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "cooldown2",
+  );
 
-const AccountSelector = ({
-  accounts,
-  selectedAccount,
-  setAccount,
-}: AccountSelectorProps) => (
-  <Field className="flex flex-row items-center gap-2">
-    <Label className="text-sm font-medium">Stake Account:</Label>
-    <Listbox
-      value={selectedAccount}
-      onChange={setAccount}
-      as="div"
-      className="relative"
-    >
-      <ListboxButton className="flex flex-row items-center gap-4 rounded border border-black px-4 py-2">
-        <pre>{selectedAccount.publicKey}</pre>
-        <ChevronDownIcon className="size-4" />
-      </ListboxButton>
-      <ListboxOptions
-        className="min-w-[var(--button-width)] rounded-xl border border-white/5 bg-white p-1 shadow transition duration-100 ease-in [--anchor-gap:var(--spacing-1)] focus:outline-none data-[leave]:data-[closed]:opacity-0"
-        anchor="bottom start"
-        transition
-      >
-        {accounts.map((account) => (
-          <ListboxOption
-            key={account.publicKey}
-            value={account}
-            className="cursor-pointer hover:bg-black/5"
-          >
-            <pre>{account.publicKey}</pre>
-          </ListboxOption>
-        ))}
-      </ListboxOptions>
-    </Listbox>
-  </Field>
-);
+  const availableToStakeIntegrity = useMemo(
+    () =>
+      total -
+      locked -
+      integrityStakingWarmup -
+      integrityStakingStaked -
+      integrityStakingCooldown -
+      integrityStakingCooldown2,
+    [
+      total,
+      locked,
+      integrityStakingWarmup,
+      integrityStakingStaked,
+      integrityStakingCooldown,
+      integrityStakingCooldown2,
+    ],
+  );
 
-const DashboardBody = () => {
-  const stakeAccountState = useStakeAccount();
+  const availableToWithdraw = useMemo(
+    () => bigIntMin(availableToStakeGovernance, availableToStakeIntegrity),
+    [availableToStakeGovernance, availableToStakeIntegrity],
+  );
 
-  switch (stakeAccountState.type) {
-    case StateType.Initialized:
-    case StateType.Loading: {
-      return <LoadingSpinner />;
-    }
-    case StateType.NoAccounts: {
-      return <p>No stake account found for your wallet!</p>;
-    }
-    case StateType.Error: {
-      return (
-        <p>
-          Uh oh, an error occurred while loading stake accounts. Please refresh
-          and try again
-        </p>
-      );
-    }
-    case StateType.Loaded: {
-      return <DashboardContents />;
-    }
-  }
+  return (
+    <div className="flex w-full flex-col gap-8">
+      <AccountSummary
+        locked={locked}
+        unlockSchedule={unlockSchedule}
+        lastSlash={lastSlash}
+        walletAmount={walletAmount}
+        total={total}
+        availableToWithdraw={availableToWithdraw}
+        availableRewards={availableRewards}
+        expiringRewards={expiringRewards}
+      />
+      <TabGroup as="section">
+        <TabList className="flex w-full flex-row font-medium">
+          <DashboardTab>Overview</DashboardTab>
+          <DashboardTab>Governance</DashboardTab>
+          <DashboardTab>Oracle Integrity Staking</DashboardTab>
+        </TabList>
+        <TabPanels className="mt-8">
+          <DashboardTabPanel>
+            <section className="py-20">
+              <p className="text-center">
+                This is an overview of the staking programs
+              </p>
+            </section>
+          </DashboardTabPanel>
+          <DashboardTabPanel>
+            <Governance
+              availableToStake={availableToStakeGovernance}
+              warmup={governance.warmup}
+              staked={governance.staked}
+              cooldown={governance.cooldown}
+              cooldown2={governance.cooldown2}
+            />
+          </DashboardTabPanel>
+          <DashboardTabPanel>
+            <OracleIntegrityStaking
+              availableToStake={availableToStakeIntegrity}
+              locked={locked}
+              warmup={integrityStakingWarmup}
+              staked={integrityStakingStaked}
+              cooldown={integrityStakingCooldown}
+              cooldown2={integrityStakingCooldown2}
+              publishers={integrityStakingPublishers}
+            />
+          </DashboardTabPanel>
+        </TabPanels>
+      </TabGroup>
+    </div>
+  );
 };
 
-const DashboardContents = () => {
-  const data = useDashboardData();
-
-  switch (data.type) {
-    case DataStateType.NotLoaded:
-    case DataStateType.Loading: {
-      return <LoadingSpinner />;
-    }
-    case DataStateType.Error: {
-      return <p>Uh oh, an error occured!</p>;
-    }
-    case DataStateType.Loaded: {
-      return <DashboardLoaded {...data.data} />;
-    }
-  }
-};
+const DashboardTab = Styled(
+  Tab,
+  "grow border-b border-neutral-600/50 px-4 py-2 focus-visible:outline-none data-[selected]:cursor-default data-[selected]:border-pythpurple-400 data-[selected]:data-[hover]:bg-transparent data-[hover]:text-pythpurple-400 data-[selected]:text-pythpurple-400 data-[focus]:outline-none data-[focus]:ring-1 data-[focus]:ring-pythpurple-400",
+);
 
-const useDashboardData = () => {
-  const apiContext = useApiContext();
+const DashboardTabPanel = Styled(
+  TabPanel,
+  "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400/50",
+);
 
-  const { data, isLoading, ...rest } = useSWR(
-    apiContext.stakeAccount.publicKey,
-    () => loadData(apiContext),
-    {
-      refreshInterval: REFRESH_INTERVAL,
-    },
+const useIntegrityStakingSum = (
+  publishers: Props["integrityStakingPublishers"],
+  field: "warmup" | "staked" | "cooldown" | "cooldown2",
+): bigint =>
+  useMemo(
+    () =>
+      publishers
+        .map((publisher) => publisher.positions?.[field] ?? 0n)
+        .reduce((acc, cur) => acc + cur, 0n),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    publishers.map((publisher) => publisher.positions?.[field]),
   );
-  const error = rest.error as unknown;
 
-  if (error) {
-    return DataState.ErrorState(error);
-  } else if (isLoading) {
-    return DataState.Loading();
-  } else if (data) {
-    return DataState.Loaded(data);
-  } else {
-    return DataState.NotLoaded();
-  }
-};
-
-enum DataStateType {
-  NotLoaded,
-  Loading,
-  Loaded,
-  Error,
-}
-const DataState = {
-  NotLoaded: () => ({ type: DataStateType.NotLoaded as const }),
-  Loading: () => ({ type: DataStateType.Loading as const }),
-  Loaded: (data: Awaited<ReturnType<typeof loadData>>) => ({
-    type: DataStateType.Loaded as const,
-    data,
-  }),
-  ErrorState: (error: unknown) => ({
-    type: DataStateType.Error as const,
-    error,
-  }),
-};
-type DataState = ReturnType<(typeof DataState)[keyof typeof DataState]>;
+// eslint-disable-next-line unicorn/no-array-reduce
+const bigIntMin = (...args: bigint[]) => args.reduce((m, e) => (e < m ? e : m));

+ 0 - 823
apps/staking/src/components/Dashboard/loaded.tsx

@@ -1,823 +0,0 @@
-import clsx from "clsx";
-import { type ReactNode, useMemo, useCallback } from "react";
-
-import { SparkChart } from "./spark-chart";
-import {
-  deposit,
-  withdraw,
-  stakeGovernance,
-  cancelWarmupGovernance,
-  unstakeGovernance,
-  delegateIntegrityStaking,
-  cancelWarmupIntegrityStaking,
-  unstakeIntegrityStaking,
-  claim,
-  calculateApy,
-} from "../../api";
-import type { Context } from "../../use-api-context";
-import { StateType, useTransfer } from "../../use-transfer";
-import { Button } from "../Button";
-import { ModalButton } from "../ModalButton";
-import { Tokens } from "../Tokens";
-import { TransferButton } from "../TransferButton";
-
-type Props = {
-  total: bigint;
-  lastSlash:
-    | {
-        amount: bigint;
-        date: Date;
-      }
-    | undefined;
-  walletAmount: bigint;
-  availableRewards: bigint;
-  expiringRewards: {
-    amount: bigint;
-    expiry: Date;
-  };
-  locked: bigint;
-  unlockSchedule: {
-    amount: bigint;
-    date: Date;
-  }[];
-  governance: {
-    warmup: bigint;
-    staked: bigint;
-    cooldown: bigint;
-    cooldown2: bigint;
-  };
-  integrityStakingPublishers: PublisherProps["publisher"][];
-};
-
-export const DashboardLoaded = ({
-  total,
-  lastSlash,
-  walletAmount,
-  availableRewards,
-  expiringRewards,
-  governance,
-  integrityStakingPublishers,
-  locked,
-  unlockSchedule,
-}: Props) => {
-  const availableToStakeGovernance = useMemo(
-    () =>
-      total -
-      governance.warmup -
-      governance.staked -
-      governance.cooldown -
-      governance.cooldown2,
-    [
-      total,
-      governance.warmup,
-      governance.staked,
-      governance.cooldown,
-      governance.cooldown2,
-    ],
-  );
-
-  const integrityStakingWarmup = useIntegrityStakingSum(
-    integrityStakingPublishers,
-    "warmup",
-  );
-  const integrityStakingStaked = useIntegrityStakingSum(
-    integrityStakingPublishers,
-    "staked",
-  );
-  const integrityStakingCooldown = useIntegrityStakingSum(
-    integrityStakingPublishers,
-    "cooldown",
-  );
-  const integrityStakingCooldown2 = useIntegrityStakingSum(
-    integrityStakingPublishers,
-    "cooldown2",
-  );
-
-  const availableToStakeIntegrity = useMemo(
-    () =>
-      total -
-      locked -
-      integrityStakingWarmup -
-      integrityStakingStaked -
-      integrityStakingCooldown -
-      integrityStakingCooldown2,
-    [
-      total,
-      locked,
-      integrityStakingWarmup,
-      integrityStakingStaked,
-      integrityStakingCooldown,
-      integrityStakingCooldown2,
-    ],
-  );
-
-  const availableToWithdraw = useMemo(
-    () => bigIntMin(availableToStakeGovernance, availableToStakeIntegrity),
-    [availableToStakeGovernance, availableToStakeIntegrity],
-  );
-
-  const self = useMemo(
-    () => integrityStakingPublishers.find((publisher) => publisher.isSelf),
-    [integrityStakingPublishers],
-  );
-
-  const otherPublishers = useMemo(
-    () => integrityStakingPublishers.filter((publisher) => !publisher.isSelf),
-    [integrityStakingPublishers],
-  );
-
-  return (
-    <>
-      <div className="flex w-full flex-col gap-8 bg-pythpurple-100 p-8">
-        <div className="flex flex-row gap-16">
-          <BalanceCategory
-            name="Total balance"
-            actions={
-              <TransferButton
-                actionDescription="Add funds to your balance"
-                actionName="Deposit"
-                max={walletAmount}
-                transfer={deposit}
-              >
-                <strong>In wallet:</strong> <Tokens>{walletAmount}</Tokens>
-              </TransferButton>
-            }
-            {...(lastSlash && {
-              disclaimer: (
-                <>
-                  <Tokens>{lastSlash.amount}</Tokens> were slashed on{" "}
-                  {lastSlash.date.toLocaleString()}
-                </>
-              ),
-            })}
-          >
-            {total}
-          </BalanceCategory>
-          <BalanceCategory
-            name="Available to withdraw"
-            description="The lesser of the amount you have available to stake in governance & integrity staking"
-            {...(availableToWithdraw > 0 && {
-              actions:
-                availableRewards > 0 ? (
-                  <ClaimRequiredButton
-                    buttonText="Withdraw"
-                    description="Before you can withdraw tokens, you must claim your unclaimed rewards"
-                    availableRewards={availableRewards}
-                  />
-                ) : (
-                  <TransferButton
-                    actionDescription="Move funds from your account back to your wallet"
-                    actionName="Withdraw"
-                    max={availableToWithdraw}
-                    transfer={withdraw}
-                  >
-                    <strong>Available to withdraw:</strong>{" "}
-                    <Tokens>{availableToWithdraw}</Tokens>
-                  </TransferButton>
-                ),
-            })}
-          >
-            {availableToWithdraw}
-          </BalanceCategory>
-          <BalanceCategory
-            name="Available rewards"
-            description="Rewards you have earned but not yet claimed from the Integrity Staking program."
-            {...(expiringRewards.amount > 0n && {
-              disclaimer: (
-                <>
-                  <Tokens>{expiringRewards.amount}</Tokens> will expire on{" "}
-                  {expiringRewards.expiry.toLocaleString()} if you have not
-                  claimed before then
-                </>
-              ),
-            })}
-            {...(availableRewards > 0 && {
-              actions: <ClaimButton />,
-            })}
-          >
-            {availableRewards}
-          </BalanceCategory>
-          {locked && (
-            <BalanceCategory
-              name="Locked tokens"
-              description="Locked tokens cannot be withdrawn to your wallet and cannot participate in Integrity Staking."
-              actions={
-                <ModalButton
-                  title="Unlock Schedule"
-                  buttonContent="Show Unlock Schedule"
-                  description="Your tokens will become available for withdrawal and for participation in Integrity Staking according to this schedule"
-                >
-                  <table>
-                    <thead className="font-medium">
-                      <tr>
-                        <td>Date</td>
-                        <td>Amount</td>
-                      </tr>
-                    </thead>
-                    <tbody>
-                      {unlockSchedule.map((unlock, i) => (
-                        <tr key={i}>
-                          <td className="pr-4">
-                            {unlock.date.toLocaleString()}
-                          </td>
-                          <td>
-                            <Tokens>{unlock.amount}</Tokens>
-                          </td>
-                        </tr>
-                      ))}
-                    </tbody>
-                  </table>
-                </ModalButton>
-              }
-            >
-              {locked}
-            </BalanceCategory>
-          )}
-        </div>
-        <div className="flex flex-col items-stretch justify-between gap-8">
-          <section className="bg-black/10 p-4">
-            <h2 className="text-2xl font-semibold">Governance</h2>
-            <p>Vote and Influence the Network</p>
-            <div className="mt-2 flex flex-row items-stretch justify-center">
-              <Position
-                className="bg-pythpurple-600/10"
-                name="Available to Stake"
-                actions={
-                  <TransferButton
-                    actionDescription="Stake funds to participate in governance votes"
-                    actionName="Stake"
-                    max={availableToStakeGovernance}
-                    transfer={stakeGovernance}
-                  >
-                    <strong>Available to stake:</strong>{" "}
-                    <Tokens>{availableToStakeGovernance}</Tokens>
-                  </TransferButton>
-                }
-              >
-                {availableToStakeGovernance}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/15"
-                name="Warmup"
-                actions={
-                  <TransferButton
-                    actionDescription="Cancel staking tokens for governance that are currently in warmup"
-                    actionName="Cancel"
-                    submitButtonText="Cancel Warmup"
-                    title="Cancel Governance Staking"
-                    max={governance.warmup}
-                    transfer={cancelWarmupGovernance}
-                  >
-                    <strong>Max:</strong> <Tokens>{governance.warmup}</Tokens>
-                  </TransferButton>
-                }
-                details={
-                  <div className="text-xs">Staking 2024-08-01T00:00Z</div>
-                }
-              >
-                {governance.warmup}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/20"
-                name="Staked"
-                actions={
-                  <TransferButton
-                    actionDescription="Unstake tokens from the Governance program"
-                    actionName="Unstake"
-                    title="Unstake From Governance"
-                    max={governance.staked}
-                    transfer={unstakeGovernance}
-                  >
-                    <strong>Max:</strong> <Tokens>{governance.staked}</Tokens>
-                  </TransferButton>
-                }
-              >
-                {governance.staked}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/25"
-                name="Cooldown (next epoch)"
-                details={<div className="text-xs">End 2024-08-01T00:00Z</div>}
-              >
-                {governance.cooldown}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/30"
-                name="Cooldown (this epoch)"
-                details={<div className="text-xs">End 2024-08-08T00:00Z</div>}
-              >
-                {governance.cooldown2}
-              </Position>
-            </div>
-          </section>
-          <section className="bg-black/10 p-4">
-            <h2 className="text-2xl font-semibold">Integrity Staking</h2>
-            <p>Protect DeFi, Earn Yield</p>
-            <div className="mt-2 flex flex-row items-stretch justify-center">
-              <Position className="bg-pythpurple-600/5" name="Locked">
-                {locked}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/10"
-                name="Available to Stake"
-              >
-                {availableToStakeIntegrity}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/15"
-                name="Warmup"
-                details={
-                  <div className="text-xs">Staking 2024-08-01T00:00Z</div>
-                }
-              >
-                {integrityStakingWarmup}
-              </Position>
-              <Position className="bg-pythpurple-600/20" name="Staked">
-                {integrityStakingStaked}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/25"
-                name="Cooldown (next epoch)"
-                details={<div className="text-xs">End 2024-08-01T00:00Z</div>}
-              >
-                {integrityStakingCooldown}
-              </Position>
-              <Position
-                className="bg-pythpurple-600/30"
-                name="Cooldown (this epoch)"
-                details={<div className="text-xs">End 2024-08-08T00:00Z</div>}
-              >
-                {integrityStakingCooldown2}
-              </Position>
-            </div>
-            {self && (
-              <div className="mt-8 bg-black/5 p-4">
-                <table className="w-full text-left">
-                  <caption className="mb-4 text-left text-xl">
-                    You ({self.name})
-                  </caption>
-                  <thead>
-                    <tr>
-                      <th className="text-center">Pool</th>
-                      <th className="text-center">Historical APY</th>
-                      <th>Number of feeds</th>
-                      <th>Quality ranking</th>
-                    </tr>
-                  </thead>
-                  <tbody>
-                    <Publisher
-                      availableToStake={availableToStakeIntegrity}
-                      availableRewards={availableRewards}
-                      publisher={self}
-                      omitName
-                      omitSelfStake
-                    />
-                  </tbody>
-                </table>
-              </div>
-            )}
-            <table className="mt-8 w-full text-left">
-              <caption className="mb-4 text-left text-xl">
-                {self ? "Other Publishers" : "Publishers"}
-              </caption>
-              <thead>
-                <tr>
-                  <th>Publisher</th>
-                  <th>Self stake</th>
-                  <th className="text-center">Pool</th>
-                  <th className="text-center">Historical APY</th>
-                  <th>Number of feeds</th>
-                  <th>Quality ranking</th>
-                </tr>
-              </thead>
-              <tbody>
-                {otherPublishers.map((publisher) => (
-                  <Publisher
-                    key={publisher.publicKey}
-                    availableToStake={availableToStakeIntegrity}
-                    availableRewards={availableRewards}
-                    publisher={publisher}
-                  />
-                ))}
-              </tbody>
-            </table>
-          </section>
-        </div>
-      </div>
-    </>
-  );
-};
-
-const useIntegrityStakingSum = (
-  publishers: Props["integrityStakingPublishers"],
-  field: "warmup" | "staked" | "cooldown" | "cooldown2",
-): bigint =>
-  useMemo(
-    () =>
-      publishers
-        .map((publisher) => publisher.positions?.[field] ?? 0n)
-        .reduce((acc, cur) => acc + cur, 0n),
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    publishers.map((publisher) => publisher.positions?.[field]),
-  );
-
-type BalanceCategoryProps = {
-  children: bigint;
-  name: ReactNode | ReactNode[];
-  description?: ReactNode | ReactNode[] | undefined;
-  disclaimer?: ReactNode | ReactNode[] | undefined;
-  actions?: ReactNode | ReactNode[];
-};
-
-const BalanceCategory = ({
-  children,
-  name,
-  description,
-  disclaimer,
-  actions,
-}: BalanceCategoryProps) => (
-  <div className="flex w-1/3 flex-col items-start justify-between gap-2">
-    <div>
-      <div className="text-4xl font-semibold">
-        <Tokens>{children}</Tokens>
-      </div>
-      <div className="flex items-center text-lg">{name}</div>
-      {description && (
-        <p className="max-w-xs text-xs font-light">{description}</p>
-      )}
-      {disclaimer && (
-        <p className="mt-2 max-w-xs text-sm font-medium text-red-600">
-          {disclaimer}
-        </p>
-      )}
-    </div>
-    {actions && <div>{actions}</div>}
-  </div>
-);
-
-type PositionProps = {
-  name: string;
-  className?: string | undefined;
-  children: bigint;
-  actions?: ReactNode | ReactNode[];
-  details?: ReactNode;
-};
-
-const Position = ({
-  name,
-  details,
-  className,
-  children,
-  actions,
-}: PositionProps) =>
-  children > 0n && (
-    <div
-      // style={{ width: `${100 * tokens / tokenData.total}%` }}
-      className={clsx(
-        "flex w-full flex-col justify-between gap-2 overflow-hidden p-2",
-        className,
-      )}
-    >
-      <div>
-        <div className="text-sm font-bold">{name}</div>
-        <div className="text-sm">
-          <Tokens>{children}</Tokens>
-        </div>
-        {details}
-      </div>
-      {actions && <div>{actions}</div>}
-    </div>
-  );
-
-type PublisherProps = {
-  availableRewards: bigint;
-  availableToStake: bigint;
-  omitName?: boolean;
-  omitSelfStake?: boolean;
-  publisher: {
-    name: string;
-    publicKey: string;
-    isSelf: boolean;
-    selfStake: bigint;
-    poolCapacity: bigint;
-    poolUtilization: bigint;
-    numFeeds: number;
-    qualityRanking: number;
-    apyHistory: { date: Date; apy: number }[];
-    positions?:
-      | {
-          warmup?: bigint | undefined;
-          staked?: bigint | undefined;
-          cooldown?: bigint | undefined;
-          cooldown2?: bigint | undefined;
-        }
-      | undefined;
-  };
-};
-
-const Publisher = ({
-  availableRewards,
-  publisher,
-  availableToStake,
-  omitName,
-  omitSelfStake,
-}: PublisherProps) => {
-  const cancelWarmup = useTransferActionForPublisher(
-    cancelWarmupIntegrityStaking,
-    publisher.publicKey,
-  );
-  const unstake = useTransferActionForPublisher(
-    unstakeIntegrityStaking,
-    publisher.publicKey,
-  );
-  const utilizationPercent = useMemo(
-    () => Number((100n * publisher.poolUtilization) / publisher.poolCapacity),
-    [publisher.poolUtilization, publisher.poolCapacity],
-  );
-
-  return (
-    <>
-      <tr>
-        {!omitName && <td className="py-4">{publisher.name}</td>}
-        {!omitSelfStake && (
-          <td>
-            <Tokens>{publisher.selfStake}</Tokens>
-          </td>
-        )}
-        <td className="flex flex-row items-center justify-center gap-2 py-4">
-          <div className="relative grid h-8 w-60 place-content-center border border-black bg-pythpurple-600/10">
-            <div
-              style={{
-                width: `${utilizationPercent.toString()}%`,
-              }}
-              className={clsx(
-                "absolute inset-0 max-w-full",
-                publisher.poolUtilization > publisher.poolCapacity
-                  ? "bg-red-500"
-                  : "bg-pythpurple-400",
-              )}
-            />
-            <div
-              className={clsx(
-                "isolate flex flex-row items-center justify-center gap-1 text-sm",
-                {
-                  "text-white":
-                    publisher.poolUtilization > publisher.poolCapacity,
-                },
-              )}
-            >
-              <span>
-                <Tokens>{publisher.poolUtilization}</Tokens>
-              </span>
-              <span>/</span>
-              <span>
-                <Tokens>{publisher.poolCapacity}</Tokens>
-              </span>
-              <span>({utilizationPercent.toFixed(2)}%)</span>
-            </div>
-          </div>
-          <div className="flex flex-row items-center gap-1">
-            <div className="font-medium">APY:</div>
-            <div>
-              {calculateApy(
-                publisher.poolCapacity,
-                publisher.poolUtilization,
-                publisher.isSelf,
-              )}
-              %
-            </div>
-          </div>
-        </td>
-        <td className="px-4">
-          <div className="mx-auto h-14 w-28 border border-black bg-white/40">
-            <SparkChart
-              data={publisher.apyHistory.map(({ date, apy }) => ({
-                date,
-                value: apy,
-              }))}
-            />
-          </div>
-        </td>
-        <td>{publisher.numFeeds}</td>
-        <td>{publisher.qualityRanking}</td>
-        {availableToStake > 0 && (
-          <td>
-            <StakeToPublisherButton
-              availableToStake={availableToStake}
-              poolCapacity={publisher.poolCapacity}
-              poolUtilization={publisher.poolUtilization}
-              publisherKey={publisher.publicKey}
-              publisherName={publisher.name}
-              isSelf={publisher.isSelf}
-            />
-          </td>
-        )}
-      </tr>
-      {publisher.positions && (
-        <tr className="group">
-          <td colSpan={6} className="border-separate border-spacing-8">
-            <div className="mx-auto mb-8 w-fit bg-black/5 p-4 group-last:mb-0">
-              <table className="w-full">
-                <caption className="mb-2 text-left text-xl">
-                  Your Positions
-                </caption>
-                <thead>
-                  <tr>
-                    <th>Status</th>
-                    <th>Amount</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  <PublisherPosition
-                    name="Warmup"
-                    actions={
-                      <TransferButton
-                        actionDescription={`Cancel tokens that are in warmup for staking to ${publisher.name}`}
-                        actionName="Cancel"
-                        submitButtonText="Cancel Warmup"
-                        title="Cancel Staking"
-                        max={publisher.positions.warmup ?? 0n}
-                        transfer={cancelWarmup}
-                      >
-                        <strong>Max:</strong>{" "}
-                        <Tokens>{publisher.positions.warmup ?? 0n}</Tokens>
-                      </TransferButton>
-                    }
-                  >
-                    {publisher.positions.warmup}
-                  </PublisherPosition>
-                  <PublisherPosition
-                    name="Staked"
-                    actions={
-                      availableRewards > 0 ? (
-                        <ClaimRequiredButton
-                          buttonText="Unstake"
-                          description={`Before you can unstake tokens from ${publisher.name}, you must claim your unclaimed rewards`}
-                          availableRewards={availableRewards}
-                        />
-                      ) : (
-                        <TransferButton
-                          actionDescription={`Unstake tokens from ${publisher.name}`}
-                          actionName="Unstake"
-                          title="Unstake"
-                          max={publisher.positions.staked ?? 0n}
-                          transfer={unstake}
-                        >
-                          <strong>Max:</strong>{" "}
-                          <Tokens>{publisher.positions.staked ?? 0n}</Tokens>
-                        </TransferButton>
-                      )
-                    }
-                  >
-                    {publisher.positions.staked}
-                  </PublisherPosition>
-                  <PublisherPosition name="Cooldown (this epoch)">
-                    {publisher.positions.cooldown}
-                  </PublisherPosition>
-                  <PublisherPosition name="Cooldown (next epoch)">
-                    {publisher.positions.cooldown2}
-                  </PublisherPosition>
-                </tbody>
-              </table>
-            </div>
-          </td>
-        </tr>
-      )}
-    </>
-  );
-};
-
-type PublisherPositionProps = {
-  name: string;
-  children: bigint | undefined;
-  actions?: ReactNode | ReactNode[];
-};
-
-const PublisherPosition = ({
-  children,
-  name,
-  actions,
-}: PublisherPositionProps) =>
-  children &&
-  children !== 0n && (
-    <tr>
-      <td className="pr-8">{name}</td>
-      <td className="pr-8">
-        <Tokens>{children}</Tokens>
-      </td>
-      {actions && <td>{actions}</td>}
-    </tr>
-  );
-
-// eslint-disable-next-line unicorn/no-array-reduce
-const bigIntMin = (...args: bigint[]) => args.reduce((m, e) => (e < m ? e : m));
-
-const ClaimButton = () => {
-  const { state, execute } = useTransfer(claim);
-
-  return (
-    <Button
-      onClick={execute}
-      disabled={state.type !== StateType.Base}
-      loading={state.type === StateType.Submitting}
-    >
-      Claim
-    </Button>
-  );
-};
-
-type ClaimRequiredButtonProps = {
-  buttonText: string;
-  description: string;
-  availableRewards: bigint;
-};
-
-const ClaimRequiredButton = ({
-  buttonText,
-  description,
-  availableRewards,
-}: ClaimRequiredButtonProps) => {
-  const { state, execute } = useTransfer(claim);
-
-  const isSubmitting = state.type === StateType.Submitting;
-
-  return (
-    <ModalButton
-      buttonContent={buttonText}
-      title="Claim Required"
-      closeDisabled={isSubmitting}
-      additionalButtons={(close) => (
-        <Button
-          onClick={() => execute().then(close)}
-          disabled={state.type !== StateType.Base}
-          loading={isSubmitting}
-        >
-          Claim
-        </Button>
-      )}
-      description={description}
-    >
-      <div>
-        <strong>Available Rewards:</strong> <Tokens>{availableRewards}</Tokens>
-      </div>
-    </ModalButton>
-  );
-};
-
-type StakeToPublisherButtonProps = {
-  publisherName: string;
-  publisherKey: string;
-  availableToStake: bigint;
-  poolCapacity: bigint;
-  poolUtilization: bigint;
-  isSelf: boolean;
-};
-
-const StakeToPublisherButton = ({
-  publisherName,
-  publisherKey,
-  poolCapacity,
-  poolUtilization,
-  availableToStake,
-  isSelf,
-}: StakeToPublisherButtonProps) => {
-  const delegate = useTransferActionForPublisher(
-    delegateIntegrityStaking,
-    publisherKey,
-  );
-
-  return (
-    <TransferButton
-      actionDescription={`Stake to ${publisherName}`}
-      actionName="Stake"
-      max={availableToStake}
-      transfer={delegate}
-    >
-      {(amount) => (
-        <>
-          <strong>Available to stake:</strong>{" "}
-          <Tokens>{availableToStake}</Tokens>
-          {amount !== undefined && (
-            <div>
-              Staking these tokens will change the APY to:{" "}
-              {calculateApy(poolCapacity, poolUtilization + amount, isSelf)}%
-            </div>
-          )}
-        </>
-      )}
-    </TransferButton>
-  );
-};
-
-const useTransferActionForPublisher = (
-  action: (
-    context: Context,
-    publicKey: string,
-    amount: bigint,
-  ) => Promise<void>,
-  publicKey: string,
-) =>
-  useCallback(
-    (context: Context, amount: bigint) => action(context, publicKey, amount),
-    [action, publicKey],
-  );

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 0
apps/staking/src/components/Footer/discord.svg


+ 3 - 0
apps/staking/src/components/Footer/github.svg

@@ -0,0 +1,3 @@
+<svg fill="currentColor" viewBox="0 0 19 19" xmlns="http://www.w3.org/2000/svg">
+  <path d="M9.32069 -0.000976406C7.10964 0.000627572 4.97125 0.782612 3.28785 2.20516C1.60446 3.62771 0.485817 5.59807 0.131945 7.76394C-0.221927 9.92982 0.212043 12.15 1.35626 14.0275C2.50048 15.905 4.28034 17.3174 6.37761 18.0122C6.84304 18.0969 7.01292 17.8113 7.01292 17.5665C7.01292 17.3471 7.00517 16.7659 7.00051 15.9946C4.41038 16.5527 3.86272 14.7552 3.86272 14.7552C3.69141 14.1965 3.32472 13.7169 2.82868 13.4027C1.98314 12.8292 2.89306 12.8407 2.89306 12.8407C3.18827 12.8813 3.47024 12.9883 3.71747 13.1534C3.96469 13.3186 4.17065 13.5376 4.31962 13.7937C4.44567 14.0219 4.61605 14.2229 4.82088 14.3853C5.02572 14.5476 5.26096 14.6681 5.513 14.7397C5.76504 14.8112 6.02888 14.8325 6.28926 14.8023C6.54964 14.7721 6.80141 14.6909 7.02999 14.5635C7.07227 14.0949 7.28191 13.6568 7.62109 13.328C5.55301 13.0971 3.37867 12.3019 3.37867 8.75929C3.3652 7.84098 3.70855 6.95278 4.33746 6.27901C4.05292 5.48173 4.08566 4.60675 4.42899 3.8326C4.42899 3.8326 5.21092 3.58396 6.98888 4.78022C8.51535 4.36562 10.126 4.36562 11.6525 4.78022C13.4305 3.58396 14.2124 3.8326 14.2124 3.8326C14.5571 4.6064 14.5901 5.4818 14.3047 6.27901C14.9338 6.9526 15.277 7.84099 15.2627 8.75929C15.2627 12.3104 13.0845 13.0917 11.0087 13.3203C11.2313 13.5443 11.4031 13.8129 11.5126 14.1082C11.622 14.4035 11.6666 14.7186 11.6432 15.0323C11.6432 16.264 11.6316 17.2648 11.6316 17.5673C11.6316 17.8144 11.7999 18.1061 12.2723 18.0122C14.3703 17.3172 16.1506 15.9041 17.2948 14.0257C18.4389 12.1473 18.8723 9.9262 18.5173 7.75976C18.1623 5.59333 17.0421 3.62292 15.3572 2.20105C13.6723 0.779177 11.5325 -0.00138072 9.32069 -0.000976406"/>
+</svg>

+ 64 - 0
apps/staking/src/components/Footer/index.tsx

@@ -0,0 +1,64 @@
+import clsx from "clsx";
+import type { HTMLAttributes } from "react";
+
+import Discord from "./discord.svg";
+import Github from "./github.svg";
+import LinkedIn from "./linkedin.svg";
+import Telegram from "./telegram.svg";
+import X from "./x.svg";
+import Youtube from "./youtube.svg";
+import { MaxWidth } from "../MaxWidth";
+
+const SOCIAL_LINKS = [
+  { name: "X", icon: X, href: "https://x.com/PythNetwork" },
+  {
+    name: "Discord",
+    icon: Discord,
+    href: "https://discord.gg/invite/PythNetwork",
+  },
+  { name: "Telegram", icon: Telegram, href: "https://t.me/Pyth_Network" },
+  {
+    name: "LinkedIn",
+    icon: LinkedIn,
+    href: "https://www.linkedin.com/company/pyth-network",
+  },
+  { name: "Github", icon: Github, href: "https://github.com/pyth-network" },
+  {
+    name: "Youtube",
+    icon: Youtube,
+    href: "https://www.youtube.com/channel/UCjCkvPN9ohl0UDvldfn1neg",
+  },
+];
+
+export const Footer = ({
+  className,
+  ...props
+}: Omit<HTMLAttributes<HTMLElement>, "children">) => (
+  <footer
+    className={clsx(
+      "sticky bottom-0 mt-4 px-4 text-xs font-light text-neutral-400",
+      className,
+    )}
+    {...props}
+  >
+    <div className="border-x border-t border-neutral-600/50 bg-pythpurple-800">
+      <MaxWidth className="-mx-4 flex h-16 items-center justify-between">
+        <div>© 2024 Pyth Data Association</div>
+        <div className="relative -right-3 flex h-full items-center">
+          {SOCIAL_LINKS.map(({ name, icon: Icon, href }) => (
+            <a
+              target="_blank"
+              href={href}
+              key={name}
+              className="grid h-full place-content-center px-3 hover:text-pythpurple-400"
+              rel="noreferrer"
+            >
+              <Icon className="size-4" />
+              <span className="sr-only">{name}</span>
+            </a>
+          ))}
+        </div>
+      </MaxWidth>
+    </div>
+  </footer>
+);

+ 3 - 0
apps/staking/src/components/Footer/linkedin.svg

@@ -0,0 +1,3 @@
+<svg fill="currentColor" viewBox="0 0 19 19"  xmlns="http://www.w3.org/2000/svg">
+  <path d="M18.1016 18.0156H14.5016V11.7165C14.5016 9.98853 13.7393 9.02462 12.3722 9.02462C10.8845 9.02462 10.0016 10.029 10.0016 11.7165V18.0156H6.40158V6.31562H10.0016V7.63142C10.0016 7.63142 11.1311 5.64962 13.6763 5.64962C16.2224 5.64962 18.1016 7.20302 18.1016 10.4178V18.0156ZM2.29938 4.44452C2.00962 4.44346 1.72291 4.38532 1.45563 4.27343C1.18834 4.16153 0.94572 3.99808 0.741623 3.79239C0.537526 3.58671 0.375953 3.34283 0.266132 3.07468C0.156312 2.80654 0.100397 2.51938 0.101581 2.22962C0.100515 1.93994 0.156518 1.65288 0.266391 1.38484C0.376265 1.1168 0.537857 0.873031 0.741942 0.667441C0.946027 0.461852 1.18861 0.298474 1.45583 0.186635C1.72306 0.074796 2.0097 0.0166868 2.29938 0.015625C2.88427 0.0180074 3.44426 0.252578 3.85624 0.667759C4.26821 1.08294 4.49843 1.64474 4.49628 2.22962C4.49758 2.51934 4.44178 2.80647 4.33206 3.07461C4.22234 3.34275 4.06085 3.58664 3.85682 3.79234C3.6528 3.99803 3.41024 4.16151 3.143 4.27341C2.87577 4.38532 2.5891 4.44346 2.29938 4.44452ZM0.101581 18.0156H4.60158V6.31562H0.101581V18.0156Z"/>
+</svg>

+ 3 - 0
apps/staking/src/components/Footer/telegram.svg

@@ -0,0 +1,3 @@
+<svg fill="currentColor" viewBox="0 0 19 18" xmlns="http://www.w3.org/2000/svg">
+  <path d="M18.6209 1.31113C18.6065 1.23908 18.5746 1.17248 18.5286 1.11813C18.4826 1.06378 18.4241 1.02364 18.3591 1.00179C18.1226 0.950958 17.878 0.969791 17.6505 1.05632C17.6505 1.05632 1.87128 7.20348 0.970498 7.87867C0.776427 8.02541 0.711438 8.11068 0.679397 8.20983C0.523774 8.69466 1.00895 8.90386 1.00895 8.90386L5.07529 10.3395C5.14393 10.3528 5.21448 10.3483 5.28126 10.3266C6.20675 9.69308 14.5893 3.96036 15.0763 3.76801C15.1523 3.74322 15.2091 3.76801 15.1944 3.82849C15.0004 4.56417 7.71996 11.5739 7.71996 11.5739C7.70197 11.5989 7.68691 11.6262 7.67511 11.6552L7.66595 11.6493L7.28605 16.0187C7.28605 16.0187 7.12767 17.3572 8.36259 16.0187C9.23408 15.0738 10.0781 14.2846 10.5001 13.8999C11.8989 14.9459 13.4039 16.103 14.0529 16.7078C14.1618 16.8221 14.2909 16.9116 14.4325 16.9707C14.574 17.0298 14.7252 17.0574 14.8768 17.0518C15.4929 17.027 15.665 16.2933 15.665 16.2933C15.665 16.2933 18.5403 3.76107 18.6319 2.0825C18.641 1.91792 18.6539 1.81282 18.6557 1.69979C18.6604 1.56907 18.6484 1.43827 18.62 1.31113"/>
+</svg>

+ 3 - 0
apps/staking/src/components/Footer/x.svg

@@ -0,0 +1,3 @@
+<svg fill="currentColor" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
+  <path d="M10.7124 7.62177L17.4133 0H15.8254L10.0071 6.61788L5.35992 0H0L7.02738 10.0074L0 18H1.58799L7.73236 11.0113L12.6401 18H18L10.7124 7.62177ZM8.53747 10.0956L7.82545 9.09906L2.16016 1.16971H4.59922L9.17118 7.56895L9.8832 8.56546L15.8262 16.8835H13.3871L8.53747 10.0956Z"/>
+</svg>

+ 3 - 0
apps/staking/src/components/Footer/youtube.svg

@@ -0,0 +1,3 @@
+<svg fill="currentColor" viewBox="0 0 19 12" xmlns="http://www.w3.org/2000/svg">
+  <path d="M10.3954 11.7887L6.70081 11.726C5.50459 11.7041 4.3054 11.7478 3.13264 11.5211C1.3486 11.1823 1.22221 9.5216 1.08996 8.12855C0.907727 6.1701 0.978274 4.1761 1.32216 2.234C1.5163 1.14427 2.28031 0.494027 3.46182 0.423268C7.45029 0.166473 11.4653 0.196906 15.4449 0.31671C15.8652 0.327693 16.2884 0.387721 16.7028 0.456048C18.7486 0.789304 18.7985 2.6713 18.9311 4.25558C19.0633 5.85621 19.0075 7.46506 18.7547 9.0548C18.5519 10.371 18.1639 11.4749 16.5265 11.5814C14.4749 11.7208 12.4703 11.8329 10.413 11.7972C10.413 11.7887 10.4012 11.7887 10.3954 11.7887ZM8.22332 8.45636C9.76938 7.6314 11.2859 6.82018 12.8232 6.00075C11.2742 5.17579 9.76054 4.36457 8.22332 3.54514V8.45636Z"/>
+</svg>

+ 41 - 0
apps/staking/src/components/Governance/index.tsx

@@ -0,0 +1,41 @@
+import {
+  stakeGovernance,
+  cancelWarmupGovernance,
+  unstakeGovernance,
+} from "../../api";
+import { ProgramSection } from "../ProgramSection";
+
+type Props = {
+  availableToStake: bigint;
+  warmup: bigint;
+  staked: bigint;
+  cooldown: bigint;
+  cooldown2: bigint;
+};
+
+export const Governance = ({
+  availableToStake,
+  warmup,
+  staked,
+  cooldown,
+  cooldown2,
+}: Props) => (
+  <ProgramSection
+    name="Governance"
+    description="Vote and Influence the Network"
+    positions={{
+      available: availableToStake,
+      warmup,
+      staked,
+      cooldown,
+      cooldown2,
+      stake: stakeGovernance,
+      stakeDescription: "Stake funds to participate in governance votes",
+      cancelWarmup: cancelWarmupGovernance,
+      cancelWarmupDescription:
+        "Cancel staking tokens for governance that are currently in warmup",
+      unstake: unstakeGovernance,
+      unstakeDescription: "Unstake tokens from the Governance program",
+    }}
+  />
+);

+ 25 - 0
apps/staking/src/components/Header/index.tsx

@@ -0,0 +1,25 @@
+"use client";
+
+import clsx from "clsx";
+import type { HTMLAttributes } from "react";
+
+import Logo from "./logo.svg";
+import { MaxWidth } from "../MaxWidth";
+import { WalletButton } from "../WalletButton";
+
+export const Header = ({
+  className,
+  ...props
+}: Omit<HTMLAttributes<HTMLElement>, "children">) => (
+  <header
+    className={clsx("sticky top-0 mb-4 w-full px-4", className)}
+    {...props}
+  >
+    <div className="border-x border-b border-neutral-600/50 bg-pythpurple-800">
+      <MaxWidth className="-mx-4 flex h-16 items-center justify-between">
+        <Logo className="h-full py-4 text-pythpurple-100" />
+        <WalletButton />
+      </MaxWidth>
+    </div>
+  </header>
+);

+ 4 - 0
apps/staking/src/components/Header/logo.svg

@@ -0,0 +1,4 @@
+<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 225 77">
+  <path d="M85.753 44.752v-5.183H98.05c1.88 0 3.347-.533 4.4-1.6 1.052-1.067 1.578-2.474 1.578-4.213 0-1.74-.526-3.173-1.578-4.213-1.053-1.039-2.52-1.559-4.4-1.559H81.838v25.287h-5.307V22.625H98.05c1.769 0 3.347.26 4.739.78 1.388.52 2.562 1.25 3.517 2.192.956.943 1.686 2.086 2.191 3.434.506 1.347.759 2.865.759 4.552 0 1.687-.253 3.166-.759 4.531-.505 1.362-1.235 2.536-2.191 3.52-.955.984-2.125 1.75-3.517 2.297-1.388.547-2.97.821-4.74.821H85.754Zm40.918 8.512V41.631l-16.253-19.006h7.2l11.919 14.117 11.957-14.117h6.824L132.02 41.631v11.633h-5.349Zm40.093 0V27.977H152.68v-5.352h33.536v5.352h-14.104v25.287h-5.348Zm52.904-30.639h5.328v30.639h-5.328V22.625Zm-22.981 30.639V40.002h19.821V35.26h-19.821V22.625h-5.328v30.639h5.328ZM38.267 30.655c0 4.23-3.427 7.66-7.654 7.66v7.66c8.453 0 15.308-6.86 15.308-15.32s-6.855-15.32-15.308-15.32c-2.787 0-5.404.746-7.654 2.052a15.308 15.308 0 0 0-7.654 13.268v38.3l6.882 6.886.772.773V30.655c0-4.23 3.427-7.66 7.654-7.66a7.657 7.657 0 0 1 7.654 7.66Z"/>
+  <path d="M30.616 0a30.442 30.442 0 0 0-15.308 4.102 30.619 30.619 0 0 0-7.654 6.274A30.535 30.535 0 0 0 0 30.639v22.98l7.654 7.66v-30.64c0-6.804 2.956-12.919 7.654-17.128a22.916 22.916 0 0 1 7.654-4.538 22.772 22.772 0 0 1 7.654-1.313c12.68 0 22.962 10.289 22.962 22.979s-10.281 22.98-22.962 22.98v7.66c16.91 0 30.616-13.72 30.616-30.64S47.527 0 30.616 0Z"/>
+</svg>

+ 121 - 32
apps/staking/src/components/Home/index.tsx

@@ -1,49 +1,138 @@
 "use client";
 
-import { useWallet } from "@solana/wallet-adapter-react";
 import { useWalletModal } from "@solana/wallet-adapter-react-ui";
 import { useCallback } from "react";
+import { useIsSSR } from "react-aria";
+import useSWR from "swr";
 
-import { useIsMounted } from "../../use-is-mounted";
+import { loadData } from "../../api";
+import { useApiContext } from "../../hooks/use-api-context";
+import { StateType, useStakeAccount } from "../../hooks/use-stake-account";
 import { Button } from "../Button";
 import { Dashboard } from "../Dashboard";
 import { LoadingSpinner } from "../LoadingSpinner";
 
-export const Home = () => (
-  <main className="px-8 py-16">
-    <h1 className="mb-8 text-4xl font-semibold text-pythpurple-600 dark:text-pythpurple-400">
-      Staking & Delegating
-    </h1>
-    <HomeContents />
-  </main>
-);
-
-const HomeContents = () => {
-  const isMounted = useIsMounted();
-  const wallet = useWallet();
+const ONE_SECOND_IN_MS = 1000;
+const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
+const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS;
+
+export const Home = () => {
+  const isSSR = useIsSSR();
+
+  return (
+    <main className="mx-4 my-6">
+      {isSSR ? <LoadingSpinner /> : <MountedHome />}
+    </main>
+  );
+};
+
+const MountedHome = () => {
+  const stakeAccountState = useStakeAccount();
+
+  switch (stakeAccountState.type) {
+    case StateType.Initialized:
+    case StateType.Loading: {
+      return <LoadingSpinner />;
+    }
+    case StateType.NoAccounts: {
+      return <p>No stake account found for your wallet!</p>;
+    }
+    case StateType.NoWallet: {
+      return <NoWalletHome />;
+    }
+    case StateType.Error: {
+      return (
+        <p>
+          Uh oh, an error occurred while loading stake accounts. Please refresh
+          and try again
+        </p>
+      );
+    }
+    case StateType.Loaded: {
+      return <StakeAccountLoadedHome />;
+    }
+  }
+};
+
+const NoWalletHome = () => {
   const modal = useWalletModal();
   const showModal = useCallback(() => {
     modal.setVisible(true);
   }, [modal]);
 
-  if (isMounted) {
-    return wallet.connected ? (
-      <Dashboard />
-    ) : (
-      <>
-        <p className="mx-auto mb-8 max-w-prose text-center">
-          The Pyth staking program allows you to stake tokens to participate in
-          governance, or to earn yield and protect DeFi by delegating to
-          publishers.
-        </p>
-        <div className="grid w-full place-content-center">
-          <Button onClick={showModal}>
-            Connect your wallet to participate
-          </Button>
-        </div>
-      </>
-    );
+  return (
+    <>
+      <h1 className="mb-8 mt-16 text-center text-4xl font-semibold text-pythpurple-400">
+        Staking & Delegating
+      </h1>
+      <p className="mx-auto mb-8 max-w-prose text-center">
+        The Pyth staking program allows you to stake tokens to participate in
+        governance, or to earn yield and protect DeFi by delegating to
+        publishers.
+      </p>
+      <div className="grid w-full place-content-center">
+        <Button onClick={showModal}>Connect your wallet to participate</Button>
+      </div>
+    </>
+  );
+};
+
+const StakeAccountLoadedHome = () => {
+  const data = useDashboardData();
+
+  switch (data.type) {
+    case DataStateType.NotLoaded:
+    case DataStateType.Loading: {
+      return <LoadingSpinner />;
+    }
+    case DataStateType.Error: {
+      return <p>Uh oh, an error occured!</p>;
+    }
+    case DataStateType.Loaded: {
+      return <Dashboard {...data.data} />;
+    }
+  }
+};
+
+const useDashboardData = () => {
+  const apiContext = useApiContext();
+
+  const { data, isLoading, ...rest } = useSWR(
+    apiContext.stakeAccount.publicKey,
+    () => loadData(apiContext),
+    {
+      refreshInterval: REFRESH_INTERVAL,
+    },
+  );
+  const error = rest.error as unknown;
+
+  if (error) {
+    return DataState.ErrorState(error);
+  } else if (isLoading) {
+    return DataState.Loading();
+  } else if (data) {
+    return DataState.Loaded(data);
   } else {
-    return <LoadingSpinner />;
+    return DataState.NotLoaded();
   }
 };
+
+enum DataStateType {
+  NotLoaded,
+  Loading,
+  Loaded,
+  Error,
+}
+const DataState = {
+  NotLoaded: () => ({ type: DataStateType.NotLoaded as const }),
+  Loading: () => ({ type: DataStateType.Loading as const }),
+  Loaded: (data: Awaited<ReturnType<typeof loadData>>) => ({
+    type: DataStateType.Loaded as const,
+    data,
+  }),
+  ErrorState: (error: unknown) => ({
+    type: DataStateType.Error as const,
+    error,
+  }),
+};
+type DataState = ReturnType<(typeof DataState)[keyof typeof DataState]>;

+ 3 - 3
apps/staking/src/components/LoadingSpinner/index.tsx

@@ -1,5 +1,5 @@
 import { ArrowPathIcon } from "@heroicons/react/24/outline";
 
-export const LoadingSpinner = () => (
-  <ArrowPathIcon className="size-6 animate-spin" />
-);
+import { Styled } from "../Styled";
+
+export const LoadingSpinner = Styled(ArrowPathIcon, "size-6 animate-spin");

+ 3 - 0
apps/staking/src/components/MaxWidth/index.tsx

@@ -0,0 +1,3 @@
+import { Styled } from "../Styled";
+
+export const MaxWidth = Styled("div", "px-12");

+ 104 - 46
apps/staking/src/components/Modal/index.tsx

@@ -8,31 +8,95 @@ import {
   Transition,
 } from "@headlessui/react";
 import { XMarkIcon } from "@heroicons/react/24/outline";
-import { type ReactNode, useCallback } from "react";
+import {
+  type ReactNode,
+  type ComponentProps,
+  type ElementType,
+  type Dispatch,
+  type SetStateAction,
+  useState,
+  useCallback,
+  useContext,
+  createContext,
+} from "react";
 
 import { Button } from "../Button";
 
-type Props = {
-  open: boolean;
+const ModalContext = createContext<
+  [boolean, Dispatch<SetStateAction<boolean>>] | undefined
+>(undefined);
+
+export const Modal = (
+  props: Omit<ComponentProps<typeof ModalContext.Provider>, "value">,
+) => {
+  const state = useState(false);
+  return <ModalContext.Provider value={state} {...props} />;
+};
+
+const useModalContext = () => {
+  const ctx = useContext(ModalContext);
+  if (ctx === undefined) {
+    throw new ContextNotInitializedError();
+  }
+  return ctx;
+};
+
+class ContextNotInitializedError extends Error {
+  constructor() {
+    super("You cannot use this component outside of a <Modal> parent!");
+  }
+}
+
+type ModalButtonProps<T extends ElementType> = Omit<ComponentProps<T>, "as"> & {
+  as?: T;
+};
+
+export const ModalButton = <T extends ElementType>({
+  as,
+  ...props
+}: ModalButtonProps<T>) => {
+  const Component = as ?? Button;
+  const [, setState] = useModalContext();
+  const toggle = useCallback(() => {
+    setState((cur) => !cur);
+  }, [setState]);
+  return <Component onClick={toggle} {...props} />;
+};
+
+export const ModalPanel = (
+  props: Omit<RawModalProps, "isOpen" | "onClose">,
+) => {
+  const [state, setState] = useModalContext();
+  const onClose = useCallback(() => {
+    setState(false);
+  }, [setState]);
+
+  return <RawModal isOpen={state} onClose={onClose} {...props} />;
+};
+
+type RawModalProps = {
+  isOpen: boolean;
   onClose: () => void;
   closeDisabled?: boolean | undefined;
   afterLeave?: (() => void) | undefined;
-  children?: ReactNode | ReactNode[] | undefined;
   title: ReactNode | ReactNode[];
   description?: string;
-  additionalButtons?: ReactNode | ReactNode[] | undefined;
+  children?:
+    | ((onClose: () => void) => ReactNode | ReactNode[])
+    | ReactNode
+    | ReactNode[]
+    | undefined;
 };
 
-export const Modal = ({
-  open,
+export const RawModal = ({
+  isOpen,
   onClose,
   closeDisabled,
   afterLeave,
   children,
   title,
   description,
-  additionalButtons,
-}: Props) => {
+}: RawModalProps) => {
   const handleClose = useCallback(() => {
     if (!closeDisabled) {
       onClose();
@@ -40,48 +104,42 @@ export const Modal = ({
   }, [closeDisabled, onClose]);
 
   return (
-    <Dialog open={open} onClose={handleClose} className="relative z-50">
-      <DialogBackdrop
-        transition
-        className="fixed inset-0 bg-black/30 duration-300 ease-out data-[closed]:opacity-0"
-      />
-      <div className="fixed inset-0 flex w-screen items-center justify-center p-4">
-        <Transition
-          as={DialogPanel}
-          show={open}
-          static
-          className="relative rounded-md bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
-          {...(afterLeave && { afterLeave })}
-        >
-          <DialogTitle
-            as="h1"
-            className="text-lg font-medium leading-6 text-neutral-800 dark:text-neutral-200 md:text-xl lg:text-2xl"
+    <Transition show={isOpen} {...(afterLeave && { afterLeave })}>
+      <Dialog
+        static
+        open={isOpen}
+        onClose={handleClose}
+        className="relative z-50"
+      >
+        <DialogBackdrop
+          transition
+          className="fixed inset-0 bg-black/30 backdrop-blur duration-300 ease-out data-[closed]:opacity-0"
+        />
+        <div className="fixed inset-0 flex w-screen items-center justify-center p-4">
+          <DialogPanel
+            transition
+            className="relative border border-neutral-600/50 bg-[#100E21] px-10 py-12 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
           >
-            {title}
-          </DialogTitle>
-          {closeDisabled !== true && (
-            <CloseButton className="absolute right-3 top-3 rounded-md p-2 text-neutral-500 transition hover:bg-black/10 dark:hover:bg-white/5">
-              <XMarkIcon className="size-5" />
-            </CloseButton>
-          )}
-          {description && (
-            <Description className="mb-10 mt-2 text-sm text-neutral-500 dark:text-neutral-400">
-              {description}
-            </Description>
-          )}
-          {children}
-          <div className="mt-8 flex flex-row justify-end gap-4 text-right">
+            <DialogTitle as="h2" className="text-3xl font-light leading-6">
+              {title}
+            </DialogTitle>
             <CloseButton
               as={Button}
-              className="px-4 py-2"
+              className="absolute right-3 top-3 grid size-10 place-content-center"
+              nopad
               disabled={closeDisabled ?? false}
             >
-              Close
+              <XMarkIcon className="size-6" />
             </CloseButton>
-            {additionalButtons}
-          </div>
-        </Transition>
-      </div>
-    </Dialog>
+            {description && (
+              <Description className="mb-10 mt-2 max-w-96 opacity-60">
+                {description}
+              </Description>
+            )}
+            {typeof children === "function" ? children(handleClose) : children}
+          </DialogPanel>
+        </div>
+      </Dialog>
+    </Transition>
   );
 };

+ 0 - 60
apps/staking/src/components/ModalButton/index.tsx

@@ -1,60 +0,0 @@
-"use client";
-
-import {
-  type ComponentProps,
-  type ReactNode,
-  useCallback,
-  useState,
-} from "react";
-
-import { Button } from "../Button";
-import { Modal } from "../Modal";
-
-type Props = Omit<
-  ComponentProps<typeof Modal>,
-  "open" | "onClose" | "additionalButtons"
-> & {
-  buttonContent?: ReactNode | ReactNode[] | undefined;
-  onClose?: () => void;
-  additionalButtons?:
-    | ((onClose: () => void) => ReactNode | ReactNode[])
-    | ReactNode
-    | ReactNode[]
-    | undefined;
-};
-
-export const ModalButton = ({
-  buttonContent,
-  title,
-  onClose,
-  additionalButtons,
-  ...props
-}: Props) => {
-  const [isOpen, setIsOpen] = useState(false);
-  const close = useCallback(() => {
-    if (onClose) {
-      onClose();
-    }
-    setIsOpen(false);
-  }, [setIsOpen, onClose]);
-  const open = useCallback(() => {
-    setIsOpen(true);
-  }, [setIsOpen]);
-
-  return (
-    <>
-      <Button onClick={open}>{buttonContent ?? title}</Button>
-      <Modal
-        open={isOpen}
-        onClose={close}
-        title={title}
-        additionalButtons={
-          typeof additionalButtons === "function"
-            ? additionalButtons(close)
-            : additionalButtons
-        }
-        {...props}
-      />
-    </>
-  );
-};

+ 350 - 0
apps/staking/src/components/OracleIntegrityStaking/index.tsx

@@ -0,0 +1,350 @@
+import clsx from "clsx";
+import { useMemo, useCallback } from "react";
+
+import {
+  type Context,
+  delegateIntegrityStaking,
+  cancelWarmupIntegrityStaking,
+  unstakeIntegrityStaking,
+  calculateApy,
+} from "../../api";
+import { PositionFlowchart } from "../PositionFlowchart";
+import { ProgramSection } from "../ProgramSection";
+import { SparkChart } from "../SparkChart";
+import { StakingTimeline } from "../StakingTimeline";
+import { Styled } from "../Styled";
+import { Tokens } from "../Tokens";
+import { AmountType, TransferButton } from "../TransferButton";
+
+type Props = {
+  availableToStake: bigint;
+  locked: bigint;
+  warmup: bigint;
+  staked: bigint;
+  cooldown: bigint;
+  cooldown2: bigint;
+  publishers: PublisherProps["publisher"][];
+};
+
+export const OracleIntegrityStaking = ({
+  availableToStake,
+  locked,
+  warmup,
+  staked,
+  cooldown,
+  cooldown2,
+  publishers,
+}: Props) => {
+  const self = useMemo(
+    () => publishers.find((publisher) => publisher.isSelf),
+    [publishers],
+  );
+
+  const otherPublishers = useMemo(
+    () => publishers.filter((publisher) => !publisher.isSelf),
+    [publishers],
+  );
+
+  return (
+    <ProgramSection
+      name="Oracle Integrity Staking"
+      description="Protect DeFi, Earn Yield"
+      className="pb-0"
+      positions={{
+        locked,
+        available: availableToStake,
+        warmup,
+        staked,
+        cooldown,
+        cooldown2,
+        className: "mb-8",
+      }}
+    >
+      {self && (
+        <div className="-mx-10 border-t border-neutral-600/50 py-16">
+          <table className="mx-auto border border-neutral-600/50 text-sm">
+            <caption className="mb-4 ml-10 text-2xl font-light">
+              You ({self.name})
+            </caption>
+            <thead className="bg-pythpurple-400/30 font-light">
+              <tr>
+                <PublisherTableHeader>Pool</PublisherTableHeader>
+                <PublisherTableHeader>APY</PublisherTableHeader>
+                <PublisherTableHeader>Historical APY</PublisherTableHeader>
+                <PublisherTableHeader>Number of feeds</PublisherTableHeader>
+                <PublisherTableHeader>Quality ranking</PublisherTableHeader>
+                {availableToStake > 0n && <PublisherTableHeader />}
+              </tr>
+            </thead>
+            <tbody className="bg-pythpurple-400/10">
+              <Publisher
+                isSelf
+                availableToStake={availableToStake}
+                publisher={self}
+              />
+            </tbody>
+          </table>
+        </div>
+      )}
+      <div className="-mx-10 border-t border-neutral-600/50 pt-4">
+        <table className="w-full text-sm">
+          <caption className="mb-4 ml-10 text-left text-2xl font-light">
+            {self ? "Other Publishers" : "Publishers"}
+          </caption>
+          <thead className="bg-pythpurple-100/30 font-light">
+            <tr>
+              <PublisherTableHeader className="pl-10 text-left">
+                Publisher
+              </PublisherTableHeader>
+              <PublisherTableHeader>Self stake</PublisherTableHeader>
+              <PublisherTableHeader>Pool</PublisherTableHeader>
+              <PublisherTableHeader>APY</PublisherTableHeader>
+              <PublisherTableHeader>Historical APY</PublisherTableHeader>
+              <PublisherTableHeader>Number of feeds</PublisherTableHeader>
+              <PublisherTableHeader
+                className={clsx({ "pr-10": availableToStake <= 0n })}
+              >
+                Quality ranking
+              </PublisherTableHeader>
+              {availableToStake > 0n && (
+                <PublisherTableHeader className="pr-10" />
+              )}
+            </tr>
+          </thead>
+          <tbody className="bg-white/5">
+            {otherPublishers.map((publisher) => (
+              <Publisher
+                key={publisher.publicKey}
+                availableToStake={availableToStake}
+                publisher={publisher}
+              />
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </ProgramSection>
+  );
+};
+
+const PublisherTableHeader = Styled(
+  "th",
+  "py-2 font-normal px-5 whitespace-nowrap",
+);
+
+type PublisherProps = {
+  availableToStake: bigint;
+  isSelf?: boolean;
+  publisher: {
+    name: string;
+    publicKey: string;
+    isSelf: boolean;
+    selfStake: bigint;
+    poolCapacity: bigint;
+    poolUtilization: bigint;
+    numFeeds: number;
+    qualityRanking: number;
+    apyHistory: { date: Date; apy: number }[];
+    positions?:
+      | {
+          warmup?: bigint | undefined;
+          staked?: bigint | undefined;
+          cooldown?: bigint | undefined;
+          cooldown2?: bigint | undefined;
+        }
+      | undefined;
+  };
+};
+
+const Publisher = ({ publisher, availableToStake, isSelf }: PublisherProps) => {
+  const cancelWarmup = useTransferActionForPublisher(
+    cancelWarmupIntegrityStaking,
+    publisher.publicKey,
+  );
+  const unstake = useTransferActionForPublisher(
+    unstakeIntegrityStaking,
+    publisher.publicKey,
+  );
+  const utilizationPercent = useMemo(
+    () => Number((100n * publisher.poolUtilization) / publisher.poolCapacity),
+    [publisher.poolUtilization, publisher.poolCapacity],
+  );
+
+  return (
+    <>
+      <tr className="border-t border-neutral-600/50 first:border-0">
+        {!isSelf && (
+          <>
+            <PublisherTableCell className="py-4 pl-10 font-medium">
+              {publisher.name}
+            </PublisherTableCell>
+            <PublisherTableCell className="text-center">
+              <Tokens>{publisher.selfStake}</Tokens>
+            </PublisherTableCell>
+          </>
+        )}
+        <PublisherTableCell className="text-center">
+          <div className="relative mx-auto grid h-5 w-52 place-content-center border border-black bg-pythpurple-600/50">
+            <div
+              style={{
+                width: `${utilizationPercent.toString()}%`,
+              }}
+              className={clsx(
+                "absolute inset-0 max-w-full",
+                publisher.poolUtilization > publisher.poolCapacity
+                  ? "bg-fuchsia-900"
+                  : "bg-pythpurple-400",
+              )}
+            />
+            <div
+              className={clsx("isolate text-sm font-medium", {
+                "mix-blend-difference":
+                  publisher.poolUtilization <= publisher.poolCapacity,
+              })}
+            >
+              {utilizationPercent.toString()}%
+            </div>
+          </div>
+          <div className="mt-2 flex flex-row items-center justify-center gap-1 text-sm">
+            <span>
+              <Tokens>{publisher.poolUtilization}</Tokens>
+            </span>
+            <span>/</span>
+            <span>
+              <Tokens>{publisher.poolCapacity}</Tokens>
+            </span>
+          </div>
+        </PublisherTableCell>
+        <PublisherTableCell className="text-center">
+          <div>
+            {calculateApy(
+              publisher.poolCapacity,
+              publisher.poolUtilization,
+              publisher.isSelf,
+            )}
+            %
+          </div>
+        </PublisherTableCell>
+        <PublisherTableCell>
+          <div className="mx-auto h-14 w-28">
+            <SparkChart
+              data={publisher.apyHistory.map(({ date, apy }) => ({
+                date,
+                value: apy,
+              }))}
+            />
+          </div>
+        </PublisherTableCell>
+        <PublisherTableCell className="text-center">
+          {publisher.numFeeds}
+        </PublisherTableCell>
+        <PublisherTableCell
+          className={clsx("text-center", {
+            "pr-10": availableToStake <= 0n && !isSelf,
+          })}
+        >
+          {publisher.qualityRanking}
+        </PublisherTableCell>
+        {availableToStake > 0 && (
+          <PublisherTableCell
+            className={clsx("text-right", { "pr-10": !isSelf })}
+          >
+            <StakeToPublisherButton
+              availableToStake={availableToStake}
+              poolCapacity={publisher.poolCapacity}
+              poolUtilization={publisher.poolUtilization}
+              publisherKey={publisher.publicKey}
+              publisherName={publisher.name}
+              isSelf={publisher.isSelf}
+            />
+          </PublisherTableCell>
+        )}
+      </tr>
+      {publisher.positions && (
+        <tr>
+          <td colSpan={8} className="border-separate border-spacing-8">
+            <div className="mx-auto w-full px-20 pb-8">
+              <PositionFlowchart
+                small
+                className="mx-auto w-[56rem]"
+                warmup={publisher.positions.warmup ?? 0n}
+                staked={publisher.positions.staked ?? 0n}
+                cooldown={publisher.positions.cooldown ?? 0n}
+                cooldown2={publisher.positions.cooldown2 ?? 0n}
+                cancelWarmup={cancelWarmup}
+                cancelWarmupDescription={`Cancel tokens that are in warmup for staking to ${publisher.name}`}
+                unstake={unstake}
+                unstakeDescription={`Unstake tokens from ${publisher.name}`}
+              />
+            </div>
+          </td>
+        </tr>
+      )}
+    </>
+  );
+};
+
+const PublisherTableCell = Styled("td", "py-4 px-5 whitespace-nowrap");
+
+type StakeToPublisherButtonProps = {
+  publisherName: string;
+  publisherKey: string;
+  availableToStake: bigint;
+  poolCapacity: bigint;
+  poolUtilization: bigint;
+  isSelf: boolean;
+};
+
+const StakeToPublisherButton = ({
+  publisherName,
+  publisherKey,
+  poolCapacity,
+  poolUtilization,
+  availableToStake,
+  isSelf,
+}: StakeToPublisherButtonProps) => {
+  const delegate = useTransferActionForPublisher(
+    delegateIntegrityStaking,
+    publisherKey,
+  );
+
+  return (
+    <TransferButton
+      small
+      actionDescription={`Stake to ${publisherName}`}
+      actionName="Stake"
+      max={availableToStake}
+      transfer={delegate}
+    >
+      {(amount) => (
+        <>
+          <div className="mb-8 flex flex-row items-center justify-between text-sm">
+            <div>APY after staking</div>
+            <div className="font-medium">
+              {calculateApy(
+                poolCapacity,
+                poolUtilization +
+                  (amount.type === AmountType.Valid ? amount.amount : 0n),
+                isSelf,
+              )}
+              %
+            </div>
+          </div>
+          <StakingTimeline />
+        </>
+      )}
+    </TransferButton>
+  );
+};
+
+const useTransferActionForPublisher = (
+  action: (
+    context: Context,
+    publicKey: string,
+    amount: bigint,
+  ) => Promise<void>,
+  publicKey: string,
+) =>
+  useCallback(
+    (context: Context, amount: bigint) => action(context, publicKey, amount),
+    [action, publicKey],
+  );

+ 232 - 0
apps/staking/src/components/PositionFlowchart/index.tsx

@@ -0,0 +1,232 @@
+import { ArrowLongRightIcon } from "@heroicons/react/24/outline";
+import clsx from "clsx";
+import type { HTMLAttributes, ReactNode, ComponentProps } from "react";
+
+import { getUpcomingEpoch, getNextFullEpoch } from "../../api";
+import { StakingTimeline } from "../StakingTimeline";
+import { Tokens } from "../Tokens";
+import { TransferButton } from "../TransferButton";
+
+type Props = HTMLAttributes<HTMLDivElement> & {
+  locked?: bigint | undefined;
+  warmup: bigint;
+  staked: bigint;
+  cooldown: bigint;
+  cooldown2: bigint;
+  small?: boolean | undefined;
+} & (
+    | {
+        stake?: never;
+        stakeDescription?: never;
+        available?: bigint | undefined;
+      }
+    | {
+        available: bigint;
+        stake: ComponentProps<typeof TransferButton>["transfer"] | undefined;
+        stakeDescription: string;
+      }
+  ) &
+  (
+    | { cancelWarmup?: never; cancelWarmupDescription?: never }
+    | {
+        cancelWarmup:
+          | ComponentProps<typeof TransferButton>["transfer"]
+          | undefined;
+        cancelWarmupDescription: string;
+      }
+  ) &
+  (
+    | { unstake?: never; unstakeDescription?: never }
+    | {
+        unstake: ComponentProps<typeof TransferButton>["transfer"] | undefined;
+        unstakeDescription: string;
+      }
+  );
+
+export const PositionFlowchart = ({
+  className,
+  small,
+  locked,
+  available,
+  warmup,
+  staked,
+  cooldown,
+  cooldown2,
+  stake,
+  stakeDescription,
+  cancelWarmup,
+  cancelWarmupDescription,
+  unstake,
+  unstakeDescription,
+  ...props
+}: Props) => (
+  <div
+    className={clsx("flex flex-row items-stretch justify-center", className)}
+    {...props}
+  >
+    {locked !== undefined && (
+      <Position
+        name="Locked"
+        className="mr-12"
+        small={small}
+        nameClassName="bg-red-950"
+      >
+        {locked}
+      </Position>
+    )}
+    {available !== undefined && (
+      <>
+        <Position
+          name="Available to Stake"
+          small={small}
+          nameClassName="bg-[rgba(43,_129,_167,_0.25)]"
+          {...(stake !== undefined &&
+            available > 0n && {
+              actions: (
+                <TransferButton
+                  small
+                  actionDescription={stakeDescription}
+                  actionName="Stake"
+                  max={available}
+                  transfer={stake}
+                >
+                  <StakingTimeline />
+                </TransferButton>
+              ),
+            })}
+        >
+          {available}
+        </Position>
+        <Arrow />
+      </>
+    )}
+    <Position
+      name="Warmup"
+      small={small}
+      nameClassName="bg-[rgba(206,_153,_247,_0.25)]"
+      {...(warmup > 0n && {
+        details: (
+          <div className="mt-2 text-xs text-neutral-500">
+            Staking {getUpcomingEpoch().toLocaleString()}
+          </div>
+        ),
+        ...(cancelWarmup !== undefined && {
+          actions: (
+            <TransferButton
+              small
+              secondary
+              actionDescription={cancelWarmupDescription}
+              actionName="Cancel"
+              submitButtonText="Cancel Warmup"
+              title="Cancel Warmup"
+              max={warmup}
+              transfer={cancelWarmup}
+            />
+          ),
+        }),
+      })}
+    >
+      {warmup}
+    </Position>
+    <Arrow />
+    <Position
+      name="Staked"
+      small={small}
+      nameClassName="bg-[rgba(105,_24,_238,_0.25)]"
+      {...(unstake !== undefined &&
+        staked > 0n && {
+          actions: (
+            <TransferButton
+              small
+              secondary
+              actionDescription={unstakeDescription}
+              actionName="Unstake"
+              max={staked}
+              transfer={unstake}
+            >
+              <StakingTimeline cooldownOnly />
+            </TransferButton>
+          ),
+        })}
+    >
+      {staked}
+    </Position>
+    <Arrow />
+    <Position
+      name="Cooldown"
+      small={small}
+      nameClassName="bg-[rgba(179,_157,_222,_0.25)]"
+      details={
+        <>
+          {cooldown > 0n && (
+            <div className="mt-2 text-xs text-neutral-500">
+              <Tokens>{cooldown}</Tokens> end{" "}
+              {getUpcomingEpoch().toLocaleString()}
+            </div>
+          )}
+          {cooldown2 > 0n && (
+            <div className="mt-2 text-xs text-neutral-500">
+              <Tokens>{cooldown2}</Tokens> end{" "}
+              {getNextFullEpoch().toLocaleString()}
+            </div>
+          )}
+        </>
+      }
+    >
+      {cooldown + cooldown2}
+    </Position>
+  </div>
+);
+
+type PositionProps = {
+  name: string;
+  small: boolean | undefined;
+  nameClassName?: string | undefined;
+  className?: string | undefined;
+  children: bigint;
+  actions?: ReactNode | ReactNode[];
+  details?: ReactNode;
+};
+
+const Position = ({
+  name,
+  small,
+  nameClassName,
+  details,
+  className,
+  children,
+  actions,
+}: PositionProps) => (
+  <div
+    className={clsx(
+      "flex w-full flex-col justify-between gap-6 overflow-hidden border border-neutral-600/50 bg-pythpurple-800",
+      small ? "p-4" : "p-6",
+      className,
+    )}
+  >
+    <div>
+      <div
+        className={clsx(
+          "mb-2 inline-block border border-neutral-600/50 text-xs text-neutral-400",
+          small ? "px-1 py-0.5" : "px-3 py-1",
+          nameClassName,
+        )}
+      >
+        {name}
+      </div>
+      <div>
+        <Tokens className={clsx("font-light", small ? "text-lg" : "text-3xl")}>
+          {children}
+        </Tokens>
+      </div>
+      {details}
+    </div>
+    {actions && <div>{actions}</div>}
+  </div>
+);
+
+const Arrow = () => (
+  <div className="grid place-content-center">
+    <ArrowLongRightIcon className="mx-4 size-4 flex-none scale-x-[200%] [mask-image:linear-gradient(to_right,_transparent,_black_125%)]" />
+  </div>
+);

+ 38 - 0
apps/staking/src/components/ProgramSection/index.tsx

@@ -0,0 +1,38 @@
+import clsx from "clsx";
+import type { HTMLAttributes, ComponentProps } from "react";
+
+import { PositionFlowchart } from "../PositionFlowchart";
+
+type Props = HTMLAttributes<HTMLElement> & {
+  name: string;
+  description: string;
+  positions: ComponentProps<typeof PositionFlowchart>;
+};
+
+export const ProgramSection = ({
+  className,
+  name,
+  description,
+  children,
+  positions,
+  ...props
+}: Props) => (
+  <section
+    className={clsx(
+      "border border-neutral-600/50 bg-pythpurple-800 p-10",
+      className,
+    )}
+    {...props}
+  >
+    <h2 className="text-3xl font-light">{name}</h2>
+    <p>{description}</p>
+    <PositionFlowchart
+      {...positions}
+      className={clsx(
+        "mt-8 border border-neutral-600/50 bg-white/5 p-10",
+        positions.className,
+      )}
+    />
+    {children}
+  </section>
+);

+ 1 - 1
apps/staking/src/components/Root/report-accessibility.tsx → apps/staking/src/components/ReportAccessibility/index.tsx

@@ -4,7 +4,7 @@ import { useEffect } from "react";
 import * as React from "react";
 import * as ReactDOM from "react-dom";
 
-import { useLogger } from "../../logger";
+import { useLogger } from "../../hooks/use-logger";
 
 export const ReportAccessibility = () => {
   const logger = useLogger();

+ 27 - 17
apps/staking/src/components/Root/index.tsx

@@ -3,17 +3,21 @@ import clsx from "clsx";
 import { Red_Hat_Text, Red_Hat_Mono } from "next/font/google";
 import type { ReactNode } from "react";
 
-import { Amplitude } from "./amplitude";
-import { ReportAccessibility } from "./report-accessibility";
-import { WalletProvider } from "./wallet-provider";
-import { LoggerProvider } from "../../logger";
 import {
   IS_PRODUCTION_SERVER,
   GOOGLE_ANALYTICS_ID,
   AMPLITUDE_API_KEY,
   WALLETCONNECT_PROJECT_ID,
   MAINNET_RPC,
-} from "../../server-config";
+} from "../../config/server";
+import { LoggerProvider } from "../../hooks/use-logger";
+import { StakeAccountProvider } from "../../hooks/use-stake-account";
+import { Amplitude } from "../Amplitude";
+import { Footer } from "../Footer";
+import { Header } from "../Header";
+import { MaxWidth } from "../MaxWidth";
+import { ReportAccessibility } from "../ReportAccessibility";
+import { WalletProvider } from "../WalletProvider";
 
 const redHatText = Red_Hat_Text({
   subsets: ["latin"],
@@ -35,18 +39,24 @@ export const Root = ({ children }: Props) => (
       walletConnectProjectId={WALLETCONNECT_PROJECT_ID}
       rpc={MAINNET_RPC}
     >
-      <html
-        lang="en"
-        dir="ltr"
-        className={clsx("h-dvh", redHatText.variable, redHatMono.variable)}
-      >
-        <body className="grid size-full grid-cols-1 grid-rows-[max-content_1fr_max-content] bg-white text-pythpurple-950 dark:bg-pythpurple-900 dark:text-white">
-          {children}
-        </body>
-        {GOOGLE_ANALYTICS_ID && <GoogleAnalytics gaId={GOOGLE_ANALYTICS_ID} />}
-        {AMPLITUDE_API_KEY && <Amplitude apiKey={AMPLITUDE_API_KEY} />}
-        {!IS_PRODUCTION_SERVER && <ReportAccessibility />}
-      </html>
+      <StakeAccountProvider>
+        <html
+          lang="en"
+          dir="ltr"
+          className={clsx(redHatText.variable, redHatMono.variable)}
+        >
+          <body className="grid min-h-dvh grid-rows-[auto_1fr_auto] text-pythpurple-100 [background:radial-gradient(113.49%_134.57%_at_5.57%_97.67%,_rgba(17,_15,_35,_0.00)_0%,_rgba(119,_49,_234,_0.20)_100%),_#0A0814] selection:bg-pythpurple-600/60">
+            <Header className="z-10" />
+            <MaxWidth className="my-4">{children}</MaxWidth>
+            <Footer className="z-10" />
+          </body>
+          {GOOGLE_ANALYTICS_ID && (
+            <GoogleAnalytics gaId={GOOGLE_ANALYTICS_ID} />
+          )}
+          {AMPLITUDE_API_KEY && <Amplitude apiKey={AMPLITUDE_API_KEY} />}
+          {!IS_PRODUCTION_SERVER && <ReportAccessibility />}
+        </html>
+      </StakeAccountProvider>
     </WalletProvider>
   </LoggerProvider>
 );

+ 12 - 3
apps/staking/src/components/Dashboard/spark-chart.tsx → apps/staking/src/components/SparkChart/index.tsx

@@ -2,6 +2,11 @@
 
 import { useCallback } from "react";
 import { ResponsiveContainer, LineChart, Tooltip, Line, XAxis } from "recharts";
+import resolveConfig from "tailwindcss/resolveConfig";
+
+import tailwindConfig from "../../../tailwind.config";
+
+const fullConfig = resolveConfig(tailwindConfig);
 
 type Props = {
   data: { date: Date; value: number }[];
@@ -17,7 +22,11 @@ export const SparkChart = ({ data }: Props) => {
           content={<TooltipContent formatDate={formatDate} />}
           allowEscapeViewBox={{ x: true, y: true }}
         />
-        <Line type="monotone" dataKey="value" />
+        <Line
+          type="monotone"
+          dataKey="value"
+          stroke={fullConfig.theme.colors.pythpurple[400]}
+        />
         <XAxis dataKey="date" hide />
       </LineChart>
     </ResponsiveContainer>
@@ -33,8 +42,8 @@ type TooltipProps = {
 };
 
 const TooltipContent = ({ payload, label, formatDate }: TooltipProps) => (
-  <div className="flex flex-row gap-2 rounded bg-white p-2 text-xs shadow">
+  <div className="flex flex-row gap-2 border border-pythpurple-400 bg-pythpurple-950 p-2 text-xs shadow">
     <span className="font-medium">{label ? formatDate(label) : ""}</span>
-    <span>{payload?.[0]?.value ?? 0}</span>
+    <span>{payload?.[0]?.value ?? 0}%</span>
   </div>
 );

+ 32 - 0
apps/staking/src/components/StakingTimeline/index.tsx

@@ -0,0 +1,32 @@
+import { ArrowLongDownIcon } from "@heroicons/react/24/outline";
+
+import { getUpcomingEpoch, getNextFullEpoch } from "../../api";
+
+type Props = {
+  cooldownOnly?: boolean | undefined;
+};
+
+export const StakingTimeline = ({ cooldownOnly }: Props) => (
+  <div className="mb-2 flex flex-col gap-1">
+    <div className="text-sm">Timeline</div>
+    <div className="grid grid-cols-[max-content_1fr_max-content] items-center gap-x-4 gap-y-3 border border-neutral-600/50 bg-pythpurple-100/10 px-8 py-6 text-sm font-light">
+      {!cooldownOnly && (
+        <>
+          <div className="size-4 rounded-full border border-dashed border-pythpurple-100" />
+          <div>Warmup</div>
+          <div className="text-right">
+            {getUpcomingEpoch().toLocaleString()}
+          </div>
+          <ArrowLongDownIcon className="size-4 scale-y-[200%] [mask-image:linear-gradient(to_bottom,_transparent,_black_125%)]" />
+          <div>Staking</div>
+          <div className="text-right">Unlimited</div>
+        </>
+      )}
+      <div className="size-4 rounded-full border border-pythpurple-100 bg-pythpurple-600" />
+      <div>Cooldown</div>
+      <div className="text-right">
+        {cooldownOnly ? getNextFullEpoch().toLocaleString() : "One full epoch"}
+      </div>
+    </div>
+  </div>
+);

+ 5 - 2
apps/staking/src/components/Tokens/index.tsx

@@ -13,10 +13,13 @@ export const Tokens = ({ children, className, ...props }: Props) => {
 
   return (
     <span
-      className={clsx("inline-flex items-center gap-0.5 align-top", className)}
+      className={clsx(
+        "inline-flex items-center gap-[0.25em] align-top",
+        className,
+      )}
       {...props}
     >
-      <Pyth className="aspect-square h-[1em]" />
+      <Pyth className="aspect-square size-[1em]" />
       <span>{value}</span>
     </span>
   );

+ 2 - 4
apps/staking/src/components/Tokens/pyth.svg

@@ -1,5 +1,3 @@
-<svg viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M1000 500C1000 776.142 776.142 1000 500 1000C223.858 1000 0 776.142 0 500C0 223.858 223.858 0 500 0C776.142 0 1000 223.858 1000 500Z" fill="#E6DAFE"/>
-<path d="M575.336 459.157C575.336 494.675 546.559 523.473 511.068 523.473V587.789C582.05 587.789 639.604 530.193 639.604 459.157C639.604 388.122 582.05 330.526 511.068 330.526C487.669 330.526 465.695 336.78 446.801 347.746C408.374 369.97 382.533 411.539 382.533 459.157V780.736L446.801 845.052V459.157C446.801 423.64 475.577 394.841 511.068 394.841C546.559 394.841 575.336 423.64 575.336 459.157Z" fill="#242235"/>
-<path d="M511.07 201.904C464.243 201.904 420.352 214.442 382.535 236.346C358.322 250.338 336.638 268.169 318.268 289.026C278.271 334.376 254 393.95 254 459.168V652.115L318.268 716.431V459.168C318.268 402.037 343.091 350.695 382.535 315.351C401.08 298.771 422.851 285.681 446.803 277.245C466.888 270.089 488.543 266.22 511.07 266.22C617.543 266.22 703.873 352.614 703.873 459.168C703.873 565.721 617.543 652.115 511.07 652.115V716.431C653.063 716.431 768.14 601.238 768.14 459.168C768.14 317.097 653.063 201.904 511.07 201.904Z" fill="#242235"/>
+<svg fill="currentColor" viewBox="0 0 264.58401 264.583">
+  <path d="M 132.292,0 C 59.23,0 0,59.23 0,132.292 c 0,73.062 59.23,132.291 132.292,132.291 73.063,0 132.292,-59.229 132.292,-132.291 C 264.584,59.229 205.354,0 132.292,0 Z m 2.93,53.42 c 37.568,0 68.016,30.479 68.016,68.068 0,37.59 -30.448,68.068 -68.017,68.068 v -17.017 c 28.171,0 51.012,-22.859 51.012,-51.05 0,-28.193 -22.841,-51.051 -51.012,-51.051 -5.96,0 -11.69,1.023 -17.004,2.916 a 50.914,50.914 0 0 0 -17.004,10.083 c -10.436,9.351 -17.004,22.935 -17.004,38.051 v 68.068 L 67.205,172.539 v -51.05 c 0,-17.256 6.421,-33.019 17.004,-45.018 a 68.016,68.016 0 0 1 17.004,-13.938 c 10.006,-5.795 21.618,-9.112 34.008,-9.112 z m -0.001,34.032 c 18.78,0 34.008,15.239 34.008,34.033 0,18.795 -15.228,34.034 -34.008,34.034 v -17.017 c 9.39,0 17.004,-7.62 17.004,-17.017 0,-9.397 -7.614,-17.016 -17.004,-17.016 -9.39,0 -17.005,7.62 -17.005,17.016 V 223.587 L 101.212,206.57 v -85.085 c 0,-12.599 6.837,-23.597 17.004,-29.477 a 33.778,33.778 0 0 1 17.005,-4.556 z" />
 </svg>

+ 190 - 60
apps/staking/src/components/TransferButton/index.tsx

@@ -1,3 +1,4 @@
+import { Field, Input, Label } from "@headlessui/react";
 import {
   type ChangeEvent,
   type ComponentProps,
@@ -7,12 +8,14 @@ import {
   useState,
 } from "react";
 
-import { useLogger } from "../../logger";
-import { stringToTokens } from "../../tokens";
-import { type Context } from "../../use-api-context";
-import { StateType, useTransfer } from "../../use-transfer";
+import type { Context } from "../../api";
+import { useLogger } from "../../hooks/use-logger";
+import { StateType, useTransfer } from "../../hooks/use-transfer";
+import { stringToTokens, tokensToString } from "../../tokens";
 import { Button } from "../Button";
-import { ModalButton } from "../ModalButton";
+import { Modal, ModalButton, ModalPanel } from "../Modal";
+import { Tokens } from "../Tokens";
+import PythTokensIcon from "../Tokens/pyth.svg";
 
 type Props = {
   actionName: string;
@@ -21,11 +24,15 @@ type Props = {
   submitButtonText?: string | undefined;
   max: bigint;
   children?:
-    | ((amount: bigint | undefined) => ReactNode | ReactNode[])
+    | ((amount: Amount) => ReactNode | ReactNode[])
     | ReactNode
     | ReactNode[]
     | undefined;
   transfer: (context: Context, amount: bigint) => Promise<void>;
+  className?: string | undefined;
+  secondary?: boolean | undefined;
+  small?: boolean | undefined;
+  disabled?: boolean | undefined;
 };
 
 export const TransferButton = ({
@@ -36,46 +43,153 @@ export const TransferButton = ({
   max,
   transfer,
   children,
+  className,
+  secondary,
+  small,
+  disabled,
 }: Props) => {
-  const { amountInput, updateAmount, resetAmount, amount } =
+  const { amountInput, setAmount, updateAmount, resetAmount, amount } =
     useAmountInput(max);
   const doTransfer = useCallback(
     (context: Context) =>
-      amount === undefined
-        ? Promise.reject(new InvalidAmountError())
-        : transfer(context, amount),
+      amount.type === AmountType.Valid
+        ? transfer(context, amount.amount)
+        : Promise.reject(new InvalidAmountError()),
     [amount, transfer],
   );
+  const setMax = useCallback(() => {
+    setAmount(tokensToString(max));
+  }, [setAmount, max]);
 
   const { state, execute } = useTransfer(doTransfer);
   const isSubmitting = state.type === StateType.Submitting;
 
   return (
-    <ModalButton
-      title={title ?? actionName}
-      buttonContent={actionName}
-      closeDisabled={isSubmitting}
-      description={actionDescription}
-      afterLeave={resetAmount}
-      additionalButtons={(close) => (
-        <ExecuteButton
-          disabled={amount === undefined}
-          execute={execute}
-          loading={isSubmitting}
-          close={close}
-        >
-          {submitButtonText ?? actionName}
-        </ExecuteButton>
-      )}
+    <Modal>
+      <ModalButton
+        className={className}
+        secondary={secondary}
+        small={small}
+        disabled={disabled}
+      >
+        {actionName}
+      </ModalButton>
+      <ModalPanel
+        title={title ?? actionName}
+        closeDisabled={isSubmitting}
+        description={actionDescription}
+        afterLeave={resetAmount}
+      >
+        {(close) => (
+          <>
+            <Field className="mb-8 flex w-full min-w-96 flex-col gap-1">
+              <div className="flex flex-row items-center justify-between">
+                <Label className="text-sm">Amount</Label>
+                <div className="flex flex-row items-center gap-2">
+                  <Tokens>{max}</Tokens>
+                  <span className="text-xs opacity-60">Max</span>
+                </div>
+              </div>
+              <div className="relative w-full">
+                <Input
+                  name="amount"
+                  className="w-full truncate border border-neutral-600/50 bg-transparent py-3 pl-12 pr-24 focus:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400"
+                  value={amountInput}
+                  onChange={updateAmount}
+                  placeholder="0.00"
+                />
+                <div className="pointer-events-none absolute inset-y-0 flex w-full items-center justify-between px-4">
+                  <PythTokensIcon className="size-6" />
+                  <Button
+                    small
+                    secondary
+                    className="pointer-events-auto"
+                    onClick={setMax}
+                    disabled={isSubmitting}
+                  >
+                    max
+                  </Button>
+                </div>
+              </div>
+              {state.type === StateType.Error && (
+                <p>Uh oh, an error occurred!</p>
+              )}
+            </Field>
+            {children && (
+              <>
+                {typeof children === "function" ? children(amount) : children}
+              </>
+            )}
+            <ExecuteButton
+              amount={amount}
+              execute={execute}
+              loading={isSubmitting}
+              close={close}
+              className="mt-6 w-full"
+            >
+              {submitButtonText ?? actionName}
+            </ExecuteButton>
+          </>
+        )}
+      </ModalPanel>
+    </Modal>
+  );
+};
+
+type ExecuteButtonProps = Omit<
+  ComponentProps<typeof Button>,
+  "onClick" | "disabled" | "children"
+> & {
+  children: ReactNode | ReactNode[];
+  amount: Amount;
+  execute: () => Promise<void>;
+  close: () => void;
+};
+
+const ExecuteButton = ({
+  amount,
+  execute,
+  close,
+  children,
+  ...props
+}: ExecuteButtonProps) => {
+  const logger = useLogger();
+  const handleClick = useCallback(async () => {
+    try {
+      await execute();
+      close();
+    } catch (error: unknown) {
+      logger.error(error);
+    }
+  }, [execute, close, logger]);
+  const contents = useMemo(() => {
+    switch (amount.type) {
+      case AmountType.Empty: {
+        return "Enter an amount";
+      }
+      case AmountType.AboveMax: {
+        return "Amount exceeds maximum";
+      }
+      case AmountType.NotPositive: {
+        return "Amount must be greater than zero";
+      }
+      case AmountType.Invalid: {
+        return "Enter a valid amount";
+      }
+      case AmountType.Valid: {
+        return children;
+      }
+    }
+  }, [amount, children]);
+
+  return (
+    <Button
+      disabled={amount.type !== AmountType.Valid}
+      onClick={handleClick}
+      {...props}
     >
-      <input name="amount" value={amountInput} onChange={updateAmount} />
-      {children && (
-        <div>
-          {typeof children === "function" ? children(amount) : children}
-        </div>
-      )}
-      {state.type === StateType.Error && <p>Uh oh, an error occurred!</p>}
-    </ModalButton>
+      {contents}
+    </Button>
   );
 };
 
@@ -85,48 +199,64 @@ const useAmountInput = (max: bigint) => {
   return {
     amountInput,
 
+    setAmount: setAmountInput,
+
     updateAmount: useCallback(
       (event: ChangeEvent<HTMLInputElement>) => {
         setAmountInput(event.target.value);
       },
       [setAmountInput],
     ),
+
     resetAmount: useCallback(() => {
       setAmountInput("");
     }, [setAmountInput]),
 
-    amount: useMemo(() => {
-      const amountAsTokens = stringToTokens(amountInput);
-      return amountAsTokens !== undefined &&
-        amountAsTokens <= max &&
-        amountAsTokens > 0n
-        ? amountAsTokens
-        : undefined;
+    amount: useMemo((): Amount => {
+      if (amountInput === "") {
+        return Amount.Empty();
+      } else {
+        const amountAsTokens = stringToTokens(amountInput);
+        if (amountAsTokens === undefined) {
+          return Amount.Invalid();
+        } else if (amountAsTokens > max) {
+          return Amount.AboveMax(amountAsTokens);
+        } else if (amountAsTokens <= 0) {
+          return Amount.NotPositive(amountAsTokens);
+        } else {
+          return Amount.Valid(amountAsTokens);
+        }
+      }
     }, [amountInput, max]),
   };
 };
 
-class InvalidAmountError extends Error {
-  constructor() {
-    super("Invalid amount");
-  }
+export enum AmountType {
+  Empty,
+  NotPositive,
+  Valid,
+  Invalid,
+  AboveMax,
 }
 
-type ExecuteButtonProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
-  execute: () => Promise<void>;
-  close: () => void;
+const Amount = {
+  Empty: () => ({ type: AmountType.Empty as const }),
+  NotPositive: (amount: bigint) => ({
+    type: AmountType.NotPositive as const,
+    amount,
+  }),
+  Valid: (amount: bigint) => ({ type: AmountType.Valid as const, amount }),
+  Invalid: () => ({ type: AmountType.Invalid as const }),
+  AboveMax: (amount: bigint) => ({
+    type: AmountType.AboveMax as const,
+    amount,
+  }),
 };
 
-const ExecuteButton = ({ execute, close, ...props }: ExecuteButtonProps) => {
-  const logger = useLogger();
-  const handleClick = useCallback(async () => {
-    try {
-      await execute();
-      close();
-    } catch (error: unknown) {
-      logger.error(error);
-    }
-  }, [execute, close, logger]);
+type Amount = ReturnType<(typeof Amount)[keyof typeof Amount]>;
 
-  return <Button onClick={handleClick} {...props} />;
-};
+class InvalidAmountError extends Error {
+  constructor() {
+    super("Invalid amount");
+  }
+}

+ 249 - 0
apps/staking/src/components/WalletButton/index.tsx

@@ -0,0 +1,249 @@
+"use client";
+
+import {
+  Menu,
+  MenuButton,
+  MenuItem,
+  MenuItems,
+  MenuSection,
+  MenuSeparator,
+  Listbox,
+  ListboxButton,
+  ListboxOptions,
+  ListboxOption,
+} from "@headlessui/react";
+import {
+  WalletIcon,
+  ArrowsRightLeftIcon,
+  XCircleIcon,
+  ChevronDownIcon,
+  TableCellsIcon,
+  BanknotesIcon,
+  ChevronRightIcon,
+} from "@heroicons/react/24/outline";
+import { useWallet } from "@solana/wallet-adapter-react";
+import { useWalletModal } from "@solana/wallet-adapter-react-ui";
+import type { PublicKey } from "@solana/web3.js";
+import clsx from "clsx";
+import {
+  type ComponentProps,
+  type ComponentType,
+  type SVGAttributes,
+  type ReactNode,
+  type ElementType,
+  useCallback,
+  useMemo,
+  useState,
+} from "react";
+
+import { usePrimaryDomain } from "../../hooks/use-primary-domain";
+import { StateType, useStakeAccount } from "../../hooks/use-stake-account";
+import { AccountHistory } from "../AccountHistory";
+import { Button } from "../Button";
+import { RawModal } from "../Modal";
+
+type Props = Omit<ComponentProps<typeof Button>, "onClick" | "children">;
+
+export const WalletButton = (props: Props) => {
+  const wallet = useWallet();
+
+  return wallet.connected ? (
+    <ConnectedButton {...props} />
+  ) : (
+    <DisconnectedButton {...props} />
+  );
+};
+
+const ConnectedButton = (props: Props) => {
+  const [accountHistoryOpen, setAccountHistoryOpen] = useState(false);
+  const openAccountHistory = useCallback(
+    () =>
+      setTimeout(() => {
+        setAccountHistoryOpen(true);
+      }, 300),
+    [setAccountHistoryOpen],
+  );
+  const closeAccountHistory = useCallback(() => {
+    setAccountHistoryOpen(false);
+  }, [setAccountHistoryOpen]);
+
+  const wallet = useWallet();
+  const modal = useWalletModal();
+  const showModal = useCallback(() => {
+    modal.setVisible(true);
+  }, [modal]);
+  const stakeAccountState = useStakeAccount();
+
+  return (
+    <>
+      <Menu as="div" className="relative">
+        <MenuButton as="div" className="group">
+          <ButtonComponent
+            className="w-52 group-data-[open]:bg-pythpurple-600/60"
+            {...props}
+          >
+            <span className="truncate">
+              <ButtonContent />
+            </span>
+            <ChevronDownIcon className="size-4 flex-none opacity-60 transition duration-300 group-data-[open]:-rotate-180" />
+          </ButtonComponent>
+        </MenuButton>
+        <MenuItems
+          transition
+          anchor="bottom end"
+          className="z-10 flex min-w-[var(--button-width)] origin-top-right flex-col border border-neutral-400 bg-pythpurple-100 py-2 text-sm text-pythpurple-950 shadow shadow-neutral-400 transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus-visible:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
+        >
+          <MenuSection className="flex w-full flex-col">
+            {stakeAccountState.type === StateType.Loaded &&
+              stakeAccountState.allAccounts.length > 1 && (
+                <Listbox
+                  value={stakeAccountState.account}
+                  onChange={stakeAccountState.selectAccount}
+                >
+                  <WalletMenuItem as={ListboxButton} icon={BanknotesIcon}>
+                    <span>Select stake account</span>
+                    <ChevronRightIcon className="size-4" />
+                  </WalletMenuItem>
+                  <ListboxOptions
+                    className="z-10 flex origin-top-right flex-col border border-neutral-400 bg-pythpurple-100 py-2 text-sm text-pythpurple-950 shadow shadow-neutral-400 transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus-visible:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
+                    anchor="left start"
+                    transition
+                  >
+                    {stakeAccountState.allAccounts.map((account) => (
+                      <WalletMenuItem
+                        as={ListboxOption}
+                        key={account.publicKey}
+                        value={account}
+                        className="cursor-pointer hover:bg-black/5"
+                      >
+                        <pre>{account.publicKey}</pre>
+                      </WalletMenuItem>
+                    ))}
+                  </ListboxOptions>
+                </Listbox>
+              )}
+            <MenuItem>
+              <WalletMenuItem
+                onClick={openAccountHistory}
+                icon={TableCellsIcon}
+              >
+                Account history
+              </WalletMenuItem>
+            </MenuItem>
+          </MenuSection>
+          <MenuSeparator className="mx-2 my-1 h-px bg-black/20" />
+          <MenuSection className="flex w-full flex-col">
+            <MenuItem>
+              <WalletMenuItem onClick={showModal} icon={ArrowsRightLeftIcon}>
+                Change wallet
+              </WalletMenuItem>
+            </MenuItem>
+            <MenuItem>
+              <WalletMenuItem
+                onClick={() => wallet.disconnect()}
+                icon={XCircleIcon}
+              >
+                Disconnect
+              </WalletMenuItem>
+            </MenuItem>
+          </MenuSection>
+        </MenuItems>
+      </Menu>
+      <RawModal
+        isOpen={accountHistoryOpen}
+        onClose={closeAccountHistory}
+        title="Account history"
+        description="A history of events that have affected your account balances"
+      >
+        <AccountHistory />
+      </RawModal>
+    </>
+  );
+};
+
+const ButtonContent = () => {
+  const wallet = useWallet();
+  const primaryDomain = usePrimaryDomain();
+
+  if (primaryDomain) {
+    return primaryDomain;
+  } else if (wallet.publicKey) {
+    return <TruncatedKey>{wallet.publicKey}</TruncatedKey>;
+  } else if (wallet.connecting) {
+    return "Connecting...";
+  } else {
+    return "Connect";
+  }
+};
+
+const TruncatedKey = ({ children }: { children: PublicKey | `0x${string}` }) =>
+  useMemo(() => {
+    const isHex = typeof children === "string";
+    const asString = isHex ? children : children.toBase58();
+    return asString.slice(0, isHex ? 6 : 4) + ".." + asString.slice(-4);
+  }, [children]);
+
+type WalletMenuItemProps<T extends ElementType> = Omit<
+  ComponentProps<T>,
+  "as" | "icon"
+> & {
+  as?: T;
+  icon?: ComponentType<SVGAttributes<SVGSVGElement>>;
+};
+
+const WalletMenuItem = <T extends ElementType>({
+  as,
+  children,
+  icon: Icon,
+  className,
+  ...props
+}: WalletMenuItemProps<T>) => {
+  const Component = as ?? "button";
+  return (
+    <Component
+      className={clsx(
+        "flex items-center gap-2 whitespace-nowrap px-4 py-2 text-left hover:bg-pythpurple-800/20 data-[focus]:bg-pythpurple-800/20",
+        className,
+      )}
+      {...props}
+    >
+      {Icon && <Icon className="size-4 text-pythpurple-600" />}
+      {children}
+    </Component>
+  );
+};
+
+const DisconnectedButton = (props: Props) => {
+  const modal = useWalletModal();
+  const showModal = useCallback(() => {
+    modal.setVisible(true);
+  }, [modal]);
+
+  return (
+    <ButtonComponent onClick={showModal} className="w-52" {...props}>
+      <span>Connect wallet</span>
+    </ButtonComponent>
+  );
+};
+
+type ButtonComponentProps = Omit<
+  ComponentProps<typeof Button>,
+  "children" | "className"
+> & {
+  className?: string | undefined;
+  children: ReactNode | ReactNode[];
+};
+
+const ButtonComponent = ({
+  className,
+  children,
+  ...props
+}: ButtonComponentProps) => (
+  <Button
+    className={clsx("flex flex-row items-center gap-2", className)}
+    {...props}
+  >
+    <WalletIcon className="size-4 flex-none opacity-60" />
+    {children}
+  </Button>
+);

+ 0 - 4
apps/staking/src/components/Root/wallet-provider.tsx → apps/staking/src/components/WalletProvider/index.tsx

@@ -10,8 +10,6 @@ import {
   GlowWalletAdapter,
   LedgerWalletAdapter,
   SolflareWalletAdapter,
-  SolletExtensionWalletAdapter,
-  SolletWalletAdapter,
   TorusWalletAdapter,
   WalletConnectWalletAdapter,
 } from "@solana/wallet-adapter-wallets";
@@ -41,8 +39,6 @@ export const WalletProvider = ({
       new GlowWalletAdapter(),
       new LedgerWalletAdapter(),
       new SolflareWalletAdapter(),
-      new SolletExtensionWalletAdapter(),
-      new SolletWalletAdapter(),
       new TorusWalletAdapter(),
       ...(walletConnectProjectId
         ? [

+ 1 - 1
apps/staking/src/isomorphic-config.ts → apps/staking/src/config/isomorphic.ts

@@ -3,7 +3,7 @@
 /**
  * Indicates this is a production-optimized build.  Note this does NOT
  * necessarily indicate that we're running on a cloud machine or the live build
- * -- use `RUNNING_IN_CLOUD` or `IS_PRODUCTION_SERVER` out of `server-config.ts`
+ * -- use `RUNNING_IN_CLOUD` or `IS_PRODUCTION_SERVER` out of `config/server.ts`
  * for that (if you need that on the client you'll need to write a client
  * component that receives that value as a prop).
  *

+ 0 - 0
apps/staking/src/server-config.ts → apps/staking/src/config/server.ts


+ 1 - 3
apps/staking/src/use-api-context.ts → apps/staking/src/hooks/use-api-context.ts

@@ -1,10 +1,8 @@
 import { useWallet, useConnection } from "@solana/wallet-adapter-react";
 import { useMemo } from "react";
 
-import type { Context } from "./api";
 import { StateType, useStakeAccount } from "./use-stake-account";
-
-export type { Context } from "./api";
+import type { Context } from "../api";
 
 export const useApiContext = (): Context => {
   const wallet = useWallet();

+ 0 - 0
apps/staking/src/use-is-mounted.ts → apps/staking/src/hooks/use-is-mounted.ts


+ 1 - 1
apps/staking/src/logger.tsx → apps/staking/src/hooks/use-logger.tsx

@@ -3,7 +3,7 @@
 import pino, { type Logger } from "pino";
 import { type ComponentProps, createContext, useContext, useMemo } from "react";
 
-import { IS_PRODUCTION_BUILD } from "./isomorphic-config";
+import { IS_PRODUCTION_BUILD } from "../config/isomorphic";
 
 const LoggerContext = createContext<undefined | Logger<string>>(undefined);
 

+ 3 - 8
apps/staking/src/components/Dashboard/wallet-button.tsx → apps/staking/src/hooks/use-primary-domain.ts

@@ -1,13 +1,8 @@
-"use client";
-
 import { getPrimaryDomain } from "@bonfida/spl-name-service";
 import { useConnection, useWallet } from "@solana/wallet-adapter-react";
-import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
-import { type ComponentProps, useEffect, useState } from "react";
+import { useEffect, useState } from "react";
 
-export const WalletButton = (
-  props: ComponentProps<typeof WalletMultiButton>,
-) => {
+export const usePrimaryDomain = () => {
   const wallet = useWallet();
   const { connection } = useConnection();
   const [primaryDomain, setPrimaryDomain] = useState<string | undefined>(
@@ -26,5 +21,5 @@ export const WalletButton = (
     }
   }, [wallet.publicKey, connection]);
 
-  return <WalletMultiButton {...props}>{primaryDomain}</WalletMultiButton>;
+  return primaryDomain;
 };

+ 35 - 21
apps/staking/src/use-stake-account.tsx → apps/staking/src/hooks/use-stake-account.tsx

@@ -2,18 +2,22 @@
 
 import { useConnection, useWallet } from "@solana/wallet-adapter-react";
 import {
+  type ComponentProps,
   createContext,
   useContext,
   useCallback,
   useState,
   useEffect,
-  type ComponentProps,
+  useRef,
 } from "react";
 
-import { type StakeAccount, getStakeAccounts } from "./api";
+import { type StakeAccount, getStakeAccounts } from "../api";
+
+export type { StakeAccount } from "../api";
 
 export enum StateType {
   Initialized,
+  NoWallet,
   Loading,
   NoAccounts,
   Loaded,
@@ -22,6 +26,7 @@ export enum StateType {
 
 const State = {
   Initialized: () => ({ type: StateType.Initialized as const }),
+  NoWallet: () => ({ type: StateType.NoWallet as const }),
   Loading: () => ({ type: StateType.Loading as const }),
   NoAccounts: () => ({ type: StateType.NoAccounts as const }),
   Loaded: (
@@ -50,6 +55,7 @@ export const StakeAccountProvider = (
 };
 
 const useStakeAccountState = () => {
+  const loading = useRef(false);
   const wallet = useWallet();
   const { connection } = useConnection();
   const [state, setState] = useState<State>(State.Initialized());
@@ -66,25 +72,33 @@ const useStakeAccountState = () => {
   );
 
   useEffect(() => {
-    setState(State.Loading());
-    getStakeAccounts(connection, wallet)
-      .then((accounts) => {
-        const [firstAccount, ...otherAccounts] = accounts;
-        if (firstAccount) {
-          setState(
-            State.Loaded(
-              firstAccount,
-              [firstAccount, ...otherAccounts],
-              setAccount,
-            ),
-          );
-        } else {
-          setState(State.NoAccounts());
-        }
-      })
-      .catch((error: unknown) => {
-        setState(State.ErrorState(error));
-      });
+    if (wallet.connected && !wallet.disconnecting && !loading.current) {
+      loading.current = true;
+      setState(State.Loading());
+      getStakeAccounts(connection, wallet)
+        .then((accounts) => {
+          const [firstAccount, ...otherAccounts] = accounts;
+          if (firstAccount) {
+            setState(
+              State.Loaded(
+                firstAccount,
+                [firstAccount, ...otherAccounts],
+                setAccount,
+              ),
+            );
+          } else {
+            setState(State.NoAccounts());
+          }
+        })
+        .catch((error: unknown) => {
+          setState(State.ErrorState(error));
+        })
+        .finally(() => {
+          loading.current = false;
+        });
+    } else if (!wallet.connected) {
+      setState(State.NoWallet());
+    }
   }, [connection, setAccount, wallet]);
 
   return state;

+ 4 - 2
apps/staking/src/use-transfer.ts → apps/staking/src/hooks/use-transfer.ts

@@ -1,9 +1,11 @@
 import { useState, useCallback } from "react";
 import { useSWRConfig } from "swr";
 
-import { type Context, useApiContext } from "./use-api-context";
+import { useApiContext } from "./use-api-context";
 
-export const useTransfer = (transfer: (context: Context) => Promise<void>) => {
+export const useTransfer = (
+  transfer: (context: ReturnType<typeof useApiContext>) => Promise<void>,
+) => {
   const context = useApiContext();
   const [state, setState] = useState<State>(State.Base());
   const { mutate } = useSWRConfig();

+ 1 - 0
apps/staking/tailwind.config.ts

@@ -19,6 +19,7 @@ const tailwindConfig = {
           100: "#E6DAFE",
           400: "#BB86FC",
           600: "#6200EE",
+          800: "#100E21",
           900: "#121212",
           950: "#0C0B1A",
         },

Dosya farkı çok büyük olduğundan ihmal edildi
+ 675 - 190
pnpm-lock.yaml


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor