diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
index 3f2ec2b..ca6abb1 100644
--- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
+++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
@@ -18,7 +18,7 @@ const menu = [
{
title: "dashboard",
href: pathsConfig.dashboard.user.index,
- icon: Icons.Home,
+ icon: Icons.Atom,
},
{
title: "meshes",
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx
new file mode 100644
index 0000000..1eab253
--- /dev/null
+++ b/apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx
@@ -0,0 +1,79 @@
+import Link from "next/link";
+
+import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
+import { handle } from "@turbostarter/api/utils";
+import { Badge } from "@turbostarter/ui-web/badge";
+import { buttonVariants } from "@turbostarter/ui-web/button";
+
+import { pathsConfig } from "~/config/paths";
+import { api } from "~/lib/api/server";
+import { getMetadata } from "~/lib/metadata";
+
+export const generateMetadata = getMetadata({
+ title: "Legacy dashboard",
+ description: "Simple legacy view of your meshes.",
+});
+
+export default async function LegacyDashboardHomePage() {
+ const { data } = await handle(api.my.meshes.$get, {
+ schema: getMyMeshesResponseSchema,
+ })({
+ query: { page: "1", perPage: "6", sort: JSON.stringify([]) },
+ });
+
+ return (
+
+
+
Your meshes
+
+ Open one to see its members, generate invites, or share it.
+
+
+
+ {data.map((m) => (
+
+
+
+
+ {m.name}
+
+
+ {m.slug}
+
+
+
+ {m.isOwner ? "owner" : m.myRole}
+
+
+
+
+ {m.tier}
+
+
+ {m.memberCount} {m.memberCount === 1 ? "member" : "members"}
+
+
+
+ ))}
+
+
+
+ All meshes
+
+
+ New mesh
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx
index bf095da..394bd7a 100644
--- a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx
+++ b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx
@@ -1,84 +1,71 @@
-import Link from "next/link";
import { redirect } from "next/navigation";
-import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
+import {
+ getMyInvitesIncomingResponseSchema,
+ getMyMeshesResponseSchema,
+} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
-import { Badge } from "@turbostarter/ui-web/badge";
-import { buttonVariants } from "@turbostarter/ui-web/button";
+import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
+import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
+import { InvitationsSection } from "~/modules/dashboard/universe/invitations";
+import { MeshesGrid } from "~/modules/dashboard/universe/meshes-grid";
+import { UniverseWelcome } from "~/modules/dashboard/universe/welcome";
export const generateMetadata = getMetadata({
- title: "Dashboard",
- description: "Your meshes.",
+ title: "Your universe",
+ description: "Meshes, peers, and invitations — all in one place.",
});
-export default async function DashboardHomePage() {
- const { data } = await handle(api.my.meshes.$get, {
- schema: getMyMeshesResponseSchema,
- })({
- query: { page: "1", perPage: "6", sort: JSON.stringify([]) },
- });
+export default async function UniversePage() {
+ const { user } = await getSession();
+ const name = user?.name ?? "there";
- // First-time onboarding: 0-mesh user → bounce to create
- if (data.length === 0) {
+ const [{ data: meshes }, { incoming }] = await Promise.all([
+ handle(api.my.meshes.$get, {
+ schema: getMyMeshesResponseSchema,
+ })({
+ query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
+ }),
+ handle(api.my.invites.incoming.$get, {
+ schema: getMyInvitesIncomingResponseSchema,
+ })(),
+ ]);
+
+ const activeMeshes = meshes.filter((m) => !m.archivedAt);
+
+ // First-time onboarding: brand-new user with nothing waiting → create flow.
+ if (activeMeshes.length === 0 && incoming.length === 0) {
redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`);
}
return (
-
-
-
Your meshes
-
- Open one to see its members, generate invites, or share it.
-
-
-
- {data.map((m) => (
-
-
-
-
- {m.name}
-
-
- {m.slug}
-
-
-
- {m.isOwner ? "owner" : m.myRole}
-
-
-
-
- {m.tier}
-
-
- {m.memberCount} {m.memberCount === 1 ? "member" : "members"}
-
-
-
- ))}
-
-
-
- All meshes
-
-
- New mesh
-
+
+ {/* Subtle radial backdrop, matching marketing hero */}
+
+
+
+
+
+
+
);
diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts
index 4ac18fc..0549daf 100644
--- a/apps/web/src/config/paths.ts
+++ b/apps/web/src/config/paths.ts
@@ -90,6 +90,7 @@ const pathsConfig = {
dashboard: {
user: {
index: DASHBOARD_PREFIX,
+ legacy: `${DASHBOARD_PREFIX}/legacy`,
ai: `${DASHBOARD_PREFIX}/ai`,
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
meshes: {
diff --git a/apps/web/src/modules/dashboard/universe/invitations.tsx b/apps/web/src/modules/dashboard/universe/invitations.tsx
new file mode 100644
index 0000000..758ae2d
--- /dev/null
+++ b/apps/web/src/modules/dashboard/universe/invitations.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useState } from "react";
+
+import { Reveal } from "./reveal";
+
+interface IncomingInvite {
+ id: string;
+ meshId: string;
+ meshName: string | null;
+ meshSlug: string | null;
+ code: string;
+ role: "admin" | "member" | null;
+ expiresAt: string | Date | null;
+ sentAt: string | Date;
+ inviterName: string | null;
+ inviterEmail: string | null;
+ memberCount: number;
+}
+
+type CardStatus = "idle" | "declining" | "declined";
+
+const formatExpiry = (d: string | Date | null): string => {
+ if (!d) return "NO EXPIRY";
+ const date = typeof d === "string" ? new Date(d) : d;
+ const diffMs = date.getTime() - Date.now();
+ if (diffMs <= 0) return "EXPIRED";
+ const h = Math.floor(diffMs / 36e5);
+ const days = Math.floor(h / 24);
+ const hoursRem = h % 24;
+ if (days > 0) return `EXPIRES IN ${days}D ${hoursRem}H`;
+ return `EXPIRES IN ${h}H`;
+};
+
+export const InvitationsSection = ({
+ incoming,
+ appBaseUrl,
+}: {
+ incoming: IncomingInvite[];
+ appBaseUrl: string;
+}) => {
+ const [dismissed, setDismissed] = useState
>({});
+
+ const visible = incoming.filter((i) => dismissed[i.id] !== "declined");
+
+ if (visible.length === 0) return null;
+
+ const decline = async (id: string) => {
+ setDismissed((s) => ({ ...s, [id]: "declining" }));
+ try {
+ const res = await fetch(`/api/my/invites/incoming/${id}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(await res.text());
+ setDismissed((s) => ({ ...s, [id]: "declined" }));
+ } catch {
+ setDismissed((s) => ({ ...s, [id]: "idle" }));
+ }
+ };
+
+ return (
+
+
+
+
+ Invitations waiting
+
+
+ {visible.length} pending
+
+
+
+
+
+ {visible.map((inv, idx) => {
+ const status = dismissed[inv.id] ?? "idle";
+ const inviterLabel =
+ inv.inviterName ?? inv.inviterEmail ?? "someone";
+ const joinHref = `${appBaseUrl}/i/${inv.code}`;
+
+ return (
+
+
+
+
+
+ From ·{" "}
+
+ {inviterLabel}
+
+
+
+
+ Join{" "}
+
+ {inv.meshName ?? inv.meshSlug ?? "a mesh"}
+
+
+
+
+ {inv.memberCount}{" "}
+ {inv.memberCount === 1 ? "member" : "members"} · you’d join as{" "}
+
+ {inv.role ?? "member"}
+
+
+
+
+
+ Accept
+
+
+
+ {formatExpiry(inv.expiresAt)}
+
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/apps/web/src/modules/dashboard/universe/meshes-grid.tsx b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx
new file mode 100644
index 0000000..c9c73e2
--- /dev/null
+++ b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx
@@ -0,0 +1,210 @@
+import Link from "next/link";
+
+import { pathsConfig } from "~/config/paths";
+
+import { Reveal } from "./reveal";
+
+interface MeshSummary {
+ id: string;
+ name: string;
+ slug: string;
+ tier: "free" | "pro" | "team" | "enterprise";
+ myRole: "admin" | "member";
+ isOwner: boolean;
+ memberCount: number;
+ archivedAt: Date | string | null;
+}
+
+const MAX_CHIPS = 6;
+
+/**
+ * Compact member-count chips. Real per-session live status would require
+ * polling /stream for each mesh — we show the structure here and defer the
+ * live overlay to the per-mesh live page.
+ */
+const MemberChips = ({ count }: { count: number }) => {
+ if (count === 0) {
+ return (
+
+
+ empty
+
+ );
+ }
+ const shown = Math.min(count, MAX_CHIPS);
+ const extra = count - shown;
+ return (
+ <>
+ {Array.from({ length: shown }).map((_, i) => (
+
+
+ member
+
+ ))}
+ {extra > 0 ? (
+
+ +{extra}
+
+ ) : null}
+ >
+ );
+};
+
+const roleClass = (isOwner: boolean, role: string) => {
+ if (isOwner) return "text-[var(--cm-clay)] border-[rgba(217,119,87,0.4)]";
+ if (role === "admin") return "text-[var(--cm-cactus)] border-[rgba(188,209,202,0.4)]";
+ return "text-[var(--cm-fg-secondary)]";
+};
+
+const MeshCard = ({
+ mesh,
+ size = "compact",
+}: {
+ mesh: MeshSummary;
+ size?: "hero" | "compact";
+}) => {
+ const isHero = size === "hero";
+ const href = pathsConfig.dashboard.user.meshes.mesh(mesh.id);
+
+ return (
+
+ {isHero ? (
+
+ ) : null}
+
+
+
+
+ {isHero ? (
+ {mesh.name}
+ ) : (
+ mesh.name
+ )}
+
+
+ {mesh.slug}
+ {isHero ? ` · id ${mesh.id.slice(0, 8)}…` : ""}
+
+
+
+ {mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole}
+
+
+
+
+
+
+
+
+ 0 ? "text-[var(--cm-cactus)]" : ""}>
+ {mesh.memberCount} {mesh.memberCount === 1 ? "MEMBER" : "MEMBERS"}
+ {" · "}
+ {mesh.tier}
+
+
+ open →
+
+
+
+ );
+};
+
+export const MeshesGrid = ({ meshes }: { meshes: MeshSummary[] }) => {
+ if (meshes.length === 0) {
+ return (
+
+
+
+ You haven’t joined any meshes yet.
+
+
+ Create your first mesh
+
+
+
+ );
+ }
+
+ const [hero, ...rest] = meshes;
+ const heroMesh = hero!;
+
+ return (
+
+
+
+
+ Your meshes
+
+
+ + New mesh
+
+
+
+
+
+
+
+
+
+
+ {rest.slice(0, 2).map((m, i) => (
+
+
+
+ ))}
+
+
+ {rest.length > 2 ? (
+
+ {rest.slice(2).map((m, i) => (
+
+
+
+ ))}
+
+ ) : null}
+
+
+ );
+};
diff --git a/apps/web/src/modules/dashboard/universe/reveal.tsx b/apps/web/src/modules/dashboard/universe/reveal.tsx
new file mode 100644
index 0000000..3629246
--- /dev/null
+++ b/apps/web/src/modules/dashboard/universe/reveal.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { motion, type Variants } from "motion/react";
+import type { ReactNode } from "react";
+
+const fade: Variants = {
+ hidden: { opacity: 0, y: 20, filter: "blur(4px)" },
+ visible: (i: number = 0) => ({
+ opacity: 1,
+ y: 0,
+ filter: "blur(0px)",
+ transition: {
+ duration: 0.7,
+ ease: [0.22, 0.61, 0.36, 1],
+ delay: i * 0.08,
+ },
+ }),
+};
+
+export const Reveal = ({
+ children,
+ delay = 0,
+ className,
+}: {
+ children: ReactNode;
+ delay?: number;
+ className?: string;
+}) => (
+
+ {children}
+
+);
diff --git a/apps/web/src/modules/dashboard/universe/welcome.tsx b/apps/web/src/modules/dashboard/universe/welcome.tsx
new file mode 100644
index 0000000..9410ae8
--- /dev/null
+++ b/apps/web/src/modules/dashboard/universe/welcome.tsx
@@ -0,0 +1,77 @@
+import { Reveal } from "./reveal";
+
+interface WelcomeProps {
+ name: string;
+ meshCount: number;
+ inviteCount: number;
+}
+
+export const UniverseWelcome = ({ name, meshCount, inviteCount }: WelcomeProps) => {
+ const inviteLine =
+ inviteCount === 0
+ ? null
+ : inviteCount === 1
+ ? "1 invitation"
+ : `${inviteCount} invitations`;
+
+ const firstName = name.split(" ")[0] ?? name;
+
+ return (
+
+ );
+};
diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts
index 546fedf..15dcaf6 100644
--- a/packages/api/src/modules/mesh/mutations.ts
+++ b/packages/api/src/modules/mesh/mutations.ts
@@ -164,6 +164,38 @@ export const archiveMyMesh = async ({
return updated;
};
+/**
+ * Decline an incoming pending invite addressed to this user's email.
+ * Marks the pending_invite row as revoked so it no longer surfaces
+ * in /invites/incoming. The underlying short-code invite is NOT revoked
+ * (inviter may re-send), only this user's copy is dismissed.
+ */
+export const declineIncomingInvite = async ({
+ email,
+ pendingInviteId,
+}: {
+ email: string;
+ pendingInviteId: string;
+}) => {
+ const [updated] = await db
+ .update(pendingInvite)
+ .set({ revokedAt: new Date() })
+ .where(
+ and(
+ eq(pendingInvite.id, pendingInviteId),
+ eq(pendingInvite.email, email),
+ isNull(pendingInvite.acceptedAt),
+ isNull(pendingInvite.revokedAt),
+ ),
+ )
+ .returning({ id: pendingInvite.id });
+
+ if (!updated) {
+ throw new Error("Invitation not found or already resolved.");
+ }
+ return updated;
+};
+
export const leaveMyMesh = async ({
userId,
meshId,
diff --git a/packages/api/src/modules/mesh/queries.ts b/packages/api/src/modules/mesh/queries.ts
index 12b2085..24684cf 100644
--- a/packages/api/src/modules/mesh/queries.ts
+++ b/packages/api/src/modules/mesh/queries.ts
@@ -5,6 +5,7 @@ import {
desc,
eq,
getOrderByFromSort,
+ gt,
ilike,
isNull,
or,
@@ -16,7 +17,9 @@ import {
mesh,
meshMember,
messageQueue,
+ pendingInvite,
presence,
+ user,
} from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
@@ -345,6 +348,48 @@ export const getMyExport = async ({ userId }: { userId: string }) => {
};
};
+/**
+ * Pending invitations addressed to this user's email. A pending_invite row is
+ * created when someone calls `claudemesh share `; we join it against the
+ * underlying `invite` row to get role + expiry, and against `user` (inviter)
+ * and `mesh` (target) for display. Returned only when unaccepted, unrevoked,
+ * and not expired.
+ */
+export const getMyInvitesIncoming = async ({ email }: { email: string }) => {
+ const now = new Date();
+ return db
+ .select({
+ id: pendingInvite.id,
+ meshId: pendingInvite.meshId,
+ meshName: mesh.name,
+ meshSlug: mesh.slug,
+ code: pendingInvite.code,
+ role: invite.role,
+ expiresAt: invite.expiresAt,
+ sentAt: pendingInvite.sentAt,
+ inviterName: user.name,
+ inviterEmail: user.email,
+ memberCount: sql`(
+ SELECT COUNT(*)::int FROM mesh.member
+ WHERE mesh_id = ${pendingInvite.meshId} AND revoked_at IS NULL
+ )`,
+ })
+ .from(pendingInvite)
+ .leftJoin(mesh, eq(pendingInvite.meshId, mesh.id))
+ .leftJoin(invite, eq(pendingInvite.code, invite.code))
+ .leftJoin(user, eq(pendingInvite.createdBy, user.id))
+ .where(
+ and(
+ eq(pendingInvite.email, email),
+ isNull(pendingInvite.acceptedAt),
+ isNull(pendingInvite.revokedAt),
+ or(isNull(invite.expiresAt), gt(invite.expiresAt, now)),
+ ),
+ )
+ .orderBy(desc(pendingInvite.sentAt))
+ .limit(50);
+};
+
export const getMyInvitesSent = async ({ userId }: { userId: string }) =>
db
.select({
diff --git a/packages/api/src/modules/mesh/router.ts b/packages/api/src/modules/mesh/router.ts
index 7c0a752..8b6bbe6 100644
--- a/packages/api/src/modules/mesh/router.ts
+++ b/packages/api/src/modules/mesh/router.ts
@@ -15,10 +15,12 @@ import {
createEmailInvite,
createMyInvite,
createMyMesh,
+ declineIncomingInvite,
leaveMyMesh,
} from "./mutations";
import {
getMyExport,
+ getMyInvitesIncoming,
getMyInvitesSent,
getMyMeshById,
getMyMeshStream,
@@ -150,6 +152,29 @@ export const myRouter = new Hono()
const user = c.var.user;
return c.json({ sent: await getMyInvitesSent({ userId: user.id }) });
})
+ .get("/invites/incoming", async (c) => {
+ const user = c.var.user;
+ if (!user.email) return c.json({ incoming: [] });
+ return c.json({
+ incoming: await getMyInvitesIncoming({ email: user.email }),
+ });
+ })
+ .delete("/invites/incoming/:id", async (c) => {
+ const user = c.var.user;
+ if (!user.email) return c.json({ error: "No email on session" }, 400);
+ try {
+ await declineIncomingInvite({
+ email: user.email,
+ pendingInviteId: c.req.param("id"),
+ });
+ return c.json({ ok: true });
+ } catch (e) {
+ return c.json(
+ { error: e instanceof Error ? e.message : "Failed to decline." },
+ 400,
+ );
+ }
+ })
.get("/export", async (c) => {
const user = c.var.user;
const data = await getMyExport({ userId: user.id });
diff --git a/packages/api/src/schema/mesh-user.ts b/packages/api/src/schema/mesh-user.ts
index 5f14a0a..4e90b49 100644
--- a/packages/api/src/schema/mesh-user.ts
+++ b/packages/api/src/schema/mesh-user.ts
@@ -296,3 +296,24 @@ export const getMyInvitesResponseSchema = z.object({
),
});
export type GetMyInvitesResponse = z.infer;
+
+export const getMyInvitesIncomingResponseSchema = z.object({
+ incoming: z.array(
+ z.object({
+ id: z.string(),
+ meshId: z.string(),
+ meshName: z.string().nullable(),
+ meshSlug: z.string().nullable(),
+ code: z.string(),
+ role: meshRoleEnum.nullable(),
+ expiresAt: z.coerce.date().nullable(),
+ sentAt: z.coerce.date(),
+ inviterName: z.string().nullable(),
+ inviterEmail: z.string().nullable(),
+ memberCount: z.number(),
+ }),
+ ),
+});
+export type GetMyInvitesIncomingResponse = z.infer<
+ typeof getMyInvitesIncomingResponseSchema
+>;
diff --git a/prototypes/live-dashboard.html b/prototypes/live-dashboard.html
new file mode 100644
index 0000000..ea7fe6a
--- /dev/null
+++ b/prototypes/live-dashboard.html
@@ -0,0 +1,1001 @@
+
+
+
+
+
+ Your universe · claudemesh
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Invitations waiting
+ 2 PENDING · EXPIRES IN 7 DAYS
+
+
+
+
+
From · Nedas Mikelionis
+
Join backend-ops
+
3 members · you’d join as member · EU-West broker
+
+
+
+
+
+
EXPIRES IN 6D 14H
+
+
+
+
+
From · Aleksandra Bakaite
+
Join design-review
+
5 members · you’d join as admin · EU-West broker
+
+
+
+
+
+
EXPIRES IN 2D 8H
+
+
+
+
+
+
+
+
+
+
Your meshes
+
+
+
+
+
+
+
+
+
+
alexis-mou
+
alexis-mou · id 7e2ad3b1…
+
+
owner · live
+
+
+
+ Mou
+ Nedas
+ Lug-Nut
+ Alexis
+ Roberto
+ Juan
+ Kiko
+
+
+
+
+
Envelopes · session
+
18,402
+
+
+
Rate · per minute
+
1,024
+
+
+
+
+
+
+
+
+
+ Tú
+ Miguel
+ Diego
+ Carlos
+
+
+
+
+
+
+
+
nedas-mesh
+
nedas-mesh
+
+
admin
+
+
+ Nedas
+ Mou
+ Sasha
+
+
+
+
+
+
+
+
+
+
+
+
+ Mou
+ sess-2
+ sess-3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+