Files
warmbly/site/src/components/IntegrationsMock.astro
T

324 lines
21 KiB
Plaintext

---
/**
* The /app/integrations screen, mirrored from the app:
*
* web/src/app/app/integrations/page.tsx PageTopbar, StatStrip, the "Your connections"
* rail, one SectionBar + hairline grid per catalog
* category, then the Meeting bookings list
* web/src/app/app/integrations/_components/ProviderGlyph.tsx the 36px brand tile (real marks from
* brandLogos.ts / BRAND_ICON, copied into lib/)
* web/src/app/app/integrations/_components/StatusPill.tsx the connected pill
* web/src/app/app/integrations/_components/ConnectDrawer.tsx the Connect drawer: 480px, slides in 28px
* over 240ms, scrim with a 2px blur
* internal/app/integration/catalog.go the 12 providers, taglines, highlights, scopes
*
* Demo: the cursor presses Connect on Slack, the Connect drawer slides in,
* then it closes from the drawer's own X.
*/
import DemoCursor from './DemoCursor.astro';
import { RAW_BRAND_LOGOS } from '../lib/brandLogos';
import { BRAND_ICON } from '../lib/brandIcons';
// ProviderGlyph, as markup: full-bleed marks fill the tile, raw marks and
// single-path marks sit on a white tile at icon size.
function glyph(provider: string, name: string, size: 7 | 9 = 9): string {
const tile = size === 7 ? 'w-7 h-7' : 'w-9 h-9';
const icon = size === 7 ? 'w-4 h-4' : 'w-5 h-5';
const raw = RAW_BRAND_LOGOS[provider];
if (raw?.fullBleed) {
return `<div class="${tile} rounded-md ring-1 ring-black/5 overflow-hidden inline-flex shrink-0"><svg viewBox="${raw.viewBox}" role="img" aria-label="${name}" class="w-full h-full">${raw.inner}</svg></div>`;
}
if (raw) {
return `<div class="${tile} rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0"><svg viewBox="${raw.viewBox}" role="img" aria-label="${name}" class="${icon}">${raw.inner}</svg></div>`;
}
const ic = BRAND_ICON[provider];
return `<div class="${tile} rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0"><svg viewBox="0 0 24 24" role="img" aria-label="${name}" class="${icon}" fill="${ic.hex}"><path d="${ic.path}"/></svg></div>`;
}
type Entry = { provider: string; name: string; tagline: string; auth: string; connected?: boolean };
type Section = { label: string; entries: Entry[] };
// catalog.go, in CATEGORY_ORDER.
const sections: Section[] = [
{ label: 'CRM', entries: [
{ provider: 'hubspot', name: 'HubSpot', tagline: 'Push positive replies and new leads straight into your CRM.', auth: 'one-click', connected: true },
{ provider: 'salesforce', name: 'Salesforce', tagline: 'Sync leads, contacts, and email activity to Salesforce.', auth: 'one-click' },
{ provider: 'pipedrive', name: 'Pipedrive', tagline: 'Persons, deals, and an activity timeline that stays in sync.', auth: 'one-click' },
{ provider: 'close', name: 'Close', tagline: 'Leads, contacts, and inbox activity for Close.', auth: 'api_key' },
]},
{ label: 'Notifications', entries: [
{ provider: 'slack', name: 'Slack', tagline: 'Real-time alerts for positive replies, bounces, and deliverability.', auth: 'one-click' },
{ provider: 'discord', name: 'Discord', tagline: 'Webhook-based notifications to a server channel.', auth: 'webhook' },
]},
{ label: 'Automation', entries: [
{ provider: 'zapier', name: 'Zapier', tagline: 'Triggers and actions across 8,000+ apps.', auth: 'api_key', connected: true },
{ provider: 'make', name: 'Make', tagline: 'Visual automation scenarios.', auth: 'api_key' },
{ provider: 'n8n', name: 'n8n', tagline: 'Self-hosted automation workflows.', auth: 'api_key' },
]},
{ label: 'Meetings', entries: [
{ provider: 'calendly', name: 'Calendly', tagline: 'Booked, rescheduled, and canceled calls land on the contact, live.', auth: 'webhook', connected: true },
{ provider: 'cal_com', name: 'Cal.com', tagline: 'Open-source booking, same live meeting tracking.', auth: 'webhook' },
]},
{ label: 'Verification', entries: [
{ provider: 'millionverifier', name: 'MillionVerifier', tagline: 'Pay-as-you-go address verification for every contact you import.', auth: 'api_key' },
]},
];
const catalogCount = sections.reduce((n, s) => n + s.entries.length, 0);
// The connections rail: label, account, status.
const connections = [
{ provider: 'hubspot', name: 'HubSpot', label: 'HubSpot', account: 'Acme Inc' },
{ provider: 'zapier', name: 'Zapier', label: 'Zapier', account: 'Zapier' },
{ provider: 'calendly', name: 'Calendly', label: 'Calendly', account: 'maya@acme.com' },
];
const stats = [
{ label: 'Available', value: String(catalogCount), sub: 'providers', accent: false, last: false },
{ label: 'Connected', value: String(connections.length), sub: `${connections.length} total`, accent: true, last: false },
{ label: 'Needs attention', value: '0', sub: 'all healthy', accent: false, last: false },
{ label: 'Meetings', value: '14', sub: 'booked via integrations', accent: false, last: true },
];
// Meeting bookings rows (toLocaleString timestamps, as the app prints them).
const bookings = [
{ source: 'calendly', email: 'jenna@northwind.dev', event: 'Intro call', when: '9/4/2026, 3:00:00 PM' },
{ source: 'calendly', email: 'rowan@stratech.io', event: 'Discovery · 30 min', when: '9/3/2026, 11:30:00 AM' },
{ source: 'cal_com', email: 'priya@brightfold.com', event: 'Demo', when: '9/2/2026, 4:15:00 PM' },
{ source: 'calendly', email: 'sam@mapleco.com', event: 'Intro call', when: '9/1/2026, 10:00:00 AM' },
];
// Slack's catalog highlights and OAuth scopes (oauth.go).
const highlights = [
'One-click OAuth into your workspace',
'Ping a channel the moment a prospect replies',
'Warn the team when warmup health or deliverability dips',
];
const scopes = ['chat:write', 'channels:read', 'groups:read'];
const settingsIcon = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
---
<div class="ig relative w-full flex flex-col bg-white overflow-hidden" style="min-height:500px;" data-demo="ig">
<!-- PageTopbar -->
<div class="min-h-12 @md:h-12 px-5 py-1.5 @md:py-0 border-b border-slate-200 flex flex-wrap @md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white sticky top-0 z-10">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Integrations</span>
<div class="h-4 w-px bg-slate-200"></div>
<span class="text-[12.5px] text-slate-600 truncate">Connect your stack — CRMs, alerts, automation, meetings, and data</span>
<div class="ml-auto flex items-center gap-1.5 min-w-0 flex-wrap justify-end @md:flex-nowrap">
<div class="flex items-center gap-2">
<div class="w-48 hidden @sm:block">
<div class="h-7 pl-2 pr-1 rounded-md border border-slate-200 bg-white flex items-center gap-1.5 min-w-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-slate-400 shrink-0" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<span class="flex-1 min-w-0 h-full inline-flex items-center text-[12.5px] text-slate-400">Search integrations</span>
</div>
</div>
<button class="h-7 w-7 rounded-md border border-slate-200 text-slate-500 inline-flex items-center justify-center" aria-label="Refresh">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
</div>
</div>
</div>
<!-- StatStrip -->
<div class="grid border-b border-slate-200 shrink-0 bg-white grid-cols-2 @md:grid-cols-4">
{stats.map((s) => (
<div class={`group px-5 py-4 transition-colors ${!s.last ? 'border-r border-slate-200' : ''}`}>
<div class="flex items-center gap-2">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{s.label}</span>
{s.accent && <span class="size-1.5 rounded-full bg-sky-500 animate-pulse"></span>}
</div>
<div class="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">{s.value}</div>
<div class="text-[10px] text-slate-400 mt-1.5 font-mono truncate">{s.sub}</div>
</div>
))}
</div>
<!-- PageBody -->
<div class="flex-1 min-h-0 overflow-hidden">
<!-- Connected rail -->
<section>
<div class="min-h-9 @md:h-9 px-5 py-1.5 @md:py-0 border-b border-slate-200/60 flex flex-wrap @md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Your connections</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">{connections.length}</span>
</div>
<div class="grid @sm:grid-cols-2 @lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
{connections.map((c) => (
<div class="text-left bg-white p-4 flex items-center gap-3">
<Fragment set:html={glyph(c.provider, c.name)} />
<div class="min-w-0 flex-1">
<div class="text-[12.5px] font-semibold text-slate-900 truncate">{c.label}</div>
<div class="text-[11px] text-slate-400 truncate">{c.account}</div>
</div>
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-emerald-50 text-emerald-700"><span class="size-1.5 rounded-full bg-emerald-500"></span>connected</span>
</div>
))}
</div>
</section>
<!-- Catalog by category: hairline gaps, white cards, no radius (CatalogCard) -->
{sections.map((sec) => (
<section>
<div class="min-h-9 @md:h-9 px-5 py-1.5 @md:py-0 border-b border-slate-200/60 flex flex-wrap @md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{sec.label}</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">{sec.entries.length}</span>
</div>
<div class="grid @sm:grid-cols-2 @lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
{sec.entries.map((t) => {
const isSlack = t.provider === 'slack';
return (
<div class="ig-tile group text-left bg-white p-5 flex flex-col min-h-[150px] transition-colors" data-tile={t.provider}>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2.5 min-w-0">
<Fragment set:html={glyph(t.provider, t.name)} />
<div class="min-w-0">
<div class="text-[13px] font-semibold text-slate-900 truncate">{t.name}</div>
<div class="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">{t.auth}</div>
</div>
</div>
{t.connected && (
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-emerald-50 text-emerald-700">
<span class="size-1.5 rounded-full bg-emerald-500"></span>
connected
</span>
)}
</div>
<p class="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-2">{t.tagline}</p>
<div class="mt-auto pt-3 flex items-center justify-between gap-2">
<span class="text-[11px] text-slate-400 inline-flex items-center gap-1 underline decoration-dotted underline-offset-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
Docs
</span>
{t.connected ? (
<span class="h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors text-slate-600 group-hover:text-slate-900 group-hover:bg-slate-100">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={settingsIcon}></svg>
Manage
</span>
) : (
<span class={`ig-connect h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 bg-sky-600 text-white transition-colors ${isSlack ? 'ig-slack-connect' : ''}`}>
Connect
</span>
)}
</div>
</div>
);
})}
</div>
</section>
))}
<!-- Meeting bookings -->
<div class="min-h-9 @md:h-9 px-5 py-1.5 @md:py-0 border-b border-slate-200/60 flex flex-wrap @md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0">
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Meeting bookings</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums">14</span>
<div class="ml-auto flex items-center gap-1.5 flex-wrap justify-end min-w-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400"><path d="M8 2v4"/><path d="M16 2v4"/><path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5"/><path d="M3 10h18"/><path d="m16 20 2 2 4-4"/></svg>
</div>
</div>
<div class="divide-y divide-slate-200/60 border-b border-slate-200/60">
{bookings.map((b) => (
<div class="px-5 h-12 flex items-center gap-3 text-[12.5px]">
<span class={`size-1.5 rounded-full shrink-0 ${b.source === 'calendly' ? 'bg-rose-400' : 'bg-indigo-400'}`}></span>
<span class="font-medium text-slate-900 truncate max-w-[40%] @sm:max-w-none @sm:w-60">{b.email}</span>
<span class="text-slate-500 truncate flex-1">{b.event}</span>
<span class="font-mono text-[10.5px] text-slate-400 tabular-nums shrink-0">{b.when}</span>
</div>
))}
</div>
</div>
<!-- ConnectDrawer for Slack: the reaction -->
<!-- Portaled to the body in the app: fixed over the whole window, the drawer on its right edge. -->
<div class="ig-react fixed inset-0 z-40 flex" aria-hidden="true">
<div class="ig-scrim absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"></div>
<div class="ig-drawer ml-auto h-full w-full @sm:w-[480px] @sm:max-w-[1178px] bg-white shadow-xl flex flex-col z-10 relative">
<div class="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0">
<Fragment set:html={glyph('slack', 'Slack', 7)} />
<div class="min-w-0 flex-1">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Connect</div>
<div class="text-[12.5px] text-slate-900 font-medium truncate">Slack</div>
</div>
<span class="h-7 w-7 rounded border border-slate-200 text-slate-500 inline-flex items-center justify-center transition-colors [&.is-hover]:border-slate-300 [&.is-hover]:text-slate-900" data-ig="close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</span>
</div>
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-5">
<p class="text-[12.5px] text-slate-600 leading-relaxed">Real-time alerts for positive replies, bounces, and deliverability.</p>
<div class="space-y-2">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">What you get</div>
<ul class="space-y-1.5">
{highlights.map((h) => (
<li class="flex items-start gap-2 text-[12.5px] text-slate-700">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-500 mt-0.5 shrink-0"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
<span>{h}</span>
</li>
))}
</ul>
</div>
<div class="space-y-2">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Permissions requested</div>
<div class="rounded-md border border-slate-200 bg-slate-50/70 divide-y divide-slate-200">
{scopes.map((sc) => (
<div class="flex items-center gap-2 px-2.5 py-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400 shrink-0"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
<code class="text-[10.5px] text-slate-600 font-mono truncate">{sc}</code>
</div>
))}
</div>
<p class="text-[10.5px] text-slate-400 leading-relaxed flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Tokens are encrypted at rest with your organization key. We never store your password.
</p>
</div>
<div>
<span class="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5">Connection label (optional)</span>
<!-- A bare TextInput: the browser's default input width. -->
<div class="h-7 w-[178px] px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-400 flex items-center">Slack</div>
<p class="text-[10.5px] text-slate-400 mt-1">Useful if you connect more than one Slack account.</p>
</div>
</div>
<div class="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2 shrink-0">
<span class="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 inline-flex items-center">Cancel</span>
<span class="h-7 px-3 rounded-md bg-sky-600 text-white text-[12px] font-medium inline-flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>
Connect with Slack
</span>
</div>
</div>
</div>
<DemoCursor />
<!-- The drawer's own motion: scrim fades over 180ms, the panel slides in
from 28px right over 240ms on the app's ease. -->
<style>
.ig-react{pointer-events:none;}
.ig-scrim{opacity:0;transition:opacity .18s ease-out;}
.ig-drawer{opacity:0;transform:translateX(28px);transition:opacity .24s cubic-bezier(.16,1,.3,1),transform .24s cubic-bezier(.16,1,.3,1);}
.ig.is-open .ig-scrim{opacity:1;}
.ig.is-open .ig-drawer{opacity:1;transform:none;}
/* The card is the button: hovering it tints the card and darkens Connect. */
.ig-tile.is-hover{background-color:rgb(248 250 252 / 0.6);}
.ig-tile.is-hover .ig-connect,.ig-connect.is-pressed{background-color:#0369a1;}
.ig.is-static .ig-react{display:none;}
</style>
</div>
<script>
import { playCursor } from '../lib/cursor';
document.querySelectorAll<HTMLElement>('[data-demo="ig"]').forEach((root) => {
playCursor(root, {
loop: 11000,
steps: [
{ at: 0, x: 24, y: 24 },
{ at: 2400, el: '.ig-slack-connect', ox: 0.5, oy: 0.55, hover: '[data-tile="slack"]' },
{ at: 2540, press: true, target: '.ig-slack-connect', add: ['is-open'] },
{ at: 6200, el: '[data-ig="close"]', ox: 0.5, oy: 0.55, hover: '[data-ig="close"]' },
{ at: 6340, press: true, remove: ['is-open'] },
{ at: 9000, x: 24, y: 24 },
],
});
});
</script>