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
6 changes: 3 additions & 3 deletions examples/nextjs-starter/.env.local.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
VANA_PRIVATE_KEY=0x... # Builder key, must be registered on-chain
APP_URL=http://localhost:3001 # Public URL of your deployed app
VANA_SCOPES=chatgpt.conversations # Comma-separated scopes
VANA_PRIVATE_KEY=0x... # Builder key, must be registered on-chain
APP_URL=http://localhost:3001 # Public URL of your deployed app
VANA_SCOPES=instagram.ads,instagram.profile # Comma-separated scopes
VANA_ENV=dev
NEXT_PUBLIC_VANA_ENV=dev
75 changes: 75 additions & 0 deletions examples/nextjs-starter/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,78 @@ body {
color: #ef4444;
font-size: 13px;
}

/* -- Instagram data display -- */

.profile-header {
display: flex;
align-items: center;
gap: 12px;
}

.profile-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #833ab4, #fd1d1d, #fcb045);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
color: #fff;
flex-shrink: 0;
}

.stats-row {
display: flex;
gap: 24px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #27272a;
}

.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}

.stat-value {
font-weight: 600;
font-size: 16px;
}

.stat-label {
font-size: 11px;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.5px;
}

.tag-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}

.tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 100px;
border: 1px solid #27272a;
background: #18181b;
color: #d4d4d8;
white-space: nowrap;
}

.tag-topic {
border-color: #7c3aed44;
color: #a78bfa;
}

.tag-advertiser {
border-color: #0ea5e944;
color: #67e8f9;
}
4 changes: 2 additions & 2 deletions examples/nextjs-starter/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
title: "Vana ConnectNext.js Starter",
description: "Example app for the Vana Connect SDK",
title: "Ad Insightspowered by Vana",
description: "See which advertisers target you on Instagram",
manifest: "/manifest.json",
};

Expand Down
4 changes: 2 additions & 2 deletions examples/nextjs-starter/src/app/manifest.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export async function GET() {
});

const manifest = {
name: "Vana ConnectNext.js Starter",
short_name: "Vana Starter",
name: "Ad Insightspowered by Vana",
short_name: "Ad Insights",
start_url: "/",
display: "standalone",
background_color: "#09090b",
Expand Down
6 changes: 3 additions & 3 deletions examples/nextjs-starter/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ export default function Home() {
return (
<main style={{ maxWidth: 540, margin: "0 auto", padding: "64px 24px" }}>
<h1 style={{ fontSize: 22, fontWeight: 600, marginBottom: 8 }}>
Vana Connect — Next.js Starter
Ad Insights
</h1>
<p style={{ fontSize: 14, color: "#71717a", marginBottom: 40 }}>
This app demonstrates how to get data from DataConnect into a Next.js
app.
Connect your Instagram to see which advertisers target you and what
topics they think you care about.
</p>
<ConnectFlow />
</main>
Expand Down
204 changes: 159 additions & 45 deletions examples/nextjs-starter/src/components/ConnectFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
"use client";

// useVanaData() manages the full connect → poll → fetch-data lifecycle.
// initConnect() starts a session, the hook polls until approved, then
// fetchData() calls /api/data with the grant to retrieve user data.

import type { ConnectionStatus } from "@opendatalabs/connect/core";
import { useVanaData } from "@opendatalabs/connect/react";
import { useEffect, useRef } from "react";

// -- Types matching the instagram.ads + instagram.profile schemas --

interface AdInterestsData {
advertisers: { name: string }[];
ad_topics: { name: string }[];
}

interface ProfileData {
username: string;
full_name: string;
bio?: string;
follower_count?: number;
following_count?: number;
media_count?: number;
is_private?: boolean;
is_verified?: boolean;
is_business?: boolean;
}

interface InstagramData {
"instagram.ads"?: AdInterestsData;
"instagram.profile"?: ProfileData;
}

// -- Status display --

const STATUS_DISPLAY: Record<
ConnectionStatus,
{ dot: string; label: string; className: string }
Expand All @@ -29,6 +51,97 @@ const STATUS_DISPLAY: Record<
error: { dot: "\u25CF", label: "Error", className: "status-error" },
};

// -- Data display components --

function ProfileCard({ profile }: { profile: ProfileData }) {
return (
<div className="card">
<div className="profile-header">
<div className="profile-avatar">
{profile.full_name.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 16 }}>
{profile.full_name}
</div>
<div className="mono" style={{ fontSize: 13 }}>
@{profile.username}
</div>
</div>
</div>
{profile.bio && (
<p style={{ fontSize: 13, color: "#a1a1aa", marginTop: 12 }}>
{profile.bio}
</p>
)}
<div className="stats-row">
{profile.follower_count != null && (
<div className="stat">
<span className="stat-value">
{profile.follower_count.toLocaleString()}
</span>
<span className="stat-label">Followers</span>
</div>
)}
{profile.following_count != null && (
<div className="stat">
<span className="stat-value">
{profile.following_count.toLocaleString()}
</span>
<span className="stat-label">Following</span>
</div>
)}
{profile.media_count != null && (
<div className="stat">
<span className="stat-value">
{profile.media_count.toLocaleString()}
</span>
<span className="stat-label">Posts</span>
</div>
)}
</div>
</div>
);
}

function AdInsightsCard({ ads }: { ads: AdInterestsData }) {
return (
<div className="card">
<div className="label" style={{ marginBottom: 16 }}>
Your Ad Profile
</div>

<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}>
Ad Topics ({ads.ad_topics.length})
</div>
<div className="tag-grid">
{ads.ad_topics.map((topic) => (
<span key={topic.name} className="tag tag-topic">
{topic.name}
</span>
))}
</div>
</div>

<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}>
Advertisers ({ads.advertisers.length})
</div>
<div className="tag-grid">
{ads.advertisers.map((adv) => (
<span key={adv.name} className="tag tag-advertiser">
{adv.name}
</span>
))}
</div>
</div>
</div>
);
}

