showpane 0.4.27 → 0.4.29

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-04-13T22:57:26.230Z",
3
+ "generatedAt": "2026-04-13T23:58:51.565Z",
4
4
  "scaffoldVersion": "0.2.7",
5
5
  "files": {
6
6
  ".env.example": "ed105f2bdcd1888a98181d55e3c9f7d6eff3ae9c3e2366c2e777a12e3caddfa7",
@@ -19,7 +19,7 @@
19
19
  "scripts/prisma-db-push.mjs": "76ac85fe65b5dc3d9cc7432e44618fcc84b7443574c8d88198d01f13ac23c040",
20
20
  "scripts/prisma-generate.mjs": "d371e63388fa39f963b7c3c7cb8f87e0d9cd43cbf69d254b999108e29b8738c8",
21
21
  "scripts/prisma-schema.mjs": "0a86cc1b5f84120948aed8f97a84f2d5b173f91a43ea34ad6767441894121d83",
22
- "src/__tests__/branding.test.ts": "dffbb3744705bc378ad91b9d7b8868004ffeaa45623f4748e1fed951096b9e3f",
22
+ "src/__tests__/branding.test.ts": "ed6246013ca9465e3ee73de72791b781556a75b65935ed61e116d3dcc7885b53",
23
23
  "src/__tests__/client-portals.test.ts": "9c3236bf0f7190b7d5ba9082287dcb29bc00d28dd63782a89505125ead06c624",
24
24
  "src/__tests__/deploy-bundle.test.ts": "abd3216170f306c09df6abb0d2afad966a5741e8859f25a310a0a09693d37609",
25
25
  "src/__tests__/portal-contracts.test.ts": "a3ecf29c460a14f22ef48449fc9de5b945ce5f03423f12380d05e01159d1e3b4",
@@ -28,7 +28,7 @@
28
28
  "src/app/(portal)/client/example/example-client.tsx": "ed32b111acea861f448d865338f8841d47c6ca7c2f87ed30d85bb0804940d4ec",
29
29
  "src/app/(portal)/client/example/page.tsx": "f330864f63c9feea76c8a62c3eba3ce57578627e0d4abd929fd7fefdfc7af058",
30
30
  "src/app/(portal)/client/layout.tsx": "4f43871510408a81da229d48ae316ec1d1c1beda93121922246300a2c8fd0999",
31
- "src/app/(portal)/client/page.tsx": "cf0d1b0517aadf1bfbfc91552d0c13201a140c31d2df9e08e1c62a5d48e59189",
31
+ "src/app/(portal)/client/page.tsx": "856b70d4514e09e6efbb933421dbe7dfcfcea04321d142d0aca4b5dff6c2dc95",
32
32
  "src/app/api/client-auth/route.ts": "ce1858559b1e944d5b1dc719d1f03bebf66286671700b1b5397382109f0f1e0d",
33
33
  "src/app/api/client-auth/share/route.ts": "ed82414212dcd26af8c6c0f2bd44d9d79a727ed35cfedbac8c4077a6220aad14",
34
34
  "src/app/api/client-events/route.ts": "13d545537b7e8ce421e6169d25c105adf2a2de3d978ae0a2c6751ff5f7d2eb33",
@@ -41,10 +41,10 @@
41
41
  "src/app/layout.tsx": "c17aabeb2b486f023e777230343ace6cc06840f641a10b9dd9f65e092018f82f",
42
42
  "src/app/page.tsx": "1f71205c3ae30bf6929d37947b0c94ae53aed33b6689b7d6b13066ad51c1bc14",
43
43
  "src/components/copy-button.tsx": "2f3d1d8a6a0a570c8d78e19c3c15519c44af17b5d8893ae5a5f57db5ecce7077",
44
- "src/components/portal-login.tsx": "8b0d91bb28674e1102fd2e5b5ddcc3a93755dd806fbd3d1b2dbea2646cffca5e",
45
- "src/components/portal-shell.tsx": "f46a0f753a4a0318f06c8b4e46295febb84b03ea082c95057a6da50c737b4e21",
44
+ "src/components/portal-login.tsx": "cd20f5023a427202fc031d403048ffd7d8966cfa07b96fadc0877e553c5bc9bb",
45
+ "src/components/portal-shell.tsx": "098e65be5a4303644e5302e345c1fb7601493dbdda0421f59b1f428c918a8918",
46
46
  "src/lib/abuse-controls.ts": "d79d58d93267aca48ad0b7b9b91f753c9a3c27263e4e98daf768a950c44a6fc6",
47
- "src/lib/branding.ts": "f0fa242c285610105c27fcaf76828f1892c8051fc2db5c36bdbe92ea916aa4bd",
47
+ "src/lib/branding.ts": "9abd43d77ea35c2e39e237cb9a5c45d20cf7830a3685c4f9a545f0d5333ee012",
48
48
  "src/lib/client-auth.ts": "b9bdfe77dbe5d6ec6c6a930627fc43d3253f0d76fd8fc4093af5a75742bebe42",
49
49
  "src/lib/client-portals.ts": "9b531f9a9ea459b4ab85257b9dd282874fa1422838fe89d511940e417114216a",
50
50
  "src/lib/control-plane.ts": "e0cf39f28ec7de715fd5cfbb5f4240773fcd3d775cd1677588dd749fff740a0e",
@@ -5,6 +5,7 @@ import {
5
5
  getDomainFromWebsite,
6
6
  getLogoUrl,
7
7
  normalizeWebsiteUrl,
8
+ resolvePortalLabel,
8
9
  } from "@/lib/branding";
9
10
 
10
11
  describe("branding helpers", () => {
@@ -40,4 +41,9 @@ describe("branding helpers", () => {
40
41
  }),
41
42
  ).toBe(getLogoUrl("", "Bidgen"));
42
43
  });
