@volter/twin-hubspot 0.1.37 → 1.0.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 +9 -193
- package/dist/generated/pack-facts.json +42 -0
- package/dist/journeys/coverage.json +60 -0
- package/dist/journeys/customer-life.json +7 -0
- package/dist/journeys/customer-life.outline.md +6 -0
- package/dist/journeys/decisions.json +623 -0
- package/dist/journeys/demand.json +171 -0
- package/dist/journeys/score.json +82 -0
- package/dist/journeys/unreachable.json +12 -0
- package/dist/journeys/vendor-examples.json +7 -0
- package/dist/spec/SOURCE.md +40 -0
- package/dist/spec/openapi/associations.json.gz +0 -0
- package/dist/spec/openapi/companies.json.gz +0 -0
- package/dist/spec/openapi/contacts.json.gz +0 -0
- package/dist/spec/openapi/deals.json.gz +0 -0
- package/dist/spec/openapi/meetings.json.gz +0 -0
- package/dist/spec/openapi/oauth.json.gz +0 -0
- package/dist/spec/openapi/owners.json.gz +0 -0
- package/dist/spec/openapi/pipelines.json.gz +0 -0
- package/dist/spec/openapi/properties.json.gz +0 -0
- package/dist/spec/openapi/tickets.json.gz +0 -0
- package/dist/spec/patches.json +235 -0
- package/dist/spec/sources.json +32 -0
- package/dist/src/cli.js +10 -22
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +13 -0
- package/dist/src/generated/surface.gen.json +1 -1
- package/dist/src/index.d.ts +5 -11
- package/dist/src/index.js +10 -94
- package/dist/src/manifest.js +32 -66
- package/dist/src/semantics/index.d.ts +2 -0
- package/dist/src/semantics/index.js +1 -0
- package/dist/src/semantics/states.d.ts +1 -0
- package/dist/src/semantics/states.js +2 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +7 -0
- package/generated/pack-facts.json +42 -0
- package/journeys/coverage.json +60 -0
- package/journeys/customer-life.json +7 -0
- package/journeys/customer-life.outline.md +6 -0
- package/journeys/decisions.json +623 -0
- package/journeys/demand.json +171 -0
- package/journeys/score.json +82 -0
- package/journeys/unreachable.json +12 -0
- package/journeys/vendor-examples.json +7 -0
- package/package.json +10 -23
- package/spec/SOURCE.md +40 -0
- package/spec/openapi/associations.json.gz +0 -0
- package/spec/openapi/companies.json.gz +0 -0
- package/spec/openapi/contacts.json.gz +0 -0
- package/spec/openapi/deals.json.gz +0 -0
- package/spec/openapi/meetings.json.gz +0 -0
- package/spec/openapi/oauth.json.gz +0 -0
- package/spec/openapi/owners.json.gz +0 -0
- package/spec/openapi/pipelines.json.gz +0 -0
- package/spec/openapi/properties.json.gz +0 -0
- package/spec/openapi/tickets.json.gz +0 -0
- package/spec/patches.json +235 -0
- package/spec/sources.json +32 -0
- package/src/cli.ts +10 -19
- package/src/fetch.ts +14 -0
- package/src/generated/surface.gen.json +1 -1
- package/src/index.ts +12 -152
- package/src/manifest.ts +33 -92
- package/src/semantics/index.ts +6 -0
- package/src/semantics/states.ts +2 -0
- package/src/server.ts +8 -0
- package/client/hubspot-mirror.css +0 -43
- package/client/hubspot-mirror.tsx +0 -132
- package/dist/client/hubspot-mirror.bundle.js +0 -516
- package/dist/client/hubspot-mirror.css +0 -43
- package/dist/client/hubspot-mirror.d.ts +0 -15
- package/dist/client/hubspot-mirror.js +0 -59
- package/dist/client/hubspot-mirror.tsx +0 -132
- package/dist/src/accounts.d.ts +0 -30
- package/dist/src/accounts.js +0 -122
- package/dist/src/hubspot-areas.d.ts +0 -10
- package/dist/src/hubspot-areas.js +0 -114
- package/dist/src/hubspot-budget.d.ts +0 -58
- package/dist/src/hubspot-budget.js +0 -176
- package/dist/src/hubspot-capabilities.d.ts +0 -3
- package/dist/src/hubspot-capabilities.js +0 -1588
- package/dist/src/hubspot-conformance.d.ts +0 -16
- package/dist/src/hubspot-conformance.js +0 -523
- package/dist/src/hubspot-connector.d.ts +0 -125
- package/dist/src/hubspot-connector.js +0 -390
- package/dist/src/hubspot-deferred-capabilities.d.ts +0 -6
- package/dist/src/hubspot-deferred-capabilities.js +0 -64
- package/dist/src/hubspot-mirror-ui.d.ts +0 -62
- package/dist/src/hubspot-mirror-ui.js +0 -152
- package/dist/src/hubspot-oauth.d.ts +0 -8
- package/dist/src/hubspot-oauth.js +0 -291
- package/dist/src/hubspot-server.d.ts +0 -24
- package/dist/src/hubspot-server.js +0 -116
- package/dist/src/hubspot-twin.d.ts +0 -65
- package/dist/src/hubspot-twin.js +0 -1558
- package/dist/src/portal.d.ts +0 -20
- package/dist/src/portal.js +0 -30
- package/dist/src/screens/account.d.ts +0 -1
- package/dist/src/screens/account.js +0 -139
- package/dist/src/screens/crm.d.ts +0 -2
- package/dist/src/screens/crm.js +0 -153
- package/dist/src/screens/developer.d.ts +0 -4
- package/dist/src/screens/developer.js +0 -191
- package/dist/src/screens/forms.d.ts +0 -5
- package/dist/src/screens/forms.js +0 -126
- package/dist/src/screens/page.d.ts +0 -21
- package/dist/src/screens/page.js +0 -49
- package/dist/src/screens/session.d.ts +0 -1
- package/dist/src/screens/session.js +0 -32
- package/dist/src/semantics/crm.d.ts +0 -8
- package/dist/src/semantics/crm.js +0 -101
- package/dist/src/webhooks.d.ts +0 -12
- package/dist/src/webhooks.js +0 -79
- package/src/accounts.ts +0 -127
- package/src/hubspot-areas.ts +0 -155
- package/src/hubspot-budget.ts +0 -202
- package/src/hubspot-capabilities.ts +0 -1523
- package/src/hubspot-conformance.ts +0 -537
- package/src/hubspot-connector.ts +0 -419
- package/src/hubspot-deferred-capabilities.ts +0 -99
- package/src/hubspot-journey.uitest.ts +0 -104
- package/src/hubspot-mirror-ui.ts +0 -166
- package/src/hubspot-oauth.tsx +0 -296
- package/src/hubspot-server.ts +0 -115
- package/src/hubspot-twin.ts +0 -1534
- package/src/portal.ts +0 -40
- package/src/screens/account.tsx +0 -129
- package/src/screens/crm.tsx +0 -154
- package/src/screens/developer.tsx +0 -181
- package/src/screens/forms.tsx +0 -117
- package/src/screens/page.tsx +0 -55
- package/src/screens/session.tsx +0 -36
- package/src/semantics/crm.ts +0 -116
- package/src/webhooks.ts +0 -82
package/src/index.ts
CHANGED
|
@@ -1,152 +1,12 @@
|
|
|
1
|
-
// @volter/twin-hubspot — the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
HUBSPOT_RATE_LIMIT_HEADERS,
|
|
14
|
-
LIST_LIMIT_MAX,
|
|
15
|
-
OBJECT_TYPES,
|
|
16
|
-
RESOURCE_TYPES,
|
|
17
|
-
SEARCH_FILTER_OPERATORS,
|
|
18
|
-
SEARCH_LIMIT_MAX,
|
|
19
|
-
SEARCH_MAX_FILTERS_PER_GROUP,
|
|
20
|
-
SEARCH_MAX_FILTERS_TOTAL,
|
|
21
|
-
SEARCH_MAX_FILTER_GROUPS,
|
|
22
|
-
SEARCH_RESULT_CAP,
|
|
23
|
-
} from './hubspot-twin.ts';
|
|
24
|
-
export type { HubspotRequest, HubspotResponse, ObjectType } from './hubspot-twin.ts';
|
|
25
|
-
export { createHubspotTwinFetch, createHubspotTwinServer, type HubspotTwinFetchOptions } from './hubspot-server.ts';
|
|
26
|
-
export {
|
|
27
|
-
hubspotRequestForAction,
|
|
28
|
-
liveHubspotExecute,
|
|
29
|
-
mapCrmObject,
|
|
30
|
-
mapOwner,
|
|
31
|
-
pollTimestamp,
|
|
32
|
-
pullHubspotAll,
|
|
33
|
-
pullHubspotObjects,
|
|
34
|
-
pullHubspotOwners,
|
|
35
|
-
pushPendingHubspotActions,
|
|
36
|
-
syncHubspotFromReal,
|
|
37
|
-
} from './hubspot-connector.ts';
|
|
38
|
-
export type { HubspotErrorBody, HubspotExecute, LiveHubspotOptions } from './hubspot-connector.ts';
|
|
39
|
-
// The client-side rate budget — the fail-closed backstop `liveHubspotExecute` routes every live
|
|
40
|
-
// request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
|
|
41
|
-
// here is HubSpot's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
|
|
42
|
-
// bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
|
|
43
|
-
// `HubspotBudgetError` by type; there is deliberately no export that disables the guard.
|
|
44
|
-
export {
|
|
45
|
-
HUBSPOT_BUDGET_CEILING,
|
|
46
|
-
HUBSPOT_BUDGET_MAX_RETRY_AFTER_S,
|
|
47
|
-
HUBSPOT_BUDGET_WINDOW_MS,
|
|
48
|
-
HUBSPOT_CALL_WEIGHTS,
|
|
49
|
-
HUBSPOT_RATE_BUDGET,
|
|
50
|
-
HubspotBudget,
|
|
51
|
-
HubspotBudgetError,
|
|
52
|
-
hubspotBudgetPath,
|
|
53
|
-
hubspotCallWeight,
|
|
54
|
-
} from './hubspot-budget.ts';
|
|
55
|
-
export type { HubspotBudgetErrorKind, HubspotBudgetOptions, HubspotBudgetReservation, HubspotBudgetSnapshot } from './hubspot-budget.ts';
|
|
56
|
-
export {
|
|
57
|
-
MIRROR_SECTIONS,
|
|
58
|
-
SECTION_COLUMNS,
|
|
59
|
-
TITLE_PROPERTY,
|
|
60
|
-
buildHubspotMirrorClient,
|
|
61
|
-
createHubspotMirrorServer,
|
|
62
|
-
formatAmount,
|
|
63
|
-
groupByStage,
|
|
64
|
-
hubspotMirrorHtml,
|
|
65
|
-
recordTitle,
|
|
66
|
-
stageTone,
|
|
67
|
-
} from './hubspot-mirror-ui.ts';
|
|
68
|
-
export type { HubspotRow, MirrorSectionKey, PillTone } from './hubspot-mirror-ui.ts';
|
|
69
|
-
|
|
70
|
-
// Registry descriptor: the pack self-describes so tooling can discover it.
|
|
71
|
-
import { registerPack, type TwinPack } from '@volter/world-core';
|
|
72
|
-
import { performHubspotAction, syncHubspotFromRemote } from './hubspot-connector.ts';
|
|
73
|
-
import { HUBSPOT_RATE_BUDGET as RATE_BUDGET } from './hubspot-budget.ts';
|
|
74
|
-
export const pack: TwinPack = {
|
|
75
|
-
// PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of
|
|
76
|
-
// the real state system. Moved 2026-09-08.
|
|
77
|
-
protocol: '2',
|
|
78
|
-
// HubSpot publishes webhooks for CRM object changes, which is what actually moves; the refresh
|
|
79
|
-
// walks every object type and is the backstop.
|
|
80
|
-
refresh: { every: '15m', webhook: true, onDemand: { atMost: '30s' } },
|
|
81
|
-
stateSystem: { perform: performHubspotAction, refresh: syncHubspotFromRemote },
|
|
82
|
-
// The round trip is a CONTACT create — HubSpot's own write, and the portal mints a fresh record
|
|
83
|
-
// id for every one, so a second send on a branch is a second contact rather than a collision.
|
|
84
|
-
roundTrip: { method: 'POST', path: '/crm/v3/objects/contacts', body: { properties: { email: 'round@trip.test', firstname: 'Round', lastname: 'Trip' } }, headers: { authorization: 'Bearer twin' } },
|
|
85
|
-
parityOrigin: 'http://twin',
|
|
86
|
-
|
|
87
|
-
vendor: 'hubspot',
|
|
88
|
-
// The SAME object hubspot-budget.ts declares at module load — one source of truth, so
|
|
89
|
-
// registering the pack and importing the connector can never arm two different ceilings.
|
|
90
|
-
rateBudget: RATE_BUDGET,
|
|
91
|
-
transport: 'rest',
|
|
92
|
-
archetype: 'crud',
|
|
93
|
-
bin: 'world-hubspot',
|
|
94
|
-
// R2 adopted-as-debt, legibly: declared types no replay can create, each with its reason.
|
|
95
|
-
resourcesUnreachable: {
|
|
96
|
-
'owner': "a portal user provisioned in HubSpot's admin, never through the CRM API",
|
|
97
|
-
},
|
|
98
|
-
resources: ['crm_object', 'property', 'property_group', 'pipeline', 'association', 'owner'],
|
|
99
|
-
specSource:
|
|
100
|
-
"nine documents of HubSpot's public spec catalog (api.hubspot.com/public/api/spec/v1/specs): Contacts, Companies, "
|
|
101
|
-
+ 'Deals, Tickets, Crm Owners, Properties and Pipelines v3, Associations v4 and OAuth v1, vendored under spec/openapi/ '
|
|
102
|
-
+ '(provenance in spec/SOURCE.md) and read as their union; the calls dubinc/dub makes (apps/web/lib/integrations/hubspot/).',
|
|
103
|
-
description: 'HubSpot CRM twin — v3/v4 objects, properties, associations, pipelines and owners, with the vendor error envelope, paging.next.after and a CRM mirror.',
|
|
104
|
-
// A browser-side HubSpot call goes to api.hubapi.com/crm/… — the dev proxy forwards '/crm/'
|
|
105
|
-
// to the twin and strips the absolute host so calls come back same-origin.
|
|
106
|
-
browserRouting: { apiPathPrefix: '/crm/', loaderHost: 'https://api.hubapi.com' },
|
|
107
|
-
|
|
108
|
-
// ADOPTION — how an app repo betrays that it talks to HubSpot.
|
|
109
|
-
// • `@hubspot/api-client` is the ONE official npm client of the surface modeled here.
|
|
110
|
-
// • The `@hubspot/` scope is NOT claimed wholesale: it also carries the CMS/CLI toolchain
|
|
111
|
-
// (`@hubspot/cli`, `@hubspot/local-dev-lib`, the `@hubspot/cms-components` family), which
|
|
112
|
-
// are build tooling for a product area this twin explicitly does not model. Claiming the
|
|
113
|
-
// scope would report a CMS-theme repo as covered by a CRM twin — the exact lie `covers`
|
|
114
|
-
// exists to prevent (the `googleapis` precedent in covers.ts).
|
|
115
|
-
// • `HUBSPOT` is the credential stem: HUBSPOT_ACCESS_TOKEN / HUBSPOT_API_KEY /
|
|
116
|
-
// HUBSPOT_CLIENT_ID / HUBSPOT_CLIENT_SECRET all normalize to it under covers.ts's
|
|
117
|
-
// ENV_SUFFIX rules (framework prefix stripped, suffix matched, remainder lowercased with
|
|
118
|
-
// punctuation removed). `HUBSPOT_PRIVATE_APP_TOKEN` — the name HubSpot's own private-app
|
|
119
|
-
// docs use — stems to `hubspotprivateapp` instead, because `_TOKEN` is the matched suffix,
|
|
120
|
-
// so it is claimed explicitly rather than assumed.
|
|
121
|
-
adoption: {
|
|
122
|
-
// HubSpot's official Python client, the counterpart of `@hubspot/api-client`.
|
|
123
|
-
pypi: ['hubspot-api-client'],
|
|
124
|
-
sdks: ['@hubspot/api-client'],
|
|
125
|
-
envStems: ['HUBSPOT', 'HUBSPOTPRIVATEAPP'],
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
// INTERCEPTION — api.hubapi.com is the SDK's only API server: every generated
|
|
129
|
-
// `servers.ts` in @hubspot/api-client 14.0.1 declares exactly
|
|
130
|
-
// `new ServerConfiguration("https://api.hubapi.com", {})`, and a sweep of the whole published
|
|
131
|
-
// tarball finds no other API host. `app.hubspot.com` appears once, in
|
|
132
|
-
// `OauthDiscovery.getAuthorizationUrl`: the URL a person is sent to for consent, which the twin serves
|
|
133
|
-
// (hubspot-oauth.tsx).
|
|
134
|
-
// app.hubspot.com carries every page the twin builds (sign-in, consent, apps, records, forms, settings) and
|
|
135
|
-
// api.hsforms.com a form's submission (src/screens/).
|
|
136
|
-
hosts: [{ host: 'api.hubapi.com' }, { host: 'app.hubspot.com' }, { host: 'api.hsforms.com' }],
|
|
137
|
-
|
|
138
|
-
// WORLD WIRING — `endpointEnvNone`, and this is a ruling, not an omission. The SDK IS
|
|
139
|
-
// base-URL-configurable, but through a CONSTRUCTOR OPTION (`IConfiguration.basePath`, read by
|
|
140
|
-
// `ApiClientConfigurator.getBaseServer`), never an environment variable: nothing in
|
|
141
|
-
// @hubspot/api-client 14.0.1 reads `process.env`, and HubSpot documents no `HUBSPOT_BASE_URL`.
|
|
142
|
-
// Inventing one would make `covers` report a world covered while the app, reading no such var,
|
|
143
|
-
// still talked to the real portal. Interception is the `hosts` entry above.
|
|
144
|
-
endpointEnvNone:
|
|
145
|
-
'the @hubspot/api-client SDK takes its base URL from the `basePath` CONSTRUCTOR option '
|
|
146
|
-
+ '(IConfiguration.basePath → ApiClientConfigurator.getBaseServer), not from any environment '
|
|
147
|
-
+ 'variable — it reads no process.env at all, and HubSpot documents no base-URL env var. '
|
|
148
|
-
+ 'Interception is the api.hubapi.com host entry; inventing a HUBSPOT_BASE_URL the app does '
|
|
149
|
-
+ 'not read would report a world covered while its traffic still left the machine.',
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
registerPack(pack);
|
|
1
|
+
// @volter/twin-hubspot — the hubspot twin (Protocol 3). A fixed file (create-pack): the descriptor is the manifest's, and the
|
|
2
|
+
// kernel derives the vendor-backed half from the generated surface, unless the manifest declares none (vendorBacked.none;
|
|
3
|
+
// architecture, "The real-system adapters").
|
|
4
|
+
import { packOf, registerPack, type DerivedSurface } from '@volter/world-core';
|
|
5
|
+
import { manifest } from './manifest.ts';
|
|
6
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
7
|
+
|
|
8
|
+
export { createHubspotFetch } from './fetch.ts';
|
|
9
|
+
export { createHubspotTwinServer } from './server.ts';
|
|
10
|
+
export { manifest };
|
|
11
|
+
|
|
12
|
+
export const pack = registerPack(packOf(manifest, surface as unknown as DerivedSurface));
|
package/src/manifest.ts
CHANGED
|
@@ -1,96 +1,37 @@
|
|
|
1
|
-
//
|
|
2
|
-
// "Protocol 3"). The surface is generated (./generated/surface.gen.json, from ../spec by scripts/derive-pack.ts): the
|
|
3
|
-
// union of nine documents from HubSpot's public spec catalog (the eight CRM documents the pack serves and OAuth v1,
|
|
4
|
-
// ../spec/SOURCE.md), their one OAuth scheme's scope lists made one (../spec/patches.json). The documents name every
|
|
5
|
-
// operation, so an operation's id is HubSpot's own (`post-/crm/v3/properties/{objectType}/batch/create_create`).
|
|
6
|
-
//
|
|
7
|
-
// THE HANDLERS. Every operation the twin serves is a semantics handler (semantics/crm.ts) over the pack's request
|
|
8
|
-
// handler (hubspot-twin.ts), whose store is the vendor's behaviour, or over the OAuth install's token endpoints
|
|
9
|
-
// (hubspot-oauth.tsx). The derived core owns no operation, so no resource is declared here and no state candidate is
|
|
10
|
-
// ruled: the spec's resources (a record, a property, a pipeline and its stages, an owner) are stored by the request
|
|
11
|
-
// handler in its own shape. An operation no handler serves answers the gap (hubspot-server.ts).
|
|
12
|
-
//
|
|
13
|
-
// WHY NO `auth`. HubSpot reads `Authorization: Bearer <access token or static app token>`. A token the twin issued (an
|
|
14
|
-
// install's access token, a static app's token) acts in its account (portal.ts) and is refused once expired, its app
|
|
15
|
-
// uninstalled or its account closed (hubspot-oauth.tsx, tokenPortal); any other bearer acts in the World's own account,
|
|
16
|
-
// as it did before accounts were modelled, and the kernel's `auth` names keys the twin refuses, never the ones it
|
|
17
|
-
// accepts. Where the twin stops: no operation is refused for a scope its token lacks.
|
|
18
|
-
//
|
|
19
|
-
// THE ERROR BODY. `{status: "error", message, correlationId, category}`, HubSpot's documented error envelope
|
|
20
|
-
// (https://developers.hubspot.com/docs/api-reference/error-handling), which the request handler answers with a
|
|
21
|
-
// correlationId derived from the request (so a replay is byte-identical). The template below is the core's, which
|
|
22
|
-
// serves nothing.
|
|
23
|
-
//
|
|
24
|
-
// THE STATE. The request handler's kernel subjects: `crm_object` (a record, keyed `<objectType>_<id>`), `property`,
|
|
25
|
-
// `property_group`, `pipeline` (its stages inside), `owner` (pulled or the seeded portal owner) and `association`;
|
|
26
|
-
// the OAuth install's `oauth_code` and `oauth_token`.
|
|
1
|
+
// Demand is recorded in journeys/demand.json; retained operations still answer the vendor-shaped gap.
|
|
27
2
|
import type { DerivedManifest } from '@volter/world-core';
|
|
28
3
|
|
|
29
4
|
export const manifest: DerivedManifest = {
|
|
30
|
-
vendor: 'hubspot',
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
id: 'app-distribution', kind: 'workspace', host: 'app.hubspot.com', path: '/developer-projects/:hubId/project/:name/component/:uid/distribution',
|
|
64
|
-
demand: 'an OAuth marketplace app is published (Begin publishing, the AUP) before any install; a static app is installed and its token shown here',
|
|
65
|
-
status: 'done', controls: ['Begin publishing', 'Sign', 'Install now', 'Show'],
|
|
66
|
-
source: 'https://developers.hubspot.com/docs/apps/developer-platform/build-apps/manage-apps-in-hubspot',
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
id: 'records', kind: 'workspace', host: 'app.hubspot.com', path: '/contacts/:hubId/record/:objectTypeId/:id',
|
|
70
|
-
demand: "reps create deals on a contact's record and move a deal's stage; account managers add companies", status: 'done',
|
|
71
|
-
controls: ['Create deal', 'Deal stage', 'Save', 'Create company'], source: 'https://knowledge.hubspot.com/records/create-records-on-the-updated-index-page',
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
id: 'pipelines', kind: 'workspace', host: 'app.hubspot.com', path: '/settings/:hubId/objects/deals/pipelines',
|
|
75
|
-
demand: "a custom pipeline's closed-won stage ID is read here (Dub's guide: \"enter the stage ID corresponding to your custom Closed Won stage\")",
|
|
76
|
-
status: 'done', source: 'https://knowledge.hubspot.com/object-settings/set-up-and-customize-pipelines',
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
id: 'forms', kind: 'workspace', host: 'app.hubspot.com', path: '/forms/:hubId',
|
|
80
|
-
demand: "Dub's guide: \"In the HubSpot form builder, add a hidden field and map it to the Dub Id contact property\"", status: 'done',
|
|
81
|
-
controls: ['Create form', 'Add hidden field'], source: 'https://dub.co/docs/integrations/hubspot',
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
id: 'connected-apps', kind: 'workspace', host: 'app.hubspot.com', path: '/connected-apps/:hubId/installed',
|
|
85
|
-
demand: 'a client uninstalls the app it connected', status: 'done', controls: ['Uninstall', 'Type uninstall'],
|
|
86
|
-
source: 'https://knowledge.hubspot.com/marketplace/install-apps-in-the-hubspot-marketplace',
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
id: 'account-and-billing', kind: 'workspace', host: 'app.hubspot.com', path: '/account-and-billing/:hubId/subscriptions',
|
|
90
|
-
demand: "a company leaving HubSpot cancels its subscription's auto-renewal, then deletes the account once the subscription has ended",
|
|
91
|
-
status: 'done', controls: ['Cancel auto-renewal', 'Next', 'Delete account', 'Enter your Hub ID to confirm', 'Yes, delete'],
|
|
92
|
-
source: 'https://knowledge.hubspot.com/account/how-do-i-cancel-my-hubspot-account',
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
resources: {},
|
|
5
|
+
vendor: 'hubspot', service: 'hubspot', body: { json: 'always', form: { coerce: false } },
|
|
6
|
+
ids: { template: '{n}' }, time: 'iso',
|
|
7
|
+
// source: https://developers.hubspot.com/docs/api-reference/error-handling "return a JSON formatted response with details about the error"
|
|
8
|
+
error: { status: 'error', message: '{message}', correlationId: '{id}' },
|
|
9
|
+
readOnly: { status: 403, message: 'This World is read-only.' },
|
|
10
|
+
// source: https://developers.hubspot.com/docs/api-reference/webhooks-webhooks-v3/guide "404 Not Found : requested resource not found"
|
|
11
|
+
// The documentation gives the not-found class, not words for every unknown path; use that class in its documented JSON envelope.
|
|
12
|
+
notFound: { status: 404, message: 'requested resource not found' },
|
|
13
|
+
gap: { status: 404, message: 'requested resource not found' },
|
|
14
|
+
// source: https://developers.hubspot.com/docs/api-reference/webhooks-webhooks-v3/guide "Authorization : Bearer YOUR_ACCESS_TOKEN"
|
|
15
|
+
// source: https://developers.hubspot.com/docs/api-reference/error-handling "Returned when the authentication provided is invalid."
|
|
16
|
+
// Where documentation stops: The 401 rule describes invalid authentication, not missing-key versus unknown-route precedence; the gap stands.
|
|
17
|
+
auth: { header: 'authorization', scheme: 'Bearer', paths: '^/crm/', gateWhenAbsent: true,
|
|
18
|
+
missing: { status: 401, message: 'Unauthorized' }, invalid: { status: 401, message: 'Unauthorized' }, invalidKeys: ['invalid'] },
|
|
19
|
+
deleted: {}, resources: {},
|
|
20
|
+
unmodeled: ["get-/crm/v3/objects/0-3_getPage", "post-/crm/v3/objects/0-3_create", "get-/crm/v3/objects/0-3/{dealId}_getById", "patch-/crm/v3/objects/0-3/{dealId}_update", "delete-/crm/v3/objects/0-3/{dealId}_archive", "post-/crm/v3/objects/0-3/batch/archive_archive", "post-/crm/v3/objects/0-3/batch/create_create", "post-/crm/v3/objects/0-3/batch/read_read", "post-/crm/v3/objects/0-3/batch/update_update", "post-/crm/v3/objects/0-3/batch/upsert_upsert", "post-/crm/v3/objects/0-3/merge_merge", "post-/crm/v3/objects/0-3/search_doSearch", "get-/crm/v3/objects/companies_getPage", "post-/crm/v3/objects/companies_create", "get-/crm/v3/objects/companies/{companyId}_getById", "patch-/crm/v3/objects/companies/{companyId}_update", "delete-/crm/v3/objects/companies/{companyId}_archive", "post-/crm/v3/objects/companies/batch/archive_archive", "post-/crm/v3/objects/companies/batch/create_create", "post-/crm/v3/objects/companies/batch/read_read", "post-/crm/v3/objects/companies/batch/update_update", "post-/crm/v3/objects/companies/batch/upsert_upsert", "post-/crm/v3/objects/companies/merge_merge", "post-/crm/v3/objects/companies/search_doSearch", "get-/crm/v3/objects/contacts_getPage", "post-/crm/v3/objects/contacts_create", "get-/crm/v3/objects/contacts/{contactId}_getById", "patch-/crm/v3/objects/contacts/{contactId}_update", "delete-/crm/v3/objects/contacts/{contactId}_archive", "post-/crm/v3/objects/contacts/batch/archive_archive", "post-/crm/v3/objects/contacts/batch/create_create", "post-/crm/v3/objects/contacts/batch/read_read", "post-/crm/v3/objects/contacts/batch/update_update", "post-/crm/v3/objects/contacts/batch/upsert_upsert", "post-/crm/v3/objects/contacts/gdpr-delete_purge", "post-/crm/v3/objects/contacts/merge_merge", "post-/crm/v3/objects/contacts/search_doSearch", "get-/crm/v3/objects/meetings_getPage", "post-/crm/v3/objects/meetings_create", "get-/crm/v3/objects/meetings/{meetingId}_getById", "patch-/crm/v3/objects/meetings/{meetingId}_update", "delete-/crm/v3/objects/meetings/{meetingId}_archive", "post-/crm/v3/objects/meetings/batch/archive_archive", "post-/crm/v3/objects/meetings/batch/create_create", "post-/crm/v3/objects/meetings/batch/read_read", "post-/crm/v3/objects/meetings/batch/update_update", "post-/crm/v3/objects/meetings/batch/upsert_upsert", "post-/crm/v3/objects/meetings/search_doSearch", "get-/crm/v3/objects/tickets_getPage", "post-/crm/v3/objects/tickets_create", "get-/crm/v3/objects/tickets/{ticketId}_getById", "patch-/crm/v3/objects/tickets/{ticketId}_update", "delete-/crm/v3/objects/tickets/{ticketId}_archive", "post-/crm/v3/objects/tickets/batch/archive_archive", "post-/crm/v3/objects/tickets/batch/create_create", "post-/crm/v3/objects/tickets/batch/read_read", "post-/crm/v3/objects/tickets/batch/update_update", "post-/crm/v3/objects/tickets/batch/upsert_upsert", "post-/crm/v3/objects/tickets/merge_merge", "post-/crm/v3/objects/tickets/search_doSearch", "get-/crm/v3/owners_/crm/v3/owners", "get-/crm/v3/owners/{ownerId}_getById", "get-/crm/v3/pipelines/{objectType}_getAll", "post-/crm/v3/pipelines/{objectType}_create", "get-/crm/v3/pipelines/{objectType}/{pipelineId}_getById", "put-/crm/v3/pipelines/{objectType}/{pipelineId}_replace", "patch-/crm/v3/pipelines/{objectType}/{pipelineId}_update", "delete-/crm/v3/pipelines/{objectType}/{pipelineId}_archive", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/audit_getAudit", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_getAll", "post-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_create", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_getById", "put-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_replace", "patch-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_update", "delete-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_archive", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}/audit_getAudit", "get-/crm/v3/properties/{objectType}_getAll", "post-/crm/v3/properties/{objectType}_create", "get-/crm/v3/properties/{objectType}/{propertyName}_getByName", "patch-/crm/v3/properties/{objectType}/{propertyName}_update", "delete-/crm/v3/properties/{objectType}/{propertyName}_archive", "post-/crm/v3/properties/{objectType}/batch/archive_archive", "post-/crm/v3/properties/{objectType}/batch/create_create", "post-/crm/v3/properties/{objectType}/batch/read_read", "get-/crm/v3/properties/{objectType}/groups_getAll", "post-/crm/v3/properties/{objectType}/groups_create", "get-/crm/v3/properties/{objectType}/groups/{groupName}_getByName", "patch-/crm/v3/properties/{objectType}/groups/{groupName}_update", "delete-/crm/v3/properties/{objectType}/groups/{groupName}_archive", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive_archive", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/associate/default_createDefault", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create_create", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/labels/archive_archiveLabels", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read_getPage", "post-/crm/v4/associations/usage/high-usage-report/{userId}_request", "put-/crm/v4/objects/{fromObjectType}/{fromObjectId}/associations/default/{toObjectType}/{toObjectId}_createDefault", "get-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}_getPage", "put-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_create", "delete-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_archive", "get-/oauth/v1/access-tokens/{token}_get", "get-/oauth/v1/refresh-tokens/{token}_get", "delete-/oauth/v1/refresh-tokens/{token}_archive", "post-/oauth/v1/token_create"],
|
|
21
|
+
vendorBacked: { none: 'No vendor resource or operation is served; every spec operation answers the gap.' },
|
|
22
|
+
// source: https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines "110 requests every 10 seconds"
|
|
23
|
+
rateBudget: { windowMs: 60000, ceiling: 660, defaultWeight: 1, maxRetryAfterSeconds: 60, reason: 'Public OAuth allowance of 110 requests per 10 seconds, expressed as 660 per minute.',
|
|
24
|
+
allowance: { perMinute: 660, source: 'https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines' } },
|
|
25
|
+
discovery: { twinOf: 'HubSpot CRM, Meetings and OAuth v1 APIs', stores: 'No vendor resources',
|
|
26
|
+
behavior: 'Every spec operation answers the vendor-shaped gap; Cal.com and Dub require an optional HubSpot app installation.' },
|
|
27
|
+
descriptor: {
|
|
28
|
+
protocol: '3', transport: 'rest', archetype: 'crud', bin: 'world-hubspot', resources: [],
|
|
29
|
+
specSource: 'HubSpot CRM, Meetings and OAuth v1 OpenAPI documents; provenance in spec/SOURCE.md',
|
|
30
|
+
description: 'HubSpot API twin: every spec operation answers the vendor-shaped gap.',
|
|
31
|
+
adoption: { sdks: ['@hubspot/api-client'], pypi: ['hubspot-api-client'], envStems: ['HUBSPOT', 'HUBSPOTPRIVATEAPP'] },
|
|
32
|
+
hosts: [{ host: 'api.hubapi.com' }, { host: 'app.hubspot.com', demandPathPattern: false }, { host: 'api.hsforms.com' }],
|
|
33
|
+
endpointEnvNone: 'The optional integrations address HubSpot hosts; no SDK base-URL environment variable is injected.',
|
|
34
|
+
// source: https://raw.githubusercontent.com/dubinc/dub/7e0101363b2ab30afc537c1893f159d1dcba513a/apps/web/lib/integrations/hubspot/oauth.ts "process.env.HUBSPOT_CLIENT_ID"
|
|
35
|
+
clientCasesNone: 'Optional integration calls remain unserved; no SDK case is claimed for the retained gap.',
|
|
36
|
+
},
|
|
96
37
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts --index: the handler map, by operationId, from the families' files (each
|
|
2
|
+
// handler named as its operation). Do not edit; add a handler to its family's file and generate again.
|
|
3
|
+
import type { Handler } from '@volter/world-core';
|
|
4
|
+
|
|
5
|
+
export const hubspotSemantics: Record<string, Handler> = {
|
|
6
|
+
};
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).
|
|
2
|
+
import { serveHttp, type PackFetchOptions } from '@volter/world-core';
|
|
3
|
+
import { createHubspotFetch } from './fetch.ts';
|
|
4
|
+
|
|
5
|
+
export async function createHubspotTwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {
|
|
6
|
+
const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: createHubspotFetch(options) });
|
|
7
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
8
|
+
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
:root {
|
|
2
|
-
--bg: #f5f8fa;
|
|
3
|
-
--panel: #ffffff;
|
|
4
|
-
--line: #dfe3eb;
|
|
5
|
-
--text: #33475b;
|
|
6
|
-
--muted: #7c98b6;
|
|
7
|
-
--accent: #eaf0f6;
|
|
8
|
-
--brand: #ff7a59;
|
|
9
|
-
--ok: #00bda5;
|
|
10
|
-
--warn: #f5c26b;
|
|
11
|
-
--bad: #f2545b;
|
|
12
|
-
}
|
|
13
|
-
* { box-sizing: border-box; }
|
|
14
|
-
body { margin: 0; font-family: -apple-system, system-ui, "Helvetica Neue", sans-serif; background: var(--bg); color: var(--text); }
|
|
15
|
-
.app { display: grid; grid-template-columns: 220px 1fr; grid-template-rows: 56px 1fr; min-height: 100vh; }
|
|
16
|
-
.topbar { grid-column: 1 / 3; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
17
|
-
.brand { font-weight: 700; color: var(--brand); letter-spacing: 0.2px; }
|
|
18
|
-
.view-toggle { border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 4px; padding: 6px 14px; cursor: pointer; font-size: 13px; }
|
|
19
|
-
.view-toggle:hover { background: var(--accent); }
|
|
20
|
-
.sidebar { border-right: 1px solid var(--line); padding: 14px 10px; background: var(--panel); }
|
|
21
|
-
.nav-item { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--muted); padding: 9px 12px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
|
22
|
-
.nav-item.active, .nav-item:hover { background: var(--accent); color: var(--text); }
|
|
23
|
-
.content { padding: 24px; overflow: auto; }
|
|
24
|
-
table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
|
25
|
-
th { text-align: left; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
|
|
26
|
-
td { padding: 12px; border-bottom: 1px solid var(--line); font-size: 14px; }
|
|
27
|
-
tr:last-child td { border-bottom: none; }
|
|
28
|
-
.cell-title { font-weight: 600; }
|
|
29
|
-
.cell-id { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
30
|
-
.cell-prop-name { color: var(--muted); font-size: 12px; width: 40%; }
|
|
31
|
-
.pill { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; background: var(--accent); }
|
|
32
|
-
.pill-ok { background: rgba(0, 189, 165, 0.16); color: var(--ok); }
|
|
33
|
-
.pill-warn { background: rgba(245, 194, 107, 0.22); color: #b07d1a; }
|
|
34
|
-
.pill-bad { background: rgba(242, 84, 91, 0.16); color: var(--bad); }
|
|
35
|
-
.empty { color: var(--muted); padding: 28px; background: var(--panel); border: 1px dashed var(--line); border-radius: 6px; }
|
|
36
|
-
.deal-board { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
|
|
37
|
-
.board-column { min-width: 220px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 12px; }
|
|
38
|
-
.board-column-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
|
39
|
-
.board-count { color: var(--muted); font-size: 12px; }
|
|
40
|
-
.deal-card { border: 1px solid var(--line); border-radius: 4px; padding: 10px; margin-bottom: 8px; }
|
|
41
|
-
.deal-amount { color: var(--muted); font-size: 13px; margin-top: 4px; }
|
|
42
|
-
.record-detail { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 20px; }
|
|
43
|
-
.detail-title { margin: 0 0 14px; font-size: 20px; }
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
// HubSpot mirror — React/TSX CRM client. Renders the twin's contacts, companies, deals and
|
|
2
|
-
// tickets by fetching the twin's OWN CRM REST API on the same origin
|
|
3
|
-
// (`/crm/v3/objects/contacts`, …) — HubSpot's real paths, the same ones the SDK calls.
|
|
4
|
-
// API↔UI parity: the screen shows real twin state, not a hardcoded shell.
|
|
5
|
-
import { createRoot } from 'react-dom/client';
|
|
6
|
-
import { useEffect, useState } from 'react';
|
|
7
|
-
import {
|
|
8
|
-
MIRROR_SECTIONS, SECTION_COLUMNS, formatAmount, groupByStage, recordTitle, stageTone,
|
|
9
|
-
type HubspotRow, type MirrorSectionKey, type PillTone,
|
|
10
|
-
} from '../src/hubspot-mirror-ui.ts';
|
|
11
|
-
|
|
12
|
-
// WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
|
|
13
|
-
// before); served under a path prefix with a <base> tag every read and write resolves inside that
|
|
14
|
-
// prefix instead of escaping it.
|
|
15
|
-
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
16
|
-
|
|
17
|
-
// Map a tone to a STATIC literal class name (a template-literal class is erased by the
|
|
18
|
-
// minifier, so `pill-${tone}` would vanish from the bundle — assert these instead).
|
|
19
|
-
const PILL_CLASS: Record<PillTone, string> = { ok: 'pill pill-ok', warn: 'pill pill-warn', bad: 'pill pill-bad', '': 'pill' };
|
|
20
|
-
|
|
21
|
-
/** Unwrap HubSpot's collection envelope (`{ results, paging }`) to its results array. */
|
|
22
|
-
async function fetchRows(path: string): Promise<HubspotRow[]> {
|
|
23
|
-
const res = await fetch(`${WIRE_BASE}${path}`);
|
|
24
|
-
const body = (await res.json()) as { results?: unknown };
|
|
25
|
-
return Array.isArray(body.results) ? (body.results as HubspotRow[]) : [];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** A CRM record list — one `list-row` landmark per record, carrying its HubSpot record id. */
|
|
29
|
-
export function RecordsTable({ section, rows }: { section: MirrorSectionKey; rows: HubspotRow[] }) {
|
|
30
|
-
if (rows.length === 0) return <div className="empty">No {section} yet.</div>;
|
|
31
|
-
const columns = SECTION_COLUMNS[section];
|
|
32
|
-
return (
|
|
33
|
-
<table className="records-table">
|
|
34
|
-
<thead>
|
|
35
|
-
<tr><th>Name</th>{columns.map((c) => <th key={c.prop}>{c.label}</th>)}<th>Record ID</th></tr>
|
|
36
|
-
</thead>
|
|
37
|
-
<tbody>
|
|
38
|
-
{rows.map((r) => (
|
|
39
|
-
<tr className="list-row" data-record-id={r.id} key={r.id}>
|
|
40
|
-
<td className="cell-title">{recordTitle(section, r.properties)}</td>
|
|
41
|
-
{columns.map((c) => (
|
|
42
|
-
<td className="cell-prop" key={c.prop}>
|
|
43
|
-
{c.prop === 'amount' ? formatAmount(r.properties?.amount)
|
|
44
|
-
: c.prop === 'dealstage'
|
|
45
|
-
? <span className={PILL_CLASS[stageTone(r.properties?.dealstage)]}>{r.properties?.dealstage ?? '—'}</span>
|
|
46
|
-
: (r.properties?.[c.prop] ?? '—')}
|
|
47
|
-
</td>
|
|
48
|
-
))}
|
|
49
|
-
<td className="cell-id">{r.id}</td>
|
|
50
|
-
</tr>
|
|
51
|
-
))}
|
|
52
|
-
</tbody>
|
|
53
|
-
</table>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** The deal pipeline board — the screen a sales rep actually works in, one column per stage. */
|
|
58
|
-
export function DealBoard({ rows }: { rows: HubspotRow[] }) {
|
|
59
|
-
const groups = groupByStage(rows);
|
|
60
|
-
if (groups.length === 0) return <div className="empty">No deals yet.</div>;
|
|
61
|
-
return (
|
|
62
|
-
<div className="deal-board">
|
|
63
|
-
{groups.map((g) => (
|
|
64
|
-
<section className="board-column" data-stage={g.stage} key={g.stage}>
|
|
65
|
-
<header className="board-column-head">
|
|
66
|
-
<span className={PILL_CLASS[stageTone(g.stage)]}>{g.stage}</span>
|
|
67
|
-
<span className="board-count">{g.rows.length}</span>
|
|
68
|
-
</header>
|
|
69
|
-
{g.rows.map((r) => (
|
|
70
|
-
<article className="deal-card list-row" data-record-id={r.id} key={r.id}>
|
|
71
|
-
<div className="cell-title">{recordTitle('deals', r.properties)}</div>
|
|
72
|
-
<div className="deal-amount">{formatAmount(r.properties?.amount)}</div>
|
|
73
|
-
</article>
|
|
74
|
-
))}
|
|
75
|
-
</section>
|
|
76
|
-
))}
|
|
77
|
-
</div>
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Record detail — the property sheet HubSpot shows on the right of a record. */
|
|
82
|
-
export function RecordDetail({ section, rows }: { section: MirrorSectionKey; rows: HubspotRow[] }) {
|
|
83
|
-
const r = rows[0];
|
|
84
|
-
if (!r) return <div className="empty">No record.</div>;
|
|
85
|
-
const entries = Object.entries(r.properties ?? {});
|
|
86
|
-
return (
|
|
87
|
-
<section className="record-detail" data-record-id={r.id}>
|
|
88
|
-
<h2 className="detail-title">{recordTitle(section, r.properties)}</h2>
|
|
89
|
-
<table className="property-table">
|
|
90
|
-
<tbody>
|
|
91
|
-
{entries.map(([k, v]) => (
|
|
92
|
-
<tr className="list-row" data-prop={k} key={k}>
|
|
93
|
-
<td className="cell-prop-name">{k}</td>
|
|
94
|
-
<td className="cell-prop-value">{v ?? '—'}</td>
|
|
95
|
-
</tr>
|
|
96
|
-
))}
|
|
97
|
-
</tbody>
|
|
98
|
-
</table>
|
|
99
|
-
</section>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function App() {
|
|
104
|
-
const [section, setSection] = useState<MirrorSectionKey>('contacts');
|
|
105
|
-
const [board, setBoard] = useState(false);
|
|
106
|
-
const [rows, setRows] = useState<HubspotRow[]>([]);
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
const sec = MIRROR_SECTIONS.find((s) => s.key === section)!;
|
|
109
|
-
fetchRows(sec.path).then(setRows).catch(() => setRows([]));
|
|
110
|
-
}, [section]);
|
|
111
|
-
return (
|
|
112
|
-
<div className="app">
|
|
113
|
-
<header className="topbar">
|
|
114
|
-
<span className="brand">HubSpot twin</span>
|
|
115
|
-
{section === 'deals' ? (
|
|
116
|
-
<button className="view-toggle" onClick={() => setBoard((b) => !b)}>{board ? 'Table' : 'Board'}</button>
|
|
117
|
-
) : null}
|
|
118
|
-
</header>
|
|
119
|
-
<nav className="sidebar">
|
|
120
|
-
{MIRROR_SECTIONS.map((s) => (
|
|
121
|
-
<button key={s.key} className={`nav-item${s.key === section ? ' active' : ''}`} onClick={() => { setSection(s.key); setBoard(false); }}>{s.label}</button>
|
|
122
|
-
))}
|
|
123
|
-
</nav>
|
|
124
|
-
<main className="content">
|
|
125
|
-
{section === 'deals' && board ? <DealBoard rows={rows} /> : <RecordsTable section={section} rows={rows} />}
|
|
126
|
-
</main>
|
|
127
|
-
</div>
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
|
|
132
|
-
if (el) createRoot(el).render(<App />);
|