@pylonsync/create-pylon 0.3.269 → 0.3.270

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/bin/create-pylon.js +11 -9
  2. package/package.json +1 -1
  3. package/templates/b2b/app/layout.tsx +1 -1
  4. package/templates/b2b/app/page.tsx +2 -2
  5. package/templates/b2b/tsconfig.json +1 -1
  6. package/templates/barebones/app/page.tsx +1 -1
  7. package/templates/barebones/tsconfig.json +1 -1
  8. package/templates/chat/app/page.tsx +1 -1
  9. package/templates/chat/tsconfig.json +1 -1
  10. package/templates/consumer/app/page.tsx +1 -1
  11. package/templates/consumer/tsconfig.json +1 -1
  12. package/templates/default/.env.example +19 -0
  13. package/templates/{ssr → default}/README.md +20 -6
  14. package/templates/default/app/auth-form.tsx +218 -0
  15. package/templates/default/app/auth-shell.tsx +76 -0
  16. package/templates/default/app/company/[slug]/page.tsx +28 -0
  17. package/templates/default/app/compare/[slug]/page.tsx +27 -0
  18. package/templates/default/app/dashboard/billing/page.tsx +49 -0
  19. package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
  20. package/templates/default/app/dashboard/members/page.tsx +37 -0
  21. package/templates/default/app/dashboard/page.tsx +64 -0
  22. package/templates/default/app/dashboard/projects/page.tsx +37 -0
  23. package/templates/default/app/dashboard/settings/page.tsx +45 -0
  24. package/templates/{ssr → default}/app/globals.css +14 -0
  25. package/templates/default/app/layout.tsx +466 -0
  26. package/templates/default/app/login/page.tsx +27 -0
  27. package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
  28. package/templates/default/app/onboarding/page.tsx +29 -0
  29. package/templates/default/app/page.tsx +653 -0
  30. package/templates/default/app/products/[slug]/page.tsx +134 -0
  31. package/templates/default/app/resources/[slug]/page.tsx +28 -0
  32. package/templates/default/app/signup/page.tsx +24 -0
  33. package/templates/default/app/sitemap.ts +40 -0
  34. package/templates/default/app/solutions/[slug]/page.tsx +28 -0
  35. package/templates/{ssr → default}/app.ts +17 -2
  36. package/templates/default/components/dashboard-shell.tsx +150 -0
  37. package/templates/default/components/marketing.tsx +370 -0
  38. package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
  39. package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
  40. package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
  41. package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
  42. package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
  43. package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
  44. package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
  45. package/templates/default/functions/cancelSubscription.ts +3 -0
  46. package/templates/default/functions/createBillingPortalSession.ts +3 -0
  47. package/templates/default/functions/createCheckoutSession.ts +3 -0
  48. package/templates/default/functions/restoreSubscription.ts +3 -0
  49. package/templates/default/functions/stripeWebhook.ts +3 -0
  50. package/templates/default/lib/billing.ts +46 -0
  51. package/templates/default/lib/products.ts +122 -0
  52. package/templates/default/lib/site.ts +261 -0
  53. package/templates/{ssr → default}/package.json +2 -0
  54. package/templates/{ssr → default}/tsconfig.json +2 -2
  55. package/templates/todo/app/page.tsx +1 -1
  56. package/templates/todo/tsconfig.json +1 -1
  57. package/templates/ssr/app/auth-form.tsx +0 -142
  58. package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -192
  59. package/templates/ssr/app/dashboard/page.tsx +0 -26
  60. package/templates/ssr/app/layout.tsx +0 -78
  61. package/templates/ssr/app/login/page.tsx +0 -47
  62. package/templates/ssr/app/page.tsx +0 -212
  63. package/templates/ssr/app/signup/page.tsx +0 -44
  64. package/templates/ssr/app/sitemap.ts +0 -27
  65. package/templates/ssr/functions/_keep.ts +0 -13
  66. /package/templates/{ssr → default}/AGENTS.md +0 -0
  67. /package/templates/{ssr → default}/app/error.tsx +0 -0
  68. /package/templates/{ssr → default}/app/not-found.tsx +0 -0
  69. /package/templates/{ssr → default}/app/robots.ts +0 -0
  70. /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
  71. /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
  72. /package/templates/{ssr → default}/components.json +0 -0
  73. /package/templates/{ssr → default}/gitignore +0 -0
  74. /package/templates/{ssr → default}/lib/utils.ts +0 -0
