mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,66 +1,123 @@
|
||||
// components/blocks/Hero.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
|
||||
// ── Hero ─────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic hero advertisement block.
|
||||
* Two-column layout: badge/headline/description/CTAs on the left, image on the right.
|
||||
* Renders null when no ad is provided — callers should not fall back to placeholder copy.
|
||||
* Hero advertisement carousel.
|
||||
* Each slide is a full-bleed background image with a bottom gradient overlay,
|
||||
* badge/headline/description/CTAs anchored bottom-left. Renders null when no
|
||||
* ads are given — callers should not fall back to placeholder copy.
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { badge_label, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* ads — array of advertisement objects { badge_label, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
||||
*/
|
||||
export function Hero({ ad, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
export function Hero({ ads, onCtaClick }) {
|
||||
const [api, setApi] = useState();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
if (!ads?.length) return null;
|
||||
|
||||
const progressValue = count ? (current / count) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex xs:flex-col lg:flex-row items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
{ad.badge_label && (
|
||||
<Badge variant="outline" className="pointer-events-none select-none">
|
||||
<Megaphone /> {ad.badge_label}
|
||||
</Badge>
|
||||
)}
|
||||
{ad.headline && (
|
||||
<div className="font-bold text-4xl leading-12 pointer-events-none select-none">
|
||||
{ad.headline}
|
||||
<div className="w-full">
|
||||
<Carousel setApi={setApi} className="w-full">
|
||||
<CarouselContent>
|
||||
{ads.map((ad) => {
|
||||
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
|
||||
return (
|
||||
<CarouselItem key={ad.advertisement_id}>
|
||||
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
|
||||
<CardContent className="relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center">
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={ad.headline || "Advertisement"}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* Bottom gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
|
||||
|
||||
{/* Bottom-left content */}
|
||||
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 xs:max-w-[280px] sm:max-w-sm lg:max-w-xl">
|
||||
{ad.badge_label && (
|
||||
<Badge variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
|
||||
<Megaphone /> {ad.badge_label}
|
||||
</Badge>
|
||||
)}
|
||||
{ad.headline && (
|
||||
<div className="font-bold text-white xs:text-2xl lg:text-4xl leading-tight tracking-tighter pointer-events-none select-none">
|
||||
{ad.headline}
|
||||
</div>
|
||||
)}
|
||||
{ad.description && (
|
||||
<p className="text-gray-200 xs:text-sm lg:text-md leading-relaxed pointer-events-none select-none line-clamp-2">
|
||||
{ad.description}
|
||||
</p>
|
||||
)}
|
||||
{ctas.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{ctas.map((cta, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
||||
className={cta.variant !== "outline" ? "bg-[oklch(0.63_0.25_302)] text-white hover:bg-[oklch(0.63_0.25_302)]/85" : undefined}
|
||||
onClick={() => onCtaClick?.(ad, cta)}
|
||||
>
|
||||
{cta.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</CarouselContent>
|
||||
|
||||
{/* Navigation and Progress — only meaningful with more than one slide */}
|
||||
{ads.length > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CarouselPrevious className="static translate-y-0 size-8 rounded-lg" />
|
||||
<CarouselNext className="static translate-y-0 size-8 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex-1 max-w-24 ml-4">
|
||||
<Progress value={progressValue} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ad.description && (
|
||||
<p className="max-w-lg pointer-events-none select-none">
|
||||
{ad.description}
|
||||
</p>
|
||||
)}
|
||||
{ctas.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{ctas.map((cta, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
||||
onClick={() => onCtaClick?.(ad, cta)}
|
||||
>
|
||||
{cta.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted w-xl aspect-video flex items-center justify-center overflow-hidden pointer-events-none select-none">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,18 +126,22 @@ export function Hero({ ad, onCtaClick }) {
|
||||
|
||||
export function HeroSkeleton() {
|
||||
return (
|
||||
<div className="flex xs:flex-col lg:flex-row items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-3 w-full max-w-lg">
|
||||
<Skeleton className="h-6 w-32 rounded-full" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="rounded-lg w-xl aspect-video" />
|
||||
<div className="w-full">
|
||||
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
|
||||
<CardContent className="relative xs:h-64 lg:h-96">
|
||||
<Skeleton className="absolute inset-0 h-full w-full rounded-none" />
|
||||
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 w-full max-w-lg">
|
||||
<Skeleton className="h-6 w-32 rounded-full" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* AppBreadcrumb
|
||||
@@ -25,7 +26,12 @@ import {
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* @param {Array} items Ordered list of breadcrumb item definitions
|
||||
* @param {Array} items Ordered list of breadcrumb item definitions
|
||||
* @param {Object} [color] Independent color overrides per element
|
||||
* @param {Object} [color.link] Override for non-last (clickable) items
|
||||
* @param {string} [color.link.color] className applied to BreadcrumbLink
|
||||
* @param {Object} [color.page] Override for the last (current page) item
|
||||
* @param {string} [color.page.color] className applied to BreadcrumbPage
|
||||
*
|
||||
* ─── Usage ───────────────────────────────────────────────────────────────────
|
||||
*
|
||||
@@ -49,10 +55,18 @@ import {
|
||||
* ]}
|
||||
* />
|
||||
*
|
||||
* // With per-element color overrides
|
||||
* <AppBreadcrumb
|
||||
* color={{ link: { color: "text-muted-foreground" }, page: { color: "text-[#000000]" } }}
|
||||
* items={items}
|
||||
* />
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
const AppBreadcrumb = ({ items = [] }) => {
|
||||
const AppBreadcrumb = ({ items = [], color = {} }) => {
|
||||
const navigate = useNavigate();
|
||||
const linkColor = color.link?.color ?? "";
|
||||
const pageColor = color.page?.color ?? "";
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
@@ -66,14 +80,14 @@ const AppBreadcrumb = ({ items = [] }) => {
|
||||
<span key={index} className="flex items-center gap-1.5">
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage className="flex items-center gap-2 max-w-[300px] truncate">
|
||||
<BreadcrumbPage className={cn("flex items-center gap-2 max-w-[300px] truncate", pageColor)}>
|
||||
{item.icon}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<div
|
||||
className="flex items-center gap-2 select-none cursor-pointer max-w-[300px]"
|
||||
className={cn("flex items-center gap-2 select-none cursor-pointer max-w-[300px]", linkColor)}
|
||||
onClick={(e) => {
|
||||
if (item.onClick) {
|
||||
item.onClick(e, navigate);
|
||||
|
||||
@@ -30,17 +30,11 @@ export default function DashboardGrid({ sections = [] }) {
|
||||
<motion.div
|
||||
key={key}
|
||||
onClick={(e) => handleNavigate(e, link)}
|
||||
className="group bg-card border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer"
|
||||
whileHover={{
|
||||
y: -6,
|
||||
scale: 1.02,
|
||||
backgroundColor: "var(--primary)",
|
||||
color: "var(--motion-card-hover)"
|
||||
}}
|
||||
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
|
||||
whileHover={{ y: -6, scale: 1.02 }}
|
||||
transition={{
|
||||
y: { type: "spring", stiffness: 300, damping: 20 },
|
||||
scale: { type: "spring", stiffness: 300, damping: 20 },
|
||||
backgroundColor: { duration: 0.2, ease: "easeOut" },
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
@@ -50,7 +44,10 @@ export default function DashboardGrid({ sections = [] }) {
|
||||
>
|
||||
<Icon className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
|
||||
</motion.div>
|
||||
<motion.div className="p-4 font-medium mt-auto" whileHover={{ y: -2 }}>
|
||||
<motion.div
|
||||
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
{label}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -186,6 +186,7 @@ function SignOutOverlay({ open }) {
|
||||
// ─── Main UserMenu ────────────────────────────────────────────────────────────
|
||||
export default function UserMenu() {
|
||||
const { user, logout } = useAuth()
|
||||
const { setTheme } = useTheme()
|
||||
const { avatarUrl } = useProfile()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -207,6 +208,7 @@ export default function UserMenu() {
|
||||
const handleLogout = async () => {
|
||||
setSigningOut(true)
|
||||
await logout()
|
||||
setTheme('light')
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ function Progress({
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("size-full flex-1 transition-all", value >= 100 ? "bg-green-500" : "bg-primary")}
|
||||
className="size-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user