@eventcatalog/core 5.0.0-beta.28 → 5.0.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +3 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-TDUUHSGF.js → chunk-34VU2X7H.js} +1 -1
  6. package/dist/{chunk-G46KUC6G.js → chunk-3RDV4HXA.js} +1 -1
  7. package/dist/{chunk-VAL22T5Y.js → chunk-4IREUUHE.js} +2 -2
  8. package/dist/{chunk-AWOO63ME.js → chunk-AD7CL772.js} +11 -4
  9. package/dist/{chunk-QWFP2PDM.js → chunk-B4OOUG5I.js} +1 -1
  10. package/dist/{chunk-EUMQAONX.js → chunk-ZK7Q7XDV.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +12 -5
  14. package/dist/eventcatalog.js +6 -6
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/utils/cli-logger.cjs +1 -1
  18. package/dist/utils/cli-logger.js +2 -2
  19. package/dist/utils/license-status.cjs +11 -4
  20. package/dist/utils/license-status.d.cts +3 -2
  21. package/dist/utils/license-status.d.ts +3 -2
  22. package/dist/utils/license-status.js +1 -1
  23. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +19 -2
  24. package/eventcatalog/src/components/Settings/AssistantSettingsForm.tsx +53 -48
  25. package/eventcatalog/src/components/Settings/GeneralSettingsForm.tsx +183 -178
  26. package/eventcatalog/src/components/Settings/LicenseSettings.tsx +140 -31
  27. package/eventcatalog/src/components/Settings/LlmAccessSettingsForm.tsx +40 -35
  28. package/eventcatalog/src/components/Settings/LogoUpload.tsx +1 -1
  29. package/eventcatalog/src/components/Settings/McpSettingsForm.tsx +9 -8
  30. package/eventcatalog/src/components/Settings/Row.tsx +7 -4
  31. package/eventcatalog/src/components/Settings/SettingsShared.tsx +3 -3
  32. package/eventcatalog/src/components/TrialBadge.astro +20 -9
  33. package/eventcatalog/src/layouts/SettingsLayout.astro +2 -2
  34. package/eventcatalog/src/pages/settings/license.astro +9 -1
  35. package/eventcatalog/src/utils/license.ts +1 -1
  36. package/eventcatalog/src/utils/trial.ts +13 -0
  37. package/package.json +3 -3
@@ -179,193 +179,198 @@ export const GeneralSettingsForm = ({ canEdit, initial, apiBase }: Props) => {
179
179
  };
180
180
 
181
181
  return (
182
- <form onSubmit={(e) => e.preventDefault()} className="divide-y divide-[rgb(var(--ec-page-border))]">
182
+ <>
183
183
  <Toaster richColors closeButton position="bottom-right" theme="system" />
184
- {!canEdit && (
185
- <div className="pb-6">
186
- <ReadOnlyBanner />
187
- </div>
188
- )}
189
-
190
- <Row
191
- title="Theme"
192
- description="Pick a built-in colour theme. Selection updates the preview live; click Save to persist."
193
- canEdit={canEdit}
194
- dirty={dirtyKeys.has('theme')}
195
- saving={savingKey === 'theme'}
196
- onSave={() => saveRow('theme')}
197
- error={errors.theme}
184
+ <form
185
+ onSubmit={(e) => e.preventDefault()}
186
+ className="divide-y divide-[rgb(var(--ec-page-border))] border-t border-[rgb(var(--ec-page-border))]"
198
187
  >
199
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
200
- {themeOptions(form.theme).map((t) => {
201
- const preview = THEME_PREVIEWS[t] ?? { accent: '#888', bg: '#1a1a1a', label: t };
202
- const isActive = form.theme === t;
203
- const isCustom = !isKnownTheme(t);
204
- return (
205
- <button
206
- type="button"
207
- key={t}
208
- onClick={() => setField('theme', t)}
209
- disabled={!canEdit}
210
- aria-pressed={isActive}
211
- className={cn(
212
- 'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-all',
213
- 'disabled:cursor-not-allowed disabled:opacity-50',
214
- isActive
215
- ? 'border-[rgb(var(--ec-accent))] ring-2 ring-[rgb(var(--ec-accent)/0.25)]'
216
- : 'border-[rgb(var(--ec-page-border))] hover:border-[rgb(var(--ec-page-text-muted)/0.6)]'
217
- )}
218
- >
219
- <div className="relative h-14 w-full overflow-hidden" style={{ background: preview.bg }}>
220
- <div
221
- className="absolute -bottom-2 -right-2 h-12 w-12 rounded-full opacity-90 blur-md"
222
- style={{ background: preview.accent }}
223
- aria-hidden
224
- />
225
- <div
226
- className="absolute left-3 top-3 h-1.5 w-10 rounded-full opacity-90"
227
- style={{ background: preview.accent }}
228
- aria-hidden
229
- />
230
- <div
231
- className="absolute left-3 top-6 h-1 w-7 rounded-full opacity-60"
232
- style={{ background: 'rgba(255,255,255,0.4)' }}
233
- aria-hidden
234
- />
235
- {isActive && (
236
- <span className="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-white text-black shadow-sm">
237
- <Check className="h-2.5 w-2.5" aria-hidden />
238
- </span>
239
- )}
240
- </div>
241
- <div className="flex items-center justify-between gap-2 px-3 py-2">
242
- <span className="text-[12px] font-medium capitalize text-[rgb(var(--ec-page-text))]">{preview.label}</span>
243
- {isCustom && (
244
- <span className="rounded-full border border-[rgb(var(--ec-page-border))] px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
245
- custom
246
- </span>
188
+ {!canEdit && (
189
+ <div className="pb-6">
190
+ <ReadOnlyBanner />
191
+ </div>
192
+ )}
193
+
194
+ <Row
195
+ title="Theme"
196
+ description="Pick a built-in colour theme. Selection updates the preview live; click Save to persist."
197
+ canEdit={canEdit}
198
+ dirty={dirtyKeys.has('theme')}
199
+ saving={savingKey === 'theme'}
200
+ onSave={() => saveRow('theme')}
201
+ error={errors.theme}
202
+ >
203
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
204
+ {themeOptions(form.theme).map((t) => {
205
+ const preview = THEME_PREVIEWS[t] ?? { accent: '#888', bg: '#1a1a1a', label: t };
206
+ const isActive = form.theme === t;
207
+ const isCustom = !isKnownTheme(t);
208
+ return (
209
+ <button
210
+ type="button"
211
+ key={t}
212
+ onClick={() => setField('theme', t)}
213
+ disabled={!canEdit}
214
+ aria-pressed={isActive}
215
+ className={cn(
216
+ 'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-all',
217
+ 'disabled:cursor-not-allowed disabled:opacity-50',
218
+ isActive
219
+ ? 'border-[rgb(var(--ec-page-text))] ring-2 ring-[rgb(var(--ec-page-text)/0.15)]'
220
+ : 'border-[rgb(var(--ec-page-border))] hover:border-[rgb(var(--ec-page-text-muted)/0.6)]'
247
221
  )}
248
- </div>
249
- </button>
250
- );
251
- })}
252
- </div>
253
- </Row>
222
+ >
223
+ <div className="relative h-14 w-full overflow-hidden" style={{ background: preview.bg }}>
224
+ <div
225
+ className="absolute -bottom-2 -right-2 h-12 w-12 rounded-full opacity-90 blur-md"
226
+ style={{ background: preview.accent }}
227
+ aria-hidden
228
+ />
229
+ <div
230
+ className="absolute left-3 top-3 h-1.5 w-10 rounded-full opacity-90"
231
+ style={{ background: preview.accent }}
232
+ aria-hidden
233
+ />
234
+ <div
235
+ className="absolute left-3 top-6 h-1 w-7 rounded-full opacity-60"
236
+ style={{ background: 'rgba(255,255,255,0.4)' }}
237
+ aria-hidden
238
+ />
239
+ {isActive && (
240
+ <span className="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-white text-black shadow-sm">
241
+ <Check className="h-2.5 w-2.5" aria-hidden />
242
+ </span>
243
+ )}
244
+ </div>
245
+ <div className="flex items-center justify-between gap-2 px-3 py-2">
246
+ <span className="text-[12px] font-medium capitalize text-[rgb(var(--ec-page-text))]">{preview.label}</span>
247
+ {isCustom && (
248
+ <span className="rounded-full border border-[rgb(var(--ec-page-border))] px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
249
+ custom
250
+ </span>
251
+ )}
252
+ </div>
253
+ </button>
254
+ );
255
+ })}
256
+ </div>
257
+ </Row>
254
258
 
255
- <Row
256
- title="Organization"
257
- description="Used as the catalog name, the brand line next to the logo, and OpenGraph metadata."
258
- canEdit={canEdit}
259
- dirty={dirtyKeys.has('organization')}
260
- saving={savingKey === 'organization'}
261
- onSave={() => saveRow('organization')}
262
- error={errors.organization}
263
- >
264
- <input
265
- type="text"
266
- value={form.organization}
267
- onChange={handleChange('organization')}
268
- disabled={!canEdit}
269
- maxLength={100}
270
- placeholder="Acme Inc."
271
- className={cn(inputBase, errors.organization && inputError)}
272
- required
273
- />
274
- </Row>
259
+ <Row
260
+ title="Organization"
261
+ description="Used as the catalog name, the brand line next to the logo, and OpenGraph metadata."
262
+ canEdit={canEdit}
263
+ dirty={dirtyKeys.has('organization')}
264
+ saving={savingKey === 'organization'}
265
+ onSave={() => saveRow('organization')}
266
+ error={errors.organization}
267
+ >
268
+ <input
269
+ type="text"
270
+ value={form.organization}
271
+ onChange={handleChange('organization')}
272
+ disabled={!canEdit}
273
+ maxLength={100}
274
+ placeholder="Acme Inc."
275
+ className={cn(inputBase, errors.organization && inputError)}
276
+ required
277
+ />
278
+ </Row>
275
279
 
276
- <Row
277
- title="Tagline"
278
- description="A short subtitle shown beneath the organization name on the homepage."
279
- canEdit={canEdit}
280
- dirty={dirtyKeys.has('tagline')}
281
- saving={savingKey === 'tagline'}
282
- onSave={() => saveRow('tagline')}
283
- error={errors.tagline}
284
- >
285
- <textarea
286
- value={form.tagline}
287
- onChange={handleChange('tagline')}
288
- disabled={!canEdit}
289
- maxLength={500}
290
- rows={2}
291
- placeholder="Discover and explore our events, services, and architecture."
292
- className={cn(inputBase, 'resize-y', errors.tagline && inputError)}
293
- />
294
- </Row>
280
+ <Row
281
+ title="Tagline"
282
+ description="A short subtitle shown beneath the organization name on the homepage."
283
+ canEdit={canEdit}
284
+ dirty={dirtyKeys.has('tagline')}
285
+ saving={savingKey === 'tagline'}
286
+ onSave={() => saveRow('tagline')}
287
+ error={errors.tagline}
288
+ >
289
+ <textarea
290
+ value={form.tagline}
291
+ onChange={handleChange('tagline')}
292
+ disabled={!canEdit}
293
+ maxLength={500}
294
+ rows={2}
295
+ placeholder="Discover and explore our events, services, and architecture."
296
+ className={cn(inputBase, 'resize-y', errors.tagline && inputError)}
297
+ />
298
+ </Row>
295
299
 
296
- <Row
297
- title="Homepage link"
298
- description="Where the logo links to. Useful for pointing back to your main site."
299
- canEdit={canEdit}
300
- dirty={dirtyKeys.has('homepageLink')}
301
- saving={savingKey === 'homepageLink'}
302
- onSave={() => saveRow('homepageLink')}
303
- error={errors.homepageLink}
304
- >
305
- <input
306
- type="url"
307
- value={form.homepageLink}
308
- onChange={handleChange('homepageLink')}
309
- onBlur={handleUrlBlur('homepageLink')}
310
- disabled={!canEdit}
311
- placeholder="https://example.com"
312
- className={cn(inputBase, monoInput, errors.homepageLink && inputError)}
313
- />
314
- </Row>
300
+ <Row
301
+ title="Homepage link"
302
+ description="Where the logo links to. Useful for pointing back to your main site."
303
+ canEdit={canEdit}
304
+ dirty={dirtyKeys.has('homepageLink')}
305
+ saving={savingKey === 'homepageLink'}
306
+ onSave={() => saveRow('homepageLink')}
307
+ error={errors.homepageLink}
308
+ >
309
+ <input
310
+ type="url"
311
+ value={form.homepageLink}
312
+ onChange={handleChange('homepageLink')}
313
+ onBlur={handleUrlBlur('homepageLink')}
314
+ disabled={!canEdit}
315
+ placeholder="https://example.com"
316
+ className={cn(inputBase, monoInput, errors.homepageLink && inputError)}
317
+ />
318
+ </Row>
315
319
 
316
- <Row
317
- title="Edit URL"
318
- description="Base URL for the “Edit on GitHub” links shown on resource pages."
319
- canEdit={canEdit}
320
- dirty={dirtyKeys.has('editUrl')}
321
- saving={savingKey === 'editUrl'}
322
- onSave={() => saveRow('editUrl')}
323
- error={errors.editUrl}
324
- >
325
- <input
326
- type="url"
327
- value={form.editUrl}
328
- onChange={handleChange('editUrl')}
329
- onBlur={handleUrlBlur('editUrl')}
330
- disabled={!canEdit}
331
- placeholder="https://github.com/org/repo/edit/main"
332
- className={cn(inputBase, monoInput, errors.editUrl && inputError)}
333
- />
334
- </Row>
320
+ <Row
321
+ title="Edit URL"
322
+ description="Base URL for the “Edit on GitHub” links shown on resource pages."
323
+ canEdit={canEdit}
324
+ dirty={dirtyKeys.has('editUrl')}
325
+ saving={savingKey === 'editUrl'}
326
+ onSave={() => saveRow('editUrl')}
327
+ error={errors.editUrl}
328
+ >
329
+ <input
330
+ type="url"
331
+ value={form.editUrl}
332
+ onChange={handleChange('editUrl')}
333
+ onBlur={handleUrlBlur('editUrl')}
334
+ disabled={!canEdit}
335
+ placeholder="https://github.com/org/repo/edit/main"
336
+ className={cn(inputBase, monoInput, errors.editUrl && inputError)}
337
+ />
338
+ </Row>
335
339
 
336
- <Row
337
- title="Repository URL"
338
- description="Link to your catalog's source repository. Shown in the header for quick navigation back to the code."
339
- canEdit={canEdit}
340
- dirty={dirtyKeys.has('repositoryUrl')}
341
- saving={savingKey === 'repositoryUrl'}
342
- onSave={() => saveRow('repositoryUrl')}
343
- error={errors.repositoryUrl}
344
- >
345
- <input
346
- type="url"
347
- value={form.repositoryUrl}
348
- onChange={handleChange('repositoryUrl')}
349
- onBlur={handleUrlBlur('repositoryUrl')}
350
- disabled={!canEdit}
351
- placeholder="https://github.com/org/repo"
352
- className={cn(inputBase, monoInput, errors.repositoryUrl && inputError)}
353
- />
354
- </Row>
340
+ <Row
341
+ title="Repository URL"
342
+ description="Link to your catalog's source repository. Shown in the header for quick navigation back to the code."
343
+ canEdit={canEdit}
344
+ dirty={dirtyKeys.has('repositoryUrl')}
345
+ saving={savingKey === 'repositoryUrl'}
346
+ onSave={() => saveRow('repositoryUrl')}
347
+ error={errors.repositoryUrl}
348
+ >
349
+ <input
350
+ type="url"
351
+ value={form.repositoryUrl}
352
+ onChange={handleChange('repositoryUrl')}
353
+ onBlur={handleUrlBlur('repositoryUrl')}
354
+ disabled={!canEdit}
355
+ placeholder="https://github.com/org/repo"
356
+ className={cn(inputBase, monoInput, errors.repositoryUrl && inputError)}
357
+ />
358
+ </Row>
355
359
 
356
- <Row
357
- title="Logo"
358
- description="Image shown in the top-left corner. PNG, JPG, SVG, or WebP up to 2MB."
359
- canEdit={canEdit}
360
- // Logo upload saves itself directly; no inline Save button needed.
361
- dirty={false}
362
- >
363
- <LogoUpload
360
+ <Row
361
+ title="Logo"
362
+ description="Image shown in the top-left corner. PNG, JPG, SVG, or WebP up to 2MB."
364
363
  canEdit={canEdit}
365
- initialSrc={initial.logo?.src ? `${initial.logo.src}?t=${Date.now()}` : undefined}
366
- apiUrl={`${apiBase}/logo`}
367
- />
368
- </Row>
369
- </form>
364
+ // Logo upload saves itself directly; no inline Save button needed.
365
+ dirty={false}
366
+ >
367
+ <LogoUpload
368
+ canEdit={canEdit}
369
+ initialSrc={initial.logo?.src ? `${initial.logo.src}?t=${Date.now()}` : undefined}
370
+ apiUrl={`${apiBase}/logo`}
371
+ />
372
+ </Row>
373
+ </form>
374
+ </>
370
375
  );
371
376
  };
@@ -1,7 +1,16 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { ArrowUpRight, Clock, TriangleAlert } from 'lucide-react';
2
+ import { ArrowUpRight, Clock, Info, Mail, TriangleAlert } from 'lucide-react';
3
3
  import { Row, cn } from './Row';
4
- import { getTrialStatus, LICENSE_FAQ_URL, LICENSE_REMINDER_DAYS, LICENSE_RENEW_URL, TRIAL_LENGTH_DAYS } from '@utils/trial';
4
+ import {
5
+ BUY_BUSINESS_URL,
6
+ getTrialStatus,
7
+ LICENSE_FAQ_URL,
8
+ LICENSE_REMINDER_DAYS,
9
+ LICENSE_RENEW_URL,
10
+ PRICING_URL,
11
+ TALK_TO_US_URL,
12
+ TRIAL_LENGTH_DAYS,
13
+ } from '@utils/trial';
5
14
 
6
15
  /**
7
16
  * The license status (see getLicenseStatus in @utils/license) with dates as unix ms, so it can be
@@ -35,7 +44,8 @@ const PILL_TONES: Record<Tone, string> = {
35
44
  'border-[rgb(var(--ec-badge-color-amber-text)/0.3)] bg-[rgb(var(--ec-badge-color-amber-background))] text-[rgb(var(--ec-badge-color-amber-text))]',
36
45
  danger:
37
46
  'border-[rgb(var(--ec-badge-color-red-text)/0.3)] bg-[rgb(var(--ec-badge-color-red-background))] text-[rgb(var(--ec-badge-color-red-text))]',
38
- info: 'border-[rgb(var(--ec-accent)/0.3)] bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]',
47
+ // Black on light themes, white on dark: the catalog's accent colour reads as a warning
48
+ info: 'border-transparent bg-[rgb(var(--ec-page-text))] text-[rgb(var(--ec-page-bg))]',
39
49
  neutral: 'border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] text-[rgb(var(--ec-page-text-muted))]',
40
50
  };
41
51
 
@@ -120,11 +130,21 @@ const LicenseNotice = ({ license, now }: { license: LicenseProps; now: number })
120
130
  <p className="text-[13px] font-semibold">
121
131
  {expired ? 'Your commercial license has expired' : `Your commercial license expires in ${formatDays(daysLeft!)}`}
122
132
  </p>
123
- <p className="text-[13px] opacity-90">
124
- {expired
125
- ? `It expired on ${formatDate(expiresAt)}. Renew it to keep using EventCatalog commercially.`
126
- : `It expires on ${formatDate(expiresAt)}. Renew it before then to keep using EventCatalog commercially.`}
127
- </p>
133
+ {expired ? (
134
+ <>
135
+ <p className="text-[13px] opacity-90">
136
+ It expired on {formatDate(expiresAt)}. Organisations that don't qualify for Free Use need a commercial license to
137
+ use EventCatalog in production.
138
+ </p>
139
+ <p className="text-[13px] opacity-90">
140
+ Your catalog keeps building and running. Renew to stay licensed, or talk to us if anything has changed.
141
+ </p>
142
+ </>
143
+ ) : (
144
+ <p className="text-[13px] opacity-90">
145
+ It expires on {formatDate(expiresAt)}. Renew it before then to keep using EventCatalog commercially.
146
+ </p>
147
+ )}
128
148
  <div className="flex flex-wrap items-center gap-3 pt-2">
129
149
  <a
130
150
  href={LICENSE_RENEW_URL}
@@ -135,6 +155,14 @@ const LicenseNotice = ({ license, now }: { license: LicenseProps; now: number })
135
155
  Renew license
136
156
  <ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
137
157
  </a>
158
+ <a
159
+ href={TALK_TO_US_URL}
160
+ target="_blank"
161
+ rel="noreferrer"
162
+ className="text-[13px] font-medium underline underline-offset-2 hover:opacity-80"
163
+ >
164
+ Talk to us
165
+ </a>
138
166
  <a
139
167
  href={LICENSE_FAQ_URL}
140
168
  target="_blank"
@@ -149,6 +177,55 @@ const LicenseNotice = ({ license, now }: { license: LicenseProps; now: number })
149
177
  );
150
178
  };
151
179
 
180
+ // Where a customer with a legacy key asks for a license.jwt (as the license FAQ does)
181
+ const LICENSE_CONTACT_EMAIL = 'hello@eventcatalog.dev';
182
+
183
+ /**
184
+ * A Scale or Starter plan uses a license key from before EventCatalog 5, checked online. It still works, but the
185
+ * customer should ask us for a license.jwt (checked offline) for their plan.
186
+ */
187
+ const LegacyKeyNotice = ({ plan }: { plan: 'scale' | 'starter' }) => {
188
+ const subject = encodeURIComponent(`A license.jwt for our ${PLAN_NAMES[plan]} plan`);
189
+ return (
190
+ <div
191
+ role="note"
192
+ className="flex gap-3 rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-text)/0.05)] p-4 text-[rgb(var(--ec-page-text))]"
193
+ >
194
+ <Info className="mt-0.5 h-4 w-4 shrink-0 text-[rgb(var(--ec-page-text))]" aria-hidden />
195
+ <div className="space-y-1">
196
+ <p className="text-[13px] font-semibold">You're using a legacy license key</p>
197
+ <p className="text-[13px] text-[rgb(var(--ec-page-text-muted))]">
198
+ Your plan still works. Its key is from before EventCatalog 5.
199
+ </p>
200
+ <p className="text-[13px] text-[rgb(var(--ec-page-text-muted))]">
201
+ Contact us for a new key: a license.jwt file, checked offline.
202
+ </p>
203
+ <div className="pt-2">
204
+ <a
205
+ href={`mailto:${LICENSE_CONTACT_EMAIL}?subject=${subject}`}
206
+ className="inline-flex items-center gap-1.5 rounded-md bg-[rgb(var(--ec-page-text))] px-3.5 py-1.5 text-[13px] font-medium text-[rgb(var(--ec-page-bg))] shadow-sm transition-opacity hover:opacity-85"
207
+ >
208
+ Contact us for a new key
209
+ <Mail className="h-3.5 w-3.5" aria-hidden />
210
+ </a>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ );
215
+ };
216
+
217
+ // For organisations that don't need to pay: without it, the trial reads as "EventCatalog is now paid"
218
+ const FreeUseNotice = () => (
219
+ <p className="text-[13px] leading-relaxed text-[rgb(var(--ec-content-text-muted,var(--ec-page-text-muted)))]">
220
+ <span className="font-medium text-[rgb(var(--ec-page-text))]">You may qualify for Free Use.</span> Under $10M revenue and $10M
221
+ raised, an individual or an open-source project? You can use EventCatalog for free.{' '}
222
+ <a href={LICENSE_RENEW_URL} target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:opacity-80">
223
+ Get a free key at eventcatalog.cloud
224
+ </a>
225
+ .
226
+ </p>
227
+ );
228
+
152
229
  const LicenseTable = ({ license, now }: { license: LicenseProps; now: number }) => {
153
230
  if (license.state === 'plan') {
154
231
  return (
@@ -243,46 +320,78 @@ interface Props {
243
320
  trialStartedAt?: number;
244
321
  /** When the page was rendered, unix ms: the first render matches the server's, then it counts from now */
245
322
  renderedAt: number;
323
+ /** EventCatalog's license (the Business Source License) on GitHub */
324
+ licenseUrl: string;
246
325
  }
247
326
 
248
- export const LicenseSettings = ({ license, trialStartedAt, renderedAt }: Props) => {
327
+ export const LicenseSettings = ({ license, trialStartedAt, renderedAt, licenseUrl }: Props) => {
249
328
  const [now, setNow] = useState(renderedAt);
250
329
  useEffect(() => setNow(Date.now()), []);
330
+ // A catalog is on the trial or has a license: an expired license is shown (to renew), not the trial. As in the header.
331
+ const onTrial = license.state === 'none' || license.state === 'invalid';
332
+ // While the trial runs, buying starts from pricing; once it has ended, from the Business order form
333
+ const trialEnded = onTrial && getTrialStatus(trialStartedAt, now)?.ended;
251
334
 
252
335
  return (
253
- <div className="divide-y divide-[rgb(var(--ec-page-border))]">
336
+ // A top border, as on the other settings pages
337
+ <div className="divide-y divide-[rgb(var(--ec-page-border))] border-t border-[rgb(var(--ec-page-border))]">
254
338
  <Row
255
339
  title="License"
256
- description="EventCatalog is licensed under the Business Source License 1.1. Your license is read from the license.jwt file in the root of your catalog."
340
+ description={
341
+ <>
342
+ EventCatalog is licensed under the{' '}
343
+ <a href={licenseUrl} target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:opacity-80">
344
+ Business Source License 1.1
345
+ </a>
346
+ . Your license is read from the license.jwt file in the root of your catalog. The{' '}
347
+ <a href={LICENSE_FAQ_URL} target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:opacity-80">
348
+ license FAQ
349
+ </a>{' '}
350
+ explains who can use EventCatalog for free.
351
+ </>
352
+ }
353
+ aside={
354
+ (onTrial || license.state === 'expired') && (
355
+ <div className="space-y-3">
356
+ <FreeUseNotice />
357
+ <p className="text-[13px] leading-relaxed text-[rgb(var(--ec-content-text-muted,var(--ec-page-text-muted)))]">
358
+ Questions about licensing, or need something different? Get in touch and let us know how we can help.
359
+ </p>
360
+ </div>
361
+ )
362
+ }
257
363
  canEdit={false}
258
364
  dirty={false}
259
365
  >
260
366
  <div className="space-y-4">
261
367
  <LicenseTable license={license} now={now} />
262
368
  <LicenseNotice license={license} now={now} />
263
- {/* The trial applies until there's a valid license or plan */}
264
- {license.state !== 'plan' && (license.state !== 'licensed' || isExpired(license, now)) && (
265
- <TrialTable trialStartedAt={trialStartedAt} now={now} />
369
+ {license.state === 'plan' && <LegacyKeyNotice plan={license.plan} />}
370
+ {onTrial && <TrialTable trialStartedAt={trialStartedAt} now={now} />}
371
+ {onTrial && (
372
+ <div className="flex flex-wrap gap-3">
373
+ <a
374
+ href={trialEnded ? BUY_BUSINESS_URL : PRICING_URL}
375
+ target="_blank"
376
+ rel="noreferrer"
377
+ className="inline-flex items-center gap-1.5 rounded-md bg-[rgb(var(--ec-page-text))] px-4 py-2 text-[13px] font-medium text-[rgb(var(--ec-page-bg))] shadow-sm transition-opacity hover:opacity-85"
378
+ >
379
+ {trialEnded ? 'Buy Business' : 'Buy a license'}
380
+ <ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
381
+ </a>
382
+ <a
383
+ href={TALK_TO_US_URL}
384
+ target="_blank"
385
+ rel="noreferrer"
386
+ className="inline-flex items-center gap-1.5 rounded-md border border-[rgb(var(--ec-page-border))] px-4 py-2 text-[13px] font-medium text-[rgb(var(--ec-page-text))] transition-opacity hover:opacity-80"
387
+ >
388
+ Talk to us
389
+ <ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
390
+ </a>
391
+ </div>
266
392
  )}
267
393
  </div>
268
394
  </Row>
269
-
270
- <Row
271
- title="Learn more"
272
- description="Who can use EventCatalog for free, and when you need a commercial license."
273
- canEdit={false}
274
- dirty={false}
275
- >
276
- <a
277
- href={LICENSE_FAQ_URL}
278
- target="_blank"
279
- rel="noreferrer"
280
- className="inline-flex items-center gap-1.5 rounded-md bg-[rgb(var(--ec-page-text))] px-4 py-2 text-[13px] font-medium text-[rgb(var(--ec-page-bg))] shadow-sm transition-opacity hover:opacity-85"
281
- >
282
- Read the license FAQ
283
- <ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
284
- </a>
285
- </Row>
286
395
  </div>
287
396
  );
288
397
  };