@@ -0,0 +1,134 @@
1
+ import React from "react";
2
+ import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
+ import {
4
+ WRAP,
5
+ Divider,
6
+ Eyebrow,
7
+ FeatureGrid,
8
+ PrimaryButton,
9
+ GhostLink,
10
+ Shot,
11
+ } from "@/components/marketing";
12
+ import { PRODUCTS, productBySlug } from "@/lib/products";
13
+
14
+ // Per-product SEO. `generateMetadata` runs on the server with the resolved
15
+ // route params, so each /products/<slug> page gets its own <title>/<meta> in
16
+ // the HTML — fully indexable, no client work.
17
+ export function generateMetadata({ params }: PageProps): Metadata {
18
+ const product = productBySlug(params.slug);
19
+ if (!product) return { title: "Not found — Acme", robots: "noindex" };
20
+ return {
21
+ title: `${product.title} — Acme`,
22
+ description: product.summary,
23
+ };
24
+ }
25
+
26
+ // `app/products/[slug]/page.tsx` → `/products/:slug`. One template, every
27
+ // product. The slug is resolved from the URL during the server render; an
28
+ // unknown slug becomes a real 404 (via `response.notFound`) before any HTML is
29
+ // sent. Add a product in lib/products.ts and its page exists automatically.
30
+ export default function ProductPage({ params, auth, response }: PageProps) {
31
+ const product = productBySlug(params.slug);
32
+ if (!product) {
33
+ response.notFound();
34
+ return null;
35
+ }
36
+
37
+ const signedIn = Boolean(auth.user_id);
38
+ const primaryHref = signedIn ? "/dashboard" : "/signup";
39
+ const others = PRODUCTS.filter((p) => p.slug !== product.slug);
40
+
41
+ return (
42
+ <div className="bg-white text-zinc-900">
43
+ {/* ============================ HERO ============================ */}
44
+ <section className={`${WRAP} pt-16 pb-16 sm:pt-20`}>
45
+ <Link
46
+ href="/#product"
47
+ className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
48
+ >
49
+ ← All products
50
+ </Link>
51
+ <div className="mt-6 flex items-center gap-3">
52
+ <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
53
+ {product.icon}
54
+ </span>
55
+ <Eyebrow>{product.eyebrow}</Eyebrow>
56
+ </div>
57
+ <h1 className="mt-5 max-w-2xl text-balance text-[2.5rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[3rem]">
58
+ {product.headline}
59
+ </h1>
60
+ <p className="mt-6 max-w-xl text-[17px] leading-relaxed text-zinc-500">
61
+ {product.summary}
62
+ </p>
63
+ <div className="mt-8 flex flex-wrap items-center gap-4">
64
+ <PrimaryButton href={primaryHref}>
65
+ {signedIn ? "Open dashboard" : "Get started"}
66
+ </PrimaryButton>
67
+ <GhostLink href="/#pricing">See pricing →</GhostLink>
68
+ </div>
69
+
70
+ <div className="mt-16">
71
+ <Shot url={product.mockupUrl} label={product.mockupLabel} />
72
+ </div>
73
+ </section>
74
+
75
+ {/* ========================= FEATURES ========================= */}
76
+ <Divider />
77
+ <section className={`${WRAP} py-20`}>
78
+ <Eyebrow>What you get</Eyebrow>
79
+ <h2 className="mt-4 max-w-xl text-balance text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-[2.5rem]">
80
+ {product.title}, end to end.
81
+ </h2>
82
+ <FeatureGrid className="mt-12" items={product.features} />
83
+ </section>
84
+
85
+ {/* ==================== EXPLORE OTHER ======================== */}
86
+ <Divider />
87
+ <section className={`${WRAP} py-20`}>
88
+ <Eyebrow>More from Acme</Eyebrow>
89
+ <h2 className="mt-4 text-balance text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-[2.5rem]">
90
+ Explore the rest of the platform.
91
+ </h2>
92
+ <div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
93
+ {others.map((p) => (
94
+ <Link
95
+ key={p.slug}
96
+ href={`/products/${p.slug}`}
97
+ className="group rounded-2xl border border-zinc-200 bg-paper p-6 transition-colors hover:border-zinc-300 hover:bg-white"
98
+ >
99
+ <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
100
+ {p.icon}
101
+ </span>
102
+ <h3 className="mt-4 text-[15px] font-semibold">{p.title}</h3>
103
+ <p className="mt-1.5 text-[13px] leading-relaxed text-zinc-500">
104
+ {p.tagline}
105
+ </p>
106
+ <span className="mt-3 inline-block text-[13px] font-medium text-brand">
107
+ Explore →
108
+ </span>
109
+ </Link>
110
+ ))}
111
+ </div>
112
+ </section>
113
+
114
+ {/* =========================== CTA ========================== */}
115
+ <Divider />
116
+ <section className={`${WRAP} py-24`}>
117
+ <h2 className="max-w-xl text-balance text-[2.25rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[2.75rem]">
118
+ Get your team on {product.title}.
119
+ </h2>
120
+ <p className="mt-6 max-w-lg text-[16px] leading-relaxed text-zinc-500">
121
+ Start free and have your first workspace running in under a minute.
122
+ </p>
123
+ <div className="mt-8">
124
+ <PrimaryButton href={primaryHref}>
125
+ {signedIn ? "Open dashboard" : "Start building with Acme"}
126
+ </PrimaryButton>
127
+ </div>
128
+ <p className="mt-4 text-[12px] text-zinc-400">
129
+ Free to start · No credit card · Cancel anytime
130
+ </p>
131
+ </section>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { ContentPage } from "@/components/marketing";
4
+ import { RESOURCES, bySlug } from "@/lib/site";
5
+
6
+ export function generateMetadata({ params }: PageProps): Metadata {
7
+ const page = bySlug(RESOURCES, params.slug);
8
+ if (!page) return { title: "Not found — Acme", robots: "noindex" };
9
+ return { title: `${page.navLabel} — Acme`, description: page.summary };
10
+ }
11
+
12
+ // `/resources/:slug` — docs, guides, changelog, API reference, status. Driven
13
+ // by RESOURCES in lib/site.ts. Unknown slugs 404.
14
+ export default function ResourcePage({ params, auth, response }: PageProps) {
15
+ const page = bySlug(RESOURCES, params.slug);
16
+ if (!page) {
17
+ response.notFound();
18
+ return null;
19
+ }
20
+ return (
21
+ <ContentPage
22
+ page={page}
23
+ siblings={RESOURCES}
24
+ basePath="/resources"
25
+ ctaHref={auth.user_id ? "/dashboard" : "/signup"}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { AuthShell } from "../auth-shell";
4
+ import { AuthForm } from "../auth-form";
5
+
6
+ export const metadata: Metadata = {
7
+ title: "Create your account — Acme",
8
+ robots: "noindex",
9
+ };
10
+
11
+ // `app/signup/page.tsx` → `/signup`. Split-screen auth, register mode.
12
+ export default function SignupPage({ auth, response }: PageProps) {
13
+ if (auth.user_id) response.redirect("/dashboard");
14
+ return (
15
+ <AuthShell
16
+ title="Create an account"
17
+ switchPrompt="Already have an account?"
18
+ switchLabel="Sign in"
19
+ switchHref="/login"
20
+ >
21
+ <AuthForm mode="signup" />
22
+ </AuthShell>
23
+ );
24
+ }
@@ -0,0 +1,40 @@
1
+ import type { Sitemap } from "@pylonsync/react";
2
+ import { PRODUCTS } from "@/lib/products";
3
+ import { SOLUTIONS, RESOURCES, COMPANY, COMPARISONS } from "@/lib/site";
4
+
5
+ // app/sitemap.ts → served at /sitemap.xml. Enumerates every public, indexable
6
+ // page. The marketing pages are driven by the SAME data modules the nav,
7
+ // footer, and [slug] routes read (lib/products.ts + lib/site.ts), so the
8
+ // sitemap can never list a page that doesn't exist — add a product or a
9
+ // solution and it shows up here automatically. /dashboard is private and
10
+ // /login + /signup are `robots: "noindex"`, so they're intentionally omitted.
11
+ // Point SITE_URL at your domain in production.
12
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
13
+
14
+ export default async function sitemap(): Promise<Sitemap> {
15
+ // Each marketing collection → its /base/:slug URLs, typed so the literal
16
+ // changeFrequency isn't widened to string.
17
+ const collection = (base: string, slugs: string[]): Sitemap =>
18
+ slugs.map((slug) => ({
19
+ url: `${SITE}${base}/${slug}`,
20
+ changeFrequency: "monthly",
21
+ priority: 0.7,
22
+ }));
23
+
24
+ return [
25
+ { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
26
+ ...collection("/products", PRODUCTS.map((p) => p.slug)),
27
+ ...collection("/solutions", SOLUTIONS.map((s) => s.slug)),
28
+ ...collection("/resources", RESOURCES.map((r) => r.slug)),
29
+ ...collection("/company", COMPANY.map((c) => c.slug)),
30
+ ...collection("/compare", COMPARISONS.map((c) => c.slug)),
31
+ ];
32
+
33
+ // The export is async, so you can also enumerate pages from a DB read:
34
+ //
35
+ // const posts = await fetchPublishedPosts();
36
+ // return [...routes, ...posts.map((p) => ({
37
+ // url: `${SITE}/blog/${p.slug}`,
38
+ // lastModified: p.updatedAt,
39
+ // }))];
40
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { ContentPage } from "@/components/marketing";
4
+ import { SOLUTIONS, bySlug } from "@/lib/site";
5
+
6
+ export function generateMetadata({ params }: PageProps): Metadata {
7
+ const page = bySlug(SOLUTIONS, params.slug);
8
+ if (!page) return { title: "Not found — Acme", robots: "noindex" };
9
+ return { title: `${page.navLabel} — Acme`, description: page.summary };
10
+ }
11
+
12
+ // `/solutions/:slug` — one template, every solution. Add an entry to SOLUTIONS
13
+ // in lib/site.ts and its page exists. Unknown slugs 404.
14
+ export default function SolutionPage({ params, auth, response }: PageProps) {
15
+ const page = bySlug(SOLUTIONS, params.slug);
16
+ if (!page) {
17
+ response.notFound();
18
+ return null;
19
+ }
20
+ return (
21
+ <ContentPage
22
+ page={page}
23
+ siblings={SOLUTIONS}
24
+ basePath="/solutions"
25
+ ctaHref={auth.user_id ? "/dashboard" : "/signup"}
26
+ />
27
+ );
28
+ }
@@ -6,6 +6,11 @@ import {
6
6
  buildManifest,
7
7
  discoverAppRoutes,
8
8
  } from "@pylonsync/sdk";
9
+ // Per-workspace Stripe billing — see lib/billing.ts. `billing.manifest` brings
10
+ // the StripeSubscription entity + checkout/portal/cancel/restore/webhook actions
11
+ // + their read policy; the matching handlers live in functions/ (one wrapper per
12
+ // handler, re-exported from lib/billing.ts).
13
+ import { billing } from "./lib/billing";
9
14
 
10
15
  // Accounts — email/password is built in (the entity named "User" is the
11
16
  // account table; passwordHash is server-only). Each user can belong to many
@@ -38,6 +43,10 @@ const Org = entity(
38
43
  name: field.string(),
39
44
  createdBy: field.id("User"),
40
45
  createdAt: field.datetime(),
46
+ // Stripe customer for this workspace's billing (referenceType: "org").
47
+ // The billing plugin creates + stamps this on first checkout; server-only
48
+ // so it never reaches the client.
49
+ stripeCustomerId: field.string().serverOnly().optional(),
41
50
  },
42
51
  { indexes: [{ name: "by_created_by", fields: ["createdBy"], unique: false }] },
43
52
  );
@@ -157,15 +166,21 @@ const projectPolicy = policy({
157
166
  const manifest = buildManifest({
158
167
  name: "__APP_NAME__",
159
168
  version: "0.1.0",
160
- entities: [User, Org, OrgMember, OrgInvite, Project],
169
+ entities: [User, Org, OrgMember, OrgInvite, Project, ...billing.manifest.entities],
161
170
  queries: [],
162
- actions: [],
171
+ // Billing actions (createCheckoutSession / createBillingPortalSession /
172
+ // cancelSubscription / restoreSubscription / stripeWebhook). The plugin also
173
+ // declares getSubscription/listSubscriptions queries, but the dashboard reads
174
+ // the StripeSubscription entity directly (it's client-readable via the
175
+ // plugin's policy), so we don't wire those.
176
+ actions: [...billing.manifest.actions],
163
177
  policies: [
164
178
  userPolicy,
165
179
  orgPolicy,
166
180
  orgMemberPolicy,
167
181
  orgInvitePolicy,
168
182
  projectPolicy,
183
+ ...billing.manifest.policies,
169
184
  ],
170
185
  // Email/password is on by default against the User entity. The org entities
171
186
  // above are named with the framework defaults (Org / OrgMember / OrgInvite),
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Link, useRouter } from "@pylonsync/react";
5
+ import { useAuth, OrganizationSwitcher } from "@pylonsync/client";
6
+ import {
7
+ LayoutDashboard,
8
+ FolderKanban,
9
+ Users,
10
+ CreditCard,
11
+ Settings as SettingsIcon,
12
+ LogOut,
13
+ ExternalLink,
14
+ type LucideIcon,
15
+ } from "lucide-react";
16
+
17
+ type NavKey = "overview" | "projects" | "members" | "billing" | "settings";
18
+
19
+ const NAV: { key: NavKey; label: string; href: string; Icon: LucideIcon }[] = [
20
+ { key: "overview", label: "Overview", href: "/dashboard", Icon: LayoutDashboard },
21
+ { key: "projects", label: "Projects", href: "/dashboard/projects", Icon: FolderKanban },
22
+ { key: "members", label: "Members", href: "/dashboard/members", Icon: Users },
23
+ { key: "billing", label: "Billing", href: "/dashboard/billing", Icon: CreditCard },
24
+ { key: "settings", label: "Settings", href: "/dashboard/settings", Icon: SettingsIcon },
25
+ ];
26
+
27
+ // Dashboard chrome: a fixed sidebar (logo, workspace switcher, nav) plus a top
28
+ // bar with a user menu. The marketing nav/footer are suppressed for /dashboard
29
+ // in the root layout, so this shell is the only chrome here. `userEmail` is
30
+ // resolved on the server (serverData.get("User", …)) and passed in, so the menu
31
+ // shows a real email instead of a raw id.
32
+ export function DashboardShell({
33
+ active,
34
+ title,
35
+ userEmail,
36
+ orgName,
37
+ children,
38
+ }: {
39
+ active: NavKey;
40
+ title: string;
41
+ userEmail: string;
42
+ // Active org name, resolved on the server, so the workspace switcher renders
43
+ // the real name on first paint instead of flashing in after hydration.
44
+ orgName?: string;
45
+ children: React.ReactNode;
46
+ }) {
47
+ const router = useRouter();
48
+ return (
49
+ <div className="flex min-h-screen bg-white text-zinc-900">
50
+ <aside className="hidden w-60 shrink-0 flex-col border-r border-zinc-200 bg-zinc-50/60 md:flex">
51
+ <div className="flex h-14 items-center border-b border-zinc-200 px-4">
52
+ <Link href="/" className="flex items-center gap-2">
53
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
54
+ A
55
+ </span>
56
+ <span className="text-[15px] font-semibold tracking-tight">Acme</span>
57
+ </Link>
58
+ </div>
59
+
60
+ <div className="px-3 py-3">
61
+ {/* Every view's data is resolved server-side for the active tenant,
62
+ so switching orgs re-renders the dashboard for the new workspace.
63
+ A soft client navigation (router.push) re-fetches the SSR page —
64
+ all data updates — without the full-reload white flash. */}
65
+ <OrganizationSwitcher
66
+ initialActiveName={orgName}
67
+ onSwitched={() => router.push("/dashboard")}
68
+ />
69
+ </div>
70
+
71
+ <nav className="flex-1 space-y-0.5 px-3">
72
+ {NAV.map((n) => {
73
+ const isActive = active === n.key;
74
+ return (
75
+ <Link
76
+ key={n.key}
77
+ href={n.href}
78
+ className={
79
+ "flex items-center gap-2.5 rounded-md px-3 py-2 text-[13.5px] font-medium transition-colors " +
80
+ (isActive
81
+ ? "bg-zinc-900 text-white"
82
+ : "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900")
83
+ }
84
+ >
85
+ <n.Icon
86
+ className={
87
+ "size-[17px] " + (isActive ? "text-white" : "text-zinc-400")
88
+ }
89
+ strokeWidth={2}
90
+ />
91
+ {n.label}
92
+ </Link>
93
+ );
94
+ })}
95
+ </nav>
96
+ </aside>
97
+
98
+ <div className="flex min-w-0 flex-1 flex-col">
99
+ <header className="flex h-14 items-center justify-between border-b border-zinc-200 px-6">
100
+ <h1 className="text-[15px] font-semibold">{title}</h1>
101
+ <UserMenu email={userEmail} />
102
+ </header>
103
+ <main className="flex-1 p-6">
104
+ <div className="mx-auto max-w-4xl">{children}</div>
105
+ </main>
106
+ </div>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ // Avatar + dropdown (email, View site, Sign out). Native <details> so it opens
112
+ // on click with no extra state; the marketing nav/footer aren't here, so this
113
+ // is the only account control.
114
+ function UserMenu({ email }: { email: string }) {
115
+ const { signOut } = useAuth();
116
+ const initial = (email.trim()[0] || "?").toUpperCase();
117
+ async function onSignOut() {
118
+ await signOut();
119
+ window.location.assign("/");
120
+ }
121
+ return (
122
+ <details className="group relative">
123
+ <summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
124
+ {initial}
125
+ </summary>
126
+ <div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
127
+ <div className="border-b border-zinc-100 px-3 py-2">
128
+ <div className="truncate text-[13px] font-medium text-zinc-900">
129
+ {email || "Signed in"}
130
+ </div>
131
+ </div>
132
+ <a
133
+ href="/"
134
+ className="flex items-center gap-2 px-3 py-2 text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
135
+ >
136
+ <ExternalLink className="size-4 text-zinc-400" strokeWidth={2} />
137
+ View site
138
+ </a>
139
+ <button
140
+ type="button"
141
+ onClick={onSignOut}
142
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
143
+ >
144
+ <LogOut className="size-4 text-zinc-400" strokeWidth={2} />
145
+ Sign out
146
+ </button>
147
+ </div>
148
+ </details>
149
+ );
150
+ }