dvijaykrishnan commited on
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 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: only the creator who owns this video can access showcase
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 (!isAuthenticated || (!isOwner && !isDemoRecord)) {
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
- {/* Demo Banner */}
70
- <div className="bg-primary/10 border-b border-primary/20 backdrop-blur-md sticky top-0 z-50">
71
- <div className="container mx-auto px-4 py-3 flex items-center justify-between gap-2">
72
- {showAddButton ? (
73
- <div className="flex items-center gap-2">
74
- <Badge variant="secondary" className="bg-amber-500/10 text-amber-600 border-amber-500/20 text-[10px] uppercase font-bold tracking-wider">
75
- Preview Mode
76
- </Badge>
77
- <span className="text-xs text-muted-foreground hidden sm:inline">Add to workbench to save permanently</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  </div>
79
- ) : <div />}
80
 
81
- <div className="flex items-center gap-2">
82
- {showAddButton && (
83
- <AddToWorkbenchButton videoId={videoId} />
84
- )}
85
- <Button asChild size="sm" variant="ghost" className="rounded-full text-xs gap-1.5 text-muted-foreground hover:text-foreground">
86
- <Link href="/dashboard">
87
- <LayoutDashboard className="h-3.5 w-3.5" />
88
- Dashboard
89
- </Link>
90
- </Button>
91
- <Button asChild size="sm" className="rounded-full shadow-lg shadow-primary/20">
92
- <Link href={`/vault/${vault.channel.creatorSlug}/video/${videoId}`}>View Public Page</Link>
93
- </Button>
 
 
 
94
  </div>
95
  </div>
96
  </div>
97
 
98
  <div className="animate-in fade-in duration-700">
99
- <VaultHeader channel={vault.channel} hideLogo={shouldHideLogo} hideSubscribers={shouldHideLogo} hideSearch={true} />
 
 
 
 
 
 
 
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
- // First, sign in
27
- const result = await signIn.social({
28
- provider: 'google',
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. User was just redirected from the "Quick Analyze" flow (claim=true param)
22
- // 2. User has added temporary products to this video
23
- const shouldShow = searchParams.get('claim') === 'true' || tempProducts.length > 0;
 
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="bg-primary/10 border-b border-primary/20 backdrop-blur-md sticky top-0 z-50">
48
- <div className="container mx-auto px-4 py-3 flex items-center justify-between gap-4">
49
- <div className="flex items-center gap-2">
50
- <span className="text-sm font-medium text-foreground/80">
51
- Like what you see? Create your own vault to monetize your content.
52
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  </div>
54
- <div className="flex items-center gap-2">
55
- <Button
56
- onClick={handleShare}
57
- size="sm"
58
- variant="ghost"
59
- className="rounded-full text-xs gap-1.5"
60
- >
61
- <Share2 className="h-3.5 w-3.5" />
62
- <span className="hidden sm:inline">Share</span>
63
- </Button>
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 max-w-7xl flex flex-col items-center text-center space-y-6">
66
 
67
  {/* Ownership/Action Banner */}
68
- <div className="w-full flex justify-between items-center -mb-4">
69
- <div className="flex items-center gap-2">
70
- <Link href="/" className="text-primary font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">Vault</Link>
71
- </div>
72
- <div className="flex items-center gap-4">
73
- {dashboardUrl && (
74
- <Button variant="ghost" size="sm" asChild className="text-muted-foreground hover:text-primary gap-2">
75
- <Link href={dashboardUrl}>
76
- <LayoutDashboard className="h-4 w-4" />
77
- Dashboard
78
- </Link>
79
- </Button>
80
- )}
81
- <UserNav />
 
 
82
  </div>
83
- </div>
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
- const result = matches.map(m => {
51
- const isDemo = m.creatorId === DEMO_USER_ID;
52
- return {
53
- creatorId: m.creatorId,
54
- name: isDemo ? "Vault Preview" : (m.userName || m.channelName),
55
- avatar: isDemo ? undefined : (m.userImage || m.thumbnailUrl),
56
- creatorSlug: m.creatorSlug,
57
- videoId: m.videoId,
58
- isDemo
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
- userId ? eq(youtubeChannels.creatorId, userId) : undefined
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