Skip to content

Commit

Permalink
feat: add highlighted market on home page (#99)
Browse files Browse the repository at this point in the history
  • Loading branch information
berteotti authored Oct 9, 2024
1 parent 817c405 commit 59f0342
Show file tree
Hide file tree
Showing 8 changed files with 397 additions and 2 deletions.
101 changes: 101 additions & 0 deletions app/MarketsHighlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use client';

import { useQueries } from '@tanstack/react-query';
import Link from 'next/link';
import { FixedProductMarketMaker, getMarket } from '@/queries/omen';
import { OutcomeBar, TokenLogo } from '@/app/components';
import { formatEtherWithFixedDecimals, remainingTime } from '@/utils';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
CarouselSelector,
} from './components/Carousel';
import Autoplay from 'embla-carousel-autoplay';
import { highlightedMarketsList } from '@/market-highlight.config';
import defaultHighlightImage from '@/public/assets/highlights/default.png';
import Image from 'next/image';
import { useState } from 'react';

export const MarketsHighlight = () => {
const { data: markets, isLoading } = useQueries({
queries: Object.keys(highlightedMarketsList).map(id => ({
queryKey: ['getMarket', id],
queryFn: async () => getMarket({ id }),
})),
combine: results => {
return {
data: results
.map(result => result.data?.fixedProductMarketMaker)
.filter((market): market is FixedProductMarketMaker => !!market),
isLoading: results.some(result => result.isPending),
};
},
});

if (markets.length === 0 || isLoading) return null;

return (
<Carousel
plugins={[
Autoplay({
delay: 10000,
}),
]}
opts={{ loop: true }}
className="group relative mb-6 w-full"
>
<CarouselSelector className="absolute bottom-0 left-0 right-0 z-50 mx-auto mb-6" />
<div className="absolute bottom-0 right-0 z-50 mb-4 mr-6 flex space-x-2 opacity-0 transition duration-300 ease-in-out group-hover:opacity-100">
<CarouselPrevious />
<CarouselNext />
</div>
<CarouselContent className="pb-2">
{markets.map(market => (
<HighlightCarouselItem key={market.id} market={market} />
))}
</CarouselContent>
</Carousel>
);
};

const HighlightCarouselItem = ({ market }: { market: FixedProductMarketMaker }) => {
const [image, setImage] = useState(highlightedMarketsList[market.id].image);
const closingDate = new Date(+market.openingTimestamp * 1000);

return (
<CarouselItem key={market.id}>
<Link
href={`/markets?id=${market.id}`}
target="_blank"
className="flex h-auto min-h-[400px] w-full flex-col-reverse justify-between rounded-20 bg-surface-primary-main bg-gradient-to-b from-surface-surface-0 to-surface-surface-1 shadow-2 ring-1 ring-outline-base-em md:h-72 md:min-h-fit md:flex-row 2xl:h-96"
>
<div className="m-0 flex w-full max-w-2xl flex-col space-y-8 p-4 md:mx-6 md:my-8 md:mr-10 md:p-0 lg:mx-8 lg:mr-28">
<div className="flex flex-col space-y-4">
<p className="text-xs font-semibold text-text-low-em first-letter:uppercase md:text-sm">
{remainingTime(closingDate)}
</p>
<p className="font-semibold md:text-lg lg:text-xl">{market.title}</p>
</div>
<OutcomeBar market={market} />
<div className="flex items-center space-x-1">
<TokenLogo address={market.collateralToken} className="size-3" />
<p className="text-xs font-semibold text-text-med-em md:text-base">
{formatEtherWithFixedDecimals(market.collateralVolume, 2)} Vol
</p>
</div>
</div>
<Image
className="h-44 w-full rounded-e-0 rounded-t-20 md:h-full md:w-1/2 md:rounded-e-20 md:rounded-s-0 2xl:w-2/5"
src={image}
priority
alt="Market highlight"
style={{ objectFit: 'cover', objectPosition: 'top' }}
onError={() => setImage(defaultHighlightImage)}
/>
</Link>
</CarouselItem>
);
};
278 changes: 278 additions & 0 deletions app/components/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
'use client';

