Skip to main content
Glama
index.tsx5.83 kB
'use client'; import { Link } from '@components/Link/Link'; import { Button, Container, MaxWidthSmoother, ProductHuntLogo, } from '@intlayer/design-system'; import { useDevice, usePersistedStore } from '@intlayer/design-system/hooks'; import { useQuery } from '@tanstack/react-query'; import { cn } from '@utils/cn'; import { X } from 'lucide-react'; import { useIntlayer } from 'next-intlayer'; import { type FC, useEffect, useState } from 'react'; const VISIBLE_START_TIME = 3000; const MINIATURIZING_END_TIME = 6000; const MIN_COUNT_TO_SHOW_ONE = 5; const PRODUCT_HUNT_SLUG = 'intlayer-i18n-cms-for-react-now-vue'; interface ProductHuntData { votesCount: number; createdAt: string; // ISO date string featuredAt: string | null; // ISO date string or null } const fetchProductHuntData = async (): Promise<ProductHuntData | null> => { try { const response = await fetch('https://api.producthunt.com/v2/api/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.NEXT_PUBLIC_PRODUCTHUNT_TOKEN}`, }, body: JSON.stringify({ query: ` query { post(slug: "${PRODUCT_HUNT_SLUG}") { votesCount createdAt featuredAt } } `, }), }); const data = await response.json(); if (data.data?.post) { return { votesCount: data.data.post.votesCount || 0, createdAt: data.data.post.createdAt, featuredAt: data.data.post.featuredAt, }; } return null; } catch (error) { console.error('Error fetching ProductHunt data:', error); return null; } }; // Launch typically lasts 24 hours const isLaunchActive = (phData: ProductHuntData | null): boolean => { if (!phData || !phData.featuredAt) return false; const featuredDate = new Date(phData.featuredAt); const endDate = new Date(featuredDate); endDate.setDate(endDate.getDate() + 1); // Launch lasts 24 hours const now = new Date(); return now < endDate; }; export const ProductHunt: FC = () => { const { isMobile } = useDevice('sm'); const [isVisible, setIsVisible] = usePersistedStore<boolean | null>( `product-hunt-visible+${PRODUCT_HUNT_SLUG}`, null ); const [isMiniaturized, setIsMiniaturized] = useState(false); const { data: phData, isLoading } = useQuery({ queryKey: ['product-hunt-data'], queryFn: fetchProductHuntData, retry: 3, refetchInterval: 5 * 60 * 1000, // 5 minutes }); const { title, content, details, linkLabel, closeLabel } = useIntlayer('product-hunt'); const upvotes = phData?.votesCount || 0; const launchActive = isLaunchActive(phData ?? null); useEffect(() => { // If launch is over, hide the component if (phData && !launchActive && isVisible !== false) { setIsVisible(false); } }, [phData, launchActive, isVisible, setIsVisible]); useEffect(() => { const timer = setTimeout(() => { setIsMiniaturized(true); }, MINIATURIZING_END_TIME); return () => clearTimeout(timer); }, [isMobile, isVisible]); useEffect(() => { if (isVisible !== null) return; const timer = setTimeout(() => { if (isVisible !== null) return; // Only show if the launch is active if (launchActive) { setIsVisible(true); } else { setIsVisible(false); } }, VISIBLE_START_TIME); return () => clearTimeout(timer); }, [isVisible, launchActive, setIsVisible]); // Don't render if the launch is over if (!launchActive && isVisible === false) return null; const isMiniaturizable = !isMobile && isMiniaturized; return ( <Container className={cn([ 'group fixed bottom-5 left-5 z-[100] mr-5 border-[2px] border-text p-2 transition-all duration-500', 'hover:translate-x-0 hover:translate-y-0 hover:scale-100', isMiniaturizable && '-translate-x-1/4 translate-y-1/4 scale-50', !isVisible && '!-translate-x-[120%]', ])} roundedSize="2xl" > <Link href="https://www.producthunt.com/posts/intlayer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-intlayer" label={linkLabel.value} color="custom" className="!no-underline" > <div className="flex flex-row gap-6 rounded-lg p-3"> <div className="flex flex-col items-center gap-2 rounded-lg"> <ProductHuntLogo className="size-[80px]" /> <div className="flex flex-col items-center rounded-lg transition group-hover:scale-110"> <span className="text-2xl tracking-widest"> ▲{' '} {isLoading ? '-' : upvotes > MIN_COUNT_TO_SHOW_ONE ? upvotes : null} </span> <span className="text-sm">Upvote</span> </div> </div> <MaxWidthSmoother isHidden={isMiniaturizable} className="group-hover:grid-cols-[1fr] max-md:grid-cols-[1fr]!" > <div className="flex w-72 flex-col gap-2 overflow-hidden rounded-lg"> <strong className="text-xl uppercase">{title}</strong> <span>{content}</span> <span className="text-neutral text-sm">{details}</span> </div> </MaxWidthSmoother> </div> </Link> <Button Icon={X} label={closeLabel.value} onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsVisible(false); }} color="text" variant="hoverable" className="!absolute top-2 right-2 cursor-pointer" size="icon-md" /> </Container> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server