diff --git a/app/build/website/page.tsx b/app/build/website/page.tsx new file mode 100644 index 0000000..4cfce33 --- /dev/null +++ b/app/build/website/page.tsx @@ -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: () => }, +); + +type Step = "build" | "domain" | "publish"; + +export default function WebsiteBuildPage() { + return ( + }> + + + ); +} + +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("build"); + const [site, setSite] = useState(null); + const [title, setTitle] = useState(existing?.title ?? "My Website"); + + if (!publicKey) { + return ( +
+

Connect your wallet to start

+

+ Use the Connect button at the top right. Your sites are tied to your wallet. +

+
+ ); + } + + 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 ( +
+
+ + Website Builder + + 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" + /> + + Drag blocks, then hit Publish to continue → + +
+ +
+ ); + } + + if (step === "domain" && site) { + return ( + setStep("build")} + onAttached={(domain) => { + setSite(saveSite({ ...site, domain })); + setStep("publish"); + }} + /> + ); + } + + if (step === "publish" && site) { + return ( + setStep("domain")} + onPublished={(pointer, domainTx) => + saveSite({ + ...site, + status: "published", + storage: { provider: "iqlabs", pointer, txSignature: domainTx }, + }) + } + /> + ); + } + + return null; +} + +function EditorLoading() { + return ( +
+ Loading builder… +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 5702251..92ffe4a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,37 +1,97 @@ -// app/page.tsx import Link from "next/link"; export default function HomePage() { return ( -
+
-

SNS × IQLabs

-

- Build a website that -
- lives forever. -

-

- Pick a template, make it yours, attach a .sol domain, and publish permanently - onchain. No code, no servers, no link rot. + +

+

SNS × IQLabs

+

+ Build things that +
+ live forever. +

+

+ Publish your identity and your sites permanently onchain. No code, no servers, + no link rot. Attach a .sol domain and you're done. +

+
+ + {/* Two paths */} +
+ + +
+ +

+ Already published something?{" "} + + View My Sites → +

-
- + ); +} + +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 ( +
+ + - Browse templates - + {badge} + +

{title}

+

{description}

+ + {cta} + + + {secondary ? ( - My Sites + {secondary.label} -
-
+ ) : null} + ); } diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..85d55cf --- /dev/null +++ b/app/profile/page.tsx @@ -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(DEFAULT_PROFILE_DATA); + const [theme, setTheme] = useState(PROFILE_THEMES[0]); + const [phase, setPhase] = useState("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 ( +
+

+ IQForge · Profile +

+

Your on-chain identity

+

+ Pick a theme, fill in your details, and publish permanently onchain. Your profile + renders identically on every IQ surface — no link rot, no middleman. +

+ + {isMock && ( +
+ Demo mode — set NEXT_PUBLIC_IQ_MOCK=0 and connect a funded wallet to publish for real. +
+ )} + + {/* Theme picker */} +
+

Choose a theme

+ +
+ + {/* Live preview — rendered through our own iqui kit */} +
+ +
+ + {/* Form */} +
+

Profile details

+ +
+ + {/* Publish */} +
+ {phase === "publishing" && ( +

{statusMsg}

+ )} + {phase === "done" && ( +
+

Profile published ✓

+

+ txId: {pointer} +

+
+ )} + {phase === "error" && ( +

+ {statusMsg} +

+ )} + +
+ + {!wallet.publicKey && ( +

Connect your wallet to publish.

+ )} +
+
+
+ ); +} diff --git a/app/templates/page.tsx b/app/templates/page.tsx index 23e5e6a..13254b6 100644 --- a/app/templates/page.tsx +++ b/app/templates/page.tsx @@ -36,16 +36,22 @@ export default function TemplateGalleryPage() {

- IQForge · Template Gallery + IQForge · Website Templates

- Pick a starting point + Pick a website template

- Beautiful, pre-built templates you can make yours in minutes. Customize text, - images, and colors — then publish permanently onchain.{" "} + Pre-built templates for tokens, NFTs, DAOs, and more. Customize and publish + permanently onchain via IQ Pages.{" "} {readyCount} live, more shipping weekly.

+

+ Looking for a personal profile?{" "} + + Build your on-chain identity → + +

{/* filters */} diff --git a/components/iqui/index.tsx b/components/iqui/index.tsx new file mode 100644 index 0000000..861d22e --- /dev/null +++ b/components/iqui/index.tsx @@ -0,0 +1,122 @@ +"use client"; + +// iqui — IQForge's own react95-style themeable UI kit. +// +// Same contract react95 proves out: a flat named color-token object + a +// ThemeProvider. A "look" is pure data (a ProfileTheme), so a new theme is zero +// new component code — the same "templates are data, not code" principle this +// repo nails, applied to UI chrome. No styled-components: components read tokens +// from context and render the classic raised/inset bevel with box-shadow. + +import { createContext, useContext, type CSSProperties, type ReactNode } from "react"; +import type { ProfileTheme } from "@/lib/profile"; + +export type IQTheme = ProfileTheme; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ theme, children }: { theme: IQTheme; children: ReactNode }) { + return {children}; +} + +export function useTheme(): IQTheme { + const t = useContext(ThemeContext); + if (!t) throw new Error("iqui components must be inside "); + return t; +} + +/** The whole point of the kit: a 3D bevel from four tokens. `raised` pops out + * (windows, buttons), `!raised` sinks in (fields, panels). Swapping the light + * and dark stops is the entire difference — kept pure so it's trivial to read + * and the only branch worth checking. */ +export function bevel(t: IQTheme, raised: boolean): CSSProperties { + const [light, lighter, dark, darker] = raised + ? [t.borderLight, t.borderLightest, t.borderDark, t.borderDarkest] + : [t.borderDark, t.borderDarkest, t.borderLight, t.borderLightest]; + return { + boxShadow: `inset 1px 1px 0 ${lighter}, inset -1px -1px 0 ${darker}, inset 2px 2px 0 ${light}, inset -2px -2px 0 ${dark}`, + }; +} + +export function Window({ + title, + children, + style, +}: { + title?: string; + children: ReactNode; + style?: CSSProperties; +}) { + const t = useTheme(); + return ( +
+ {title && ( +
+ {title} +
+ )} +
{children}
+
+ ); +} + +/** Inset frame — fields, content wells, image holders. */ +export function Panel({ children, style }: { children: ReactNode; style?: CSSProperties }) { + const t = useTheme(); + return ( +
+ {children} +
+ ); +} + +export function Button({ + children, + onClick, + disabled, +}: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; +}) { + const t = useTheme(); + return ( + + ); +} + +export function Anchor({ href, children }: { href: string; children: ReactNode }) { + const t = useTheme(); + return ( + + {children} + + ); +} + +/** First-class IQ themes ship from lib/profile (PROFILE_THEMES). Re-export the + * default so kit consumers can grab a look without reaching past the kit. */ +export { PROFILE_THEMES as IQ_THEMES } from "@/lib/profile"; diff --git a/components/profile/profile-card.tsx b/components/profile/profile-card.tsx new file mode 100644 index 0000000..af5f06f --- /dev/null +++ b/components/profile/profile-card.tsx @@ -0,0 +1,51 @@ +"use client"; + +// Profile preview rendered entirely through our own iqui kit — the same flat +// ProfileTheme that gets stored on-chain drives the look here. Proves the kit: +// new theme object = new look, zero new component code. + +import { ThemeProvider, Window, Panel, Anchor } from "@/components/iqui"; +import { SOCIAL_PLATFORMS, type ProfileData, type ProfileTheme } from "@/lib/profile"; + +const isUrl = (v: string) => /^(https?:|mailto:|\/\/)/.test(v.trim()); + +export function ProfileCard({ data, theme }: { data: ProfileData; theme: ProfileTheme }) { + return ( + + +
+ {data.profilePicture && ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + avatar + + )} +
+

{data.name || "Your Name"}

+ {data.bio &&

{data.bio}

} +
+
+ + {Object.keys(data.socials).length > 0 && ( + +
+ {SOCIAL_PLATFORMS.filter((p) => data.socials[p.key]).map((p) => { + const v = data.socials[p.key] as string; + return ( + + {p.label}:{" "} + {isUrl(v) ? {v.replace(/^https?:\/\//, "")} : v} + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/components/profile/profile-form.tsx b/components/profile/profile-form.tsx new file mode 100644 index 0000000..e5c1fe2 --- /dev/null +++ b/components/profile/profile-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { SOCIAL_PLATFORMS, type ProfileData } from "@/lib/profile"; + +export function ProfileForm({ + data, + onChange, +}: { + data: ProfileData; + onChange: (data: ProfileData) => void; +}) { + const set = (key: K, value: ProfileData[K]) => + onChange({ ...data, [key]: value }); + + const setSocial = (key: string, value: string) => { + const next = { ...data.socials }; + if (value.trim()) next[key as keyof typeof next] = value; + else delete next[key as keyof typeof next]; + set("socials", next); + }; + + return ( +
+ + set("name", e.target.value)} + maxLength={40} + placeholder="Satoshi" + className={INPUT_CLS} + /> + + + +