showpane 0.4.1 → 0.4.2
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/README.md +14 -1
- package/bundle/meta/scaffold-manifest.json +73 -0
- package/bundle/scaffold/VERSION +1 -0
- package/bundle/scaffold/__dot__env.example +24 -0
- package/bundle/scaffold/__dot__gitignore +41 -0
- package/bundle/scaffold/docker/Caddyfile +3 -0
- package/bundle/scaffold/docker/Dockerfile +30 -0
- package/bundle/scaffold/docker-compose.yml +53 -0
- package/bundle/scaffold/next.config.ts +20 -0
- package/bundle/scaffold/package-lock.json +5843 -0
- package/bundle/scaffold/package.json +42 -0
- package/bundle/scaffold/postcss.config.js +6 -0
- package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
- package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
- package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
- package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
- package/bundle/scaffold/prisma/schema.local.prisma +131 -0
- package/bundle/scaffold/prisma/schema.prisma +128 -0
- package/bundle/scaffold/prisma/seed.ts +49 -0
- package/bundle/scaffold/public/example-avatar.svg +4 -0
- package/bundle/scaffold/public/example-logo.svg +4 -0
- package/bundle/scaffold/public/robots.txt +2 -0
- package/bundle/scaffold/scripts/backup.sh +19 -0
- package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
- package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
- package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
- package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
- package/bundle/scaffold/scripts/restore.sh +31 -0
- package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
- package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
- package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
- package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
- package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
- package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
- package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
- package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
- package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
- package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
- package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
- package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
- package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
- package/bundle/scaffold/src/app/api/health/route.ts +19 -0
- package/bundle/scaffold/src/app/globals.css +7 -0
- package/bundle/scaffold/src/app/layout.tsx +25 -0
- package/bundle/scaffold/src/app/page.tsx +171 -0
- package/bundle/scaffold/src/components/portal-login.tsx +169 -0
- package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
- package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
- package/bundle/scaffold/src/lib/branding.ts +50 -0
- package/bundle/scaffold/src/lib/client-auth.ts +98 -0
- package/bundle/scaffold/src/lib/client-portals.ts +134 -0
- package/bundle/scaffold/src/lib/control-plane.ts +100 -0
- package/bundle/scaffold/src/lib/db.ts +7 -0
- package/bundle/scaffold/src/lib/files.ts +124 -0
- package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
- package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
- package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
- package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
- package/bundle/scaffold/src/lib/storage.ts +204 -0
- package/bundle/scaffold/src/lib/token.ts +186 -0
- package/bundle/scaffold/src/lib/utils.ts +6 -0
- package/bundle/scaffold/src/middleware.ts +61 -0
- package/bundle/scaffold/tailwind.config.ts +15 -0
- package/bundle/scaffold/tests/__dot__gitkeep +0 -0
- package/bundle/scaffold/tsconfig.json +23 -0
- package/bundle/scaffold/vitest.config.ts +13 -0
- package/bundle/toolchain/VERSION +1 -0
- package/bundle/toolchain/bin/check-slug.ts +59 -0
- package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
- package/bundle/toolchain/bin/create-portal.ts +71 -0
- package/bundle/toolchain/bin/delete-portal.ts +48 -0
- package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
- package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
- package/bundle/toolchain/bin/generate-share-link.ts +68 -0
- package/bundle/toolchain/bin/list-portals.ts +53 -0
- package/bundle/toolchain/bin/materialize-file.ts +35 -0
- package/bundle/toolchain/bin/query-analytics.ts +88 -0
- package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
- package/bundle/toolchain/bin/showpane-config +63 -0
- package/bundle/toolchain/bin/tsconfig.json +13 -0
- package/bundle/toolchain/skills/VERSION +1 -0
- package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
- package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
- package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
- package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
- package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
- package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
- package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
- package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
- package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
- package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
- package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
- package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
- package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
- package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
- package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
- package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
- package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
- package/bundle/toolchain/skills/shared/preamble.md +137 -0
- package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
- package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
- package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
- package/dist/index.js +873 -166
- package/package.json +3 -2
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock prisma before importing the module under test
|
|
4
|
+
vi.mock("@/lib/db", () => ({
|
|
5
|
+
prisma: {
|
|
6
|
+
organization: {
|
|
7
|
+
findUnique: vi.fn(),
|
|
8
|
+
findFirst: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
clientPortal: {
|
|
11
|
+
findFirst: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { resolveDefaultOrganizationId } from "@/lib/client-portals";
|
|
17
|
+
import { prisma } from "@/lib/db";
|
|
18
|
+
|
|
19
|
+
const mockedPrisma = vi.mocked(prisma);
|
|
20
|
+
|
|
21
|
+
describe("resolveDefaultOrganizationId", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.resetAllMocks();
|
|
24
|
+
delete process.env.ORG_ID;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns org id from ORG_ID env var when set and org exists", async () => {
|
|
28
|
+
process.env.ORG_ID = "cloud-org-123";
|
|
29
|
+
mockedPrisma.organization.findUnique.mockResolvedValue({
|
|
30
|
+
id: "cloud-org-123",
|
|
31
|
+
} as never);
|
|
32
|
+
|
|
33
|
+
const result = await resolveDefaultOrganizationId();
|
|
34
|
+
|
|
35
|
+
expect(result).toBe("cloud-org-123");
|
|
36
|
+
expect(mockedPrisma.organization.findUnique).toHaveBeenCalledWith({
|
|
37
|
+
where: { id: "cloud-org-123" },
|
|
38
|
+
select: { id: true },
|
|
39
|
+
});
|
|
40
|
+
expect(mockedPrisma.organization.findFirst).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null when ORG_ID is set but org not in DB", async () => {
|
|
44
|
+
process.env.ORG_ID = "nonexistent-org";
|
|
45
|
+
mockedPrisma.organization.findUnique.mockResolvedValue(null);
|
|
46
|
+
|
|
47
|
+
const result = await resolveDefaultOrganizationId();
|
|
48
|
+
|
|
49
|
+
expect(result).toBeNull();
|
|
50
|
+
expect(mockedPrisma.organization.findUnique).toHaveBeenCalledWith({
|
|
51
|
+
where: { id: "nonexistent-org" },
|
|
52
|
+
select: { id: true },
|
|
53
|
+
});
|
|
54
|
+
expect(mockedPrisma.organization.findFirst).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to first org in DB when ORG_ID not set", async () => {
|
|
58
|
+
// ORG_ID is not set (deleted in beforeEach)
|
|
59
|
+
mockedPrisma.organization.findFirst.mockResolvedValue({
|
|
60
|
+
id: "self-hosted-org-1",
|
|
61
|
+
} as never);
|
|
62
|
+
|
|
63
|
+
const result = await resolveDefaultOrganizationId();
|
|
64
|
+
|
|
65
|
+
expect(result).toBe("self-hosted-org-1");
|
|
66
|
+
expect(mockedPrisma.organization.findFirst).toHaveBeenCalledWith({
|
|
67
|
+
select: { id: true },
|
|
68
|
+
orderBy: { createdAt: "asc" },
|
|
69
|
+
});
|
|
70
|
+
expect(mockedPrisma.organization.findUnique).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null when ORG_ID not set and no orgs in DB", async () => {
|
|
74
|
+
mockedPrisma.organization.findFirst.mockResolvedValue(null);
|
|
75
|
+
|
|
76
|
+
const result = await resolveDefaultOrganizationId();
|
|
77
|
+
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ANALYTICS_METADATA_KEYS,
|
|
5
|
+
isPortalEventType,
|
|
6
|
+
toCloudPortalEventPayload,
|
|
7
|
+
} from "@/lib/portal-contracts";
|
|
8
|
+
|
|
9
|
+
describe("portal contracts", () => {
|
|
10
|
+
it("recognizes valid portal event types", () => {
|
|
11
|
+
expect(isPortalEventType("portal_view")).toBe(true);
|
|
12
|
+
expect(isPortalEventType("section_time")).toBe(true);
|
|
13
|
+
expect(isPortalEventType("cta_click")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("maps local event payloads to cloud payloads", () => {
|
|
17
|
+
expect(
|
|
18
|
+
toCloudPortalEventPayload("acme-health", {
|
|
19
|
+
event: "section_time",
|
|
20
|
+
detail: "pricing",
|
|
21
|
+
visitorId: "visitor-123",
|
|
22
|
+
metadata: { [ANALYTICS_METADATA_KEYS.durationSeconds]: 12 },
|
|
23
|
+
})
|
|
24
|
+
).toEqual({
|
|
25
|
+
portalSlug: "acme-health",
|
|
26
|
+
eventType: "section_time",
|
|
27
|
+
sectionName: "pricing",
|
|
28
|
+
visitorId: "visitor-123",
|
|
29
|
+
metadata: { durationSeconds: 12 },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { prisma } from "@/lib/db";
|
|
3
|
+
import { resolveDefaultOrganizationId } from "@/lib/client-portals";
|
|
4
|
+
import { getRuntimePortalBySlug, isRuntimeSnapshotMode } from "@/lib/runtime-state";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
|
|
7
|
+
export default async function PortalFallback({
|
|
8
|
+
params,
|
|
9
|
+
}: {
|
|
10
|
+
params: Promise<{ slug: string }>;
|
|
11
|
+
}) {
|
|
12
|
+
const { slug } = await params;
|
|
13
|
+
|
|
14
|
+
let portal: { companyName: string } | null = null;
|
|
15
|
+
try {
|
|
16
|
+
const orgId = await resolveDefaultOrganizationId();
|
|
17
|
+
if (!orgId) redirect("/client");
|
|
18
|
+
if (isRuntimeSnapshotMode()) {
|
|
19
|
+
const runtimePortal = await getRuntimePortalBySlug(slug);
|
|
20
|
+
portal = runtimePortal ? { companyName: runtimePortal.companyName } : null;
|
|
21
|
+
} else {
|
|
22
|
+
portal = await prisma.clientPortal.findFirst({
|
|
23
|
+
where: { slug, isActive: true, organizationId: orgId! },
|
|
24
|
+
select: { companyName: true },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
redirect("/client");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!portal) {
|
|
32
|
+
redirect("/client");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<main className="min-h-screen bg-[#FDFBF7] flex items-center justify-center px-4">
|
|
37
|
+
<div className="max-w-md w-full">
|
|
38
|
+
<div className="border border-gray-200 rounded-lg p-6 bg-white text-center">
|
|
39
|
+
<div className="w-10 h-10 rounded-full bg-green-100 text-green-700 flex items-center justify-center mx-auto mb-4 text-lg font-bold">
|
|
40
|
+
✓
|
|
41
|
+
</div>
|
|
42
|
+
<h1 className="text-lg font-bold text-gray-900 mb-1">
|
|
43
|
+
{portal.companyName} portal is set up
|
|
44
|
+
</h1>
|
|
45
|
+
<p className="text-sm text-gray-500 mb-6">
|
|
46
|
+
Now add content with Claude Code.
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
<div className="space-y-2 text-left">
|
|
50
|
+
<code className="block text-sm text-gray-300 font-mono bg-[#111827] px-3 py-2 rounded">
|
|
51
|
+
claude
|
|
52
|
+
</code>
|
|
53
|
+
<code className="block text-sm text-gray-300 font-mono bg-[#111827] px-3 py-2 rounded">
|
|
54
|
+
Create a portal for {slug}
|
|
55
|
+
</code>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<p className="mt-6 text-xs text-gray-400">
|
|
59
|
+
Don't have Claude Code?{" "}
|
|
60
|
+
<a
|
|
61
|
+
href="https://claude.ai/code"
|
|
62
|
+
className="text-blue-600 hover:underline"
|
|
63
|
+
target="_blank"
|
|
64
|
+
rel="noopener noreferrer"
|
|
65
|
+
>
|
|
66
|
+
Install it here
|
|
67
|
+
</a>
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="mt-4 text-center">
|
|
72
|
+
<Link href="/" className="text-xs text-gray-400 hover:text-gray-600">
|
|
73
|
+
← Back to welcome page
|
|
74
|
+
</Link>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</main>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
CLIENT_SHARE_TOKEN_MAX_AGE_SECONDS,
|
|
4
|
+
setClientAuthCookie,
|
|
5
|
+
verifyClientToken,
|
|
6
|
+
} from "@/lib/client-auth";
|
|
7
|
+
|
|
8
|
+
export async function GET(
|
|
9
|
+
req: NextRequest,
|
|
10
|
+
{ params }: { params: Promise<{ slug: string; token: string }> }
|
|
11
|
+
) {
|
|
12
|
+
const { slug, token } = await params;
|
|
13
|
+
|
|
14
|
+
const verified = await verifyClientToken(token, "share");
|
|
15
|
+
if (!verified || verified.slug !== slug) {
|
|
16
|
+
return NextResponse.redirect(new URL("/client", req.url));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const res = NextResponse.redirect(new URL(`/client/${slug}`, req.url));
|
|
20
|
+
setClientAuthCookie(res, token, CLIENT_SHARE_TOKEN_MAX_AGE_SECONDS);
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reference portal — the /portal create skill reads this file as the quality
|
|
5
|
+
* and style guide when generating new portals. Keep it polished.
|
|
6
|
+
* Login: username "example", password "demo-only-password" (seeded by prisma/seed.ts)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type ReactNode } from "react";
|
|
10
|
+
import { BarChart3, CalendarDays, ChevronDown, Download, FileText, Presentation } from "lucide-react";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
import { PortalShell } from "@/components/portal-shell";
|
|
13
|
+
|
|
14
|
+
function OverviewTab() {
|
|
15
|
+
const steps = [
|
|
16
|
+
{
|
|
17
|
+
done: false,
|
|
18
|
+
label: "Sign and return the NDA",
|
|
19
|
+
text: (
|
|
20
|
+
<>
|
|
21
|
+
<a href="#documents" className="font-medium text-gray-900 underline underline-offset-2">
|
|
22
|
+
Download here
|
|
23
|
+
</a>{" "}
|
|
24
|
+
sign and email to <span className="font-medium text-gray-900">jane@example.com</span>
|
|
25
|
+
</>
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
done: false,
|
|
30
|
+
label: "Review an opportunity together",
|
|
31
|
+
text: (
|
|
32
|
+
<>
|
|
33
|
+
Send the grant opportunity link to{" "}
|
|
34
|
+
<span className="font-medium text-gray-900">jane@example.com</span> so we can review
|
|
35
|
+
ahead of our call
|
|
36
|
+
</>
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
done: false,
|
|
41
|
+
label: "Map the wider landscape",
|
|
42
|
+
text: "We'll walk through the opportunity, share our market research, and project what tendering could deliver",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
done: false,
|
|
46
|
+
label: "First bid on pay-as-you-go",
|
|
47
|
+
text: "We handle it end-to-end — you see exactly what we deliver",
|
|
48
|
+
},
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<div className="mb-6 overflow-hidden rounded-2xl border bg-white shadow-sm">
|
|
54
|
+
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 sm:px-6">
|
|
55
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-blue-100">
|
|
56
|
+
<span className="text-sm font-bold text-blue-600">JS</span>
|
|
57
|
+
</div>
|
|
58
|
+
<div className="flex-1">
|
|
59
|
+
<div className="flex items-center gap-2">
|
|
60
|
+
<span className="text-sm font-semibold text-gray-900">Jane Smith</span>
|
|
61
|
+
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500">
|
|
62
|
+
Account Manager
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="mt-0.5 flex gap-3">
|
|
66
|
+
<span className="text-xs text-gray-400">jane@example.com</span>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="px-4 py-3 sm:px-5">
|
|
71
|
+
<p className="text-sm leading-relaxed text-gray-600">
|
|
72
|
+
<span className="font-semibold text-gray-900">Welcome Sam and team.</span> I've
|
|
73
|
+
put everything together here: services overview, next steps, and documents. Looking
|
|
74
|
+
forward to talking again next week.
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div className="mt-6">
|
|
80
|
+
<h3 className="mb-4 text-base font-bold tracking-tight text-gray-900">Next steps</h3>
|
|
81
|
+
<div className="rounded-2xl border bg-white p-5 shadow-sm sm:p-6">
|
|
82
|
+
<ol className="space-y-0">
|
|
83
|
+
{steps.map((step, index, items) => (
|
|
84
|
+
<li key={step.label} className="flex items-stretch gap-3 sm:gap-4">
|
|
85
|
+
<div className="flex flex-col items-center">
|
|
86
|
+
<span
|
|
87
|
+
className={cn(
|
|
88
|
+
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold",
|
|
89
|
+
step.done ? "bg-green-500 text-white" : "bg-gray-900 text-white"
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{step.done ? "✓" : index + 1}
|
|
93
|
+
</span>
|
|
94
|
+
{index < items.length - 1 ? <div className="w-px flex-1 bg-gray-100" /> : null}
|
|
95
|
+
</div>
|
|
96
|
+
<div className="pb-5">
|
|
97
|
+
<p className="text-sm font-semibold text-gray-900">{step.label}</p>
|
|
98
|
+
<p className="mt-0.5 text-sm leading-relaxed text-gray-500">{step.text}</p>
|
|
99
|
+
</div>
|
|
100
|
+
</li>
|
|
101
|
+
))}
|
|
102
|
+
</ol>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="mt-6">
|
|
107
|
+
<h3 className="mb-4 text-base font-bold tracking-tight text-gray-900">Our services</h3>
|
|
108
|
+
<div className="rounded-2xl border bg-white p-5 shadow-sm sm:p-6">
|
|
109
|
+
<p className="text-sm leading-relaxed text-gray-600">
|
|
110
|
+
We provide end-to-end bid management: opportunity sourcing, bid writing,
|
|
111
|
+
submission handling, and post-submission feedback. Our pay-as-you-go model
|
|
112
|
+
means you only pay when we work on a bid, with a success fee on wins.
|
|
113
|
+
</p>
|
|
114
|
+
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
115
|
+
{[
|
|
116
|
+
{ title: "Opportunity Sourcing", desc: "We find and qualify tenders that match your capabilities" },
|
|
117
|
+
{ title: "Bid Writing", desc: "Professional responses tailored to each opportunity" },
|
|
118
|
+
{ title: "Submission Management", desc: "Portal compliance, formatting, and on-time delivery" },
|
|
119
|
+
{ title: "Post-Bid Analysis", desc: "Feedback review and continuous improvement" },
|
|
120
|
+
].map((item) => (
|
|
121
|
+
<div key={item.title} className="rounded-xl border border-gray-100 p-4">
|
|
122
|
+
<p className="text-sm font-semibold text-gray-900">{item.title}</p>
|
|
123
|
+
<p className="mt-1 text-xs text-gray-500">{item.desc}</p>
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function MarketOverviewTab() {
|
|
134
|
+
return (
|
|
135
|
+
<div className="w-full">
|
|
136
|
+
<div className="mb-4 flex items-center justify-between">
|
|
137
|
+
<h3 className="text-base font-bold tracking-tight text-gray-900">Bids strategy</h3>
|
|
138
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
|
139
|
+
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
|
140
|
+
In progress
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div className="rounded-2xl border bg-white p-5 shadow-sm sm:p-6">
|
|
144
|
+
<p className="text-sm leading-relaxed text-gray-500">
|
|
145
|
+
Your bids strategy starts with a thorough analysis of the tendering landscape in your
|
|
146
|
+
sectors. We're currently researching historic opportunities, competitive dynamics,
|
|
147
|
+
and sector fit to build a clear picture of where the strongest opportunities lie for
|
|
148
|
+
Acme Health.
|
|
149
|
+
</p>
|
|
150
|
+
<div className="mt-5 border-t border-gray-100 pt-4">
|
|
151
|
+
<p className="mb-2 text-xs font-medium text-gray-500">What this will include</p>
|
|
152
|
+
<ul className="space-y-1.5">
|
|
153
|
+
{[
|
|
154
|
+
"Relevant tenders from the last 12 months",
|
|
155
|
+
"Sector and buyer type analysis",
|
|
156
|
+
"Competitive landscape overview",
|
|
157
|
+
"Revenue projection from tendering",
|
|
158
|
+
].map((item) => (
|
|
159
|
+
<li key={item} className="flex items-start gap-2 text-xs leading-relaxed text-gray-500">
|
|
160
|
+
<span className="mt-1 block h-1 w-1 shrink-0 rounded-full bg-gray-300" />
|
|
161
|
+
{item}
|
|
162
|
+
</li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="mt-4 border-t border-gray-100 pt-4">
|
|
167
|
+
<div className="flex items-center justify-between">
|
|
168
|
+
<p className="text-xs text-gray-400">
|
|
169
|
+
We'll share our findings and recommendations here once our analysis is complete.
|
|
170
|
+
</p>
|
|
171
|
+
<span className="ml-4 shrink-0 text-xs font-medium text-gray-500">
|
|
172
|
+
Expected next week
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function DocumentsTab() {
|
|
182
|
+
return (
|
|
183
|
+
<div className="w-full">
|
|
184
|
+
<div className="mb-4 flex items-center justify-between">
|
|
185
|
+
<h3 className="text-base font-bold tracking-tight text-gray-900">Documents</h3>
|
|
186
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
|
187
|
+
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
|
188
|
+
Action required
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="rounded-2xl border bg-white p-5 shadow-sm sm:p-6">
|
|
192
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
193
|
+
<div className="flex items-start gap-3">
|
|
194
|
+
<FileText className="mt-0.5 h-5 w-5 shrink-0 text-gray-400" />
|
|
195
|
+
<div>
|
|
196
|
+
<p className="text-sm font-medium text-gray-900">Mutual Non-Disclosure Agreement</p>
|
|
197
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
198
|
+
Please sign and return to{" "}
|
|
199
|
+
<span className="font-medium text-gray-700">jane@example.com</span>
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-5 py-2 text-xs font-semibold text-white transition-colors hover:bg-gray-800 sm:w-auto"
|
|
206
|
+
>
|
|
207
|
+
<Download className="h-3.5 w-3.5" />
|
|
208
|
+
Download PDF
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function MeetingSection({
|
|
217
|
+
title,
|
|
218
|
+
children,
|
|
219
|
+
defaultOpen = true,
|
|
220
|
+
}: {
|
|
221
|
+
title: string;
|
|
222
|
+
children: ReactNode;
|
|
223
|
+
defaultOpen?: boolean;
|
|
224
|
+
}) {
|
|
225
|
+
return (
|
|
226
|
+
<details open={defaultOpen} className="group">
|
|
227
|
+
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-left">
|
|
228
|
+
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400 transition-transform group-open:rotate-180" />
|
|
229
|
+
<h4 className="text-sm font-semibold text-gray-900">{title}</h4>
|
|
230
|
+
</summary>
|
|
231
|
+
<div className="mt-2 pl-5">{children}</div>
|
|
232
|
+
</details>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function MeetingsTab() {
|
|
237
|
+
return (
|
|
238
|
+
<div className="w-full">
|
|
239
|
+
<div className="mb-4">
|
|
240
|
+
<h3 className="text-base font-bold tracking-tight text-gray-900">Meetings</h3>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div className="rounded-2xl border bg-white p-5 shadow-sm sm:p-6">
|
|
244
|
+
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
|
|
245
|
+
<h4 className="text-sm font-semibold text-gray-900">Intro Call</h4>
|
|
246
|
+
<span className="text-xs font-medium text-gray-500">2 April 2026</span>
|
|
247
|
+
</div>
|
|
248
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
249
|
+
Jane (Demo Company) & Sam Johnson (Acme Health) · ~25 mins
|
|
250
|
+
</p>
|
|
251
|
+
|
|
252
|
+
<div className="mt-5 space-y-4">
|
|
253
|
+
<MeetingSection title="What we discussed">
|
|
254
|
+
<ul className="space-y-2 text-sm text-gray-600">
|
|
255
|
+
<li className="flex gap-2.5">
|
|
256
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60" />
|
|
257
|
+
Your current tendering setup and the challenges with the existing part-time
|
|
258
|
+
arrangement
|
|
259
|
+
</li>
|
|
260
|
+
<li className="flex gap-2.5">
|
|
261
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60" />
|
|
262
|
+
What we offer: the full end-to-end bid service, from opportunity sourcing through
|
|
263
|
+
to feedback and continuous improvement
|
|
264
|
+
</li>
|
|
265
|
+
<li className="flex gap-2.5">
|
|
266
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60" />
|
|
267
|
+
How our pricing works: pay-as-you-go to start, then
|
|
268
|
+
move to a performance-guaranteed retainer if both sides want to continue
|
|
269
|
+
</li>
|
|
270
|
+
<li className="flex gap-2.5">
|
|
271
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60" />
|
|
272
|
+
Capacity: we place no limits, so multiple tenders landing at once is never a problem
|
|
273
|
+
</li>
|
|
274
|
+
</ul>
|
|
275
|
+
</MeetingSection>
|
|
276
|
+
|
|
277
|
+
<MeetingSection title="What we noted about Acme Health">
|
|
278
|
+
<ul className="space-y-2 text-sm text-gray-600">
|
|
279
|
+
<li className="flex gap-2.5">
|
|
280
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-gray-300" />
|
|
281
|
+
15+ years in the healthcare sector with strong public sector track record
|
|
282
|
+
</li>
|
|
283
|
+
<li className="flex gap-2.5">
|
|
284
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-gray-300" />
|
|
285
|
+
Approximately 8–12 applicable tenders per year, value ranging £150K–£3M
|
|
286
|
+
</li>
|
|
287
|
+
<li className="flex gap-2.5">
|
|
288
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-gray-300" />
|
|
289
|
+
Strategic shift toward private sector alongside continued public sector work
|
|
290
|
+
</li>
|
|
291
|
+
<li className="flex gap-2.5">
|
|
292
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-gray-300" />
|
|
293
|
+
Versatile product applicable across multiple healthcare settings
|
|
294
|
+
</li>
|
|
295
|
+
</ul>
|
|
296
|
+
</MeetingSection>
|
|
297
|
+
|
|
298
|
+
<MeetingSection title="Immediate opportunity">
|
|
299
|
+
<ul className="space-y-2 text-sm text-gray-600">
|
|
300
|
+
<li className="flex gap-2.5">
|
|
301
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
|
|
302
|
+
Innovation grant for digital health: budget £1.5M
|
|
303
|
+
</li>
|
|
304
|
+
<li className="flex gap-2.5">
|
|
305
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
|
|
306
|
+
Deadline: 6 May 2026
|
|
307
|
+
</li>
|
|
308
|
+
<li className="flex gap-2.5">
|
|
309
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
|
|
310
|
+
Strong potential fit with your existing product applied to new patient populations
|
|
311
|
+
</li>
|
|
312
|
+
</ul>
|
|
313
|
+
</MeetingSection>
|
|
314
|
+
|
|
315
|
+
<MeetingSection title="Agreed next steps">
|
|
316
|
+
<ul className="space-y-2 text-sm text-gray-600">
|
|
317
|
+
<li className="flex gap-2.5">
|
|
318
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" />
|
|
319
|
+
Sign and return the NDA (sent via this portal)
|
|
320
|
+
</li>
|
|
321
|
+
<li className="flex gap-2.5">
|
|
322
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" />
|
|
323
|
+
Sam to share the grant opportunity link
|
|
324
|
+
</li>
|
|
325
|
+
<li className="flex gap-2.5">
|
|
326
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" />
|
|
327
|
+
Sam to take materials to executive team for review
|
|
328
|
+
</li>
|
|
329
|
+
<li className="flex gap-2.5">
|
|
330
|
+
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" />
|
|
331
|
+
Follow-up call next week to review the opportunity, market research, and projected
|
|
332
|
+
tendering impact
|
|
333
|
+
</li>
|
|
334
|
+
</ul>
|
|
335
|
+
<p className="mt-3 text-xs text-gray-400">
|
|
336
|
+
For the latest on these actions, see the Next steps timeline on the Services overview
|
|
337
|
+
tab.
|
|
338
|
+
</p>
|
|
339
|
+
</MeetingSection>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function ExamplePortalClient() {
|
|
347
|
+
return (
|
|
348
|
+
<PortalShell
|
|
349
|
+
companyName="Demo Company"
|
|
350
|
+
companyLogo={
|
|
351
|
+
<span className="text-xs font-bold text-white">D</span>
|
|
352
|
+
}
|
|
353
|
+
clientName="Acme Health"
|
|
354
|
+
clientLogoSrc="/example-logo.svg"
|
|
355
|
+
clientLogoAlt="Acme Health"
|
|
356
|
+
lastUpdated="2 April 2026"
|
|
357
|
+
hideFooterOnTab="overview"
|
|
358
|
+
contact={{
|
|
359
|
+
name: "Jane Smith",
|
|
360
|
+
title: "Account Manager",
|
|
361
|
+
avatarSrc: "/example-avatar.svg",
|
|
362
|
+
email: "jane@example.com",
|
|
363
|
+
}}
|
|
364
|
+
tabs={[
|
|
365
|
+
{ id: "overview", label: "Services overview", icon: Presentation, content: <OverviewTab /> },
|
|
366
|
+
{ id: "market", label: "Bids strategy", icon: BarChart3, content: <MarketOverviewTab /> },
|
|
367
|
+
{ id: "documents", label: "Documents", icon: FileText, badge: "amber", content: <DocumentsTab /> },
|
|
368
|
+
{ id: "meetings", label: "Meetings", icon: CalendarDays, content: <MeetingsTab /> },
|
|
369
|
+
]}
|
|
370
|
+
/>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { PortalLogin } from "@/components/portal-login";
|
|
4
|
+
|
|
5
|
+
export default function ClientLogin() {
|
|
6
|
+
return (
|
|
7
|
+
<PortalLogin
|
|
8
|
+
companyName="Demo Company"
|
|
9
|
+
companyLogo={
|
|
10
|
+
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gray-900">
|
|
11
|
+
<span className="text-xs font-bold text-white">D</span>
|
|
12
|
+
</div>
|
|
13
|
+
}
|
|
14
|
+
companyUrl="https://example.com"
|
|
15
|
+
supportEmail="support@example.com"
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|