// -- Main flow --

export default function ConnectFlow() {
const {
status,
Expand All @@ -54,11 +167,24 @@ export default function ConnectFlow() {
const display = STATUS_DISPLAY[status];
const sessionReady = !!connectUrl;
const hasConnectFailure = !sessionReady && !!error;
// Response shape: { data: { "instagram.ads": { data: { advertisers, ... } }, ... } }
// useVanaData sets data = json (the full response), so we unwrap: .data (API envelope) → .data (schema envelope)
const outer = data as Record<string, unknown> | null;
const scoped = (outer?.data ?? outer) as Record<string, unknown> | null;
const igData: InstagramData | null = scoped?.["instagram.ads"]
? {
"instagram.ads": (scoped["instagram.ads"] as Record<string, unknown>)
?.data as AdInterestsData | undefined,
"instagram.profile": (
scoped["instagram.profile"] as Record<string, unknown>
)?.data as ProfileData | undefined,
}
: null;

return (
<div>
{/* Launch button — shown until approved */}
{status !== "approved" && (
{/* Connect card — shown until data is loaded */}
{!igData && (
<div className="card">
<div style={{ marginBottom: 20 }}>
<div className="field-row">
Expand All @@ -69,7 +195,23 @@ export default function ConnectFlow() {
</div>
</div>

{sessionReady ? (
{status === "approved" && grant ? (
<button
type="button"
onClick={fetchData}
disabled={isLoading}
className="btn-primary"
style={{ width: "100%" }}
>
{isLoading ? (
<>
<span className="spinner" /> Loading your data...
</>
) : (
"View My Ad Profile"
)}
</button>
) : sessionReady ? (
<a
href={connectUrl}
target="_blank"
Expand All @@ -84,7 +226,7 @@ export default function ConnectFlow() {
width: "100%",
}}
>
Connect with Vana
Connect Instagram with Vana
</a>
) : (
<button
Expand All @@ -101,49 +243,21 @@ export default function ConnectFlow() {
<span className="spinner" /> Creating session...
</>
) : hasConnectFailure ? (
"Retry session"
"Retry"
) : (
"Create session"
"Connect Instagram"
)}
</button>
)}
</div>
)}

{/* Grant details + data */}
{status === "approved" && grant && (
<div className="card card-approved">
<div style={{ marginBottom: 20 }}>
<div className="field-row">
<span className="label">Status</span>
<span className={`mono ${display.className}`}>
{display.dot} {display.label}
</span>
</div>
</div>

<div className="label">Grant</div>
<pre className="pre-block">{JSON.stringify(grant, null, 2)}</pre>

<button
type="button"
onClick={fetchData}
disabled={isLoading}
className="btn-primary"
style={{ marginTop: 16, width: "100%" }}
>
{isLoading ? "Fetching..." : "Fetch Data"}
</button>

{data != null && (
<div style={{ marginTop: 16 }}>
<div className="label">Response</div>
<pre className="pre-block" style={{ maxHeight: 400 }}>
{JSON.stringify(data, null, 2)}
</pre>
</div>
)}
</div>
{/* Data display */}
{igData?.["instagram.profile"]?.full_name && (
<ProfileCard profile={igData["instagram.profile"]} />
)}
{igData?.["instagram.ads"]?.ad_topics && (
<AdInsightsCard ads={igData["instagram.ads"]} />
)}

{/* Errors */}
Expand All @@ -155,7 +269,7 @@ export default function ConnectFlow() {
</div>
)}

{/* Reset — reloads the page to start a fresh session */}
{/* Reset */}
{status !== "idle" && status !== "connecting" && (
<button
type="button"
Expand Down
Loading
Loading