Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ AI-powered sponsorship matching for YouTube Shorts. Connects Shopify merchants w

🏆 **Hack the North 2025 Finalist** · [Live Demo](https://www.maatchaa.co)

<img width="1904" height="956" alt="image" src="https://github.com/user-attachments/assets/56a661cd-bcfe-49f5-8214-430c3a636b34" />

## The Problem

Small Shopify brands don't have the budget for influencer marketing agencies, and micro-creators with engaged audiences have no streamlined way to find brand deals. Manually searching through thousands of YouTube Shorts to find creators whose content matches a product's aesthetic doesn't scale. Maatchaa automates that matching using AI embeddings so both sides can find each other in minutes instead of weeks.
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/app/api/products/resync/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';

/**
* Products resync endpoint.
*
* A full re-sync from Shopify (re-fetching the catalog + regenerating embeddings)
* is performed by the Python backend worker. When that backend is configured,
* `fetchWithFallback` calls it first and only falls back to this route when it is
* unavailable. This fallback acknowledges the request gracefully so the UI can
* simply refresh the already-synced catalog from Supabase instead of erroring
* with a 404 (the previous behavior, since no such route existed).
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));

if (!body.company_id) {
return NextResponse.json({ error: 'company_id is required' }, { status: 400 });
}

const backendConfigured = Boolean(process.env.NEXT_PUBLIC_API_URL);

return NextResponse.json({
success: true,
synced: false,
message: backendConfigured
? 'Resync requested. Showing the latest synced catalog.'
: 'Live Shopify resync is handled by the sync service (currently offline). Showing the latest synced catalog.',
});
} catch (error) {
console.error('Error in products resync:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
6 changes: 3 additions & 3 deletions frontend/src/app/dashboard/communications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const mockConversations: Conversation[] = [
creatorName: "Sarah Chen",
creatorHandle: "@teawithsarah",
creatorEmail: "sarah@teawithsarah.com",
creatorAvatar: "/cooking-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
followers: "125K",
status: "responded",
lastMessage: "I'd love to feature your ceremonial matcha in my morning routine videos!",
Expand Down Expand Up @@ -120,7 +120,7 @@ const mockConversations: Conversation[] = [
creatorName: "Alex Tanaka",
creatorHandle: "@mindfulmixology",
creatorEmail: "alex@mindfulmixology.com",
creatorAvatar: "/fitness-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
followers: "89K",
status: "partnered",
lastMessage: "Just posted the video! The matcha whisk works perfectly.",
Expand Down Expand Up @@ -159,7 +159,7 @@ const mockConversations: Conversation[] = [
creatorName: "Emma Lifestyle",
creatorHandle: "@emmalifestyle",
creatorEmail: "contact@emmalifestyle.com",
creatorAvatar: "/tech-channel-avatar.png",
creatorAvatar: "/placeholder-logo.svg",
followers: "234K",
status: "pending",
lastMessage: "We'd love to partner with you to feature our Le Labo collection...",
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/app/dashboard/help/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import { Card, Flex, Text, Box, Heading, Link as RadixLink } from "@radix-ui/themes";
import DashboardLayout from "@/components/dashboard/DashboardLayout";

const faqs = [
{
q: "How does matching work?",
a: "We generate vector embeddings of your product catalog and of creator videos, then rank creators by similarity so you see the best-fit matches first in Discover Shorts.",
},
{
q: "How do I connect my Shopify store?",
a: "Head to Get Started and enter your store URL to begin the secure Shopify OAuth flow. Your products sync automatically once connected.",
},
{
q: "How do partnerships progress?",
a: "Swipe right on a creator to create a partnership, then move it through To Contact → Contacted → In Discussion → Active on the Partnerships board.",
},
];

export default function HelpPage() {
return (
<DashboardLayout>
<Flex direction="column" gap="6" style={{ maxWidth: "760px" }}>
<Box>
<Heading size="8" weight="bold">Help & Support</Heading>
<Text size="3" color="gray" style={{ display: "block", marginTop: "0.5rem" }}>
Answers to common questions, and how to reach us.
</Text>
</Box>

<Flex direction="column" gap="3">
{faqs.map((f) => (
<Card key={f.q} style={{ padding: "1.25rem" }}>
<Text size="3" weight="medium" style={{ display: "block", marginBottom: "0.5rem" }}>
{f.q}
</Text>
<Text size="2" color="gray">{f.a}</Text>
</Card>
))}
</Flex>

<Card style={{ padding: "1.25rem" }}>
<Text size="3" weight="medium" style={{ display: "block", marginBottom: "0.5rem" }}>
Still need help?
</Text>
<Text size="2" color="gray">
Email us at{" "}
<RadixLink href="mailto:support@maatchaa.co">support@maatchaa.co</RadixLink>{" "}
and we&apos;ll get back to you.
</Text>
</Card>
</Flex>
</DashboardLayout>
);
}
20 changes: 10 additions & 10 deletions frontend/src/app/dashboard/partnerships/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const mockPartnerships: Partnership[] = [
id: "1",
creatorName: "Sarah Chen",
creatorHandle: "@teawithsarah",
creatorAvatar: "/cooking-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "My Morning Matcha Ritual (Life-Changing)",
videoThumbnail: "/youtube-shorts-cooking-video.jpg",
videoUrl: "https://youtube.com/shorts/example1",
Expand Down Expand Up @@ -126,7 +126,7 @@ const mockPartnerships: Partnership[] = [
id: "2",
creatorName: "Alex Tanaka",
creatorHandle: "@mindfulmixology",
creatorAvatar: "/fitness-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Why I Switched to Ceremonial Grade Matcha",
videoThumbnail: "/fitness-workout-video.png",
videoUrl: "https://youtube.com/shorts/example2",
Expand All @@ -144,7 +144,7 @@ const mockPartnerships: Partnership[] = [
id: "3",
creatorName: "Emma Lifestyle",
creatorHandle: "@emmalifestyle",
creatorAvatar: "/tech-channel-avatar.png",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Bougie Morning Routine Essentials",
videoThumbnail: "/tech-review-video.jpg",
videoUrl: "https://youtube.com/shorts/example3",
Expand All @@ -162,7 +162,7 @@ const mockPartnerships: Partnership[] = [
id: "4",
creatorName: "James Brewer",
creatorHandle: "@coffeewjames",
creatorAvatar: "/cooking-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Pro Barista Tools You Actually Need",
videoThumbnail: "/youtube-shorts-cooking-video.jpg",
videoUrl: "https://youtube.com/shorts/example4",
Expand All @@ -180,7 +180,7 @@ const mockPartnerships: Partnership[] = [
id: "5",
creatorName: "Maya Johnson",
creatorHandle: "@mayacooks",
creatorAvatar: "/cooking-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Easy Matcha Latte Recipe",
videoThumbnail: "/youtube-shorts-cooking-video.jpg",
videoUrl: "https://youtube.com/shorts/example5",
Expand All @@ -198,7 +198,7 @@ const mockPartnerships: Partnership[] = [
id: "6",
creatorName: "David Park",
creatorHandle: "@davidfitness",
creatorAvatar: "/fitness-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Pre-Workout Matcha Energy Boost",
videoThumbnail: "/fitness-workout-video.png",
videoUrl: "https://youtube.com/shorts/example6",
Expand All @@ -220,7 +220,7 @@ const mockPartnerships: Partnership[] = [
id: "7",
creatorName: "Sophie Laurent",
creatorHandle: "@sophiestyle",
creatorAvatar: "/tech-channel-avatar.png",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Aesthetic Desk Setup Essentials",
videoThumbnail: "/tech-review-video.jpg",
videoUrl: "https://youtube.com/shorts/example7",
Expand All @@ -242,7 +242,7 @@ const mockPartnerships: Partnership[] = [
id: "8",
creatorName: "Ryan Mitchell",
creatorHandle: "@ryaneats",
creatorAvatar: "/cooking-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Ultimate Matcha Taste Test",
videoThumbnail: "/youtube-shorts-cooking-video.jpg",
videoUrl: "https://youtube.com/shorts/example8",
Expand Down Expand Up @@ -270,7 +270,7 @@ const mockPartnerships: Partnership[] = [
id: "12",
creatorName: "Marco Rossi",
creatorHandle: "@marcobarista",
creatorAvatar: "/cooking-channel-avatar.jpg",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "Professional Latte Art Tutorial",
videoThumbnail: "/youtube-shorts-cooking-video.jpg",
videoUrl: "https://youtube.com/shorts/example12",
Expand Down Expand Up @@ -298,7 +298,7 @@ const mockPartnerships: Partnership[] = [
id: "14",
creatorName: "Chris Anderson",
creatorHandle: "@chrisathome",
creatorAvatar: "/tech-channel-avatar.png",
creatorAvatar: "/placeholder-logo.svg",
videoTitle: "My WFH Setup Tour 2025",
videoThumbnail: "/tech-review-video.jpg",
videoUrl: "https://youtube.com/shorts/example14",
Expand Down
82 changes: 1 addition & 81 deletions frontend/src/app/dashboard/reels/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import DashboardLayout from "@/components/dashboard/DashboardLayout";
import { supabase } from "@/lib/supabase";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { getCurrentUser, getApiUrl } from "@/lib/auth";
import { getCurrentUser } from "@/lib/auth";

function ReelsPageContent() {
const searchParams = useSearchParams();
Expand Down Expand Up @@ -46,7 +46,6 @@ function ReelsPageContent() {
};

const [data, setData] = useState<Reel[] | null>(null);
const [isIngesting, setIsIngesting] = useState(false);

useEffect(() => {
const fetchData = async () => {
Expand Down Expand Up @@ -147,80 +146,7 @@ function ReelsPageContent() {
}
};

const checkAndTriggerIngestion = async (shop_name: string | null) => {
if (!shop_name || isIngesting) return;

try {
console.log("Checking ingestion for shop:", shop_name);

// Get company data to check last ingestion attempt
const { data: company, error: companyError } = await supabase
.from("companies")
.select("last_ingest_attempt, access_token")
.eq("shop_name", shop_name)
.single();

if (companyError) {
console.error("Error fetching company data:", companyError);
console.log("Company lookup failed - this might mean the shop isn't registered yet");
return;
}

if (!company) {
console.log("No company found with shop_name:", shop_name);
return;
}

// Check cooldown (2 minutes = 120000 milliseconds)
const now = new Date();
const lastAttempt = company.last_ingest_attempt ? new Date(company.last_ingest_attempt) : null;
const cooldownPeriod = 2 * 60 * 1000; // 2 minutes in milliseconds

if (lastAttempt && (now.getTime() - lastAttempt.getTime()) < cooldownPeriod) {
console.log("Ingestion is on cooldown. Last attempt:", lastAttempt);
return;
}

const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (!backendUrl) {
console.log("Backend URL not configured, skipping ingestion");
return;
}

setIsIngesting(true);
console.log("No shorts found, triggering ingestion for:", shop_name);

await supabase
.from("companies")
.update({ last_ingest_attempt: now.toISOString() })
.eq("shop_name", shop_name);

const response = await fetch(`${backendUrl}/ingest`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ shop_url: shop_name, access_token: company.access_token }),
});

if (response.ok) {
console.log("Ingestion triggered successfully");
setTimeout(() => {
fetchData();
}, 5000);
} else {
console.error("Failed to trigger ingestion:", response.statusText);
}

} catch (error) {
console.error("Error triggering ingestion:", error);
} finally {
setIsIngesting(false);
}
};

fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId]); // Refetch when product filter changes

return (
Expand All @@ -230,12 +156,6 @@ function ReelsPageContent() {
hideHeader={true}
>
<div className="fixed inset-0">
{isIngesting && (
<div className="fixed top-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg z-50">
Generating new content...
</div>
)}

{/* Back to dashboard — on mobile the sidebar/header are hidden, so this is
the only way out of the full-screen reels view. */}
<a
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/app/logout/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { useEffect } from "react";
import { signOut } from "next-auth/react";
import { Flex, Text } from "@radix-ui/themes";

