Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions __tests__/lib/routes-f/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getPeriodKey } from "@/lib/routes-f/format";

describe("getPeriodKey", () => {
it("groups by day", () => {
expect(getPeriodKey("2026-03-27T10:00:00.000Z", "day")).toBe("2026-03-27");
});

it("groups by week using monday as the start of the week", () => {
expect(getPeriodKey("2026-03-27T10:00:00.000Z", "week")).toBe("2026-03-23");
});

it("groups by month", () => {
expect(getPeriodKey("2026-03-27T10:00:00.000Z", "month")).toBe(
"2026-03-01"
);
});
});
15 changes: 15 additions & 0 deletions app/(landing)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import About from "@/components/landing-page/about";
import Benefits from "@/components/landing-page/Benefits";
import Community from "@/components/landing-page/Community";
Expand All @@ -9,6 +10,20 @@ import StreamTokenUtility from "@/components/landing-page/stream-token-utility";
import Testimonials from "@/components/landing-page/Testimonials";
import Waitlist from "@/components/landing-page/Waitlist";

export const metadata: Metadata = {
// absolute bypasses the root layout template ("%s | StreamFi") to avoid duplication
title: { absolute: "StreamFi – Own Your Stream. Own Your Earnings" },
description:
"Stream without limits, engage your community, and earn instantly with a blockchain-powered ecosystem that ensures true ownership, decentralized rewards, and frictionless transactions.",
openGraph: {
title: "StreamFi – Own Your Stream. Own Your Earnings",
description:
"Stream without limits, engage your community, and earn instantly with a blockchain-powered ecosystem that ensures true ownership, decentralized rewards, and frictionless transactions.",
url: "https://www.streamfi.media",
type: "website",
},
};

