@pylonsync/create-pylon 0.3.269 → 0.3.271
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.
- package/bin/create-pylon.js +11 -9
- package/package.json +1 -1
- package/templates/b2b/app/layout.tsx +1 -1
- package/templates/b2b/app/page.tsx +2 -2
- package/templates/b2b/tsconfig.json +1 -1
- package/templates/barebones/app/page.tsx +1 -1
- package/templates/barebones/tsconfig.json +1 -1
- package/templates/chat/app/page.tsx +1 -1
- package/templates/chat/tsconfig.json +1 -1
- package/templates/consumer/app/page.tsx +1 -1
- package/templates/consumer/tsconfig.json +1 -1
- package/templates/default/.env.example +19 -0
- package/templates/{ssr → default}/README.md +20 -6
- package/templates/default/app/auth-form.tsx +218 -0
- package/templates/default/app/auth-shell.tsx +76 -0
- package/templates/default/app/company/[slug]/page.tsx +28 -0
- package/templates/default/app/compare/[slug]/page.tsx +27 -0
- package/templates/default/app/dashboard/billing/page.tsx +49 -0
- package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
- package/templates/default/app/dashboard/members/page.tsx +37 -0
- package/templates/default/app/dashboard/page.tsx +64 -0
- package/templates/default/app/dashboard/projects/page.tsx +37 -0
- package/templates/default/app/dashboard/settings/page.tsx +45 -0
- package/templates/{ssr → default}/app/globals.css +14 -0
- package/templates/default/app/layout.tsx +466 -0
- package/templates/default/app/login/page.tsx +27 -0
- package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
- package/templates/default/app/onboarding/page.tsx +29 -0
- package/templates/default/app/page.tsx +653 -0
- package/templates/default/app/products/[slug]/page.tsx +134 -0
- package/templates/default/app/resources/[slug]/page.tsx +28 -0
- package/templates/default/app/signup/page.tsx +24 -0
- package/templates/default/app/sitemap.ts +40 -0
- package/templates/default/app/solutions/[slug]/page.tsx +28 -0
- package/templates/{ssr → default}/app.ts +17 -2
- package/templates/default/components/dashboard-shell.tsx +150 -0
- package/templates/default/components/marketing.tsx +370 -0
- package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
- package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
- package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
- package/templates/default/functions/cancelSubscription.ts +3 -0
- package/templates/default/functions/createBillingPortalSession.ts +3 -0
- package/templates/default/functions/createCheckoutSession.ts +3 -0
- package/templates/default/functions/restoreSubscription.ts +3 -0
- package/templates/default/functions/stripeWebhook.ts +3 -0
- package/templates/default/lib/billing.ts +46 -0
- package/templates/default/lib/products.ts +122 -0
- package/templates/default/lib/site.ts +261 -0
- package/templates/{ssr → default}/package.json +2 -0
- package/templates/{ssr → default}/tsconfig.json +2 -2
- package/templates/todo/app/page.tsx +1 -1
- package/templates/todo/tsconfig.json +1 -1
- package/templates/ssr/app/auth-form.tsx +0 -142
- package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -192
- package/templates/ssr/app/dashboard/page.tsx +0 -26
- package/templates/ssr/app/layout.tsx +0 -78
- package/templates/ssr/app/login/page.tsx +0 -47
- package/templates/ssr/app/page.tsx +0 -212
- package/templates/ssr/app/signup/page.tsx +0 -44
- package/templates/ssr/app/sitemap.ts +0 -27
- package/templates/ssr/functions/_keep.ts +0 -13
- /package/templates/{ssr → default}/AGENTS.md +0 -0
- /package/templates/{ssr → default}/app/error.tsx +0 -0
- /package/templates/{ssr → default}/app/not-found.tsx +0 -0
- /package/templates/{ssr → default}/app/robots.ts +0 -0
- /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
- /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
- /package/templates/{ssr → default}/components.json +0 -0
- /package/templates/{ssr → default}/gitignore +0 -0
- /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
|
+
}
|