import * as React from 'react';
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react';
import { cx } from 'class-variance-authority';
import { IconButton } from '@swapr/ui';

type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];

type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};

type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;

const CarouselContext = React.createContext<CarouselContextProps | null>(null);

function useCarousel() {
const context = React.useContext(CarouselContext);

if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}

return context;
}

const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props },
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);

const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}

setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);

const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);

const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);

const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);

React.useEffect(() => {
if (!api || !setApi) {
return;
}

setApi(api);
}, [api, setApi]);

React.useEffect(() => {
if (!api) {
return;
}

onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);

return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);

return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cx('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';

const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef } = useCarousel();

return (
<div ref={carouselRef} className="overflow-hidden">
<div ref={ref} className={cx('flex', className)} {...props} />
</div>
);
});
CarouselContent.displayName = 'CarouselContent';

const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cx('min-w-0 shrink-0 grow-0 basis-full', className)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';

const CarouselSelector = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { api } = useCarousel();

const [selected, setSelected] = React.useState(api?.selectedScrollSnap() ?? 0);

const nodes = api?.slideNodes() ?? [];

React.useEffect(() => {
if (!api) {
return;
}

api.on('select', api => setSelected(api.selectedScrollSnap()));

return () => {
api.off('select', api => setSelected(api.selectedScrollSnap()));
};
}, [api]);

if (nodes.length < 2) return null;

return (
<div ref={ref} className={cx('flex w-fit space-x-2', className)} {...props}>
{nodes.map((_, index) => (
<button
className={cx(
'size-2 rounded-100',
index === selected
? 'bg-surface-primary-accent-3'
: 'bg-surface-primary-accent-2'
)}
key={index}
onClick={() => {
if (!api) return;

api.reInit();
api.scrollTo(index);
}}
/>
))}
</div>
);
});
CarouselSelector.displayName = 'CarouselSelector';

const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
Omit<React.ComponentProps<typeof IconButton>, 'name'>
>(({ className, variant = 'outline', size = 'sm', ...props }, ref) => {
const { scrollPrev, canScrollPrev, api } = useCarousel();

const nodes = api?.slideNodes() ?? [];

if (nodes.length < 2) return null;

return (
<IconButton
ref={ref}
variant={variant}
size={size}
disabled={!canScrollPrev}
onClick={scrollPrev}
name="chevron-left"
{...props}
>
<span className="sr-only">Previous slide</span>
</IconButton>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';

const CarouselNext = React.forwardRef<
HTMLButtonElement,
Omit<React.ComponentProps<typeof IconButton>, 'name'>
>(({ className, variant = 'outline', size = 'sm', ...props }, ref) => {
const { scrollNext, canScrollNext, api } = useCarousel();

const nodes = api?.slideNodes() ?? [];

if (nodes.length < 2) return null;

return (
<IconButton
ref={ref}
variant={variant}
size={size}
disabled={!canScrollNext}
onClick={scrollNext}
name="chevron-right"
{...props}
>
<span className="sr-only">Next slide</span>
</IconButton>
);
});
CarouselNext.displayName = 'CarouselNext';

export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselSelector,
CarouselPrevious,
CarouselNext,
};
2 changes: 1 addition & 1 deletion app/components/OutcomeBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const OutcomeBar = ({ market }: OutcomeBarProps) => {
const hasOutcomePercentages = outcome0.percentage && outcome1.percentage;

return (
<div className="space-y-1">
<div className="w-full space-y-1">
<div className="flex space-x-1 transition-all">
{outcome0.percentage !== '0' && (
<div
Expand Down
Loading

0 comments on commit 59f0342

Please sign in to comment.