Spaces:
Runtime error
Runtime error
Commit ·
c565358
1
Parent(s): a52dee3
feat: Enhance showcase and vault pages with flexible header options, improved demo content handling, and an unauthenticated claim banner.
Browse files- src/app/showcase/[videoId]/page.tsx +54 -29
- src/app/vault/[creatorSlug]/page.tsx +1 -0
- src/features/vault/components/claim-vault-button.tsx +7 -11
- src/features/vault/components/unauthenticated-banner.tsx +37 -45
- src/features/vault/components/vault-header.tsx +22 -17
- src/features/vault/services/showcase.service.ts +40 -13
- src/features/vault/types/vault.types.ts +2 -0
src/app/showcase/[videoId]/page.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { auth } from "@/lib/auth";
|
|
| 19 |
import { headers } from "next/headers";
|
| 20 |
import { SearchBar } from '@/features/vault/components/search-bar';
|
| 21 |
import { AddToWorkbenchButton } from '@/features/vault/components/add-to-workbench-button';
|
|
|
|
| 22 |
|
| 23 |
interface ShowcasePageProps {
|
| 24 |
params: Promise<{
|
|
@@ -43,13 +44,11 @@ export default async function ShowcasePage({ params }: ShowcasePageProps) {
|
|
| 43 |
const videoTitle = vault.videos?.[0]?.title || "Showcase Video";
|
| 44 |
const editUrl = `/showcase/${videoId}/moderate`;
|
| 45 |
|
| 46 |
-
// Check ownership
|
| 47 |
-
// CRITICAL FIX: Allow authenticated users to view the showcase if it's currently
|
| 48 |
-
// a "demo" or "fallback" record (owned by demo-creator or similar).
|
| 49 |
const isDemoRecord = vault.channel.creatorId.startsWith('demo');
|
| 50 |
const isOwner = session?.user?.id === vault.channel.creatorId;
|
| 51 |
|
| 52 |
-
if (!
|
| 53 |
// Redirect to public vault page instead, signaling that it came from the showcase/claim flow
|
| 54 |
redirect(`/vault/${vault.channel.creatorSlug}/video/${videoId}?claim=true`);
|
| 55 |
}
|
|
@@ -66,37 +65,63 @@ export default async function ShowcasePage({ params }: ShowcasePageProps) {
|
|
| 66 |
return (
|
| 67 |
<Suspense fallback={<VaultGridSkeleton />}>
|
| 68 |
<div className="min-h-screen bg-background pb-20 overflow-x-hidden">
|
| 69 |
-
{/*
|
| 70 |
-
<div className="
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
-
) : <div />}
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
|
| 98 |
<div className="animate-in fade-in duration-700">
|
| 99 |
-
<VaultHeader
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</div>
|
| 101 |
|
| 102 |
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 -mt-6 relative z-20 space-y-8 animate-in slide-in-from-bottom-4 duration-700 delay-150">
|
|
@@ -165,6 +190,6 @@ export default async function ShowcasePage({ params }: ShowcasePageProps) {
|
|
| 165 |
)}
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
-
</Suspense
|
| 169 |
);
|
| 170 |
}
|
|
|
|
| 19 |
import { headers } from "next/headers";
|
| 20 |
import { SearchBar } from '@/features/vault/components/search-bar';
|
| 21 |
import { AddToWorkbenchButton } from '@/features/vault/components/add-to-workbench-button';
|
| 22 |
+
import { UnauthenticatedBanner } from '@/features/vault/components/unauthenticated-banner';
|
| 23 |
|
| 24 |
interface ShowcasePageProps {
|
| 25 |
params: Promise<{
|
|
|
|
| 44 |
const videoTitle = vault.videos?.[0]?.title || "Showcase Video";
|
| 45 |
const editUrl = `/showcase/${videoId}/moderate`;
|
| 46 |
|
| 47 |
+
// Check ownership or demo status
|
|
|
|
|
|
|
| 48 |
const isDemoRecord = vault.channel.creatorId.startsWith('demo');
|
| 49 |
const isOwner = session?.user?.id === vault.channel.creatorId;
|
| 50 |
|
| 51 |
+
if (!isDemoRecord && !isOwner) {
|
| 52 |
// Redirect to public vault page instead, signaling that it came from the showcase/claim flow
|
| 53 |
redirect(`/vault/${vault.channel.creatorSlug}/video/${videoId}?claim=true`);
|
| 54 |
}
|
|
|
|
| 65 |
return (
|
| 66 |
<Suspense fallback={<VaultGridSkeleton />}>
|
| 67 |
<div className="min-h-screen bg-background pb-20 overflow-x-hidden">
|
| 68 |
+
{/* Sticky Header Section */}
|
| 69 |
+
<div className="sticky top-0 z-50 flex flex-col">
|
| 70 |
+
{/* 1. Claim Banner (Full width, Centered) */}
|
| 71 |
+
{!isAuthenticated && isDemoRecord && (
|
| 72 |
+
<UnauthenticatedBanner
|
| 73 |
+
videoId={videoId}
|
| 74 |
+
creatorSlug={vault.channel.creatorSlug}
|
| 75 |
+
existingProductCount={products.length}
|
| 76 |
+
forceShow={true}
|
| 77 |
+
/>
|
| 78 |
+
)}
|
| 79 |
+
|
| 80 |
+
{/* 2. Navigation Bar (Container constrained to align with content, justify-between) */}
|
| 81 |
+
<div className="bg-background/80 backdrop-blur-md border-b border-border/40 py-2">
|
| 82 |
+
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between gap-2">
|
| 83 |
+
<div className="flex items-center gap-4">
|
| 84 |
+
<Link href="/" className="text-primary font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">Vault</Link>
|
| 85 |
+
|
| 86 |
+
{showAddButton && (
|
| 87 |
+
<>
|
| 88 |
+
<Badge variant="secondary" className="bg-amber-500/10 text-amber-600 border-amber-500/20 text-[10px] uppercase font-bold tracking-wider">
|
| 89 |
+
Preview Mode
|
| 90 |
+
</Badge>
|
| 91 |
+
<span className="text-xs text-muted-foreground hidden sm:inline">Add to workbench to save permanently</span>
|
| 92 |
+
</>
|
| 93 |
+
)}
|
| 94 |
</div>
|
|
|
|
| 95 |
|
| 96 |
+
<div className="flex items-center gap-2">
|
| 97 |
+
{showAddButton && (
|
| 98 |
+
<AddToWorkbenchButton videoId={videoId} />
|
| 99 |
+
)}
|
| 100 |
+
{isAuthenticated && (
|
| 101 |
+
<Button asChild size="sm" variant="ghost" className="rounded-full text-xs gap-1.5 text-muted-foreground hover:text-foreground">
|
| 102 |
+
<Link href="/dashboard">
|
| 103 |
+
<LayoutDashboard className="h-3.5 w-3.5" />
|
| 104 |
+
Dashboard
|
| 105 |
+
</Link>
|
| 106 |
+
</Button>
|
| 107 |
+
)}
|
| 108 |
+
<Button asChild size="sm" className="rounded-full border shadow-sm">
|
| 109 |
+
<Link href={`/vault/${vault.channel.creatorSlug}/video/${videoId}`}>View Public Page</Link>
|
| 110 |
+
</Button>
|
| 111 |
+
</div>
|
| 112 |
</div>
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
|
| 116 |
<div className="animate-in fade-in duration-700">
|
| 117 |
+
<VaultHeader
|
| 118 |
+
channel={vault.channel}
|
| 119 |
+
hideLogo={shouldHideLogo}
|
| 120 |
+
hideSubscribers={shouldHideLogo}
|
| 121 |
+
hideSearch={true}
|
| 122 |
+
hideBrand={true}
|
| 123 |
+
maxWidth="max-w-5xl"
|
| 124 |
+
/>
|
| 125 |
</div>
|
| 126 |
|
| 127 |
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 -mt-6 relative z-20 space-y-8 animate-in slide-in-from-bottom-4 duration-700 delay-150">
|
|
|
|
| 190 |
)}
|
| 191 |
</div>
|
| 192 |
</div>
|
| 193 |
+
</Suspense>
|
| 194 |
);
|
| 195 |
}
|
src/app/vault/[creatorSlug]/page.tsx
CHANGED
|
@@ -111,6 +111,7 @@ export default async function VaultPage({ params, searchParams }: PageProps) {
|
|
| 111 |
hideSearch={true}
|
| 112 |
hideLogo={isSocial}
|
| 113 |
hideSubscribers={isSocial}
|
|
|
|
| 114 |
/>
|
| 115 |
</div>
|
| 116 |
|
|
|
|
| 111 |
hideSearch={true}
|
| 112 |
hideLogo={isSocial}
|
| 113 |
hideSubscribers={isSocial}
|
| 114 |
+
maxWidth="max-w-5xl"
|
| 115 |
/>
|
| 116 |
</div>
|
| 117 |
|
src/features/vault/components/claim-vault-button.tsx
CHANGED
|
@@ -8,14 +8,16 @@ import { claimVideo } from '@/actions/claim-video';
|
|
| 8 |
import { signIn } from '@/lib/auth-client';
|
| 9 |
import { toast } from 'sonner';
|
| 10 |
import { useRouter } from 'next/navigation';
|
|
|
|
| 11 |
|
| 12 |
interface ClaimVaultButtonProps {
|
| 13 |
videoId: string;
|
| 14 |
creatorSlug: string;
|
| 15 |
existingCount?: number;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
-
export function ClaimVaultButton({ videoId, creatorSlug, existingCount = 0 }: ClaimVaultButtonProps) {
|
| 19 |
const router = useRouter();
|
| 20 |
const [isPending, startTransition] = useTransition();
|
| 21 |
const { tempProducts, getAllTemporaryProducts } = useTemporaryProducts(videoId);
|
|
@@ -23,15 +25,9 @@ export function ClaimVaultButton({ videoId, creatorSlug, existingCount = 0 }: Cl
|
|
| 23 |
const totalCount = existingCount + tempProducts.length;
|
| 24 |
|
| 25 |
const handleClaim = async () => {
|
| 26 |
-
//
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
callbackURL: `/vault/${creatorSlug}/video/${videoId}?claim=true`,
|
| 30 |
-
});
|
| 31 |
-
|
| 32 |
-
if (result.error) {
|
| 33 |
-
toast.error('Failed to sign in');
|
| 34 |
-
}
|
| 35 |
};
|
| 36 |
|
| 37 |
return (
|
|
@@ -39,7 +35,7 @@ export function ClaimVaultButton({ videoId, creatorSlug, existingCount = 0 }: Cl
|
|
| 39 |
onClick={handleClaim}
|
| 40 |
disabled={isPending}
|
| 41 |
size="sm"
|
| 42 |
-
className="rounded-full shadow-lg shadow-primary/20 gap-1.5"
|
| 43 |
>
|
| 44 |
<Sparkles className="h-3.5 w-3.5" />
|
| 45 |
{isPending ? 'Claiming...' : 'Claim Your Vault'}
|
|
|
|
| 8 |
import { signIn } from '@/lib/auth-client';
|
| 9 |
import { toast } from 'sonner';
|
| 10 |
import { useRouter } from 'next/navigation';
|
| 11 |
+
import { cn } from '@/lib/utils';
|
| 12 |
|
| 13 |
interface ClaimVaultButtonProps {
|
| 14 |
videoId: string;
|
| 15 |
creatorSlug: string;
|
| 16 |
existingCount?: number;
|
| 17 |
+
className?: string;
|
| 18 |
}
|
| 19 |
|
| 20 |
+
export function ClaimVaultButton({ videoId, creatorSlug, existingCount = 0, className }: ClaimVaultButtonProps) {
|
| 21 |
const router = useRouter();
|
| 22 |
const [isPending, startTransition] = useTransition();
|
| 23 |
const { tempProducts, getAllTemporaryProducts } = useTemporaryProducts(videoId);
|
|
|
|
| 25 |
const totalCount = existingCount + tempProducts.length;
|
| 26 |
|
| 27 |
const handleClaim = async () => {
|
| 28 |
+
// Redirect to sign-in page instead of direct social sign-in
|
| 29 |
+
const callbackUrl = `/vault/${creatorSlug}/video/${videoId}?claim=true`;
|
| 30 |
+
router.push(`/sign-in?callbackURL=${encodeURIComponent(callbackUrl)}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
};
|
| 32 |
|
| 33 |
return (
|
|
|
|
| 35 |
onClick={handleClaim}
|
| 36 |
disabled={isPending}
|
| 37 |
size="sm"
|
| 38 |
+
className={cn("rounded-full shadow-lg shadow-primary/20 gap-1.5", className)}
|
| 39 |
>
|
| 40 |
<Sparkles className="h-3.5 w-3.5" />
|
| 41 |
{isPending ? 'Claiming...' : 'Claim Your Vault'}
|
src/features/vault/components/unauthenticated-banner.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
-
import { Share2 } from 'lucide-react';
|
| 5 |
import Link from 'next/link';
|
| 6 |
import { useTemporaryProducts } from '@/features/vault/hooks/use-temporary-products';
|
| 7 |
import { useSearchParams } from 'next/navigation';
|
|
@@ -11,61 +11,53 @@ interface UnauthenticatedBannerProps {
|
|
| 11 |
videoId: string;
|
| 12 |
creatorSlug: string;
|
| 13 |
existingProductCount: number;
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
-
export function UnauthenticatedBanner({ videoId, creatorSlug, existingProductCount }: UnauthenticatedBannerProps) {
|
| 17 |
const searchParams = useSearchParams();
|
| 18 |
const { tempProducts } = useTemporaryProducts(videoId);
|
| 19 |
|
| 20 |
// Only show banner if:
|
| 21 |
-
// 1.
|
| 22 |
-
// 2. User
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
if (!shouldShow) return null;
|
| 26 |
|
| 27 |
-
const handleShare = async () => {
|
| 28 |
-
const url = `${window.location.origin}/vault/${creatorSlug}/video/${videoId}`;
|
| 29 |
-
if (navigator.share) {
|
| 30 |
-
try {
|
| 31 |
-
await navigator.share({
|
| 32 |
-
title: 'Check out this vault!',
|
| 33 |
-
url: url,
|
| 34 |
-
});
|
| 35 |
-
} catch (err) {
|
| 36 |
-
// User cancelled or share failed
|
| 37 |
-
console.log('Share cancelled');
|
| 38 |
-
}
|
| 39 |
-
} else {
|
| 40 |
-
// Fallback: copy to clipboard
|
| 41 |
-
await navigator.clipboard.writeText(url);
|
| 42 |
-
// Could add a toast notification here
|
| 43 |
-
}
|
| 44 |
-
};
|
| 45 |
-
|
| 46 |
return (
|
| 47 |
-
<div className="
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
</div>
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
</
|
| 64 |
-
<ClaimVaultButton
|
| 65 |
-
videoId={videoId}
|
| 66 |
-
creatorSlug={creatorSlug}
|
| 67 |
-
existingCount={existingProductCount}
|
| 68 |
-
/>
|
| 69 |
</div>
|
| 70 |
</div>
|
| 71 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
+
import { Share2, Sparkles, ArrowRight } from 'lucide-react';
|
| 5 |
import Link from 'next/link';
|
| 6 |
import { useTemporaryProducts } from '@/features/vault/hooks/use-temporary-products';
|
| 7 |
import { useSearchParams } from 'next/navigation';
|
|
|
|
| 11 |
videoId: string;
|
| 12 |
creatorSlug: string;
|
| 13 |
existingProductCount: number;
|
| 14 |
+
forceShow?: boolean;
|
| 15 |
}
|
| 16 |
|
| 17 |
+
export function UnauthenticatedBanner({ videoId, creatorSlug, existingProductCount, forceShow }: UnauthenticatedBannerProps) {
|
| 18 |
const searchParams = useSearchParams();
|
| 19 |
const { tempProducts } = useTemporaryProducts(videoId);
|
| 20 |
|
| 21 |
// Only show banner if:
|
| 22 |
+
// 1. forceShow is true (explicitly requested)
|
| 23 |
+
// 2. User was just redirected from the "Quick Analyze" flow (claim=true param)
|
| 24 |
+
// 3. User has added temporary products to this video
|
| 25 |
+
const shouldShow = forceShow || searchParams.get('claim') === 'true' || tempProducts.length > 0;
|
| 26 |
|
| 27 |
if (!shouldShow) return null;
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
return (
|
| 30 |
+
<div className="relative group overflow-hidden">
|
| 31 |
+
{/* Animated Gradient Background */}
|
| 32 |
+
<div className="absolute inset-0 bg-gradient-to-r from-indigo-600 via-violet-600 to-primary opacity-90 transition-all duration-700 group-hover:opacity-100" />
|
| 33 |
+
|
| 34 |
+
{/* Glassmorphism Overlay */}
|
| 35 |
+
<div className="absolute inset-0 backdrop-blur-md bg-black/10" />
|
| 36 |
+
|
| 37 |
+
<div className="container relative mx-auto px-4 py-3.5 flex flex-col sm:flex-row items-center justify-center gap-4 z-10">
|
| 38 |
+
<div className="flex items-center gap-3">
|
| 39 |
+
<div className="p-2 bg-white/20 rounded-full animate-pulse shadow-lg shadow-white/5">
|
| 40 |
+
<Sparkles className="h-4 w-4 text-white" />
|
| 41 |
+
</div>
|
| 42 |
+
<div className="flex flex-col">
|
| 43 |
+
<span className="text-sm font-bold text-white tracking-tight text-center sm:text-left">
|
| 44 |
+
Like what you see?
|
| 45 |
+
</span>
|
| 46 |
+
<span className="text-[11px] text-white/80 font-medium text-center sm:text-left">
|
| 47 |
+
Transform these results into your private vault 💎
|
| 48 |
+
</span>
|
| 49 |
+
</div>
|
| 50 |
</div>
|
| 51 |
+
|
| 52 |
+
<div className="flex items-center gap-2 w-full sm:w-auto justify-center">
|
| 53 |
+
<div className="flex-1 sm:flex-none max-w-[200px]">
|
| 54 |
+
<ClaimVaultButton
|
| 55 |
+
videoId={videoId}
|
| 56 |
+
creatorSlug={creatorSlug}
|
| 57 |
+
existingCount={existingProductCount}
|
| 58 |
+
className="w-full sm:w-auto bg-white text-indigo-600 hover:bg-neutral-100 border-none shadow-xl shadow-indigo-500/20 font-bold group/btn"
|
| 59 |
+
/>
|
| 60 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
</div>
|
src/features/vault/components/vault-header.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
|
| 9 |
import { toast } from 'sonner';
|
| 10 |
import { SearchBar } from './search-bar';
|
| 11 |
import { UserNav } from '@/components/auth/user-nav';
|
|
|
|
| 12 |
import type { VaultHeaderProps } from '../types/vault.types';
|
| 13 |
|
| 14 |
function formatSubscriberCount(count: number | null): string {
|
|
@@ -28,7 +29,9 @@ export function VaultHeader({
|
|
| 28 |
dashboardUrl,
|
| 29 |
hideLogo,
|
| 30 |
hideSubscribers,
|
| 31 |
-
hideSearch
|
|
|
|
|
|
|
| 32 |
}: VaultHeaderProps & { dashboardUrl?: string }) {
|
| 33 |
const handleShare = async () => {
|
| 34 |
const url = window.location.href;
|
|
@@ -62,25 +65,27 @@ export function VaultHeader({
|
|
| 62 |
|
| 63 |
return (
|
| 64 |
<div className="bg-background/80 backdrop-blur-md border-b border-border/40 px-4 py-8 md:px-6">
|
| 65 |
-
<div className="mx-auto
|
| 66 |
|
| 67 |
{/* Ownership/Action Banner */}
|
| 68 |
-
|
| 69 |
-
<div className="flex items-center
|
| 70 |
-
<
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
<
|
| 76 |
-
<
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
-
|
| 84 |
|
| 85 |
{/* Creator Info Section */}
|
| 86 |
<div className="flex flex-col items-center gap-4">
|
|
|
|
| 9 |
import { toast } from 'sonner';
|
| 10 |
import { SearchBar } from './search-bar';
|
| 11 |
import { UserNav } from '@/components/auth/user-nav';
|
| 12 |
+
import { cn } from '@/lib/utils';
|
| 13 |
import type { VaultHeaderProps } from '../types/vault.types';
|
| 14 |
|
| 15 |
function formatSubscriberCount(count: number | null): string {
|
|
|
|
| 29 |
dashboardUrl,
|
| 30 |
hideLogo,
|
| 31 |
hideSubscribers,
|
| 32 |
+
hideSearch,
|
| 33 |
+
hideBrand,
|
| 34 |
+
maxWidth = 'max-w-7xl'
|
| 35 |
}: VaultHeaderProps & { dashboardUrl?: string }) {
|
| 36 |
const handleShare = async () => {
|
| 37 |
const url = window.location.href;
|
|
|
|
| 65 |
|
| 66 |
return (
|
| 67 |
<div className="bg-background/80 backdrop-blur-md border-b border-border/40 px-4 py-8 md:px-6">
|
| 68 |
+
<div className={cn("mx-auto flex flex-col items-center text-center space-y-6", maxWidth)}>
|
| 69 |
|
| 70 |
{/* Ownership/Action Banner */}
|
| 71 |
+
{!hideBrand && (
|
| 72 |
+
<div className="w-full flex justify-between items-center -mb-4">
|
| 73 |
+
<div className="flex items-center gap-2">
|
| 74 |
+
<Link href="/" className="text-primary font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">Vault</Link>
|
| 75 |
+
</div>
|
| 76 |
+
<div className="flex items-center gap-4">
|
| 77 |
+
{dashboardUrl && (
|
| 78 |
+
<Button variant="ghost" size="sm" asChild className="text-muted-foreground hover:text-primary gap-2">
|
| 79 |
+
<Link href={dashboardUrl}>
|
| 80 |
+
<LayoutDashboard className="h-4 w-4" />
|
| 81 |
+
Dashboard
|
| 82 |
+
</Link>
|
| 83 |
+
</Button>
|
| 84 |
+
)}
|
| 85 |
+
<UserNav />
|
| 86 |
+
</div>
|
| 87 |
</div>
|
| 88 |
+
)}
|
| 89 |
|
| 90 |
{/* Creator Info Section */}
|
| 91 |
<div className="flex flex-col items-center gap-4">
|
src/features/vault/services/showcase.service.ts
CHANGED
|
@@ -32,11 +32,13 @@ export class ShowcaseService {
|
|
| 32 |
thumbnailUrl: youtubeChannels.thumbnailUrl,
|
| 33 |
videoId: youtubeVideos.videoId,
|
| 34 |
userName: users.name,
|
| 35 |
-
userImage: users.image
|
|
|
|
| 36 |
})
|
| 37 |
.from(youtubeVideos)
|
| 38 |
.innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
|
| 39 |
.leftJoin(users, eq(youtubeChannels.creatorId, users.id))
|
|
|
|
| 40 |
.where(
|
| 41 |
and(
|
| 42 |
inArray(youtubeVideos.videoId, videoIdVariations),
|
|
@@ -44,20 +46,37 @@ export class ShowcaseService {
|
|
| 44 |
excludeCreatorId ? ne(youtubeChannels.creatorId, excludeCreatorId) : undefined
|
| 45 |
)
|
| 46 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
.limit(10);
|
| 48 |
|
| 49 |
// Map and deduplicate by creatorId
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
const uniqueMatches = Array.from(new Map(result.map(m => [m.creatorId, m])).values());
|
| 63 |
return uniqueMatches;
|
|
@@ -146,21 +165,29 @@ export class ShowcaseService {
|
|
| 146 |
.select({
|
| 147 |
video: youtubeVideos,
|
| 148 |
channel: youtubeChannels,
|
|
|
|
|
|
|
| 149 |
})
|
| 150 |
.from(youtubeVideos)
|
| 151 |
.innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
|
|
|
|
| 152 |
.where(
|
| 153 |
and(
|
| 154 |
inArray(youtubeVideos.videoId, videoIdVariations),
|
| 155 |
-
|
| 156 |
)
|
| 157 |
)
|
| 158 |
.limit(1);
|
| 159 |
|
| 160 |
const video = videoResult[0]?.video;
|
| 161 |
const channel = videoResult[0]?.channel;
|
|
|
|
|
|
|
| 162 |
|
| 163 |
if (video && channel) {
|
|
|
|
|
|
|
|
|
|
| 164 |
// Attach channel for the 'with' equivalent
|
| 165 |
const videoWithChannel = { ...video, channel };
|
| 166 |
// Self-healing: If subscriberCount is missing or 0, trigger a background refresh
|
|
|
|
| 32 |
thumbnailUrl: youtubeChannels.thumbnailUrl,
|
| 33 |
videoId: youtubeVideos.videoId,
|
| 34 |
userName: users.name,
|
| 35 |
+
userImage: users.image,
|
| 36 |
+
approvedProductCount: sql<number>`COUNT(DISTINCT CASE WHEN ${detectedObjects.moderationStatus} = 'APPROVED' THEN ${detectedObjects.id} END)`
|
| 37 |
})
|
| 38 |
.from(youtubeVideos)
|
| 39 |
.innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
|
| 40 |
.leftJoin(users, eq(youtubeChannels.creatorId, users.id))
|
| 41 |
+
.leftJoin(detectedObjects, eq(detectedObjects.videoId, youtubeVideos.id))
|
| 42 |
.where(
|
| 43 |
and(
|
| 44 |
inArray(youtubeVideos.videoId, videoIdVariations),
|
|
|
|
| 46 |
excludeCreatorId ? ne(youtubeChannels.creatorId, excludeCreatorId) : undefined
|
| 47 |
)
|
| 48 |
)
|
| 49 |
+
.groupBy(
|
| 50 |
+
youtubeChannels.creatorId,
|
| 51 |
+
youtubeChannels.channelName,
|
| 52 |
+
youtubeChannels.creatorSlug,
|
| 53 |
+
youtubeChannels.thumbnailUrl,
|
| 54 |
+
youtubeVideos.videoId,
|
| 55 |
+
users.name,
|
| 56 |
+
users.image
|
| 57 |
+
)
|
| 58 |
.limit(10);
|
| 59 |
|
| 60 |
// Map and deduplicate by creatorId
|
| 61 |
+
// Also filter: Demo users MUST have at least 1 product
|
| 62 |
+
const result = matches
|
| 63 |
+
.filter(m => {
|
| 64 |
+
if (m.creatorId === DEMO_USER_ID) {
|
| 65 |
+
return Number(m.approvedProductCount) > 0;
|
| 66 |
+
}
|
| 67 |
+
return true;
|
| 68 |
+
})
|
| 69 |
+
.map(m => {
|
| 70 |
+
const isDemo = m.creatorId === DEMO_USER_ID;
|
| 71 |
+
return {
|
| 72 |
+
creatorId: m.creatorId,
|
| 73 |
+
name: isDemo ? "Vault Preview" : (m.userName || m.channelName),
|
| 74 |
+
avatar: isDemo ? undefined : (m.userImage || m.thumbnailUrl),
|
| 75 |
+
creatorSlug: m.creatorSlug,
|
| 76 |
+
videoId: m.videoId,
|
| 77 |
+
isDemo
|
| 78 |
+
};
|
| 79 |
+
});
|
| 80 |
|
| 81 |
const uniqueMatches = Array.from(new Map(result.map(m => [m.creatorId, m])).values());
|
| 82 |
return uniqueMatches;
|
|
|
|
| 165 |
.select({
|
| 166 |
video: youtubeVideos,
|
| 167 |
channel: youtubeChannels,
|
| 168 |
+
userName: users.name,
|
| 169 |
+
userImage: users.image,
|
| 170 |
})
|
| 171 |
.from(youtubeVideos)
|
| 172 |
.innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
|
| 173 |
+
.leftJoin(users, eq(youtubeChannels.creatorId, users.id))
|
| 174 |
.where(
|
| 175 |
and(
|
| 176 |
inArray(youtubeVideos.videoId, videoIdVariations),
|
| 177 |
+
eq(youtubeChannels.creatorId, userId || DEMO_USER_ID)
|
| 178 |
)
|
| 179 |
)
|
| 180 |
.limit(1);
|
| 181 |
|
| 182 |
const video = videoResult[0]?.video;
|
| 183 |
const channel = videoResult[0]?.channel;
|
| 184 |
+
const userName = videoResult[0]?.userName;
|
| 185 |
+
const userImage = videoResult[0]?.userImage;
|
| 186 |
|
| 187 |
if (video && channel) {
|
| 188 |
+
// Override channel info with human name if available
|
| 189 |
+
if (userName) channel.channelName = userName;
|
| 190 |
+
if (userImage) channel.thumbnailUrl = userImage;
|
| 191 |
// Attach channel for the 'with' equivalent
|
| 192 |
const videoWithChannel = { ...video, channel };
|
| 193 |
// Self-healing: If subscriberCount is missing or 0, trigger a background refresh
|
src/features/vault/types/vault.types.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface VaultHeaderProps {
|
|
| 54 |
hideLogo?: boolean;
|
| 55 |
hideSubscribers?: boolean;
|
| 56 |
hideSearch?: boolean;
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
|
|
|
|
| 54 |
hideLogo?: boolean;
|
| 55 |
hideSubscribers?: boolean;
|
| 56 |
hideSearch?: boolean;
|
| 57 |
+
hideBrand?: boolean;
|
| 58 |
+
maxWidth?: string;
|
| 59 |
}
|
| 60 |
|
| 61 |
|