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
135 changes: 135 additions & 0 deletions app/build/website/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// app/build/website/page.tsx
//
// The Website path: a real visual drag-and-drop builder (Puck) wired into the
// SAME publish pipeline as templates. Flow: build → domain → publish. The Puck
// "Publish" button just hands the editor Data to us; nothing goes on-chain
// until the user finishes the domain + publish steps (reusing those components).

"use client";

import { Suspense, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { useWallet } from "@solana/wallet-adapter-react";
import type { Data } from "@measured/puck";
import { DomainStep } from "@/components/publish/domain-step";
import { PublishStep } from "@/components/publish/publish-step";
import { initialPuckData } from "@/lib/puck-config";
import { getSite, newPuckSite, saveSite } from "@/lib/site-store";
import type { Site } from "@/lib/types";

// Puck is browser-only — never server-render it.
const WebsiteEditor = dynamic(
() => import("@/components/website/website-editor").then((m) => m.WebsiteEditor),
{ ssr: false, loading: () => <EditorLoading /> },
);

type Step = "build" | "domain" | "publish";

export default function WebsiteBuildPage() {
return (
<Suspense fallback={<EditorLoading />}>
<WebsiteFlow />
</Suspense>
);
}

function WebsiteFlow() {
const editId = useSearchParams().get("site");
const { publicKey } = useWallet();

const existing = useMemo(() => (editId ? getSite(editId) : undefined), [editId]);
const initialData = (existing?.builder === "puck" ? existing.puckData : undefined) as
| Data
| undefined;

const [step, setStep] = useState<Step>("build");
const [site, setSite] = useState<Site | null>(null);
const [title, setTitle] = useState(existing?.title ?? "My Website");

if (!publicKey) {
return (
<div className="mx-auto max-w-md px-6 py-24 text-center">
<h1 className="font-display text-2xl font-bold">Connect your wallet to start</h1>
<p className="mt-2 text-muted-foreground">
Use the Connect button at the top right. Your sites are tied to your wallet.
</p>
</div>
);
}

const handlePublishFromEditor = (data: Data) => {
try {
const draft =
existing && existing.builder === "puck"
? saveSite({ ...existing, title, puckData: data })
: saveSite(newPuckSite(publicKey.toBase58(), title, data));
setSite(draft);
setStep("domain");
} catch (e) {
alert(e instanceof Error ? e.message : "Couldn't save your draft.");
}
};

if (step === "build") {
return (
<div>
<div className="flex h-14 items-center gap-3 border-b border-border px-4">
<span className="font-mono text-[10px] font-bold uppercase tracking-widest text-primary">
Website Builder
</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Site title"
className="flex-1 max-w-xs rounded-md border border-border bg-background px-3 py-1.5 text-sm outline-none focus:border-primary/60"
/>
<span className="ml-auto text-xs text-muted-foreground">
Drag blocks, then hit <span className="text-primary">Publish</span> to continue →
</span>
</div>
<WebsiteEditor data={initialData ?? initialPuckData} onPublish={handlePublishFromEditor} />
</div>
);
}

if (step === "domain" && site) {
return (
<DomainStep
ownerWallet={site.ownerWallet}
onBack={() => setStep("build")}
onAttached={(domain) => {
setSite(saveSite({ ...site, domain }));
setStep("publish");
}}
/>
);
}

if (step === "publish" && site) {
return (
<PublishStep
site={site}
domain={site.domain}
onBack={() => setStep("domain")}
onPublished={(pointer, domainTx) =>
saveSite({
...site,
status: "published",
storage: { provider: "iqlabs", pointer, txSignature: domainTx },
})
}
/>
);
}

return null;
}

function EditorLoading() {
return (
<div className="flex h-[60vh] items-center justify-center text-muted-foreground">
Loading builder…
</div>
);
}
104 changes: 82 additions & 22 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,97 @@
// app/page.tsx
import Link from "next/link";

export default function HomePage() {
return (
<main className="relative mx-auto max-w-3xl px-6 py-28 text-center">
<main className="relative mx-auto max-w-4xl px-6 py-24">
<div
className="pointer-events-none absolute inset-0 -z-10 opacity-70"
style={{ background: "radial-gradient(circle at 50% 0%, hsl(var(--iq-green) / 0.12), transparent 60%)" }}
/>
<p className="font-mono text-xs uppercase tracking-widest text-primary">SNS × IQLabs</p>
<h1 className="mt-4 font-display text-5xl font-bold leading-tight sm:text-6xl">
Build a website that
<br />
<span className="text-primary text-glow">lives forever</span>.
</h1>
<p className="mx-auto mt-6 max-w-xl text-lg text-muted-foreground">
Pick a template, make it yours, attach a .sol domain, and publish permanently
onchain. No code, no servers, no link rot.

<div className="text-center">
<p className="font-mono text-xs uppercase tracking-widest text-primary">SNS × IQLabs</p>
<h1 className="mt-4 font-display text-5xl font-bold leading-tight sm:text-6xl">
Build things that
<br />
<span className="text-primary text-glow">live forever</span>.
</h1>
<p className="mx-auto mt-6 max-w-xl text-lg text-muted-foreground">
Publish your identity and your sites permanently onchain. No code, no servers,
no link rot. Attach a .sol domain and you&apos;re done.
</p>
</div>

{/* Two paths */}
<div className="mt-16 grid gap-5 sm:grid-cols-2">
<PathCard
href="/profile"
badge="Identity"
title="Build a Profile"
description="Your permanent on-chain identity. Pick a theme, fill in your details, and publish once — renders identically on every IQ surface."
cta="Create profile →"
accent="#3DFE7E"
/>
<PathCard
href="/build/website"
badge="Website"
title="Build a Website"
description="Drag-and-drop a full page in the visual builder, then publish on-chain via IQ Pages. Attach a .sol domain and it lives forever."
cta="Open the builder →"
accent="#818CF8"
secondary={{ href: "/templates", label: "or start from a template" }}
/>
</div>

<p className="mt-10 text-center text-sm text-muted-foreground">
Already published something?{" "}
<Link href="/dashboard" className="font-medium text-primary hover:underline">
View My Sites →
</Link>
</p>
<div className="mt-10 flex justify-center gap-4">
<Link
href="/templates"
className="rounded-xl bg-primary px-7 py-3.5 font-medium text-primary-foreground transition-transform hover:-translate-y-0.5"
</main>
);
}

function PathCard({
href,
badge,
title,
description,
cta,
accent,
secondary,
}: {
href: string;
badge: string;
title: string;
description: string;
cta: string;
accent: string;
secondary?: { href: string; label: string };
}) {
return (
<div className="group flex flex-col rounded-2xl border border-border bg-card p-7 transition-all hover:border-primary/60 hover:glow">
<Link href={href} className="flex flex-1 flex-col">
<span
className="mb-4 inline-block self-start rounded-full px-3 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest"
style={{ background: `${accent}26`, color: accent }}
>
Browse templates
</Link>
{badge}
</span>
<h2 className="font-display text-2xl font-bold">{title}</h2>
<p className="mt-3 flex-1 text-sm leading-relaxed text-muted-foreground">{description}</p>
<span className="mt-6 font-medium" style={{ color: accent }}>
{cta}
</span>
</Link>
{secondary ? (
<Link
href="/dashboard"
className="rounded-xl border border-border px-7 py-3.5 font-medium transition-colors hover:border-primary"
href={secondary.href}
className="mt-3 text-xs text-muted-foreground hover:text-primary hover:underline"
>
My Sites
{secondary.label}
</Link>
</div>
</main>
) : null}
</div>
);
}
130 changes: 130 additions & 0 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"use client";

import { useState } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import {
DEFAULT_PROFILE_DATA,
PROFILE_THEMES,
publishProfile,
type ProfileMeta,
type ProfileData,
type ProfileTheme,
} from "@/lib/profile";
import { ThemePicker } from "@/components/profile/theme-picker";
import { ProfileForm } from "@/components/profile/profile-form";
import { ProfileCard } from "@/components/profile/profile-card";

type Phase = "edit" | "publishing" | "done" | "error";

export default function ProfilePage() {
const { connection } = useConnection();
const wallet = useWallet();

const [data, setData] = useState<ProfileData>(DEFAULT_PROFILE_DATA);
const [theme, setTheme] = useState<ProfileTheme>(PROFILE_THEMES[0]);
const [phase, setPhase] = useState<Phase>("edit");
const [statusMsg, setStatusMsg] = useState("");
const [pointer, setPointer] = useState("");

const publish = async () => {
if (!wallet.publicKey || !wallet.signTransaction) {
setStatusMsg("Connect your wallet first.");
setPhase("error");
return;
}

setPhase("publishing");

const profile: ProfileMeta = { ...data, theme };

try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await publishProfile({
profile,
wallet: wallet as any,
connection,
onProgress: (step) => setStatusMsg(step),
});
setPointer(result.pointer);
setPhase("done");
} catch (e) {
setStatusMsg(e instanceof Error ? e.message : "Something went wrong.");
setPhase("error");
}
};

const isMock = process.env.NEXT_PUBLIC_IQ_MOCK === "1";

return (
<div className="mx-auto max-w-2xl px-6 py-16">
<p className="font-mono text-xs uppercase tracking-widest text-primary">
IQForge · Profile
</p>
<h1 className="mt-3 font-display text-4xl font-bold">Your on-chain identity</h1>
<p className="mt-2 text-muted-foreground">
Pick a theme, fill in your details, and publish permanently onchain. Your profile
renders identically on every IQ surface — no link rot, no middleman.
</p>

{isMock && (
<div className="mt-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-4 py-2 font-mono text-xs text-yellow-400">
Demo mode — set NEXT_PUBLIC_IQ_MOCK=0 and connect a funded wallet to publish for real.
</div>
)}

{/* Theme picker */}
<section className="mt-10">
<h2 className="mb-3 font-display text-lg font-semibold">Choose a theme</h2>
<ThemePicker themes={PROFILE_THEMES} selected={theme} onChange={setTheme} />
</section>

{/* Live preview — rendered through our own iqui kit */}
<section className="mt-6">
<ProfileCard data={data} theme={theme} />
</section>

{/* Form */}
<section className="mt-8">
<h2 className="mb-4 font-display text-lg font-semibold">Profile details</h2>
<ProfileForm data={data} onChange={setData} />
</section>

{/* Publish */}
<section className="mt-10 border-t border-border pt-8">
{phase === "publishing" && (
<p className="mb-4 animate-pulse font-mono text-sm text-primary">{statusMsg}</p>
)}
{phase === "done" && (
<div className="mb-4 rounded-xl border border-primary/40 bg-primary/10 p-4">
<p className="font-display font-bold text-primary">Profile published ✓</p>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
txId: {pointer}
</p>
</div>
)}
{phase === "error" && (
<p className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{statusMsg}
</p>
)}

<div className="flex items-center gap-4">
<button
onClick={publish}
disabled={phase === "publishing"}
className="rounded-xl bg-primary px-7 py-3 font-medium text-primary-foreground disabled:opacity-50"
>
{phase === "publishing"
? "Publishing…"
: phase === "done"
? "Update profile"
: "Publish profile"}
</button>
{!wallet.publicKey && (
<p className="text-sm text-muted-foreground">Connect your wallet to publish.</p>
)}
</div>
</section>
</div>
);
}
Loading