export default function LogoutPage() {
useEffect(() => {
// Clear any locally-stored demo/session state, then end the NextAuth session.
try {
localStorage.removeItem("access_token");
localStorage.removeItem("youtube_channel_id");
} catch {
// ignore storage access errors (SSR / privacy mode)
}
signOut({ callbackUrl: "/" });
}, []);

return (
<Flex align="center" justify="center" style={{ minHeight: "100vh" }}>
<Text size="3" color="gray">
Signing you out…
</Text>
</Flex>
);
}
17 changes: 15 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;

// Max time to wait on the (optional) external backend before falling back to the
// local Next.js API route. Without this, a backend that accepts the connection
// but never responds (e.g. a cold/hung Cloud Run instance) would hang the caller
// forever instead of degrading gracefully.
const BACKEND_TIMEOUT_MS = 8000;

export async function fetchWithFallback(
backendPath: string,
fallbackPath: string,
options?: RequestInit
): Promise<Response> {
if (BACKEND_URL) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BACKEND_TIMEOUT_MS);
try {
const response = await fetch(`${BACKEND_URL}${backendPath}`, options);
const response = await fetch(`${BACKEND_URL}${backendPath}`, {
...options,
signal: controller.signal,
});
if (response.status < 500) return response;
} catch {
// Network error — fall through to local API
// Network error, timeout, or abort — fall through to local API.
} finally {
clearTimeout(timeout);
}
}
return fetch(fallbackPath, options);
Expand Down