44
+
45
+ it("uses the org name when the stored portal label is still generic", () => {
46
+ expect(resolvePortalLabel("Bidgen-Test", "Client Portal")).toBe("Bidgen-Test Portal");
47
+ expect(resolvePortalLabel("Bidgen-Test", "Bidgen-Test Workspace")).toBe("Bidgen-Test Workspace");
48
+ });
43
49
  });
@@ -1,5 +1,5 @@
1
1
  import { PortalLogin } from "@/components/portal-login";
2
- import { getBrandLogoUrl } from "@/lib/branding";
2
+ import { getBrandLogoUrl, resolvePortalLabel } from "@/lib/branding";
3
3
  import { prisma } from "@/lib/db";
4
4
  import { resolveDefaultOrganizationId } from "@/lib/client-portals";
5
5
  import { getRuntimePortalBySlug, getRuntimeState, isRuntimeSnapshotMode } from "@/lib/runtime-state";
@@ -13,7 +13,7 @@ export default async function ClientLogin({
13
13
  const portalSlug = params.portal?.trim() || null;
14
14
 
15
15
  let companyName = "Showpane";
16
- let companyUrl = "https://showpane.com";
16
+ let companyUrl: string | null = "https://showpane.com";
17
17
  let supportEmail = "support@showpane.com";
18
18
  let portalLabel = "Client Portal";
19
19
  let description = "Private portal access. Sign in with the credentials you were sent.";
@@ -29,16 +29,14 @@ export default async function ClientLogin({
29
29
  const orgName = state?.organization?.name || companyName;
30
30
  companyName = orgName;
31
31
  companyLogoAlt = orgName;
32
- portalLabel = state?.organization?.portalLabel || `${orgName} Portal`;
32
+ portalLabel = resolvePortalLabel(orgName, state?.organization?.portalLabel);
33
33
  description = `Private portal created by ${orgName} for ${portal.companyName}. Sign in with the credentials you were sent.`;
34
34
  companyLogoSrc = getBrandLogoUrl({
35
35
  logoUrl: state?.organization?.logoUrl,
36
36
  websiteUrl: state?.organization?.websiteUrl,
37
37
  fallbackName: orgName,
38
38
  });
39
- if (state?.organization?.websiteUrl) {
40
- companyUrl = state.organization.websiteUrl;
41
- }
39
+ companyUrl = state?.organization?.websiteUrl ?? null;
42
40
  if (state?.organization?.supportEmail) {
43
41
  supportEmail = state.organization.supportEmail;
44
42
  }
@@ -71,7 +69,7 @@ export default async function ClientLogin({
71
69
  const orgName = organization?.name || companyName;
72
70
  companyName = orgName;
73
71
  companyLogoAlt = orgName;
74
- portalLabel = organization?.portalLabel || `${orgName} Portal`;
72
+ portalLabel = resolvePortalLabel(orgName, organization?.portalLabel);
75
73
  description = `Private portal created by ${orgName} for ${portal.companyName}. Sign in with the credentials you were sent.`;
76
74
  companyLogoSrc = getBrandLogoUrl({
77
75
  logoUrl: organization?.logoUrl,
@@ -79,9 +77,7 @@ export default async function ClientLogin({
79
77
  fallbackName: orgName,
80
78
  });
81
79
  }
82
- if (organization?.websiteUrl) {
83
- companyUrl = organization.websiteUrl;
84
- }
80
+ companyUrl = organization?.websiteUrl ?? null;
85
81
  if (organization?.supportEmail) {
86
82
  supportEmail = organization.supportEmail;
87
83
  }
@@ -95,21 +91,8 @@ export default async function ClientLogin({
95
91
  return (
96
92
  <PortalLogin
97
93
  companyName={companyName}
98
- companyLogo={
99
- companyLogoSrc ? (
100
- <img
101
- src={companyLogoSrc}
102
- alt={companyLogoAlt}
103
- className="h-7 w-7 rounded-lg object-cover"
104
- />
105
- ) : (
106
- <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gray-900">
107
- <span className="text-xs font-bold text-white">
108
- {companyName[0]?.toUpperCase() || "S"}
109
- </span>
110
- </div>
111
- )
112
- }
94
+ companyLogoSrc={companyLogoSrc}
95
+ companyLogoAlt={companyLogoAlt}
113
96
  companyUrl={companyUrl}
114
97
  supportEmail={supportEmail}
115
98
  portalLabel={portalLabel}
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { useState, FormEvent, ReactNode } from "react";
3
+ import { useState, FormEvent } from "react";
4
4
  import { ArrowRight, Eye, EyeOff } from "lucide-react";
5
5
 
6
6
  export type PortalLoginProps = {
7
7
  companyName: string;
8
- companyLogo: ReactNode;
9
- companyUrl: string;
8
+ companyLogoSrc?: string | null;
9
+ companyLogoAlt?: string;
10
+ companyUrl?: string | null;
10
11
  portalLabel?: string;
11
12
  description?: string;
12
13
  supportEmail: string;
@@ -16,7 +17,8 @@ export type PortalLoginProps = {
16
17
 
17
18
  export function PortalLogin({
18
19
  companyName,
19
- companyLogo,
20
+ companyLogoSrc,
21
+ companyLogoAlt,
20
22
  companyUrl,
21
23
  portalLabel,
22
24
  description,
@@ -29,17 +31,40 @@ export function PortalLogin({
29
31
  const [error, setError] = useState<{ message: string; hint?: string } | null>(null);
30
32
  const [loading, setLoading] = useState(false);
31
33
  const [showPassword, setShowPassword] = useState(false);
34
+ const [logoFailed, setLogoFailed] = useState(false);
32
35
 
33
36
  const resolvedPortalLabel = portalLabel ?? "Client Portal";
34
37
  const resolvedDescription = description ?? `Private portal for ${companyName} clients. Sign in with the credentials we sent you.`;
35
38
  const resolvedAuthEndpoint = authEndpoint ?? "/api/client-auth";
36
39
  const resolvedRedirectBasePath = redirectBasePath ?? "/client";
40
+ const resolvedCompanyLogoAlt = companyLogoAlt ?? companyName;
41
+ const brandMark = (
42
+ <>
43
+ {companyLogoSrc && !logoFailed ? (
44
+ <img
45
+ src={companyLogoSrc}
46
+ alt={resolvedCompanyLogoAlt}
47
+ className="h-7 w-7 rounded-lg object-cover"
48
+ onError={() => setLogoFailed(true)}
49
+ />
50
+ ) : (
51
+ <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gray-900">
52
+ <span className="text-xs font-bold text-white">
53
+ {companyName[0]?.toUpperCase() || "S"}
54
+ </span>
55
+ </div>
56
+ )}
57
+ <span className="text-base font-bold tracking-tight text-gray-900">{companyName}</span>
58
+ </>
59
+ );
37
60
 
38
- let displayDomain: string;
39
- try {
40
- displayDomain = new URL(companyUrl).hostname;
41
- } catch {
42
- displayDomain = companyUrl;
61
+ let displayDomain: string | null = null;
62
+ if (companyUrl) {
63
+ try {
64
+ displayDomain = new URL(companyUrl).hostname;
65
+ } catch {
66
+ displayDomain = companyUrl;
67
+ }
43
68
  }
44
69
 
45
70
  async function handleSubmit(e: FormEvent) {
@@ -85,10 +110,15 @@ export function PortalLogin({
85
110
  <div className="absolute -bottom-32 -left-32 h-96 w-96 rounded-full bg-primary/5 blur-[120px]" />
86
111
 
87
112
  <div className="relative z-10 w-full max-w-sm">
88
- <a href={companyUrl} className="mx-auto mb-8 flex w-fit items-center gap-2 transition-opacity hover:opacity-70">
89
- {companyLogo}
90
- <span className="text-base font-bold tracking-tight text-gray-900">{companyName}</span>
91
- </a>
113
+ {companyUrl ? (
114
+ <a href={companyUrl} className="mx-auto mb-8 flex w-fit items-center gap-2 transition-opacity hover:opacity-70">
115
+ {brandMark}
116
+ </a>
117
+ ) : (
118
+ <div className="mx-auto mb-8 flex w-fit items-center gap-2">
119
+ {brandMark}
120
+ </div>
121
+ )}
92
122
 
93
123
  <div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
94
124
  <div className="mb-5">
@@ -156,9 +186,11 @@ export function PortalLogin({
156
186
  </div>
157
187
 
158
188
  <div className="mt-5 space-y-2 text-center text-xs">
159
- <p className="text-gray-400">
160
- Not a client? <a href={companyUrl} className="text-gray-500 underline underline-offset-2 transition-colors hover:text-gray-700">Visit {displayDomain}</a>
161
- </p>
189
+ {companyUrl && displayDomain ? (
190
+ <p className="text-gray-400">
191
+ Not a client? <a href={companyUrl} className="text-gray-500 underline underline-offset-2 transition-colors hover:text-gray-700">Visit {displayDomain}</a>
192
+ </p>
193
+ ) : null}
162
194
  <p className="text-gray-400">
163
195
  Lost your credentials? Email <span className="font-medium text-gray-500">{supportEmail}</span>
164
196
  </p>
@@ -33,7 +33,7 @@ export type PortalShellProps = {
33
33
  portalLabel?: string;
34
34
 
35
35
  clientName: string;
36
- clientLogoSrc: string;
36
+ clientLogoSrc?: string | null;
37
37
  clientLogoAlt: string;
38
38
 
39
39
  tabs: PortalTab[];
@@ -181,6 +181,7 @@ export function PortalShell({
181
181
  const [copyError, setCopyError] = useState(false);
182
182
  const [visitorId] = useState(() => getOrCreateVisitorId());
183
183
  const [showLocalBanner, setShowLocalBanner] = useState(false);
184
+ const [clientLogoFailed, setClientLogoFailed] = useState(false);
184
185
 
185
186
  useEffect(() => {
186
187
  const syncFromHash = () => setActiveTab(readHashTab(tabIds));
@@ -207,6 +208,10 @@ export function PortalShell({
207
208
  setShowLocalBanner(host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0");
208
209
  }, []);
209
210
 
211
+ useEffect(() => {
212
+ setClientLogoFailed(false);
213
+ }, [clientLogoSrc]);
214
+
210
215
  useSectionTimeTracking(activeTab, resolvedEventsEndpoint, visitorId);
211
216
 
212
217
  function switchTab(tab: string) {
@@ -234,7 +239,8 @@ export function PortalShell({
234
239
  }
235
240
 
236
241
  const activeContent = tabs.find((t) => t.id === activeTab)?.content ?? null;
237
- const showFooter = hideFooterOnTab ? activeTab !== hideFooterOnTab : true;
242
+ const showContactFooter = hideFooterOnTab ? activeTab !== hideFooterOnTab : true;
243
+ const clientInitial = clientName[0]?.toUpperCase() || "?";
238
244
 
239
245
  return (
240
246
  <div className="flex min-h-screen flex-col bg-gray-50">
@@ -262,14 +268,25 @@ export function PortalShell({
262
268
  <div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-gray-900">
263
269
  {companyLogo}
264
270
  </div>
265
- <img
266
- src={clientLogoSrc}
267
- alt={clientLogoAlt}
268
- width={32}
269
- height={32}
270
- className="-ml-2 h-8 w-8 rounded-full border-2 border-white"
271
- loading="eager"
272
- />
271
+ {clientLogoSrc && !clientLogoFailed ? (
272
+ <img
273
+ src={clientLogoSrc}
274
+ alt={clientLogoAlt}
275
+ width={32}
276
+ height={32}
277
+ className="-ml-2 h-8 w-8 rounded-full border-2 border-white bg-white object-cover"
278
+ loading="eager"
279
+ onError={() => setClientLogoFailed(true)}
280
+ />
281
+ ) : (
282
+ <div
283
+ role="img"
284
+ aria-label={clientLogoAlt}
285
+ className="-ml-2 flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-gray-100"
286
+ >
287
+ <span className="text-[11px] font-semibold text-gray-700">{clientInitial}</span>
288
+ </div>
289
+ )}
273
290
  </div>
274
291
  <div>
275
292
  <h1 className="text-sm font-bold tracking-tight text-gray-900">
@@ -342,8 +359,8 @@ export function PortalShell({
342
359
  {activeContent}
343
360
  </main>
344
361
 
345
- {showFooter ? (
346
- <footer className="mt-auto border-t bg-white">
362
+ <footer className="mt-auto border-t bg-white">
363
+ {showContactFooter ? (
347
364
  <div className="mx-auto flex max-w-4xl items-center gap-4 px-4 py-4 sm:px-6">
348
365
  <img
349
366
  src={contact.avatarSrc}
@@ -377,20 +394,28 @@ export function PortalShell({
377
394
  ) : null}
378
395
  </div>
379
396
  </div>
380
- <div className="flex items-center justify-center gap-2 pb-3">
381
- <p className="text-[11px] text-gray-300">Last updated {lastUpdated}</p>
382
- <span className="text-gray-200">·</span>
397
+ ) : null}
398
+ <div
399
+ className={cn(
400
+ "flex flex-wrap items-center justify-center gap-2 px-4 py-3 text-[11px] text-gray-400 sm:px-6",
401
+ showContactFooter ? "border-t border-gray-100" : "",
402
+ )}
403
+ >
404
+ <p>Last updated {lastUpdated}</p>
405
+ <span className="text-gray-200">·</span>
406
+ <p>
407
+ Created using{" "}
383
408
  <a
384
409
  href="https://showpane.com"
385
410
  target="_blank"
386
411
  rel="noopener noreferrer"
387
- className="text-[10px] text-gray-300 transition-colors hover:text-gray-400"
412
+ className="font-medium text-gray-500 transition-colors hover:text-gray-700"
388
413
  >
389
- Powered by Showpane
414
+ showpane.com
390
415
  </a>
391
- </div>
392
- </footer>
393
- ) : null}
416
+ </p>
417
+ </div>
418
+ </footer>
394
419
  </div>
395
420
  );
396
421
  }
@@ -36,13 +36,18 @@ export function getDomainFromWebsite(value?: string | null): string | null {
36
36
 
37
37
  /**
38
38
  * Fetch a company logo URL from a domain.
39
- * Uses Clearbit Logo API (free, no key required).
39
+ * Uses Logo.dev for hosted brand logos.
40
40
  * Falls back to a UI Avatars URL with the company initial.
41
41
  */
42
42
  export function getLogoUrl(domain: string, fallbackName?: string): string {
43
43
  if (domain) {
44
- // Clearbit Logo API - free, no authentication needed
45
- return `https://logo.clearbit.com/${domain}`;
44
+ const params = new URLSearchParams({
45
+ size: "128",
46
+ format: "webp",
47
+ fallback: "monogram",
48
+ retina: "true",
49
+ });
50
+ return `https://img.logo.dev/${domain}?${params.toString()}`;
46
51
  }
47
52
  // Fallback: initial-based avatar via ui-avatars.com
48
53
  const initial = (fallbackName || "?")[0].toUpperCase();
@@ -66,6 +71,17 @@ export function getBrandLogoUrl(options: {
66
71
  return getLogoUrl(domain ?? "", options.fallbackName);
67
72
  }
68
73
 
74
+ export function resolvePortalLabel(
75
+ organizationName: string,
76
+ portalLabel?: string | null,
77
+ ): string {
78
+ if (portalLabel && portalLabel.trim() && portalLabel !== "Client Portal") {
79
+ return portalLabel;
80
+ }
81
+
82
+ return `${organizationName} Portal`;
83
+ }
84
+
69
85
  /**
70
86
  * Fetch a Gravatar URL for an email address.
71
87
  * Falls back to UI Avatars if no Gravatar exists.
@@ -1 +1 @@
1
- 0.4.27
1
+ 0.4.29
@@ -13,6 +13,21 @@ function fail(message: string): never {
13
13
  process.exit(1);
14
14
  }
15
15
 
16
+ function shouldRetryWithoutWebsiteUrl(message: string): boolean {
17
+ const normalized = message.toLowerCase();
18
+ return (
19
+ normalized.includes("websiteurl") &&
20
+ (
21
+ normalized.includes("unknown arg") ||
22
+ normalized.includes("unknown argument") ||
23
+ normalized.includes("unknown field") ||
24
+ normalized.includes("no column named") ||
25
+ normalized.includes("has no column") ||
26
+ normalized.includes("does not exist")
27
+ )
28
+ );
29
+ }
30
+
16
31
  async function main() {
17
32
  const args = process.argv.slice(2);
18
33
 
@@ -74,11 +89,7 @@ async function main() {
74
89
  });
75
90
  } catch (error) {
76
91
  const message = error instanceof Error ? error.message : String(error);
77
- if (
78
- websiteUrl &&
79
- (message.includes("Unknown arg `websiteUrl`") ||
80
- message.includes("Unknown argument `websiteUrl`"))
81
- ) {
92
+ if (websiteUrl && shouldRetryWithoutWebsiteUrl(message)) {
82
93
  delete createData.websiteUrl;
83
94
  portal = await prisma.clientPortal.create({
84
95
  data: createData,
@@ -45,21 +45,57 @@ async function main() {
45
45
  fail("No organization found");
46
46
  }
47
47
 
48
- const portals = await prisma.clientPortal.findMany({
49
- where: { organizationId: organization.id },
50
- orderBy: { createdAt: "asc" },
51
- select: {
52
- slug: true,
53
- companyName: true,
54
- websiteUrl: true,
55
- logoUrl: true,
56
- username: true,
57
- passwordHash: true,
58
- credentialVersion: true,
59
- isActive: true,
60
- lastUpdated: true,
61
- },
62
- });
48
+ let portals: Array<{
49
+ slug: string;
50
+ companyName: string;
51
+ websiteUrl?: string | null;
52
+ logoUrl?: string | null;
53
+ username: string;
54
+ passwordHash: string;
55
+ credentialVersion: string;
56
+ isActive: boolean;
57
+ lastUpdated?: string | null;
58
+ }> = [];
59
+ try {
60
+ portals = await prisma.clientPortal.findMany({
61
+ where: { organizationId: organization.id },
62
+ orderBy: { createdAt: "asc" },
63
+ select: {
64
+ slug: true,
65
+ companyName: true,
66
+ websiteUrl: true,
67
+ logoUrl: true,
68
+ username: true,
69
+ passwordHash: true,
70
+ credentialVersion: true,
71
+ isActive: true,
72
+ lastUpdated: true,
73
+ },
74
+ });
75
+ } catch (error) {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ if (!message.toLowerCase().includes("websiteurl")) {
78
+ throw error;
79
+ }
80
+ const legacyPortals = await prisma.clientPortal.findMany({
81
+ where: { organizationId: organization.id },
82
+ orderBy: { createdAt: "asc" },
83
+ select: {
84
+ slug: true,
85
+ companyName: true,
86
+ logoUrl: true,
87
+ username: true,
88
+ passwordHash: true,
89
+ credentialVersion: true,
90
+ isActive: true,
91
+ lastUpdated: true,
92
+ },
93
+ });
94
+ portals = legacyPortals.map((portal) => ({
95
+ ...portal,
96
+ websiteUrl: null,
97
+ }));
98
+ }
63
99
 
64
100
  console.log(JSON.stringify({
65
101
  ok: true,
@@ -168,11 +168,10 @@ The script returns `{"valid":true}` or `{"valid":false,"reason":"...","message":
168
168
 
169
169
  If invalid, explain the issue and ask for a different slug.
170
170
 
171
- Also determine the client's website domain. This enables auto-branding and is worth a quick best-effort pass:
171
+ Also determine the client's website domain. This enables auto-branding, but keep the step deterministic:
172
172
  - if the domain is explicit in the transcript or user prompt, use it
173
- - otherwise make one quick best-effort lookup for the official site
174
- - if one clear candidate stands out, suggest it briefly: `I found <domain> I'll use that for the logo unless you want a different one`
175
- - if confidence is low, ask directly
173
+ - otherwise ask the user directly for the official site/domain
174
+ - do not guess, browse, or invent a domain as part of this skill flow
176
175
 
177
176
  Store the confirmed value in `ClientPortal.websiteUrl`.
178
177
  Use it to derive `ClientPortal.logoUrl` via `getBrandLogoUrl(...)`.
@@ -299,6 +298,7 @@ import { PortalShell } from "@/components/portal-shell";
299
298
  - `contact` — object with `name`, `title`, `avatarSrc`, `email` (from the `get-org.ts` result, not from ad-hoc config or DB probing)
300
299
  - `tabs` — array of tab objects with `id`, `label`, `icon`, `content`, and optional `badge`
301
300
  - `hideFooterOnTab` — set to `"overview"` (hides the contact footer on the first tab since it typically has contact info inline)
301
+ - Keep the shared `PortalShell` footer attribution intact. New portals should retain the `Created using showpane.com` link rendered by the shell.
302
302
 
303
303
  **Styling conventions (match the example portal exactly):**
304
304
  - Cards: `rounded-2xl border bg-white shadow-sm`
@@ -356,13 +356,14 @@ For onboarding, carry them forward quietly and show them at the access phase.
356
356
  After generating the files, read them back and verify:
357
357
 
358
358
  1. **PortalShell used?** The client component must use `<PortalShell>` as its root element.
359
- 2. **Minimum 2 tabs?** Check the `tabs` array has at least 2 entries.
360
- 3. **Contact info in props?** The `contact` prop must have `name`, `title`, `avatarSrc`, `email`.
361
- 4. **"use client" directive?** Must be the first line of the client component.
362
- 5. **Imports correct?** `cn` from `@/lib/utils`, `PortalShell` from `@/components/portal-shell`.
363
- 6. **No hardcoded localhost URLs?** Links should be relative or use placeholders.
364
- 7. **Responsive patterns?** Check for `sm:` breakpoints on grids and padding.
365
- 8. **Tab content functions?** Each tab should have its own function, not inline JSX.
359
+ 2. **Shared attribution preserved?** Do not replace or bypass `PortalShell` in a way that removes the `Created using showpane.com` footer link.
360
+ 3. **Minimum 2 tabs?** Check the `tabs` array has at least 2 entries.
361
+ 4. **Contact info in props?** The `contact` prop must have `name`, `title`, `avatarSrc`, `email`.
362
+ 5. **"use client" directive?** Must be the first line of the client component.
363
+ 6. **Imports correct?** `cn` from `@/lib/utils`, `PortalShell` from `@/components/portal-shell`.
364
+ 7. **No hardcoded localhost URLs?** Links should be relative or use placeholders.
365
+ 8. **Responsive patterns?** Check for `sm:` breakpoints on grids and padding.
366
+ 9. **Tab content functions?** Each tab should have its own function, not inline JSX.
366
367
 
367
368
  If any check fails, fix the issue before proceeding.
368
369
 
@@ -73,11 +73,10 @@ The script returns `{"valid":true}` or `{"valid":false,"reason":"...","message":
73
73
 
74
74
  If invalid, explain the issue and ask for a different slug.
75
75
 
76
- Also determine the client's website domain. This enables auto-branding and is worth a quick best-effort pass:
76
+ Also determine the client's website domain. This enables auto-branding, but keep the step deterministic:
77
77
  - if the domain is explicit in the transcript or user prompt, use it
78
- - otherwise make one quick best-effort lookup for the official site
79
- - if one clear candidate stands out, suggest it briefly: `I found <domain> I'll use that for the logo unless you want a different one`
80
- - if confidence is low, ask directly
78
+ - otherwise ask the user directly for the official site/domain
79
+ - do not guess, browse, or invent a domain as part of this skill flow
81
80
 
82
81
  Store the confirmed value in `ClientPortal.websiteUrl`.
83
82
  Use it to derive `ClientPortal.logoUrl` via `getBrandLogoUrl(...)`.
@@ -204,6 +203,7 @@ import { PortalShell } from "@/components/portal-shell";
204
203
  - `contact` — object with `name`, `title`, `avatarSrc`, `email` (from the `get-org.ts` result, not from ad-hoc config or DB probing)
205
204
  - `tabs` — array of tab objects with `id`, `label`, `icon`, `content`, and optional `badge`
206
205
  - `hideFooterOnTab` — set to `"overview"` (hides the contact footer on the first tab since it typically has contact info inline)
206
+ - Keep the shared `PortalShell` footer attribution intact. New portals should retain the `Created using showpane.com` link rendered by the shell.
207
207
 
208
208
  **Styling conventions (match the example portal exactly):**
209
209
  - Cards: `rounded-2xl border bg-white shadow-sm`
@@ -261,13 +261,14 @@ For onboarding, carry them forward quietly and show them at the access phase.
261
261
  After generating the files, read them back and verify:
262
262
 
263
263
  1. **PortalShell used?** The client component must use `<PortalShell>` as its root element.
264
- 2. **Minimum 2 tabs?** Check the `tabs` array has at least 2 entries.
265
- 3. **Contact info in props?** The `contact` prop must have `name`, `title`, `avatarSrc`, `email`.
266
- 4. **"use client" directive?** Must be the first line of the client component.
267
- 5. **Imports correct?** `cn` from `@/lib/utils`, `PortalShell` from `@/components/portal-shell`.
268
- 6. **No hardcoded localhost URLs?** Links should be relative or use placeholders.
269
- 7. **Responsive patterns?** Check for `sm:` breakpoints on grids and padding.
270
- 8. **Tab content functions?** Each tab should have its own function, not inline JSX.
264
+ 2. **Shared attribution preserved?** Do not replace or bypass `PortalShell` in a way that removes the `Created using showpane.com` footer link.
265
+ 3. **Minimum 2 tabs?** Check the `tabs` array has at least 2 entries.
266
+ 4. **Contact info in props?** The `contact` prop must have `name`, `title`, `avatarSrc`, `email`.
267
+ 5. **"use client" directive?** Must be the first line of the client component.
268
+ 6. **Imports correct?** `cn` from `@/lib/utils`, `PortalShell` from `@/components/portal-shell`.
269
+ 7. **No hardcoded localhost URLs?** Links should be relative or use placeholders.
270
+ 8. **Responsive patterns?** Check for `sm:` breakpoints on grids and padding.
271
+ 9. **Tab content functions?** Each tab should have its own function, not inline JSX.
271
272
 
272
273
  If any check fails, fix the issue before proceeding.
273
274
 
@@ -197,9 +197,9 @@ Do not restart the whole org questionnaire from the top.
197
197
  3. **Contact email** (required) — e.g., "jane@acme.com". Displayed in the portal footer as a mailto link.
198
198
  4. **Contact title** (optional, default: "Account Manager") — e.g., "Director", "Partner", "Client Success Lead". Shown next to the contact name in the portal footer.
199
199
  5. **Contact phone** (optional) — e.g., "+44 7700 900000". If provided, displayed alongside email in the portal footer.
200
- 6. **Company website URL** — e.g., "acme.com". Ask for it plainly instead of framing it as optional. Used to auto-fetch the company logo via Clearbit.
201
- - If provided, fetch logo URL: `https://logo.clearbit.com/{domain}` and store in `Organization.logoUrl`
202
- - Also store the URL in `Organization.websiteUrl`
200
+ 6. **Company website URL** — e.g., "acme.com". Ask for it plainly instead of framing it as optional. Store the normalized value in `Organization.websiteUrl` so runtime branding can derive the company logo.
201
+ - Do not generate and persist a provider logo URL in `Organization.logoUrl` from this field
202
+ - Only set `Organization.logoUrl` if the user explicitly gives you a direct logo asset URL they want pinned
203
203
  7. **Contact avatar**: Auto-populated from the contact email via Gravatar. No need to ask — just use `getAvatarUrl(email, contactName)` from `app/src/lib/branding.ts` and store in `Organization.contactAvatar`
204
204
 
205
205
  Generate an org slug from the organization name: lowercase, replace spaces with hyphens, strip non-alphanumeric characters except hyphens, remove consecutive hyphens. For example, "Acme Consulting Ltd." becomes `acme-consulting-ltd`. Confirm the generated slug with the user and allow them to override it.
@@ -114,9 +114,9 @@ Do not restart the whole org questionnaire from the top.
114
114
  3. **Contact email** (required) — e.g., "jane@acme.com". Displayed in the portal footer as a mailto link.
115
115
  4. **Contact title** (optional, default: "Account Manager") — e.g., "Director", "Partner", "Client Success Lead". Shown next to the contact name in the portal footer.
116
116
  5. **Contact phone** (optional) — e.g., "+44 7700 900000". If provided, displayed alongside email in the portal footer.
117
- 6. **Company website URL** — e.g., "acme.com". Ask for it plainly instead of framing it as optional. Used to auto-fetch the company logo via Clearbit.
118
- - If provided, fetch logo URL: `https://logo.clearbit.com/{domain}` and store in `Organization.logoUrl`
119
- - Also store the URL in `Organization.websiteUrl`
117
+ 6. **Company website URL** — e.g., "acme.com". Ask for it plainly instead of framing it as optional. Store the normalized value in `Organization.websiteUrl` so runtime branding can derive the company logo.
118
+ - Do not generate and persist a provider logo URL in `Organization.logoUrl` from this field
119
+ - Only set `Organization.logoUrl` if the user explicitly gives you a direct logo asset URL they want pinned
120
120
  7. **Contact avatar**: Auto-populated from the contact email via Gravatar. No need to ask — just use `getAvatarUrl(email, contactName)` from `app/src/lib/branding.ts` and store in `Organization.contactAvatar`
121
121
 
122
122
  Generate an org slug from the organization name: lowercase, replace spaces with hyphens, strip non-alphanumeric characters except hyphens, remove consecutive hyphens. For example, "Acme Consulting Ltd." becomes `acme-consulting-ltd`. Confirm the generated slug with the user and allow them to override it.
@@ -225,6 +225,7 @@ Ask the user what they want to change. Common requests and how to handle them:
225
225
  **Updating contact info or metadata:**
226
226
  1. Update the relevant PortalShell props
227
227
  2. Update any inline mentions of the contact in tab content
228
+ 3. Keep the shared `PortalShell` footer attribution intact. Do not remove or replace the `Created using showpane.com` link.
228
229
 
229
230
  ### Step 7: Make the edits
230
231
 
@@ -247,6 +248,7 @@ After making edits, show the user a summary of what changed:
247
248
  - Which tabs were added, removed, or modified
248
249
  - What content was updated
249
250
  - Any prop changes on PortalShell
251
+ - Confirm the shared `Created using showpane.com` footer attribution is still present
250
252
 
251
253
  If using git, show the actual diff:
252
254
 
@@ -130,6 +130,7 @@ Ask the user what they want to change. Common requests and how to handle them:
130
130
  **Updating contact info or metadata:**
131
131
  1. Update the relevant PortalShell props
132
132
  2. Update any inline mentions of the contact in tab content
133
+ 3. Keep the shared `PortalShell` footer attribution intact. Do not remove or replace the `Created using showpane.com` link.
133
134
 
134
135
  ### Step 7: Make the edits
135
136
 
@@ -152,6 +153,7 @@ After making edits, show the user a summary of what changed:
152
153
  - Which tabs were added, removed, or modified
153
154
  - What content was updated
154
155
  - Any prop changes on PortalShell
156
+ - Confirm the shared `Created using showpane.com` footer attribution is still present
155
157
 
156
158
  If using git, show the actual diff:
157
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "showpane",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "CLI for Showpane — AI-generated client portals",
5
5
  "type": "module",
6
6
  "bin": {