586 lines
25 KiB
TypeScript
586 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { appConfig } from '@/config/app.config';
|
|
import { toast } from "sonner";
|
|
|
|
// Import shared components
|
|
import { Connector } from "@/components/shared/layout/curvy-rect";
|
|
import HeroFlame from "@/components/shared/effects/flame/hero-flame";
|
|
import AsciiExplosion from "@/components/shared/effects/flame/ascii-explosion";
|
|
import { HeaderProvider } from "@/components/shared/header/HeaderContext";
|
|
|
|
// Import hero section components
|
|
import HomeHeroBackground from "@/components/app/(home)/sections/hero/Background/Background";
|
|
import { BackgroundOuterPiece } from "@/components/app/(home)/sections/hero/Background/BackgroundOuterPiece";
|
|
import HomeHeroBadge from "@/components/app/(home)/sections/hero/Badge/Badge";
|
|
import HomeHeroPixi from "@/components/app/(home)/sections/hero/Pixi/Pixi";
|
|
import HomeHeroTitle from "@/components/app/(home)/sections/hero/Title/Title";
|
|
import HeroInputSubmitButton from "@/components/app/(home)/sections/hero-input/Button/Button";
|
|
import Globe from "@/components/app/(home)/sections/hero-input/_svg/Globe";
|
|
|
|
// Import header components
|
|
import HeaderBrandKit from "@/components/shared/header/BrandKit/BrandKit";
|
|
import HeaderWrapper from "@/components/shared/header/Wrapper/Wrapper";
|
|
import HeaderDropdownWrapper from "@/components/shared/header/Dropdown/Wrapper/Wrapper";
|
|
import GithubIcon from "@/components/shared/header/Github/_svg/GithubIcon";
|
|
import ButtonUI from "@/components/ui/shadcn/button"
|
|
|
|
interface SearchResult {
|
|
url: string;
|
|
title: string;
|
|
description: string;
|
|
screenshot: string | null;
|
|
markdown: string;
|
|
}
|
|
|
|
export default function HomePage() {
|
|
const [url, setUrl] = useState<string>("");
|
|
const [selectedStyle, setSelectedStyle] = useState<string>("1");
|
|
const [selectedModel, setSelectedModel] = useState<string>(appConfig.ai.defaultModel);
|
|
const [isValidUrl, setIsValidUrl] = useState<boolean>(false);
|
|
const [showSearchTiles, setShowSearchTiles] = useState<boolean>(false);
|
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
|
const [isSearching, setIsSearching] = useState<boolean>(false);
|
|
const [hasSearched, setHasSearched] = useState<boolean>(false);
|
|
const [isFadingOut, setIsFadingOut] = useState<boolean>(false);
|
|
const [showSelectMessage, setShowSelectMessage] = useState<boolean>(false);
|
|
const router = useRouter();
|
|
|
|
// Simple URL validation
|
|
const validateUrl = (urlString: string) => {
|
|
if (!urlString) return false;
|
|
// Basic URL pattern - accepts domains with or without protocol
|
|
const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/;
|
|
return urlPattern.test(urlString.toLowerCase());
|
|
};
|
|
|
|
// Check if input is a URL (contains a dot)
|
|
const isURL = (str: string): boolean => {
|
|
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/.*)?$/;
|
|
return urlPattern.test(str.trim());
|
|
};
|
|
|
|
const styles = [
|
|
{ id: "1", name: "Glassmorphism", description: "Frosted glass effect" },
|
|
{ id: "2", name: "Neumorphism", description: "Soft 3D shadows" },
|
|
{ id: "3", name: "Brutalism", description: "Bold and raw" },
|
|
{ id: "4", name: "Minimalist", description: "Clean and simple" },
|
|
{ id: "5", name: "Dark Mode", description: "Dark theme design" },
|
|
{ id: "6", name: "Gradient Rich", description: "Vibrant gradients" },
|
|
{ id: "7", name: "3D Depth", description: "Dimensional layers" },
|
|
{ id: "8", name: "Retro Wave", description: "80s inspired" },
|
|
];
|
|
|
|
const models = appConfig.ai.availableModels.map(model => ({
|
|
id: model,
|
|
name: appConfig.ai.modelDisplayNames[model] || model,
|
|
}));
|
|
|
|
const handleSubmit = async (selectedResult?: SearchResult) => {
|
|
const inputValue = url.trim();
|
|
|
|
if (!inputValue) {
|
|
toast.error("Please enter a URL or search term");
|
|
return;
|
|
}
|
|
|
|
// If it's a search result being selected, fade out and redirect
|
|
if (selectedResult) {
|
|
setIsFadingOut(true);
|
|
|
|
// Wait for fade animation
|
|
setTimeout(() => {
|
|
sessionStorage.setItem('targetUrl', selectedResult.url);
|
|
sessionStorage.setItem('selectedStyle', selectedStyle);
|
|
sessionStorage.setItem('selectedModel', selectedModel);
|
|
sessionStorage.setItem('autoStart', 'true');
|
|
if (selectedResult.markdown) {
|
|
sessionStorage.setItem('siteMarkdown', selectedResult.markdown);
|
|
}
|
|
router.push('/generation');
|
|
}, 500);
|
|
return;
|
|
}
|
|
|
|
// If it's a URL, go straight to generation
|
|
if (isURL(inputValue)) {
|
|
sessionStorage.setItem('targetUrl', inputValue);
|
|
sessionStorage.setItem('selectedStyle', selectedStyle);
|
|
sessionStorage.setItem('selectedModel', selectedModel);
|
|
sessionStorage.setItem('autoStart', 'true');
|
|
router.push('/generation');
|
|
} else {
|
|
// It's a search term, fade out if results exist, then search
|
|
if (hasSearched && searchResults.length > 0) {
|
|
setIsFadingOut(true);
|
|
|
|
setTimeout(async () => {
|
|
setSearchResults([]);
|
|
setIsFadingOut(false);
|
|
setShowSelectMessage(true);
|
|
|
|
// Perform new search
|
|
await performSearch(inputValue);
|
|
setHasSearched(true);
|
|
setShowSearchTiles(true);
|
|
setShowSelectMessage(false);
|
|
|
|
// Smooth scroll to carousel
|
|
setTimeout(() => {
|
|
const carouselSection = document.querySelector('.carousel-section');
|
|
if (carouselSection) {
|
|
carouselSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, 300);
|
|
}, 500);
|
|
} else {
|
|
// First search, no fade needed
|
|
setShowSelectMessage(true);
|
|
setIsSearching(true);
|
|
setHasSearched(true);
|
|
setShowSearchTiles(true);
|
|
|
|
// Scroll to carousel area immediately
|
|
setTimeout(() => {
|
|
const carouselSection = document.querySelector('.carousel-section');
|
|
if (carouselSection) {
|
|
carouselSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, 100);
|
|
|
|
await performSearch(inputValue);
|
|
setShowSelectMessage(false);
|
|
setIsSearching(false);
|
|
|
|
// Smooth scroll to carousel
|
|
setTimeout(() => {
|
|
const carouselSection = document.querySelector('.carousel-section');
|
|
if (carouselSection) {
|
|
carouselSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, 300);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Perform search when user types
|
|
const performSearch = async (searchQuery: string) => {
|
|
if (!searchQuery.trim() || isURL(searchQuery)) {
|
|
setSearchResults([]);
|
|
return;
|
|
}
|
|
|
|
setIsSearching(true);
|
|
try {
|
|
const response = await fetch('/api/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query: searchQuery }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setSearchResults(data.results || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<HeaderProvider>
|
|
<div className="min-h-screen bg-background-base">
|
|
{/* Header/Navigation Section */}
|
|
<HeaderDropdownWrapper />
|
|
|
|
<div className="sticky top-0 left-0 w-full z-[101] bg-background-base header">
|
|
<div className="absolute top-0 cmw-container border-x border-border-faint h-full pointer-events-none" />
|
|
<div className="h-1 bg-border-faint w-full left-0 -bottom-1 absolute" />
|
|
<div className="cmw-container absolute h-full pointer-events-none top-0">
|
|
<Connector className="absolute -left-[10.5px] -bottom-11" />
|
|
<Connector className="absolute -right-[10.5px] -bottom-11" />
|
|
</div>
|
|
|
|
<HeaderWrapper>
|
|
<div className="max-w-[900px] mx-auto w-full flex justify-between items-center">
|
|
<div className="flex gap-24 items-center">
|
|
<HeaderBrandKit />
|
|
</div>
|
|
<div className="flex gap-8">
|
|
<a
|
|
className="contents"
|
|
href="https://github.com/mendableai/open-lovable"
|
|
target="_blank"
|
|
>
|
|
<ButtonUI variant="tertiary">
|
|
<GithubIcon />
|
|
Use this Template
|
|
</ButtonUI>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</HeaderWrapper>
|
|
</div>
|
|
|
|
{/* Hero Section */}
|
|
<section className="overflow-x-clip" id="home-hero">
|
|
<div className="pt-28 lg:pt-254 lg:-mt-100 pb-115 relative" id="hero-content">
|
|
<HomeHeroPixi />
|
|
<HeroFlame />
|
|
<BackgroundOuterPiece />
|
|
<HomeHeroBackground />
|
|
|
|
<div className="relative container px-16">
|
|
<HomeHeroBadge />
|
|
<HomeHeroTitle />
|
|
<p className="text-center text-body-large">
|
|
Re-imagine any website, in seconds.
|
|
</p>
|
|
<Link
|
|
className="bg-black-alpha-4 hover:bg-black-alpha-6 rounded-6 px-8 lg:px-6 text-label-large h-30 lg:h-24 block mt-8 mx-auto w-max gap-4 transition-all"
|
|
href="#"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
Powered by Firecrawl.
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mini Playground Input */}
|
|
<div className="container lg:contents !p-16 relative -mt-90">
|
|
<div className="absolute top-0 left-[calc(50%-50vw)] w-screen h-1 bg-border-faint lg:hidden" />
|
|
<div className="absolute bottom-0 left-[calc(50%-50vw)] w-screen h-1 bg-border-faint lg:hidden" />
|
|
<Connector className="-top-10 -left-[10.5px] lg:hidden" />
|
|
<Connector className="-top-10 -right-[10.5px] lg:hidden" />
|
|
<Connector className="-bottom-10 -left-[10.5px] lg:hidden" />
|
|
<Connector className="-bottom-10 -right-[10.5px] lg:hidden" />
|
|
|
|
{/* Hero Input Component */}
|
|
<div className="max-w-552 mx-auto z-[11] lg:z-[2]">
|
|
<div className="rounded-20 -mt-30 lg:-mt-30">
|
|
<div
|
|
className="bg-white rounded-20"
|
|
style={{
|
|
boxShadow:
|
|
"0px 0px 44px 0px rgba(0, 0, 0, 0.02), 0px 88px 56px -20px rgba(0, 0, 0, 0.03), 0px 56px 56px -20px rgba(0, 0, 0, 0.02), 0px 32px 32px -20px rgba(0, 0, 0, 0.03), 0px 16px 24px -12px rgba(0, 0, 0, 0.03), 0px 0px 0px 1px rgba(0, 0, 0, 0.05), 0px 0px 0px 10px #F9F9F9",
|
|
}}
|
|
>
|
|
|
|
<div className="p-16 flex gap-12 items-center w-full relative bg-white rounded-20">
|
|
{isURL(url) ? (
|
|
// Scrape icon for URLs
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="opacity-40 flex-shrink-0"
|
|
>
|
|
<rect x="3" y="3" width="14" height="14" rx="2" stroke="currentColor" strokeWidth="1.5"/>
|
|
<path d="M7 10L9 12L13 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
) : (
|
|
// Search icon for search terms
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="opacity-40 flex-shrink-0"
|
|
>
|
|
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" strokeWidth="1.5"/>
|
|
<path d="M12.5 12.5L16.5 16.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
</svg>
|
|
)}
|
|
<input
|
|
className="flex-1 bg-transparent text-body-input text-accent-black placeholder:text-black-alpha-48 focus:outline-none focus:ring-0 focus:border-transparent"
|
|
placeholder="Enter URL or search term..."
|
|
type="text"
|
|
value={url}
|
|
disabled={isSearching}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setUrl(value);
|
|
setIsValidUrl(validateUrl(value));
|
|
// Reset search state when input changes
|
|
if (value.trim() === "") {
|
|
setShowSearchTiles(false);
|
|
setHasSearched(false);
|
|
setSearchResults([]);
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !isSearching) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}}
|
|
onFocus={() => {
|
|
if (url.trim() && !isURL(url)) {
|
|
setShowSearchTiles(true);
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
setTimeout(() => setShowSearchTiles(false), 200);
|
|
}}
|
|
/>
|
|
<div
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (!isSearching) {
|
|
handleSubmit();
|
|
}
|
|
}}
|
|
className={isSearching ? 'pointer-events-none' : ''}
|
|
>
|
|
<HeroInputSubmitButton
|
|
dirty={url.length > 0}
|
|
buttonText={isURL(url) ? 'Scrape Site' : 'Search'}
|
|
disabled={isSearching}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* Options Section - Only show when valid URL */}
|
|
<div className={`overflow-hidden transition-all duration-500 ease-in-out ${
|
|
isValidUrl ? 'max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
|
}`}>
|
|
<div className="p-[28px]">
|
|
<div className="border-t border-gray-100 bg-white">
|
|
{/* Style Selector */}
|
|
<div className={`mb-2 pt-4 transition-all duration-300 transform ${
|
|
isValidUrl ? 'translate-y-0 opacity-100' : '-translate-y-2 opacity-0'
|
|
}`} style={{ transitionDelay: '100ms' }}>
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{styles.map((style, index) => (
|
|
<button
|
|
key={style.id}
|
|
onClick={() => setSelectedStyle(style.id)}
|
|
className={`
|
|
py-2.5 px-2 rounded text-[10px] font-medium border transition-all text-center
|
|
${selectedStyle === style.id
|
|
? 'border-orange-500 bg-orange-50 text-orange-900'
|
|
: 'border-gray-200 hover:border-gray-300 bg-white text-gray-700'
|
|
}
|
|
${isValidUrl ? 'opacity-100' : 'opacity-0'}
|
|
`}
|
|
style={{
|
|
transitionDelay: `${150 + index * 30}ms`,
|
|
transition: 'all 0.3s ease-in-out'
|
|
}}
|
|
>
|
|
{style.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model Selector Dropdown and Additional Instructions */}
|
|
<div className={`flex gap-3 mt-2 pb-4 transition-all duration-300 transform ${
|
|
isValidUrl ? 'translate-y-0 opacity-100' : '-translate-y-2 opacity-0'
|
|
}`} style={{ transitionDelay: '400ms' }}>
|
|
{/* Model Dropdown */}
|
|
<select
|
|
value={selectedModel}
|
|
onChange={(e) => setSelectedModel(e.target.value)}
|
|
className="px-3 py-2.5 text-[10px] font-medium text-gray-700 bg-white rounded border border-gray-200 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
|
>
|
|
{models.map((model) => (
|
|
<option key={model.id} value={model.id}>
|
|
{model.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Additional Instructions */}
|
|
<input
|
|
type="text"
|
|
className="flex-1 px-3 py-2.5 text-[10px] text-gray-700 bg-gray-50 rounded border border-gray-200 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 placeholder:text-gray-400"
|
|
placeholder="Additional instructions (optional)"
|
|
onChange={(e) => sessionStorage.setItem('additionalInstructions', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="h-248 top-84 cw-768 pointer-events-none absolute overflow-clip -z-10">
|
|
<AsciiExplosion className="-top-200" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Full-width oval carousel section */}
|
|
{showSearchTiles && hasSearched && (
|
|
<section className={`carousel-section relative w-full overflow-hidden mt-32 mb-32 transition-opacity duration-500 ${
|
|
isFadingOut ? 'opacity-0' : 'opacity-100'
|
|
}`}>
|
|
<div className="absolute inset-0 bg-gradient-to-b from-gray-50/50 to-white rounded-[50%] transform scale-x-150 -translate-y-24" />
|
|
|
|
{isSearching ? (
|
|
// Loading state with animated skeletons
|
|
<div className="relative h-[250px] overflow-hidden">
|
|
{/* Edge fade overlays */}
|
|
<div className="absolute left-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to right, white 0%, white 20%, transparent 100%)'}} />
|
|
<div className="absolute right-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to left, white 0%, white 20%, transparent 100%)'}} />
|
|
|
|
<div className="flex gap-12 py-4 px-8">
|
|
{[0, 1, 2, 3, 4].map((index) => (
|
|
<div
|
|
key={`loading-${index}`}
|
|
className="flex-shrink-0 w-[400px] h-[240px] rounded-24 overflow-hidden border-2 border-gray-200/30 bg-white relative"
|
|
style={{
|
|
animation: `fadeIn 0.5s ease-out forwards`,
|
|
animationDelay: `${index * 100}ms`,
|
|
opacity: 0
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 skeleton-shimmer">
|
|
<div className="absolute inset-0 bg-gradient-to-r from-gray-100 via-gray-50 to-gray-100 skeleton-gradient" />
|
|
</div>
|
|
|
|
{/* Fake browser UI */}
|
|
<div className="absolute top-0 left-0 right-0 h-8 bg-gray-100 border-b border-gray-200/50 flex items-center px-3 gap-2">
|
|
<div className="flex gap-1">
|
|
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
|
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
|
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
|
</div>
|
|
<div className="flex-1 h-4 bg-gray-200 rounded-sm mx-4" />
|
|
</div>
|
|
|
|
{/* Content skeleton */}
|
|
<div className="p-4 mt-8">
|
|
<div className="h-6 bg-gray-200 rounded w-3/4 mb-3" />
|
|
<div className="h-4 bg-gray-150 rounded w-full mb-2" />
|
|
<div className="h-4 bg-gray-150 rounded w-5/6 mb-2" />
|
|
<div className="h-4 bg-gray-150 rounded w-4/6" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Loading text */}
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className="bg-white/95 backdrop-blur-sm rounded-full px-6 py-3 shadow-lg border border-gray-200">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex gap-1">
|
|
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
<span className="text-sm font-medium text-gray-700">Searching for sites...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : searchResults.length > 0 ? (
|
|
// Actual results
|
|
<div className="relative h-[250px] overflow-hidden">
|
|
{/* Edge fade overlays */}
|
|
<div className="absolute left-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to right, white 0%, white 20%, transparent 100%)'}} />
|
|
<div className="absolute right-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to left, white 0%, white 20%, transparent 100%)'}} />
|
|
|
|
<div className="carousel-container absolute left-0 flex gap-12 py-4">
|
|
{/* Duplicate results for infinite scroll */}
|
|
{[...searchResults, ...searchResults].map((result, index) => (
|
|
<button
|
|
key={`${result.url}-${index}`}
|
|
onClick={() => handleSubmit(result)}
|
|
className="flex-shrink-0 w-[400px] h-[240px] rounded-24 overflow-hidden border-2 border-gray-200/50 hover:border-orange-500 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] cursor-pointer bg-white"
|
|
>
|
|
{result.screenshot ? (
|
|
<img
|
|
src={result.screenshot}
|
|
alt={result.title}
|
|
className="w-full h-full object-cover object-top"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-gradient-to-br from-gray-100 to-gray-50" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// No results state
|
|
<div className="relative h-[250px] flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="mb-4">
|
|
<svg className="w-16 h-16 mx-auto text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-gray-500 text-lg">No results found</p>
|
|
<p className="text-gray-400 text-sm mt-1">Try a different search term</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
@keyframes infiniteScroll {
|
|
from {
|
|
transform: translateX(0);
|
|
}
|
|
to {
|
|
transform: translateX(-50%);
|
|
}
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% {
|
|
transform: translateX(-100%);
|
|
}
|
|
100% {
|
|
transform: translateX(100%);
|
|
}
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.carousel-container {
|
|
animation: infiniteScroll 30s linear infinite;
|
|
}
|
|
|
|
.carousel-container:hover {
|
|
animation-play-state: paused;
|
|
}
|
|
|
|
.skeleton-shimmer {
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.skeleton-gradient {
|
|
animation: shimmer 2s infinite;
|
|
}
|
|
`}</style>
|
|
</HeaderProvider>
|
|
);
|
|
} |