diff --git a/public/widget/widget-execution-dark.png b/public/widget/widget-execution-dark.png new file mode 100644 index 000000000..f12429d0b Binary files /dev/null and b/public/widget/widget-execution-dark.png differ diff --git a/public/widget/widget-execution-light.png b/public/widget/widget-execution-light.png new file mode 100644 index 000000000..245167627 Binary files /dev/null and b/public/widget/widget-execution-light.png differ diff --git a/public/widget/widget-quotes-dark.png b/public/widget/widget-quotes-dark.png new file mode 100644 index 000000000..3ed1ece64 Binary files /dev/null and b/public/widget/widget-quotes-dark.png differ diff --git a/public/widget/widget-quotes-light.png b/public/widget/widget-quotes-light.png new file mode 100644 index 000000000..ee3dbbdf4 Binary files /dev/null and b/public/widget/widget-quotes-light.png differ diff --git a/public/widget/widget-review-bridge-dark.png b/public/widget/widget-review-bridge-dark.png new file mode 100644 index 000000000..854bc4c54 Binary files /dev/null and b/public/widget/widget-review-bridge-dark.png differ diff --git a/public/widget/widget-review-bridge-light.png b/public/widget/widget-review-bridge-light.png new file mode 100644 index 000000000..f3549a6bc Binary files /dev/null and b/public/widget/widget-review-bridge-light.png differ diff --git a/public/widget/widget-selection-dark-raw.png b/public/widget/widget-selection-dark-raw.png new file mode 100644 index 000000000..ff2ab7871 Binary files /dev/null and b/public/widget/widget-selection-dark-raw.png differ diff --git a/public/widget/widget-selection-dark.png b/public/widget/widget-selection-dark.png new file mode 100644 index 000000000..ebe14a7d6 Binary files /dev/null and b/public/widget/widget-selection-dark.png differ diff --git a/public/widget/widget-selection-light-raw.png b/public/widget/widget-selection-light-raw.png new file mode 100644 index 000000000..abb61ee9e Binary files /dev/null and b/public/widget/widget-selection-light-raw.png differ diff --git a/public/widget/widget-selection-light.png b/public/widget/widget-selection-light.png new file mode 100644 index 000000000..9762f9a8a Binary files /dev/null and b/public/widget/widget-selection-light.png differ diff --git a/public/widget/widget-success-dark.png b/public/widget/widget-success-dark.png new file mode 100644 index 000000000..8fa739505 Binary files /dev/null and b/public/widget/widget-success-dark.png differ diff --git a/public/widget/widget-success-light.png b/public/widget/widget-success-light.png new file mode 100644 index 000000000..03402ecf8 Binary files /dev/null and b/public/widget/widget-success-light.png differ diff --git a/src/app/api/widget-execution/route.tsx b/src/app/api/widget-execution/route.tsx new file mode 100644 index 000000000..1c3682477 --- /dev/null +++ b/src/app/api/widget-execution/route.tsx @@ -0,0 +1,133 @@ +/* eslint-disable @next/next/no-img-element */ + +/** + * Image Generation of Widget for SEO pages + * Step 4 - Route execution + * + * Example: + * ``` + * http://localhost:3000/api/widget-execution?fromToken=0x0000000000000000000000000000000000000000&fromChainId=137&toToken=0x0000000000000000000000000000000000000000&toChainId=42161&amount=10&&theme=light&isSwap=true + * ``` + * + * @typedef {Object} SearchParams + * @property {string} fromToken - The token address to send from. + * @property {number} fromChainId - The chain ID to send from. + * @property {string} toToken - The token address to send to. + * @property {number} toChainId - The chain ID to send to. + * @property {number} amount - The amount of tokens. + * @property {number} [amountUSD] - The USD equivalent amount (optional). + * @property {boolean} [isSwap] - True if transaction is a swap, default and false if transaction is a bridge (optional). + * @property {'light'|'dark'} [theme] - The theme for the widget (optional). + * @property {'from'|'to'|'amount'} [highlighted] - The highlighted element (optional). + * + */ + +import type { ChainId } from '@lifi/sdk'; +import { ChainType, getChains, getToken } from '@lifi/sdk'; +import { ImageResponse } from 'next/og'; +import type { HighlightedAreas } from 'src/components/ImageGeneration/ImageGeneration.types'; +import { imageResponseOptions } from 'src/components/ImageGeneration/imageResponseOptions'; +import WidgetExecutionSSR from 'src/components/ImageGeneration/WidgetExecutionSSR'; + +const WIDGET_IMAGE_WIDTH = 416; +const WIDGET_IMAGE_HEIGHT = 432; +const WIDGET_IMAGE_SCALING_FACTOR = 2; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const amount = searchParams.get('amount'); + const fromToken = searchParams.get('fromToken'); + const fromChainId = searchParams.get('fromChainId'); + const toToken = searchParams.get('toToken'); + const toChainId = searchParams.get('toChainId'); + const highlighted = searchParams.get('highlighted'); + const theme = searchParams.get('theme'); + const isSwap = searchParams.get('isSwap'); + + // Fetch chain data asynchronously before rendering + const getChainData = async (chainId: ChainId) => { + const chainsData = await getChains({ + chainTypes: [ChainType.EVM, ChainType.SVM], + }); + return chainsData.find((chainEl) => chainEl.id === chainId); + }; + + // Fetch from and to chain details (await this before rendering) + const fromChain = fromChainId + ? await getChainData(parseInt(fromChainId) as ChainId) + : null; + const toChain = toChainId + ? await getChainData(parseInt(toChainId) as ChainId) + : null; + + // Fetch token asynchronously + const fetchToken = async (chainId: ChainId | null, token: string | null) => { + if (!chainId || !token) { + return null; + } + try { + const fetchedToken = await getToken(chainId, token); + return fetchedToken; + } catch (error) { + console.error('Error fetching token:', error); + return null; + } + }; + + const fromTokenData = + fromChainId && fromToken + ? await fetchToken(parseInt(fromChainId) as ChainId, fromToken) + : null; + const toTokenData = + toChainId && toToken + ? await fetchToken(parseInt(toChainId) as ChainId, toToken) + : null; + + const options = await imageResponseOptions({ + width: WIDGET_IMAGE_WIDTH, + height: WIDGET_IMAGE_HEIGHT, + scalingFactor: WIDGET_IMAGE_SCALING_FACTOR, + }); + + return new ImageResponse( + ( +
+ Widget Example + +
+ ), + options, + ); +} diff --git a/src/app/api/widget-quotes/route.tsx b/src/app/api/widget-quotes/route.tsx new file mode 100644 index 000000000..acd657978 --- /dev/null +++ b/src/app/api/widget-quotes/route.tsx @@ -0,0 +1,142 @@ +/* eslint-disable @next/next/no-img-element */ + +/** + * Image Generation of Widget for SEO pages + * Step 2 - Quotes + * + * Example: + * ``` + * http://localhost:3000/api/widget-quotes?fromToken=0x0000000000000000000000000000000000000000&fromChainId=137&toToken=0x0000000000000000000000000000000000000000&toChainId=42161&amount=10&highlighted=0&theme=light + * ``` + * + * @typedef {Object} SearchParams + * @property {string} fromToken - The token address to send from. + * @property {number} fromChainId - The chain ID to send from. + * @property {string} toToken - The token address to send to. + * @property {number} toChainId - The chain ID to send to. + * @property {number} amount - The amount of tokens. + * @property {number} [amountUSD] - The USD equivalent amount (optional). + * @property {boolean} [isSwap] - True if transaction is a swap, default and false if transaction is a bridge (optional). + * @property {'light'|'dark'} [theme] - The theme for the widget (optional). + * @property {'from'|'to'|'amount'|'0'|'1'|'2'} [highlighted] - The highlighted element, numbers refer to quote index (optional). + * + */ +import type { ChainId } from '@lifi/sdk'; +import { ChainType, getChains, getToken } from '@lifi/sdk'; +import { ImageResponse } from 'next/og'; +import type { HighlightedAreas } from 'src/components/ImageGeneration/ImageGeneration.types'; +import { imageResponseOptions } from 'src/components/ImageGeneration/imageResponseOptions'; +import WidgetQuoteSSR from 'src/components/ImageGeneration/WidgetQuotesSSR'; + +const WIDGET_IMAGE_WIDTH = 856; +const WIDGET_IMAGE_HEIGHT = 490; //376; +const WIDGET_IMAGE_SCALING_FACTOR = 2; + +export async function GET(request: Request) { + // console.time('start-time'); + const { searchParams } = new URL(request.url); + const amount = searchParams.get('amount'); + const amountUSD = searchParams.get('amountUSD'); + const fromToken = searchParams.get('fromToken'); + const fromChainId = searchParams.get('fromChainId'); + const toToken = searchParams.get('toToken'); + const toChainId = searchParams.get('toChainId'); + const highlighted = searchParams.get('highlighted'); + const theme = searchParams.get('theme'); + const isSwap = searchParams.get('isSwap'); + + // Fetch chain data asynchronously before rendering + const getChainData = async (chainId: ChainId) => { + const chainsData = await getChains({ + chainTypes: [ChainType.EVM, ChainType.SVM], + }); + return chainsData.find((chainEl) => chainEl.id === chainId); + }; + + // Fetch from and to chain details (await this before rendering) + const fromChain = fromChainId + ? await getChainData(parseInt(fromChainId) as ChainId) + : null; + const toChain = toChainId + ? await getChainData(parseInt(toChainId) as ChainId) + : null; + + // Fetch token asynchronously + const fetchToken = async (chainId: ChainId | null, token: string | null) => { + if (!chainId || !token) { + return null; + } + try { + const fetchedToken = await getToken(chainId, token); + return fetchedToken; + } catch (error) { + console.error('Error fetching token:', error); + return null; + } + }; + + const fromTokenData = + fromChainId && fromToken + ? await fetchToken(parseInt(fromChainId) as ChainId, fromToken) + : null; + const toTokenData = + toChainId && toToken + ? await fetchToken(parseInt(toChainId) as ChainId, toToken) + : null; + + const routeAmount = + (parseFloat(fromTokenData?.priceUSD || '0') * parseFloat(amount || '0')) / + parseFloat(toTokenData?.priceUSD || '0'); + + const options = await imageResponseOptions({ + width: WIDGET_IMAGE_WIDTH, + height: WIDGET_IMAGE_HEIGHT, + scalingFactor: WIDGET_IMAGE_SCALING_FACTOR, + }); + + const ImageResp = new ImageResponse( + ( +
+ Widget Quotes Example + +
+ ), + options, + ); + // console.timeEnd('start-time'); + return ImageResp; +} diff --git a/src/app/api/widget-review/route.tsx b/src/app/api/widget-review/route.tsx new file mode 100644 index 000000000..04f11c874 --- /dev/null +++ b/src/app/api/widget-review/route.tsx @@ -0,0 +1,137 @@ +/* eslint-disable @next/next/no-img-element */ + +/** + * Image Generation of Widget for SEO pages + * Step 3 - Review quote + * + * Example: + * ``` + * http://localhost:3000/api/widget-review?fromToken=0x0000000000000000000000000000000000000000&fromChainId=137&toToken=0x0000000000000000000000000000000000000000&toChainId=42161&amount=10&highlighted=amount&theme=dark + * ``` + * + * @typedef {Object} SearchParams + * @property {string} fromToken - The token address to send from. + * @property {number} fromChainId - The chain ID to send from. + * @property {string} toToken - The token address to send to. + * @property {number} toChainId - The chain ID to send to. + * @property {number} amount - The amount of tokens. + * @property {number} [amountUSD] - The USD equivalent amount (optional). + * @property {boolean} [isSwap] - True if transaction is a swap, default and false if transaction is a bridge (optional). + * @property {'light'|'dark'} [theme] - The theme for the widget (optional). + * @property {'from'|'to'|'amount'} [highlighted] - The highlighted element (optional). + * + */ + +import type { ChainId } from '@lifi/sdk'; +import { ChainType, getChains, getToken } from '@lifi/sdk'; +import { ImageResponse } from 'next/og'; +import type { HighlightedAreas } from 'src/components/ImageGeneration/ImageGeneration.types'; +import { imageResponseOptions } from 'src/components/ImageGeneration/imageResponseOptions'; +import WidgetReviewSSR from 'src/components/ImageGeneration/WidgetReviewSSR'; + +const WIDGET_IMAGE_WIDTH = 416; +const WIDGET_IMAGE_HEIGHT = 440; +const WIDGET_IMAGE_SCALING_FACTOR = 2; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const amount = searchParams.get('amount'); + const amountUSD = searchParams.get('amountUSD'); + const fromToken = searchParams.get('fromToken'); + const fromChainId = searchParams.get('fromChainId'); + const toToken = searchParams.get('toToken'); + const toChainId = searchParams.get('toChainId'); + const highlighted = searchParams.get('highlighted'); + const theme = searchParams.get('theme'); + const isSwap = searchParams.get('isSwap'); + + // Fetch chain data asynchronously before rendering + const getChainData = async (chainId: ChainId) => { + const chainsData = await getChains({ + chainTypes: [ChainType.EVM, ChainType.SVM], + }); + return chainsData.find((chainEl) => chainEl.id === chainId); + }; + + // Fetch from and to chain details (await this before rendering) + const fromChain = fromChainId + ? await getChainData(parseInt(fromChainId) as ChainId) + : null; + const toChain = toChainId + ? await getChainData(parseInt(toChainId) as ChainId) + : null; + + // Fetch token asynchronously + const fetchToken = async (chainId: ChainId | null, token: string | null) => { + if (!chainId || !token) { + return null; + } + try { + const fetchedToken = await getToken(chainId, token); + return fetchedToken; + } catch (error) { + console.error('Error fetching token:', error); + return null; + } + }; + + const fromTokenData = + fromChainId && fromToken + ? await fetchToken(parseInt(fromChainId) as ChainId, fromToken) + : null; + const toTokenData = + toChainId && toToken + ? await fetchToken(parseInt(toChainId) as ChainId, toToken) + : null; + + const routeAmount = + (parseFloat(fromTokenData?.priceUSD || '0') * parseFloat(amount || '0')) / + parseFloat(toTokenData?.priceUSD || '0'); + + const options = await imageResponseOptions({ + width: WIDGET_IMAGE_WIDTH, + height: WIDGET_IMAGE_HEIGHT, + scalingFactor: WIDGET_IMAGE_SCALING_FACTOR, + }); + return new ImageResponse( + ( +
+ Widget Review Example + +
+ ), + options, + ); +} diff --git a/src/app/api/widget-selection/route.tsx b/src/app/api/widget-selection/route.tsx new file mode 100644 index 000000000..4e4b613ea --- /dev/null +++ b/src/app/api/widget-selection/route.tsx @@ -0,0 +1,132 @@ +/* eslint-disable @next/next/no-img-element */ + +/** + * Image Generation of Widget for SEO pages + * Step 1 - Selecting Tokens + * + * Example: + * ``` + * http://localhost:3000/api/widget-selection?fromToken=0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063&fromChainId=137&toToken=0xdAC17F958D2ee523a2206206994597C13D831ec7&toChainId=1&amount=3&theme=dark + * ``` + * + * @typedef {Object} SearchParams + * @property {string} fromToken - The token address to send from. + * @property {number} fromChainId - The chain ID to send from. + * @property {string} toToken - The token address to send to. + * @property {number} toChainId - The chain ID to send to. + * @property {number} amount - The amount of tokens. + * @property {number} [amountUSD] - The USD equivalent amount (optional). + * @property {'light'|'dark'} [theme] - The theme for the widget (optional). + * @property {'from'|'to'|'amount'} [highlighted] - The highlighted element (optional). + * + */ + +import type { ChainId } from '@lifi/sdk'; +import { ChainType, getChains, getToken } from '@lifi/sdk'; +import { ImageResponse } from 'next/og'; +import type { HighlightedAreas } from 'src/components/ImageGeneration/ImageGeneration.types'; +import { imageResponseOptions } from 'src/components/ImageGeneration/imageResponseOptions'; +import WidgetImageSSR from 'src/components/ImageGeneration/WidgetImageSSR'; + +const WIDGET_IMAGE_WIDTH = 416; +const WIDGET_IMAGE_HEIGHT = 496; +const WIDGET_IMAGE_SCALING_FACTOR = 2; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const amount = searchParams.get('amount'); + const amountUSD = searchParams.get('amountUSD'); + const fromToken = searchParams.get('fromToken'); + const fromChainId = searchParams.get('fromChainId'); + const toToken = searchParams.get('toToken'); + const toChainId = searchParams.get('toChainId'); + const highlighted = searchParams.get('highlighted'); + const theme = searchParams.get('theme'); + + // Fetch chain data asynchronously before rendering + const getChainData = async (chainId: ChainId) => { + const chainsData = await getChains({ + chainTypes: [ChainType.EVM, ChainType.SVM], + }); + return chainsData.find((chainEl) => chainEl.id === chainId); + }; + + // Fetch from and to chain details (await this before rendering) + const fromChain = fromChainId + ? await getChainData(parseInt(fromChainId) as ChainId) + : null; + const toChain = toChainId + ? await getChainData(parseInt(toChainId) as ChainId) + : null; + + // Fetch token asynchronously + const fetchToken = async (chainId: ChainId | null, token: string | null) => { + if (!chainId || !token) { + return null; + } + try { + const fetchedToken = await getToken(chainId, token); + return fetchedToken; + } catch (error) { + console.error('Error fetching token:', error); + return null; + } + }; + + const fromTokenData = + fromChainId && fromToken + ? await fetchToken(parseInt(fromChainId) as ChainId, fromToken) + : null; + const toTokenData = + toChainId && toToken + ? await fetchToken(parseInt(toChainId) as ChainId, toToken) + : null; + + const options = await imageResponseOptions({ + width: WIDGET_IMAGE_WIDTH, + height: WIDGET_IMAGE_HEIGHT, + scalingFactor: WIDGET_IMAGE_SCALING_FACTOR, + }); + + return new ImageResponse( + ( +
+ Widget Selection Example + {' '} +
+ ), + options, + ); +} diff --git a/src/app/api/widget-success/route.tsx b/src/app/api/widget-success/route.tsx new file mode 100644 index 000000000..5aa0064d6 --- /dev/null +++ b/src/app/api/widget-success/route.tsx @@ -0,0 +1,115 @@ +/* eslint-disable @next/next/no-img-element */ + +/** + * Image Generation of Widget for SEO pages + * Step 5 - Route success + * + * Example: + * ``` + * http://localhost:3000/api/widget-success?toToken=0x0000000000000000000000000000000000000000&toChainId=42161&amount=10&&theme=light&isSwap=true + * ``` + * + * @typedef {Object} SearchParams + * @property {string} toToken - The token address to send to. + * @property {number} toChainId - The chain ID to send to. + * @property {number} amount - The amount of tokens. + * @property {boolean} [isSwap] - True if transaction is a swap, default and false if transaction is a bridge (optional). + * @property {'light'|'dark'} [theme] - The theme for the widget (optional). + * + */ + +import type { ChainId } from '@lifi/sdk'; +import { ChainType, getChains, getToken } from '@lifi/sdk'; +import { ImageResponse } from 'next/og'; +import { imageResponseOptions } from 'src/components/ImageGeneration/imageResponseOptions'; +import WidgetSuccessSSR from 'src/components/ImageGeneration/WidgetSuccessSSR'; + +const WIDGET_IMAGE_WIDTH = 416; +const WIDGET_IMAGE_HEIGHT = 432; +const WIDGET_IMAGE_SCALING_FACTOR = 2; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const amount = searchParams.get('amount'); + const toToken = searchParams.get('toToken'); + const toChainId = searchParams.get('toChainId'); + const theme = searchParams.get('theme'); + const isSwap = searchParams.get('isSwap'); + + // Fetch chain data asynchronously before rendering + const getChainData = async (chainId: ChainId) => { + const chainsData = await getChains({ + chainTypes: [ChainType.EVM, ChainType.SVM], + }); + return chainsData.find((chainEl) => chainEl.id === chainId); + }; + + // Fetch to chain details (await this before rendering) + const toChain = toChainId + ? await getChainData(parseInt(toChainId) as ChainId) + : null; + + // Fetch token asynchronously + const fetchToken = async (chainId: ChainId | null, token: string | null) => { + if (!chainId || !token) { + return null; + } + try { + const fetchedToken = await getToken(chainId, token); + return fetchedToken; + } catch (error) { + console.error('Error fetching token:', error); + return null; + } + }; + + const toTokenData = + toChainId && toToken + ? await fetchToken(parseInt(toChainId) as ChainId, toToken) + : null; + + const options = await imageResponseOptions({ + width: WIDGET_IMAGE_WIDTH, + height: WIDGET_IMAGE_HEIGHT, + scalingFactor: WIDGET_IMAGE_SCALING_FACTOR, + }); + + return new ImageResponse( + ( +
+ Widget Example + +
+ ), + options, + ); +} diff --git a/src/components/AvatarBadge/AvatarBadge.style.ts b/src/components/AvatarBadge/AvatarBadge.style.ts new file mode 100644 index 000000000..b29dfec0d --- /dev/null +++ b/src/components/AvatarBadge/AvatarBadge.style.ts @@ -0,0 +1,72 @@ +import { Avatar, Badge, Avatar as MuiAvatar } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import type { BadgeOffsetProps } from './AvatarBadge'; +import { getAvatarMask } from './getAvatarMask'; + +interface StyledAvatarProps { + avatarSize: number; + badgeSize: number; + badgeOffset?: BadgeOffsetProps; + badgeGap?: number; +} + +// Styled Avatar component for the badge +export const StyledAvatar = styled(Avatar, { + shouldForwardProp: (prop) => + prop !== 'avatarSize' && + prop !== 'badgeSize' && + prop !== 'badgeOffset' && + prop !== 'badgeGap', +})(({ avatarSize, badgeSize, badgeOffset, badgeGap }) => ({ + height: avatarSize, + width: avatarSize, + mask: getAvatarMask({ avatarSize, badgeSize, badgeOffset, badgeGap }), // Apply dynamic mask based on avatar and badge size + '> img': { + height: '100%', + width: '100%', + objectFit: 'contain', + }, +})); + +interface StyledBadgeProps { + badgeOffset?: BadgeOffsetProps; + avatarSize: number; +} + +// Styled Badge component for the badge +export const StyledBadge = styled(Badge, { + shouldForwardProp: (prop) => prop !== 'badgeOffset' && prop !== 'avatarSize', +})(({ badgeOffset, avatarSize }) => ({ + borderRadius: '50%', + display: 'block', + height: avatarSize, + width: avatarSize, + + '.MuiBadge-badge': { + position: 'static', + transform: 'none', + top: 'unset', + right: 'unset', + zIndex: 'unset', + minWidth: 'unset', + padding: 'unset', + height: 'unset', + lineHeight: 'unset', + ...((badgeOffset?.x || badgeOffset?.y) && { + transform: `translate(${badgeOffset?.x ? badgeOffset.x : 0}px, ${badgeOffset?.y ? badgeOffset.y : 0}px)`, + }), + }, +})); + +// Styled avatar +export const StyledBadgeAvatar = styled(MuiAvatar)<{ + badgeSize: number; +}>(({ badgeSize }) => ({ + width: badgeSize, + height: badgeSize, + position: 'absolute', + bottom: 0, + right: 0, + top: 'unset', + left: 'unset', +})); diff --git a/src/components/AvatarBadge/AvatarBadge.tsx b/src/components/AvatarBadge/AvatarBadge.tsx new file mode 100644 index 000000000..23792f16b --- /dev/null +++ b/src/components/AvatarBadge/AvatarBadge.tsx @@ -0,0 +1,61 @@ +import { Skeleton } from '@mui/material'; +import React from 'react'; +import { + StyledAvatar, + StyledBadge, + StyledBadgeAvatar, +} from './AvatarBadge.style'; + +export interface BadgeOffsetProps { + x?: number; + y?: number; +} + +type AvatarBadgeProps = { + avatarAlt: string; + avatarSize: number; + avatarSrc?: string; + badgeAlt: string; + badgeSize: number; + badgeSrc?: string; + badgeOffset?: BadgeOffsetProps; + badgeGap?: number; +}; + +const AvatarBadge: React.FC = ({ + avatarAlt, + avatarSize, + avatarSrc, + badgeAlt, + badgeSize, + badgeSrc, + badgeOffset, + badgeGap, +}) => { + return ( + + + + } + > + + + + + ); +}; + +export default AvatarBadge; diff --git a/src/components/AvatarBadge/SSR/AvatarBadgeSSR.tsx b/src/components/AvatarBadge/SSR/AvatarBadgeSSR.tsx new file mode 100644 index 000000000..1dbc44205 --- /dev/null +++ b/src/components/AvatarBadge/SSR/AvatarBadgeSSR.tsx @@ -0,0 +1,79 @@ +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ +import type { BadgeOffsetProps } from 'src/components/AvatarBadge/AvatarBadge'; + +type AvatarBadgeSSRProps = { + avatarSrc?: string; + badgeSrc?: string; + badgeOffset?: BadgeOffsetProps; + avatarSize: number; + badgeGap?: number; + badgeSize: number; + theme?: 'light' | 'dark'; +}; + +export const AvatarBadgeSSR = ({ + avatarSrc, + badgeSrc, + badgeOffset, + badgeGap, + avatarSize, + badgeSize, + theme, +}: AvatarBadgeSSRProps) => { + return ( +
+
+ +
+ +
+
+ ); +}; diff --git a/src/components/AvatarBadge/getAvatarMask.ts b/src/components/AvatarBadge/getAvatarMask.ts new file mode 100644 index 000000000..632cb00b7 --- /dev/null +++ b/src/components/AvatarBadge/getAvatarMask.ts @@ -0,0 +1,52 @@ +/** + * Generates a dynamic avatar mask that positions a badge based on the avatar size and optional badge gap/offset. + * + * The mask uses a radial gradient to blend the badge into the avatar, simulating the appearance of the badge + * partially overlapping the avatar's edge. The function also allows specifying a gap between the avatar and badge, + * along with custom x/y offsets for fine-tuning positioning. + * + * @param {number} avatarSize - The size of the avatar in pixels (e.g. 44, 32). + * @param {number} badgeSize - The size of the badge in pixels (e.g. 12). + * @param {BadgeOffsetProps} [badgeOffset] - Optional x and y offset values to adjust the badge's position. + * @param {number} [badgeGap] - Optional gap between the avatar and badge, defaults to a quarter of the badge size. + * @returns {string} The radial gradient mask for the avatar. + * + * The `badgeGap` introduces space between the avatar and badge by modifying the badge's radius. + * If `badgeGap` is not provided, a default value of 25% of the badge size is used. + * + * Example usage: + * ``` + * getAvatarMask({ + * avatarSize: 44, + * badgeSize: 12, + * badgeOffset: { x: 4, y: 4 }, + * badgeGap: 2, + * }); + * ``` + */ + +import type { BadgeOffsetProps } from './AvatarBadge'; + +interface GetAvatarMask { + avatarSize: number; + badgeSize: number; + badgeOffset?: BadgeOffsetProps; + badgeGap?: number; +} + +export const getAvatarMask = ({ + avatarSize, + badgeSize, + badgeOffset, + badgeGap, +}: GetAvatarMask) => { + const badgeRadius = + badgeGap !== undefined + ? (badgeSize + badgeGap) / 2 + : (badgeSize + badgeSize / 4) / 2; // Badge radius with default gap if not provided + const badgeOffsetX = + avatarSize - badgeSize / 2 + (!!badgeOffset?.x ? badgeOffset?.x : 0); + const badgeOffsetY = + avatarSize - badgeSize / 2 + (!!badgeOffset?.y ? badgeOffset?.y : 0); + return `radial-gradient(circle ${badgeRadius}px at calc(${badgeOffsetX}px) calc(${badgeOffsetY}px), #fff0 96%, #fff) 100% 100% / 100% 100% no-repeat`; +}; diff --git a/src/components/ImageGeneration/Field.tsx b/src/components/ImageGeneration/Field.tsx new file mode 100644 index 000000000..7f3a514ff --- /dev/null +++ b/src/components/ImageGeneration/Field.tsx @@ -0,0 +1,265 @@ +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ +import type { ExtendedChain, Token } from '@lifi/sdk'; +import { AvatarBadgeSSR } from '../AvatarBadge/SSR/AvatarBadgeSSR'; +import { FieldSkeleton } from './FieldSkeleton'; +import type { ImageTheme } from './ImageGeneration.types'; + +function formatDecimal(n: number): string | number { + // Check if the number is a whole number + if (Number.isInteger(n)) { + return n; // Return the number without decimals + } + + // Convert to string to check the number of decimals + const decimalPart = n.toString().split('.')[1]; + + // If it has 2 or fewer decimal places, return it with 2 decimals + if (decimalPart && decimalPart.length <= 2) { + return n.toFixed(2); + } + + // Otherwise, return the number rounded to 6 decimal places + return n.toFixed(6); +} + +const Field = ({ + sx, + token, + chain, + type, + amount = 0, + extendedHeight, + theme, + amountUSD, + routeAmount, + routeAmountUSD, + highlighted, + fullWidth, + showSkeletons, +}: { + sx?: any; //SxProps; + token?: Token | null; + chain?: ExtendedChain | null; + theme?: ImageTheme; + type: + | 'amount' + | 'token' + | 'quote' + | 'review' + | 'quote-amount' + | 'amount-selection' + | 'button' + | 'title' + | 'card-title' + | 'success'; + amount?: number | null; + amountUSD?: number | null; + highlighted?: boolean | null; + routeAmount?: number | null; + routeAmountUSD?: number | null; + extendedHeight?: boolean; + fullWidth?: boolean; + showSkeletons?: boolean; +}) => { + // Function to calculate top offset based on conditions + const getOffset = () => { + if (type === 'amount') { + return 46; + } + if (type === 'quote') { + if (extendedHeight) { + return 56; + } + return 16; + } else { + return 46; + } + }; + const getWidth = () => { + if (type === 'quote') { + return 315; + } else if (fullWidth) { + return 368; + } else { + return 174; + } + }; + + const containerOffset = getOffset(); + const containerWidth = getWidth(); + + return ( +
+
+ {type !== 'button' && ( +
+ {token && chain && ( + + )} + {type === 'token' && ( +
+
+

+ {token?.symbol} +

+
+
+

+ {chain?.name} +

+
+
+ )} + {type !== 'token' && ( +
+

+ {formatDecimal(routeAmount || amount || 0)} +

+ {!showSkeletons && token ? ( +
+

+ ${(routeAmountUSD || amountUSD || amount || 0).toFixed(2)} +

+ {type === 'review' && ( +

+ {`${token.symbol} on ${chain?.name}`} +

+ )} +
+ ) : ( + <> + {type === 'review' && ( + + )} + {type === 'success' && ( + + )} + {type === 'quote' && ( + + )} + + )} +
+ )} +
+ )} + {type === 'quote' && ( + + )} +
+
+ ); +}; + +export default Field; diff --git a/src/components/ImageGeneration/FieldSkeleton.tsx b/src/components/ImageGeneration/FieldSkeleton.tsx new file mode 100644 index 000000000..5ac1bb709 --- /dev/null +++ b/src/components/ImageGeneration/FieldSkeleton.tsx @@ -0,0 +1,26 @@ +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ +import type { CSSProperties } from 'react'; + +export const FieldSkeleton = ({ + width, + height, + sx, +}: { + width: number; + height: number; + sx?: CSSProperties; +}) => { + return ( +
+ ); +}; diff --git a/src/components/ImageGeneration/ImageGeneration.types.ts b/src/components/ImageGeneration/ImageGeneration.types.ts new file mode 100644 index 000000000..c76b8676f --- /dev/null +++ b/src/components/ImageGeneration/ImageGeneration.types.ts @@ -0,0 +1,3 @@ +export type HighlightedAreas = 'from' | 'to' | 'amount'; + +export type ImageTheme = 'light' | 'dark'; diff --git a/src/components/ImageGeneration/Label.tsx b/src/components/ImageGeneration/Label.tsx new file mode 100644 index 000000000..c8c73aaa6 --- /dev/null +++ b/src/components/ImageGeneration/Label.tsx @@ -0,0 +1,103 @@ +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ + +import type { ImageTheme } from './ImageGeneration.types'; + +const Label = ({ + sx, + buttonLabel, + cardTitle, + cardContent, + title, + theme, + fullWidth, +}: { + sx?: any; //SxProps; + cardTitle?: string; + cardContent?: string; + theme?: ImageTheme; + buttonLabel?: string; + title?: string; + fullWidth?: boolean; +}) => { + return ( + <> + {!!title && ( +
+

+ {title} +

+
+ )} + {!!cardTitle && ( +

+ {cardTitle} +

+ )} + {!!cardContent && ( +

+ {cardContent} +

+ )} + {!!buttonLabel && ( +
+

+ {buttonLabel} +

+
+ )} + + ); +}; + +export default Label; diff --git a/src/components/ImageGeneration/WidgetExecutionSSR.tsx b/src/components/ImageGeneration/WidgetExecutionSSR.tsx new file mode 100644 index 000000000..1786b5750 --- /dev/null +++ b/src/components/ImageGeneration/WidgetExecutionSSR.tsx @@ -0,0 +1,129 @@ +import type { ExtendedChain, Token } from '@lifi/sdk'; +import WidgetFieldSSR from './Field'; +import { FieldSkeleton } from './FieldSkeleton'; +import type { HighlightedAreas, ImageTheme } from './ImageGeneration.types'; +import Label from './Label'; + +const SCALING_FACTOR = 2; + +interface WidgetReviewSSRProps { + fromChain?: ExtendedChain | null; + toChain?: ExtendedChain | null; + fromToken?: Token | null; + toToken?: Token | null; + theme?: ImageTheme; + isSwap?: boolean; + amount?: string | null; + width: number; + height: number; + highlighted?: HighlightedAreas; +} + +const WidgetExecutionSSR = ({ + fromChain, + toChain, + theme, + fromToken, + isSwap, + toToken, + amount, + width, + height, + highlighted, +}: WidgetReviewSSRProps) => { + return ( +
+
+ { + // pages container --> + } +
+
+
+
+ ); +}; + +export default WidgetExecutionSSR; diff --git a/src/components/ImageGeneration/WidgetImageSSR.tsx b/src/components/ImageGeneration/WidgetImageSSR.tsx new file mode 100644 index 000000000..f35cb77e1 --- /dev/null +++ b/src/components/ImageGeneration/WidgetImageSSR.tsx @@ -0,0 +1,97 @@ +// WidgetImageSSR.tsx + +import type { ExtendedChain, Token } from '@lifi/sdk'; +import WidgetFieldSSR from './Field'; +import type { HighlightedAreas, ImageTheme } from './ImageGeneration.types'; + +const SCALING_FACTOR = 2; + +interface WidgetImageSSRProps { + fromChain?: ExtendedChain | null; + toChain?: ExtendedChain | null; + fromToken?: Token | null; + toToken?: Token | null; + amount?: string | null; + amountUSD?: string | null; + width: number; + height: number; + theme?: ImageTheme | null; + highlighted?: HighlightedAreas; +} + +const WidgetImageSSR = ({ + fromChain, + toChain, + fromToken, + toToken, + amount, + amountUSD, + width, + height, + theme, + highlighted, +}: WidgetImageSSRProps) => { + return ( +
+
+
+ + + +
+
+
+ ); +}; + +export default WidgetImageSSR; diff --git a/src/components/ImageGeneration/WidgetQuotesSSR.tsx b/src/components/ImageGeneration/WidgetQuotesSSR.tsx new file mode 100644 index 000000000..b0ed750f6 --- /dev/null +++ b/src/components/ImageGeneration/WidgetQuotesSSR.tsx @@ -0,0 +1,153 @@ +import type { ExtendedChain, Token } from '@lifi/sdk'; +import WidgetFieldSSR from './Field'; +import type { HighlightedAreas, ImageTheme } from './ImageGeneration.types'; +import Label from './Label'; + +const SCALING_FACTOR = 2; + +interface WidgetQuoteSSRProps { + fromChain?: ExtendedChain | null; + toChain?: ExtendedChain | null; + fromToken?: Token | null; + toToken?: Token | null; + routeAmount?: number | null; + amount?: string | null; + isSwap?: boolean; + amountUSD?: string | null; + width: number; + height: number; + highlighted?: HighlightedAreas; + theme?: ImageTheme | null; +} + +const WidgetQuoteSSR = ({ + fromChain, + toChain, + fromToken, + toToken, + theme, + amount, + isSwap, + amountUSD, + routeAmount, + width, + height, + highlighted, +}: WidgetQuoteSSRProps) => { + return ( +
+
+ { + // pages container --> + } +
+
+
+ + +
+ +
+
+
+ {Array(5) + .fill(0) + .map((_, index) => ( + + ))} +
+
+
+
+
+ ); +}; + +export default WidgetQuoteSSR; diff --git a/src/components/ImageGeneration/WidgetReviewSSR.tsx b/src/components/ImageGeneration/WidgetReviewSSR.tsx new file mode 100644 index 000000000..fd5e1de58 --- /dev/null +++ b/src/components/ImageGeneration/WidgetReviewSSR.tsx @@ -0,0 +1,120 @@ +import type { ExtendedChain, Token } from '@lifi/sdk'; +import WidgetFieldSSR from './Field'; +import { FieldSkeleton } from './FieldSkeleton'; +import type { HighlightedAreas, ImageTheme } from './ImageGeneration.types'; +import Label from './Label'; + +const SCALING_FACTOR = 2; + +interface WidgetReviewSSRProps { + fromChain?: ExtendedChain | null; + toChain?: ExtendedChain | null; + fromToken?: Token | null; + toToken?: Token | null; + theme?: ImageTheme; + isSwap?: boolean; + amount?: string | null; + width: number; + height: number; + highlighted?: HighlightedAreas; +} + +const WidgetReviewSSR = ({ + fromChain, + toChain, + theme, + fromToken, + isSwap, + toToken, + amount, + width, + height, + highlighted, +}: WidgetReviewSSRProps) => { + return ( +
+
+ { + // pages container --> + } +
+
+
+
+ ); +}; + +export default WidgetReviewSSR; diff --git a/src/components/ImageGeneration/WidgetSuccessSSR.tsx b/src/components/ImageGeneration/WidgetSuccessSSR.tsx new file mode 100644 index 000000000..f91fb8959 --- /dev/null +++ b/src/components/ImageGeneration/WidgetSuccessSSR.tsx @@ -0,0 +1,85 @@ +import type { ExtendedChain, Token } from '@lifi/sdk'; +import WidgetFieldSSR from './Field'; +import { FieldSkeleton } from './FieldSkeleton'; +import type { ImageTheme } from './ImageGeneration.types'; +import Label from './Label'; + +const SCALING_FACTOR = 2; + +interface WidgetReviewSSRProps { + toChain?: ExtendedChain | null; + toToken?: Token | null; + theme?: ImageTheme; + isSwap?: boolean; + amount?: string | null; + width: number; + height: number; +} + +const WidgetSuccessSSR = ({ + toChain, + theme, + isSwap, + toToken, + amount, + width, + height, +}: WidgetReviewSSRProps) => { + return ( +
+
+ { + // pages container --> + } +
+
+
+
+ ); +}; + +export default WidgetSuccessSSR; diff --git a/src/components/ImageGeneration/imageResponseOptions.ts b/src/components/ImageGeneration/imageResponseOptions.ts new file mode 100644 index 000000000..eb9d52702 --- /dev/null +++ b/src/components/ImageGeneration/imageResponseOptions.ts @@ -0,0 +1,57 @@ +import type { Font } from 'node_modules/next/dist/compiled/@vercel/og/satori'; + +export const imageResponseOptions = async ({ + width, + height, + scalingFactor, +}: { + width: number; + height: number; + scalingFactor: number; +}) => { + return { + headers: { + 'Cache-Control': `public, max-age=${60 * 60 * 1000 * 24}, immutable`, + }, + width: width * scalingFactor, + height: height * scalingFactor, + fonts: await getFonts(), // Await properly within the async function + }; +}; + +async function getFonts(): Promise { + // This is unfortunate but I can't figure out how to load local font files + // when deployed to vercel. + const [interRegular, interSemiBold, interBold] = await Promise.all([ + fetch(`https://fonts.cdnfonts.com/s/19795/Inter-Regular.woff`).then((res) => + res.arrayBuffer(), + ), + fetch(`https://fonts.cdnfonts.com/s/19795/Inter-SemiBold.woff`).then( + (res) => res.arrayBuffer(), + ), + fetch(`https://fonts.cdnfonts.com/s/19795/Inter-Bold.woff`).then((res) => + res.arrayBuffer(), + ), + ]); + + return [ + { + name: 'Inter', + data: interRegular, + style: 'normal', + weight: 400, + }, + { + name: 'Inter', + data: interSemiBold, + style: 'normal', + weight: 600, + }, + { + name: 'Inter', + data: interBold, + style: 'normal', + weight: 700, + }, + ]; +} diff --git a/src/components/Menus/WalletMenu/WalletCard.style.ts b/src/components/Menus/WalletMenu/WalletCard.style.ts index 7bd599a5f..82a16b214 100644 --- a/src/components/Menus/WalletMenu/WalletCard.style.ts +++ b/src/components/Menus/WalletMenu/WalletCard.style.ts @@ -1,13 +1,10 @@ 'use client'; -import { avatarMask32 } from '@/components/Mask.style'; +import { ButtonTransparent } from '@/components/Button'; import type { Breakpoint } from '@mui/material'; -import { alpha } from '@mui/material'; -import { Avatar, Badge, Container } from '@mui/material'; -import { styled } from '@mui/material/styles'; +import { alpha, Avatar, Badge, Container } from '@mui/material'; import type { ButtonProps as MuiButtonProps } from '@mui/material/Button/Button'; -import { getContrastAlphaColor } from '@/utils/colors'; -import { ButtonTransparent } from '@/components/Button'; +import { styled } from '@mui/material/styles'; export const WalletAvatar = styled(Avatar)(({ theme }) => ({ margin: 'auto', diff --git a/src/components/Menus/WalletMenu/WalletMenu.style.ts b/src/components/Menus/WalletMenu/WalletMenu.style.ts index ebbaf06ca..836ed4946 100644 --- a/src/components/Menus/WalletMenu/WalletMenu.style.ts +++ b/src/components/Menus/WalletMenu/WalletMenu.style.ts @@ -3,9 +3,7 @@ import { ButtonSecondary, ButtonTransparent } from '@/components/Button'; import { avatarMask32 } from '@/components/Mask.style'; import type { Breakpoint, ButtonProps } from '@mui/material'; -import { Box, Drawer } from '@mui/material'; -import { alpha } from '@mui/material'; -import { Avatar, Badge, Container } from '@mui/material'; +import { alpha, Avatar, Badge, Container, Drawer } from '@mui/material'; import { styled } from '@mui/material/styles'; export interface WalletButtonProps extends ButtonProps { diff --git a/src/components/Navbar/WalletButton.style.ts b/src/components/Navbar/WalletButton.style.ts index 57a069266..483da6d9c 100644 --- a/src/components/Navbar/WalletButton.style.ts +++ b/src/components/Navbar/WalletButton.style.ts @@ -1,9 +1,9 @@ import { ButtonPrimary } from '@/components/Button'; import { alpha, Avatar, Badge, Skeleton, styled } from '@mui/material'; +import Image from 'next/image'; import { getContrastAlphaColor } from 'src/utils/colors'; import { ButtonTransparent } from '../Button'; import { avatarMask12 } from '../Mask.style'; -import Image from 'next/image'; export const WalletMgmtWalletAvatar = styled(Avatar)(() => ({ height: 32, diff --git a/src/components/Navbar/WalletButton.tsx b/src/components/Navbar/WalletButton.tsx index e56371c2e..71ad5fdb1 100644 --- a/src/components/Navbar/WalletButton.tsx +++ b/src/components/Navbar/WalletButton.tsx @@ -3,15 +3,20 @@ import { useChains } from '@/hooks/useChains'; import { useMenuStore } from '@/stores/menu'; import { walletDigest } from '@/utils/walletDigest'; import type { Chain } from '@lifi/sdk'; -import type { Theme } from '@mui/material'; import { getConnectorIcon, useAccount, useWalletMenu, } from '@lifi/wallet-management'; +import type { Theme } from '@mui/material'; import { Stack, Typography, useMediaQuery } from '@mui/material'; +import { usePathname, useRouter } from 'next/navigation'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { JUMPER_LOYALTY_PATH, JUMPER_SCAN_PATH } from 'src/const/urls'; +import useImageStatus from 'src/hooks/useImageStatus'; +import { useLoyaltyPass } from 'src/hooks/useLoyaltyPass'; +import { XPIcon } from '../illustrations/XPIcon'; import { ConnectButton, ImageWalletMenuButton, @@ -21,12 +26,6 @@ import { WalletMgmtChainAvatar, WalletMgmtWalletAvatar, } from './WalletButton.style'; -import { XPIcon } from '../illustrations/XPIcon'; -import { useLoyaltyPass } from 'src/hooks/useLoyaltyPass'; -import { JUMPER_LOYALTY_PATH, JUMPER_SCAN_PATH } from 'src/const/urls'; -import { usePathname, useRouter } from 'next/navigation'; -import useImageStatus from 'src/hooks/useImageStatus'; -import useEffigyLink from 'src/hooks/useEffigyLink'; export const WalletButtons = () => { const { chains } = useChains();