export default function Home() {
return (
<div className="relative w-full bg-[#07060f] min-h-screen overflow-x-hidden">
Expand Down
10 changes: 6 additions & 4 deletions app/[username]/UsernameLayoutClient.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import React, { useState, useEffect } from "react";
import { notFound, usePathname, useRouter } from "next/navigation";
import { notFound, usePathname } from "next/navigation";
import { toast } from "sonner";

import Banner from "@/components/shared/profile/Banner";
Expand All @@ -23,10 +23,9 @@
username,
}: UsernameLayoutClientProps) {
const pathname = usePathname();
const router = useRouter();

const [isLive, setIsLive] = useState<boolean | null>(null);
const [userData, setUserData] = useState<any>(null);

Check warning on line 28 in app/[username]/UsernameLayoutClient.tsx

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type

const [userExists, setUserExists] = useState(true);
const [showWalletModal, setShowWalletModal] = useState(false);
Expand All @@ -51,9 +50,11 @@
const isOwner = loggedInUsername?.toLowerCase() === username.toLowerCase();

// When the user visits /{username} and they're live, redirect to the canonical watch URL.
// Use window.location to avoid the RSC fetch that router.replace() triggers, which can
// fail with "Failed to fetch" when Turbopack hasn't compiled the route yet.
useEffect(() => {
if (isDefaultRoute && isLive === true) {
router.replace(`/${username}/watch`);
window.location.replace(`/${username}/watch`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDefaultRoute, isLive, username]);
Expand Down Expand Up @@ -189,7 +190,7 @@
// /watch and /clips/[id] routes: render children without the profile banner/header/tabs overlay
if (isWatchRoute || isClipRoute) {
return (
<div className="flex flex-col h-dvh bg-secondary text-foreground">
<div className="flex flex-col h-full bg-secondary text-foreground">
{/* Watch: overflow-hidden so ViewStream manages its own internal scroll.
Clips: overflow-y-auto so the page can scroll normally. */}
<main
Expand All @@ -211,6 +212,7 @@
streamTitle={
userData?.creator?.streamTitle || userData?.creator?.title
}
bannerUrl={userData?.banner}
/>
<ProfileHeader
username={username}
Expand Down
63 changes: 63 additions & 0 deletions app/[username]/clips/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { sql } from "@vercel/postgres";

const BASE = "https://www.streamfi.media";

interface Props {
children: ReactNode;
params: Promise<{ username: string; id: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { username, id } = await params;

try {
const { rows } = await sql`
SELECT r.playback_id, r.title, u.avatar, u.bio
FROM stream_recordings r
JOIN users u ON u.id = r.user_id
WHERE r.id = ${id} AND r.status = 'ready'
LIMIT 1
`;
const rec = rows[0];
if (!rec) {
return { title: `${username} – StreamFi` };
}

const recTitle: string = rec.title ?? `${username}'s Past Stream`;
const description: string =
rec.bio?.trim() || `Watch ${username}'s past stream on StreamFi.`;
const pageUrl = `${BASE}/${username}/clips/${id}`;

// Use Mux thumbnail as the OG image — real video frame, best for sharing
const thumbUrl = `https://image.mux.com/${rec.playback_id}/thumbnail.jpg?width=1280&height=720&fit_mode=smartcrop`;

const images = [{ url: thumbUrl, width: 1280, height: 720, alt: recTitle }];

return {
title: `${recTitle} – ${username} | StreamFi`,
description,
alternates: { canonical: pageUrl },
openGraph: {
title: `${recTitle} – ${username}`,
description,
url: pageUrl,
type: "video.other",
images,
},
twitter: {
card: "summary_large_image",
title: `${recTitle} – ${username}`,
description,
images: [thumbUrl],
},
};
} catch {
return { title: `${username} – StreamFi` };
}
}

export default function ClipLayout({ children }: Props) {
return <>{children}</>;
}
208 changes: 208 additions & 0 deletions app/[username]/clips/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { ImageResponse } from "next/og";
import { sql } from "@vercel/postgres";

export const runtime = "nodejs";
export const contentType = "image/png";
export const size = { width: 1200, height: 630 };

interface Props {
params: Promise<{ username: string; id: string }>;
}

export default async function Image({ params }: Props) {
const { username, id } = await params;

let playbackId: string | null = null;
let recTitle: string | null = null;
let avatarUrl: string | null = null;
let displayName = username;

try {
const { rows } = await sql`
SELECT r.playback_id, r.title, u.username, u.avatar
FROM stream_recordings r
JOIN users u ON u.id = r.user_id
WHERE r.id = ${id} AND r.status = 'ready'
LIMIT 1
`;
const rec = rows[0];
if (rec) {
playbackId = rec.playback_id ?? null;
recTitle = rec.title ?? null;
avatarUrl = rec.avatar ?? null;
displayName = rec.username ?? username;
}
} catch {
// Fall through to defaults
}

const thumbUrl = playbackId
? `https://image.mux.com/${playbackId}/thumbnail.jpg?width=1200&height=630&fit_mode=smartcrop`
: null;

const title = recTitle ?? `${displayName}'s Past Stream`;

return new ImageResponse(
<div
style={{
width: "1200px",
height: "630px",
display: "flex",
position: "relative",
fontFamily: "sans-serif",
overflow: "hidden",
background: "#07060f",
}}
>
{/* Mux video thumbnail as full background */}
{thumbUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumbUrl}
alt=""
width={1200}
height={630}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}

{/* Dark gradient overlay — bottom-heavy so text stays readable */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(to top, rgba(7,6,15,0.95) 0%, rgba(7,6,15,0.5) 50%, rgba(7,6,15,0.15) 100%)",
display: "flex",
}}
/>

{/* Purple glow accent */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: "60%",
background:
"radial-gradient(ellipse at 20% 100%, rgba(172,57,242,0.2) 0%, transparent 60%)",
display: "flex",
}}
/>

{/* Content anchored to bottom-left */}
<div
style={{
position: "absolute",
bottom: "50px",
left: "60px",
right: "60px",
display: "flex",
flexDirection: "column",
gap: "0px",
}}
>
{/* Streamer row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
marginBottom: "16px",
}}
>
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarUrl}
alt={displayName}
width={56}
height={56}
style={{
borderRadius: "50%",
border: "2.5px solid #ac39f2",
objectFit: "cover",
}}
/>
) : (
<div
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
background: "#ac39f2",
border: "2.5px solid #ac39f2",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "24px",
fontWeight: "700",
color: "white",
}}
>
{displayName.charAt(0).toUpperCase()}
</div>
)}
<span
style={{
fontSize: "28px",
fontWeight: "600",
color: "rgba(255,255,255,0.85)",
}}
>
{displayName}
</span>
</div>

{/* Recording title */}
<div
style={{
fontSize: "46px",
fontWeight: "800",
color: "white",
lineHeight: "1.15",
display: "flex",
}}
>
{title.length > 60 ? title.slice(0, 57) + "…" : title}
</div>
</div>

{/* StreamFi wordmark */}
<div
style={{
position: "absolute",
top: "44px",
right: "56px",
fontSize: "22px",
fontWeight: "700",
color: "#ac39f2",
display: "flex",
}}
>
StreamFi
</div>

{/* Bottom purple line */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: "4px",
background: "linear-gradient(90deg, #ac39f2 0%, #7c3aed 100%)",
display: "flex",
}}
/>
</div>,
{ ...size }
);
}
Loading
Loading