@graph8/sdk 0.361.0 → 0.370.0
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 -10
- package/blocks/_foundation/README.md +172 -1
- package/blocks/_foundation/block.json +234 -13
- package/blocks/_foundation/components/api-error.tsx +71 -25
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/empty-state.tsx +39 -7
- package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
- package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
- package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
- package/blocks/_foundation/components/screen/index.ts +22 -0
- package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
- package/blocks/_foundation/components/screen/section-card.tsx +47 -0
- package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
- package/blocks/_foundation/components/screen/split-view.tsx +125 -0
- package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
- package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-state.ts +74 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +14 -0
- package/blocks/agents/block.json +33 -1
- package/blocks/agents/components/agent-builder.tsx +13 -8
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
- package/blocks/agents/components/agent-run-detail.tsx +102 -0
- package/blocks/agents/components/agent-runs.tsx +16 -7
- package/blocks/agents/components/agents-screen.tsx +184 -0
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +209 -0
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +23 -9
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +21 -1
- package/blocks/appointments/block.json +43 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +77 -0
- package/blocks/appointments/components/meeting-list.tsx +14 -6
- package/blocks/appointments/components/meetings-screen.tsx +157 -0
- package/blocks/appointments/components/scheduling-screen.tsx +122 -0
- package/blocks/appointments/hooks/use-event-types.ts +17 -0
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +26 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +16 -5
- package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +154 -0
- package/blocks/campaigns/components/icp-editor.tsx +15 -15
- package/blocks/campaigns/components/persona-detail.tsx +172 -0
- package/blocks/campaigns/components/persona-editor.tsx +14 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/coach-screen.tsx +222 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
- package/blocks/crm/README.md +28 -0
- package/blocks/crm/block.json +58 -2
- package/blocks/crm/components/companies-screen.tsx +345 -0
- package/blocks/crm/components/company-detail.tsx +8 -6
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +6 -3
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +359 -0
- package/blocks/crm/components/dedupe-review.tsx +7 -5
- package/blocks/crm/components/import-wizard.tsx +4 -2
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-form.tsx +5 -3
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +379 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +7 -1
- package/blocks/crm/lib/crm-types.ts +12 -2
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +34 -3
- package/blocks/deals/block.json +45 -4
- package/blocks/deals/components/deal-detail.tsx +27 -13
- package/blocks/deals/components/deal-related.tsx +87 -0
- package/blocks/deals/components/deals-screen.tsx +298 -0
- package/blocks/deals/components/pipeline-board.tsx +4 -2
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-default-owner.ts +60 -0
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deals-types.ts +14 -0
- package/blocks/dialer/README.md +34 -2
- package/blocks/dialer/block.json +67 -1
- package/blocks/dialer/components/call-contact-button.tsx +252 -0
- package/blocks/dialer/components/call-log-screen.tsx +205 -0
- package/blocks/dialer/components/call-log.tsx +7 -3
- package/blocks/dialer/components/call-queue.tsx +1 -1
- package/blocks/dialer/components/dialer-screen.tsx +319 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/session-start-form.tsx +4 -2
- package/blocks/dialer/components/softphone.tsx +3 -1
- package/blocks/enrichment/README.md +14 -2
- package/blocks/enrichment/block.json +32 -4
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
- package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
- package/blocks/enrichment/components/waterfall-config.tsx +39 -10
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +12 -0
- package/blocks/forms/block.json +39 -17
- package/blocks/forms/components/form-builder.tsx +3 -1
- package/blocks/forms/components/form-embed.tsx +3 -1
- package/blocks/forms/components/forms-screen.tsx +353 -0
- package/blocks/forms/components/submissions-table.tsx +16 -6
- package/blocks/inbox/README.md +27 -3
- package/blocks/inbox/block.json +14 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +9 -0
- package/blocks/integrations/block.json +46 -1
- package/blocks/integrations/components/crm-detail.tsx +100 -0
- package/blocks/integrations/components/integration-list.tsx +9 -4
- package/blocks/integrations/components/integrations-screen.tsx +135 -0
- package/blocks/integrations/components/sync-status.tsx +5 -3
- package/blocks/integrations/components/webhook-detail.tsx +160 -0
- package/blocks/integrations/components/webhook-manager.tsx +13 -8
- package/blocks/integrations/lib/integrations-utils.ts +6 -1
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/intent-feed.tsx +5 -2
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/lifecycle/README.md +16 -0
- package/blocks/lifecycle/block.json +34 -7
- package/blocks/lifecycle/components/account-related.tsx +66 -0
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +13 -0
- package/blocks/lists/block.json +39 -1
- package/blocks/lists/components/add-to-list-button.tsx +3 -2
- package/blocks/lists/components/list-picker.tsx +5 -1
- package/blocks/lists/components/list-table.tsx +11 -5
- package/blocks/lists/components/lists-screen.tsx +344 -0
- package/blocks/lists/components/suppression-manager.tsx +6 -2
- package/blocks/lists/components/suppressions-screen.tsx +255 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +65 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
- package/blocks/mailboxes/components/domain-detail.tsx +142 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
- package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
- package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
- package/blocks/mailboxes/components/warmup-health.tsx +4 -2
- package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
- package/blocks/newsletter/README.md +13 -0
- package/blocks/newsletter/block.json +47 -23
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
- package/blocks/newsletter/components/subscriber-table.tsx +12 -6
- package/blocks/nurture/README.md +12 -0
- package/blocks/nurture/block.json +35 -13
- package/blocks/nurture/components/nurture-builder.tsx +3 -1
- package/blocks/nurture/components/nurture-enroll.tsx +6 -3
- package/blocks/nurture/components/nurture-form.tsx +23 -3
- package/blocks/nurture/components/nurture-screen.tsx +283 -0
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +17 -0
- package/blocks/objects/block.json +29 -16
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +2 -1
- package/blocks/objects/components/object-schema-editor.tsx +3 -1
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +12 -0
- package/blocks/pages/block.json +35 -22
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +46 -11
- package/blocks/pages/components/page-list.tsx +6 -3
- package/blocks/pages/components/pages-screen.tsx +154 -0
- package/blocks/quotes/README.md +17 -0
- package/blocks/quotes/block.json +38 -9
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-list.tsx +7 -3
- package/blocks/quotes/components/quote-related.tsx +123 -0
- package/blocks/quotes/components/quotes-screen.tsx +169 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +49 -9
- package/blocks/sequencer/block.json +99 -19
- package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
- package/blocks/sequencer/components/sequence-builder.tsx +211 -46
- package/blocks/sequencer/components/sequence-detail.tsx +259 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
- package/blocks/sequencer/components/sequence-list.tsx +33 -8
- package/blocks/sequencer/components/sequence-stats.tsx +3 -2
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +159 -0
- package/blocks/sequencer/components/step-editor.tsx +3 -1
- package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +11 -0
- package/blocks/social-ads/block.json +101 -2
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
- package/blocks/social-ads/components/audience-sync.tsx +11 -7
- package/blocks/social-ads/components/campaign-detail.tsx +133 -0
- package/blocks/social-ads/components/creative-detail.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +15 -5
- package/blocks/social-ads/components/post-composer.tsx +3 -1
- package/blocks/social-ads/components/post-detail.tsx +160 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +160 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +3 -1
- package/blocks/visitors/components/visitor-company-card.tsx +2 -2
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +11 -2
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +7 -4
- package/blocks/webchat/components/conversation-list.tsx +7 -3
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
- package/blocks/workbench/components/workbench-grid.tsx +9 -3
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +192 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +3 -1
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +2785 -1637
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +113 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +25 -8
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +20 -0
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +32 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense.json +41 -16
- package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
- package/stacks/apollo.json +118 -36
- package/stacks/calendly.json +60 -0
- package/stacks/clay/pages/companies.tsx.tmpl +15 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
- package/stacks/clay.json +59 -26
- package/stacks/gong.json +81 -35
- package/stacks/hootsuite.json +99 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot.json +167 -66
- package/stacks/intercom.json +48 -20
- package/stacks/mailchimp.json +52 -33
- package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
- package/stacks/outreach.json +88 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive.json +115 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce.json +81 -31
- package/stacks/stack.schema.json +6 -1
- package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +102 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +55 -8
- package/templates/next-app/app/globals.css.tmpl +105 -0
- package/templates/next-app/app/layout.tsx.tmpl +18 -4
- package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
- package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
- package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
- package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/lib/nav.ts.tmpl +1 -1
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SplitView: list on the left, the selected record on the right. On wide screens the detail is a
|
|
5
|
+
* fixed-width pane with a hairline; under `breakpoint` (default 1024px, any value works) it becomes a right Sheet so the list
|
|
6
|
+
* keeps its full width on tablets and phones. Pair it with `useUrlState("id")` so the open record
|
|
7
|
+
* survives reload and Back. `expanded` turns the detail into a full-page record screen (the list
|
|
8
|
+
* steps aside, a Back affordance returns to it); pair it with `useUrlState("view") === "full"` so
|
|
9
|
+
* `?id=X&view=full` deep-links to the full page.
|
|
10
|
+
*
|
|
11
|
+
* SDK operations: none.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
14
|
+
import { ArrowLeft } from "lucide-react";
|
|
15
|
+
import { Button, Sheet, SheetContent, SheetDescription, SheetTitle, cn } from "@graph8/sdk/ui";
|
|
16
|
+
|
|
17
|
+
export interface SplitViewProps {
|
|
18
|
+
list: ReactNode;
|
|
19
|
+
detail?: ReactNode;
|
|
20
|
+
detailOpen: boolean;
|
|
21
|
+
onCloseDetail: () => void;
|
|
22
|
+
detailWidth?: "md" | "lg" | "xl";
|
|
23
|
+
/** Accessible name for the sheet on narrow screens. */
|
|
24
|
+
detailTitle?: string;
|
|
25
|
+
/** Pixel width under which the detail becomes a sheet. Default 1024. */
|
|
26
|
+
breakpoint?: number;
|
|
27
|
+
/** Full-page mode: the detail takes the whole content area and the list steps aside. */
|
|
28
|
+
expanded?: boolean;
|
|
29
|
+
/** Called with false from the Back affordance; wire it to `useUrlState("view")`. */
|
|
30
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
31
|
+
/** Label of the Back affordance in full-page mode. Default "Back to list". */
|
|
32
|
+
backLabel?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
listClassName?: string;
|
|
35
|
+
detailClassName?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const WIDTH: Record<NonNullable<SplitViewProps["detailWidth"]>, { pane: string; sheet: string }> = {
|
|
39
|
+
md: { pane: "lg:w-[24rem] xl:w-[26rem]", sheet: "sm:max-w-md" },
|
|
40
|
+
lg: { pane: "lg:w-[30rem] xl:w-[34rem]", sheet: "sm:max-w-lg" },
|
|
41
|
+
xl: { pane: "lg:w-[36rem] xl:w-[42rem] 2xl:w-[48rem]", sheet: "sm:max-w-2xl" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pane width with a custom `breakpoint`: the `lg:`-prefixed classes above do not apply below 1024px, so the
|
|
46
|
+
* pane gets a fixed width (the `lg` step) capped at 60% of the row, and the list keeps the rest.
|
|
47
|
+
*/
|
|
48
|
+
const CUSTOM_PANE: Record<NonNullable<SplitViewProps["detailWidth"]>, CSSProperties> = {
|
|
49
|
+
md: { width: "24rem", maxWidth: "60%" },
|
|
50
|
+
lg: { width: "30rem", maxWidth: "60%" },
|
|
51
|
+
xl: { width: "36rem", maxWidth: "60%" },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** SSR-safe media query: false on the server and the first client render, then live. */
|
|
55
|
+
export function useMediaQuery(query: string): boolean {
|
|
56
|
+
const [matches, setMatches] = useState(false);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
59
|
+
const mql = window.matchMedia(query);
|
|
60
|
+
const update = () => setMatches(mql.matches);
|
|
61
|
+
update();
|
|
62
|
+
mql.addEventListener?.("change", update);
|
|
63
|
+
return () => mql.removeEventListener?.("change", update);
|
|
64
|
+
}, [query]);
|
|
65
|
+
return matches;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function SplitView({ list, detail, detailOpen, onCloseDetail, detailWidth = "lg", detailTitle = "Details", breakpoint = 1024, expanded, onExpandedChange, backLabel = "Back to list", className, listClassName, detailClassName }: SplitViewProps) {
|
|
69
|
+
const narrow = useMediaQuery(`(max-width: ${breakpoint - 1}px)`);
|
|
70
|
+
const open = detailOpen && detail != null;
|
|
71
|
+
const w = WIDTH[detailWidth] ?? WIDTH.lg;
|
|
72
|
+
const defaultBreakpoint = breakpoint === 1024;
|
|
73
|
+
|
|
74
|
+
if (open && expanded) {
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn("flex h-full min-h-0 w-full min-w-0 flex-col", className)} data-testid="split-view" data-detail-open="true" data-expanded="true">
|
|
77
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background px-2 py-1.5">
|
|
78
|
+
<Button
|
|
79
|
+
type="button"
|
|
80
|
+
variant="ghost"
|
|
81
|
+
size="sm"
|
|
82
|
+
className="h-7 gap-1.5 px-2 text-text-color-muted hover:text-foreground"
|
|
83
|
+
onClick={() => (onExpandedChange ? onExpandedChange(false) : onCloseDetail())}
|
|
84
|
+
>
|
|
85
|
+
<ArrowLeft className="size-4" strokeWidth={1.5} aria-hidden />
|
|
86
|
+
{backLabel}
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
<div className={cn("flex min-h-0 flex-1 flex-col overflow-hidden", detailClassName)} data-testid="split-view-full">
|
|
90
|
+
{detail}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className={cn("flex h-full min-h-0 w-full min-w-0", className)} data-testid="split-view" data-detail-open={open ? "true" : "false"}>
|
|
98
|
+
<div className={cn("min-h-0 min-w-0 flex-1", listClassName)}>{list}</div>
|
|
99
|
+
{open && !narrow ? (
|
|
100
|
+
<aside
|
|
101
|
+
aria-label={detailTitle}
|
|
102
|
+
className={cn(
|
|
103
|
+
"min-h-0 shrink-0 flex-col overflow-hidden border-l border-border bg-background",
|
|
104
|
+
// The default breakpoint matches Tailwind's lg, so CSS keeps the pane hidden on narrow screens
|
|
105
|
+
// before the media query resolves (SSR), and the lg:/xl: width classes apply. A custom breakpoint
|
|
106
|
+
// relies on the media query alone and gets an explicit width so the list keeps its share.
|
|
107
|
+
defaultBreakpoint ? cn("hidden w-full lg:flex", w.pane) : "flex",
|
|
108
|
+
detailClassName,
|
|
109
|
+
)}
|
|
110
|
+
style={defaultBreakpoint ? undefined : (CUSTOM_PANE[detailWidth] ?? CUSTOM_PANE.lg)}
|
|
111
|
+
data-testid="split-view-detail"
|
|
112
|
+
>
|
|
113
|
+
{detail}
|
|
114
|
+
</aside>
|
|
115
|
+
) : null}
|
|
116
|
+
<Sheet open={open && narrow} onOpenChange={(next) => (!next ? onCloseDetail() : undefined)}>
|
|
117
|
+
<SheetContent side="right" hideCloseButton className={cn("w-full gap-0 p-0 sm:w-full", w.sheet, detailClassName)} data-testid="split-view-sheet">
|
|
118
|
+
<SheetTitle className="sr-only">{detailTitle}</SheetTitle>
|
|
119
|
+
<SheetDescription className="sr-only">Selected record</SheetDescription>
|
|
120
|
+
<div className="flex h-full min-h-0 flex-col">{detail}</div>
|
|
121
|
+
</SheetContent>
|
|
122
|
+
</Sheet>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StatStrip: a row of KPI tiles (label, big number, delta, hint, sparkline) for the top of a screen.
|
|
5
|
+
* Numbers are ink; only a delta takes colour, and only through `tone` (good or bad FOR THE USER).
|
|
6
|
+
* A delta without a tone is an ink arrow and number.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: none.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { ArrowDown, ArrowUp, Info } from "lucide-react";
|
|
12
|
+
import { Skeleton, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from "@graph8/sdk/ui";
|
|
13
|
+
import { Sparkline } from "./sparkline";
|
|
14
|
+
import { TONE_CLASSES, type Tone } from "./status-pill";
|
|
15
|
+
|
|
16
|
+
export interface StatItem {
|
|
17
|
+
label: string;
|
|
18
|
+
value: ReactNode;
|
|
19
|
+
/** Change versus the previous period, in percent (12 = up 12%). */
|
|
20
|
+
delta?: number;
|
|
21
|
+
/** Explanation shown in a tooltip on the label. */
|
|
22
|
+
hint?: string;
|
|
23
|
+
/** Colours the delta. Omit when the direction has no good or bad meaning. */
|
|
24
|
+
tone?: Tone;
|
|
25
|
+
sparkline?: number[];
|
|
26
|
+
/** Optional link or button rendered under the value ("View all"). */
|
|
27
|
+
footer?: ReactNode;
|
|
28
|
+
onClick?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StatStripProps {
|
|
32
|
+
items: StatItem[];
|
|
33
|
+
loading?: boolean;
|
|
34
|
+
/** Tiles per row on wide screens. Defaults to the number of items, up to 6. */
|
|
35
|
+
columns?: number;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatDelta(delta: number): string {
|
|
40
|
+
const abs = Math.abs(delta);
|
|
41
|
+
const text = abs >= 100 ? abs.toFixed(0) : abs >= 10 ? abs.toFixed(1).replace(/\.0$/, "") : abs.toFixed(1).replace(/\.0$/, "");
|
|
42
|
+
return `${text}%`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Delta({ delta, tone }: { delta: number; tone?: Tone }) {
|
|
46
|
+
const Arrow = delta < 0 ? ArrowDown : ArrowUp;
|
|
47
|
+
const colour = tone ? TONE_CLASSES[tone].text : "text-foreground";
|
|
48
|
+
return (
|
|
49
|
+
<span className={cn("inline-flex items-center gap-0.5 text-xs font-medium tabular-nums", colour)} data-tone={tone ?? "ink"}>
|
|
50
|
+
{delta === 0 ? null : <Arrow className="size-3" strokeWidth={2} aria-hidden />}
|
|
51
|
+
<span className="sr-only">{delta < 0 ? "down" : delta > 0 ? "up" : "unchanged"}</span>
|
|
52
|
+
{formatDelta(delta)}
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function StatStrip({ items, loading, columns, className }: StatStripProps) {
|
|
58
|
+
const cols = Math.min(Math.max(columns ?? items.length, 1), 6);
|
|
59
|
+
const style = { ["--g8-stat-cols" as string]: String(cols) };
|
|
60
|
+
return (
|
|
61
|
+
<TooltipProvider delayDuration={150}>
|
|
62
|
+
<dl
|
|
63
|
+
className={cn(
|
|
64
|
+
"grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border",
|
|
65
|
+
"md:[grid-template-columns:repeat(var(--g8-stat-cols),minmax(0,1fr))]",
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
style={style}
|
|
69
|
+
data-testid="stat-strip"
|
|
70
|
+
>
|
|
71
|
+
{loading
|
|
72
|
+
? Array.from({ length: cols }, (_, i) => (
|
|
73
|
+
<div key={i} className="bg-card px-4 py-3">
|
|
74
|
+
<Skeleton className="h-3 w-20" />
|
|
75
|
+
<Skeleton className="mt-3 h-7 w-16" />
|
|
76
|
+
</div>
|
|
77
|
+
))
|
|
78
|
+
: items.map((item, i) => {
|
|
79
|
+
const Tag = item.onClick ? "button" : "div";
|
|
80
|
+
return (
|
|
81
|
+
<Tag
|
|
82
|
+
key={`${item.label}-${i}`}
|
|
83
|
+
type={item.onClick ? "button" : undefined}
|
|
84
|
+
onClick={item.onClick}
|
|
85
|
+
className={cn(
|
|
86
|
+
"group min-w-0 bg-card px-4 py-3 text-left",
|
|
87
|
+
item.onClick && "cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<dt className="flex items-center gap-1 text-xs text-text-color-muted">
|
|
91
|
+
<span className="truncate">{item.label}</span>
|
|
92
|
+
{item.hint ? (
|
|
93
|
+
<Tooltip>
|
|
94
|
+
<TooltipTrigger asChild>
|
|
95
|
+
<span tabIndex={0} className="inline-flex rounded-sm text-text-color-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={item.hint}>
|
|
96
|
+
<Info className="size-3.5" strokeWidth={1.5} aria-hidden />
|
|
97
|
+
</span>
|
|
98
|
+
</TooltipTrigger>
|
|
99
|
+
<TooltipContent side="top" className="max-w-xs text-xs">
|
|
100
|
+
{item.hint}
|
|
101
|
+
</TooltipContent>
|
|
102
|
+
</Tooltip>
|
|
103
|
+
) : null}
|
|
104
|
+
</dt>
|
|
105
|
+
<dd className="mt-1 flex items-end justify-between gap-3">
|
|
106
|
+
<div className="min-w-0">
|
|
107
|
+
<div className="flex items-baseline gap-2">
|
|
108
|
+
<span className="truncate text-2xl font-medium leading-none tracking-tight text-foreground tabular-nums">{item.value}</span>
|
|
109
|
+
{item.delta != null ? <Delta delta={item.delta} tone={item.tone} /> : null}
|
|
110
|
+
</div>
|
|
111
|
+
{item.footer ? <div className="mt-1.5 text-xs text-text-color-muted">{item.footer}</div> : null}
|
|
112
|
+
</div>
|
|
113
|
+
{item.sparkline && item.sparkline.length > 1 ? <Sparkline data={item.sparkline} tone={item.tone} className="h-8 w-20 shrink-0" /> : null}
|
|
114
|
+
</dd>
|
|
115
|
+
</Tag>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</dl>
|
|
119
|
+
</TooltipProvider>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StatusPill: a small state badge whose colour carries meaning (positive, warning, negative) or none
|
|
5
|
+
* (neutral, info). Use it for record states (active, paused, bounced, won). Unknown states render as
|
|
6
|
+
* `warning`, never as positive: being "active" is opt-in.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: none.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { cn } from "@graph8/sdk/ui";
|
|
12
|
+
|
|
13
|
+
export type Tone = "neutral" | "info" | "positive" | "warning" | "negative";
|
|
14
|
+
|
|
15
|
+
export const TONES: readonly Tone[] = ["neutral", "info", "positive", "warning", "negative"];
|
|
16
|
+
|
|
17
|
+
/** Tailwind classes per tone: the pill surface, its dot, and plain text. Shared by the kit. */
|
|
18
|
+
export const TONE_CLASSES: Record<Tone, { pill: string; dot: string; text: string; bar: string }> = {
|
|
19
|
+
neutral: {
|
|
20
|
+
pill: "border-border bg-muted text-foreground",
|
|
21
|
+
dot: "bg-text-color-muted",
|
|
22
|
+
text: "text-text-color-muted",
|
|
23
|
+
bar: "bg-foreground/15",
|
|
24
|
+
},
|
|
25
|
+
info: {
|
|
26
|
+
pill: "border-transparent bg-info text-info-foreground",
|
|
27
|
+
dot: "bg-info-foreground",
|
|
28
|
+
text: "text-info-foreground",
|
|
29
|
+
bar: "bg-info-foreground",
|
|
30
|
+
},
|
|
31
|
+
positive: {
|
|
32
|
+
pill: "border-transparent bg-positive text-positive-foreground",
|
|
33
|
+
dot: "bg-positive-foreground",
|
|
34
|
+
text: "text-positive-foreground",
|
|
35
|
+
bar: "bg-positive-foreground",
|
|
36
|
+
},
|
|
37
|
+
warning: {
|
|
38
|
+
pill: "border-transparent bg-warning text-warning-foreground",
|
|
39
|
+
dot: "bg-warning-foreground",
|
|
40
|
+
text: "text-warning-foreground",
|
|
41
|
+
bar: "bg-warning-foreground",
|
|
42
|
+
},
|
|
43
|
+
negative: {
|
|
44
|
+
pill: "border-transparent bg-error text-error-foreground",
|
|
45
|
+
dot: "bg-error-foreground",
|
|
46
|
+
text: "text-error-foreground",
|
|
47
|
+
bar: "bg-error-foreground",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** "scheduled_for_cancellation" -> "Scheduled for cancellation". */
|
|
52
|
+
export function humanizeStatus(value: string | null | undefined): string {
|
|
53
|
+
if (!value) return "Unknown";
|
|
54
|
+
const text = String(value).replace(/[_-]+/g, " ").trim().toLowerCase();
|
|
55
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map a raw API status to a tone. Only the listed good states are positive; anything unlisted is a
|
|
60
|
+
* warning so a new upstream value is visible instead of looking healthy.
|
|
61
|
+
*/
|
|
62
|
+
export function toneForStatus(
|
|
63
|
+
value: string | null | undefined,
|
|
64
|
+
overrides: Partial<Record<string, Tone>> = {},
|
|
65
|
+
): Tone {
|
|
66
|
+
const key = String(value ?? "").toLowerCase();
|
|
67
|
+
if (overrides[key]) return overrides[key] as Tone;
|
|
68
|
+
if (["active", "connected", "won", "completed", "complete", "succeeded", "success", "delivered", "replied", "verified", "booked", "sent", "live", "published", "enabled", "healthy", "ok"].includes(key)) return "positive";
|
|
69
|
+
if (["paused", "inactive", "draft", "archived", "closed", "disabled", "cancelled", "canceled", "stopped", "ended", "done", "skipped", "none"].includes(key)) return "neutral";
|
|
70
|
+
if (["pending", "queued", "scheduled", "running", "in_progress", "processing", "syncing", "open", "new", "trial", "sending", "calling", "ringing"].includes(key)) return "info";
|
|
71
|
+
if (["failed", "error", "errored", "bounced", "lost", "rejected", "expired", "blocked", "suspended", "unsubscribed", "disconnected", "invalid", "overdue", "missed", "spam"].includes(key)) return "negative";
|
|
72
|
+
return "warning";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface StatusPillProps {
|
|
76
|
+
tone: Tone;
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
/** Leading dot in the tone colour: use it in dense rows where the fill would be noisy. */
|
|
79
|
+
dot?: boolean;
|
|
80
|
+
/** `dot` alone renders only the dot and the text, with no pill surface. */
|
|
81
|
+
quiet?: boolean;
|
|
82
|
+
size?: "sm" | "md";
|
|
83
|
+
className?: string;
|
|
84
|
+
title?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function StatusPill({ tone, children, dot, quiet, size = "sm", className, title }: StatusPillProps) {
|
|
88
|
+
const t = TONE_CLASSES[tone] ?? TONE_CLASSES.neutral;
|
|
89
|
+
return (
|
|
90
|
+
<span
|
|
91
|
+
data-tone={tone}
|
|
92
|
+
title={title}
|
|
93
|
+
className={cn(
|
|
94
|
+
"inline-flex max-w-full items-center gap-1.5 whitespace-nowrap rounded-full font-medium leading-none",
|
|
95
|
+
size === "sm" ? "h-5 px-2 text-[11px]" : "h-6 px-2.5 text-xs",
|
|
96
|
+
quiet ? "px-0 text-foreground" : cn("border", t.pill),
|
|
97
|
+
className,
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{dot || quiet ? <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", t.dot)} /> : null}
|
|
101
|
+
<span className="truncate">{children}</span>
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The recharts half of TimeSeriesChart, in its own module so it is code-split: `time-series-chart.tsx`
|
|
5
|
+
* loads it on demand (React.lazy), and pages without a chart never download recharts. Import this file
|
|
6
|
+
* directly only if you need the chart rendered synchronously (it then joins your page bundle).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: none. Peer: recharts.
|
|
9
|
+
*/
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
|
12
|
+
import { ChartContainer, ChartTooltip, ChartTooltipContent, cn, type ChartConfig } from "@graph8/sdk/ui";
|
|
13
|
+
import { TONE_VAR, defaultFormatX, defaultFormatY } from "./time-series-format";
|
|
14
|
+
import type { TimeSeries, TimeSeriesChartProps } from "./time-series-format";
|
|
15
|
+
|
|
16
|
+
export function TimeSeriesChartRecharts({ data, xKey, series, kind = "line", height = 220, formatX = defaultFormatX, formatY = defaultFormatY, stacked, className }: TimeSeriesChartProps) {
|
|
17
|
+
const config = useMemo<ChartConfig>(
|
|
18
|
+
() => Object.fromEntries(series.map((s) => [s.key, { label: s.label ?? s.key, color: TONE_VAR[s.tone ?? "ink"] }])),
|
|
19
|
+
[series],
|
|
20
|
+
);
|
|
21
|
+
const axes = (
|
|
22
|
+
<>
|
|
23
|
+
<CartesianGrid vertical={false} strokeDasharray="0" stroke="var(--border)" strokeOpacity={0.9} />
|
|
24
|
+
<XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} tickFormatter={formatX} tick={{ fontSize: 11 }} />
|
|
25
|
+
<YAxis width={36} tickLine={false} axisLine={false} tickMargin={4} tickFormatter={formatY} tick={{ fontSize: 11 }} allowDecimals={false} />
|
|
26
|
+
<ChartTooltip cursor={{ stroke: "var(--border)" }} content={<ChartTooltipContent labelFormatter={(v) => formatX(v)} formatter={(v, name) => [`${formatY(Number(v))} `, String(config[String(name)]?.label ?? name)]} indicator="line" />} />
|
|
27
|
+
</>
|
|
28
|
+
);
|
|
29
|
+
const stroke = (s: TimeSeries) => ({
|
|
30
|
+
stroke: `var(--color-${s.key})`,
|
|
31
|
+
strokeWidth: s.tone === "prior" ? 1.25 : 1.5,
|
|
32
|
+
strokeDasharray: s.tone === "prior" ? "4 3" : undefined,
|
|
33
|
+
dot: false as const,
|
|
34
|
+
activeDot: { r: 3 },
|
|
35
|
+
isAnimationActive: false,
|
|
36
|
+
});
|
|
37
|
+
const style = { height } as const;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ChartContainer config={config} className={cn("aspect-auto w-full text-text-color-muted", className)} style={style} data-testid="time-series-chart">
|
|
41
|
+
{kind === "bar" ? (
|
|
42
|
+
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }} barCategoryGap="25%">
|
|
43
|
+
{axes}
|
|
44
|
+
{series.map((s) => (
|
|
45
|
+
<Bar key={s.key} dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone ? 0.85 : 1} radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
|
46
|
+
))}
|
|
47
|
+
</BarChart>
|
|
48
|
+
) : kind === "area" ? (
|
|
49
|
+
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
|
50
|
+
{axes}
|
|
51
|
+
{series.map((s) => (
|
|
52
|
+
<Area key={s.key} type="monotone" dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone === "prior" ? 0 : 0.06} {...stroke(s)} />
|
|
53
|
+
))}
|
|
54
|
+
</AreaChart>
|
|
55
|
+
) : (
|
|
56
|
+
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
|
57
|
+
{axes}
|
|
58
|
+
{series.map((s) => (
|
|
59
|
+
<Line key={s.key} type="monotone" dataKey={s.key} name={s.key} {...stroke(s)} />
|
|
60
|
+
))}
|
|
61
|
+
</LineChart>
|
|
62
|
+
)}
|
|
63
|
+
</ChartContainer>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TimeSeriesChart: a line, area or bar chart over time (sent, replies, meetings by day). Ink by
|
|
5
|
+
* default; a series takes a tone only when it means good or bad (replies positive, bounces negative).
|
|
6
|
+
* A `prior` series is the dashed warm-grey comparison. Chrome is recessive: hairline gridlines, muted
|
|
7
|
+
* axis labels, no borders. Built on recharts through the UI kit's ChartContainer.
|
|
8
|
+
*
|
|
9
|
+
* recharts is code-split: the chart module (time-series-chart-recharts.tsx) loads on first render, with a
|
|
10
|
+
* same-height skeleton meanwhile, so a page that shows no chart never downloads it. Same props as before.
|
|
11
|
+
*
|
|
12
|
+
* SDK operations: none. Peer: recharts.
|
|
13
|
+
*/
|
|
14
|
+
import { Suspense, lazy } from "react";
|
|
15
|
+
import { Skeleton, cn } from "@graph8/sdk/ui";
|
|
16
|
+
import type { TimeSeriesChartProps } from "./time-series-format";
|
|
17
|
+
|
|
18
|
+
export type { TimeSeries, TimeSeriesChartProps } from "./time-series-format";
|
|
19
|
+
export { defaultFormatX, defaultFormatY } from "./time-series-format";
|
|
20
|
+
|
|
21
|
+
const Recharts = lazy(() => import("./time-series-chart-recharts").then((m) => ({ default: m.TimeSeriesChartRecharts })));
|
|
22
|
+
|
|
23
|
+
export function TimeSeriesChart(props: TimeSeriesChartProps) {
|
|
24
|
+
const fallback = (
|
|
25
|
+
<Skeleton className={cn("w-full", props.className)} style={{ height: props.height ?? 220 }} aria-label="Loading chart" data-testid="time-series-chart-loading" />
|
|
26
|
+
);
|
|
27
|
+
return (
|
|
28
|
+
<Suspense fallback={fallback}>
|
|
29
|
+
<Recharts {...props} />
|
|
30
|
+
</Suspense>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Types and formatters shared by TimeSeriesChart and its lazily loaded recharts implementation.
|
|
5
|
+
* No recharts here, so importing them costs nothing.
|
|
6
|
+
*/
|
|
7
|
+
import type { Tone } from "./status-pill";
|
|
8
|
+
|
|
9
|
+
export interface TimeSeries {
|
|
10
|
+
/** The field of each row to plot. */
|
|
11
|
+
key: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Ink unless the direction means good or bad. "prior" is the dashed comparison line. */
|
|
14
|
+
tone?: Tone | "prior";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TimeSeriesChartProps {
|
|
18
|
+
data: Array<Record<string, unknown>>;
|
|
19
|
+
/** Field holding the x value (an ISO date, a label). */
|
|
20
|
+
xKey: string;
|
|
21
|
+
series: TimeSeries[];
|
|
22
|
+
kind?: "line" | "area" | "bar";
|
|
23
|
+
height?: number;
|
|
24
|
+
/** Format an x value for the axis ("2026-10-01" -> "Oct 1"). */
|
|
25
|
+
formatX?: (value: unknown) => string;
|
|
26
|
+
/** Format a y value for the axis and the tooltip. */
|
|
27
|
+
formatY?: (value: number) => string;
|
|
28
|
+
/** Stack bars/areas. */
|
|
29
|
+
stacked?: boolean;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const TONE_VAR: Record<NonNullable<TimeSeries["tone"]> | "ink", string> = {
|
|
34
|
+
ink: "var(--foreground)",
|
|
35
|
+
neutral: "var(--text-color-muted)",
|
|
36
|
+
info: "var(--info-foreground)",
|
|
37
|
+
positive: "var(--positive-foreground)",
|
|
38
|
+
warning: "var(--warning-foreground)",
|
|
39
|
+
negative: "var(--error-foreground)",
|
|
40
|
+
prior: "var(--text-color-muted)",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function defaultFormatX(value: unknown): string {
|
|
44
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
45
|
+
const d = new Date(value.length === 10 ? `${value}T00:00:00Z` : value);
|
|
46
|
+
if (!Number.isNaN(d.getTime())) return d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: value.length === 10 ? "UTC" : undefined });
|
|
47
|
+
}
|
|
48
|
+
return String(value ?? "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function defaultFormatY(value: number): string {
|
|
52
|
+
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
53
|
+
if (Math.abs(value) >= 10_000) return `${(value / 1_000).toFixed(0)}k`;
|
|
54
|
+
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
55
|
+
return value.toLocaleString();
|
|
56
|
+
}
|
|
57
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Toolbar: the row above a list. Left: debounced search and filters. Right: actions (bulk, export, the
|
|
5
|
+
* primary). The search calls `onChange` after `debounceMs` of quiet typing, immediately on Enter, and
|
|
6
|
+
* clears on Escape or the clear button. Out-of-order results are the list's job (useG8Query does it).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: none.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
|
11
|
+
import { Search, X } from "lucide-react";
|
|
12
|
+
import { Input, cn } from "@graph8/sdk/ui";
|
|
13
|
+
|
|
14
|
+
export interface ToolbarSearch {
|
|
15
|
+
value: string;
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
/** Milliseconds of quiet typing before `onChange` fires. 0 fires on every keystroke. */
|
|
19
|
+
debounceMs?: number;
|
|
20
|
+
/** Accessible name; defaults to the placeholder or "Search". */
|
|
21
|
+
label?: string;
|
|
22
|
+
autoFocus?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolbarProps {
|
|
26
|
+
search?: ToolbarSearch;
|
|
27
|
+
/** Filter controls (selects, chips, a Filters button), rendered after the search. */
|
|
28
|
+
filters?: ReactNode;
|
|
29
|
+
/** Right-aligned actions; the primary action goes last. */
|
|
30
|
+
actions?: ReactNode;
|
|
31
|
+
/** A second row (applied filter chips, a selection summary). */
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
/** Result count or any status text, read by screen readers politely. */
|
|
34
|
+
status?: ReactNode;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ToolbarSearchInput({ value, onChange, placeholder = "Search", debounceMs = 250, label, autoFocus }: ToolbarSearch) {
|
|
39
|
+
const [text, setText] = useState(value);
|
|
40
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
|
+
const latest = useRef(onChange);
|
|
42
|
+
latest.current = onChange;
|
|
43
|
+
// Values sent to onChange that the parent has not echoed back yet (an async parent, e.g. router.push).
|
|
44
|
+
const sent = useRef<string[]>([]);
|
|
45
|
+
|
|
46
|
+
// The parent owns the value (URL, store): follow it when it changes from outside. A late echo of
|
|
47
|
+
// something we sent is ignored (it would drop characters typed since). A real outside change (a "Clear
|
|
48
|
+
// filters" button) wins, and cancels a pending debounce so the old text is not sent back afterwards.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const i = sent.current.indexOf(value);
|
|
51
|
+
if (i !== -1) {
|
|
52
|
+
sent.current = sent.current.slice(i + 1);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (timer.current) {
|
|
56
|
+
clearTimeout(timer.current);
|
|
57
|
+
timer.current = null;
|
|
58
|
+
}
|
|
59
|
+
sent.current = [];
|
|
60
|
+
setText(value);
|
|
61
|
+
}, [value]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
64
|
+
|
|
65
|
+
const send = (next: string) => {
|
|
66
|
+
sent.current = [...sent.current.slice(-19), next];
|
|
67
|
+
latest.current(next);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const emit = (next: string, immediate = false) => {
|
|
71
|
+
if (timer.current) clearTimeout(timer.current);
|
|
72
|
+
if (immediate || debounceMs <= 0) {
|
|
73
|
+
timer.current = null;
|
|
74
|
+
send(next);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
timer.current = setTimeout(() => {
|
|
78
|
+
timer.current = null;
|
|
79
|
+
send(next);
|
|
80
|
+
}, debounceMs);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
84
|
+
if (e.key === "Enter") {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
emit(text, true);
|
|
87
|
+
} else if (e.key === "Escape" && text) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setText("");
|
|
90
|
+
emit("", true);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="relative w-full min-w-0 sm:w-72">
|
|
96
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-text-color-muted" strokeWidth={1.5} aria-hidden />
|
|
97
|
+
<Input
|
|
98
|
+
type="search"
|
|
99
|
+
role="searchbox"
|
|
100
|
+
value={text}
|
|
101
|
+
autoFocus={autoFocus}
|
|
102
|
+
aria-label={label ?? placeholder}
|
|
103
|
+
placeholder={placeholder}
|
|
104
|
+
onChange={(e) => {
|
|
105
|
+
setText(e.target.value);
|
|
106
|
+
emit(e.target.value);
|
|
107
|
+
}}
|
|
108
|
+
onKeyDown={onKeyDown}
|
|
109
|
+
className="h-8 bg-background pl-8 pr-8 text-sm shadow-none [&::-webkit-search-cancel-button]:hidden"
|
|
110
|
+
/>
|
|
111
|
+
{text ? (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
aria-label="Clear search"
|
|
115
|
+
onClick={() => {
|
|
116
|
+
setText("");
|
|
117
|
+
emit("", true);
|
|
118
|
+
}}
|
|
119
|
+
className="absolute right-1.5 top-1/2 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-sm text-text-color-muted hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
120
|
+
>
|
|
121
|
+
<X className="size-3.5" strokeWidth={1.5} aria-hidden />
|
|
122
|
+
</button>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function Toolbar({ search, filters, actions, children, status, className }: ToolbarProps) {
|
|
129
|
+
return (
|
|
130
|
+
<div role="toolbar" aria-label="List tools" className={cn("flex flex-col gap-2", className)} data-testid="toolbar">
|
|
131
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
132
|
+
{search ? <ToolbarSearchInput {...search} /> : null}
|
|
133
|
+
{filters ? <div className="flex flex-wrap items-center gap-1.5">{filters}</div> : null}
|
|
134
|
+
{status ? (
|
|
135
|
+
<span role="status" aria-live="polite" className="text-xs text-text-color-muted tabular-nums">
|
|
136
|
+
{status}
|
|
137
|
+
</span>
|
|
138
|
+
) : null}
|
|
139
|
+
{actions ? <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div> : null}
|
|
140
|
+
</div>
|
|
141
|
+
{children ? <div className="flex flex-wrap items-center gap-1.5">{children}</div> : null}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|