@portaki/module-sections 1.3.1 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portaki/module-sections",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Portaki module — editorial sections (TipTap)",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  "fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
10
10
  "en": "Rich content blocks (TipTap) for the guest welcome book."
11
11
  },
12
- "version": "1.3.1",
12
+ "version": "1.4.0",
13
13
  "releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
14
14
  "changelog": [
15
15
  {
@@ -122,6 +122,6 @@
122
122
  },
123
123
  "artifacts": {
124
124
  "wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
125
- "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.3.1"
125
+ "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.4.0"
126
126
  }
127
127
  }
@@ -9,7 +9,7 @@
9
9
  "fr": "Blocs de contenu riches (TipTap) pour le carnet d’accueil invité.",
10
10
  "en": "Rich content blocks (TipTap) for the guest welcome book."
11
11
  },
12
- "version": "1.3.1",
12
+ "version": "1.4.0",
13
13
  "releaseNotesUrl": "https://github.com/PortakiApp/portaki-modules/releases",
14
14
  "changelog": [
15
15
  {
@@ -122,6 +122,6 @@
122
122
  },
123
123
  "artifacts": {
124
124
  "wasmUrl": "oci://ghcr.io/portakiapp/portaki-module-sections",
125
- "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.3.1"
125
+ "guestEsmUrl": "https://esm.sh/@portaki/module-sections@1.4.0"
126
126
  }
127
127
  }
@@ -22,10 +22,7 @@ import { FileText, GripVertical, Plus, Trash2 } from 'lucide-react'
22
22
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
23
23
 
24
24
  import { SectionCreateModal, type SectionCreatePayload } from './SectionCreateModal'
25
- import { sectionsEditorDesign } from './sections-editor-design'
26
25
  import type { SectionsEditorDesign } from './sections-host.types'
27
- import { buildSectionTipTapPreset } from './section-tiptap-presets'
28
- import { sectionsToastCopy } from './sections-toast-copy'
29
26
 
30
27
  type SectionRow = {
31
28
  id: string;
@@ -38,9 +35,10 @@ type SectionRow = {
38
35
 
39
36
  type LangTab = "fr" | "en";
40
37
 
41
- const DEFAULT_DESIGN = sectionsEditorDesign
42
-
43
- function unwrapJsonbField(raw: unknown): unknown {
38
+ function unwrapJsonbField(
39
+ raw: unknown,
40
+ parseTipTapDoc: HostModuleContext['shell']['parseTipTapDoc'],
41
+ ): unknown {
44
42
  if (raw == null) {
45
43
  return null;
46
44
  }
@@ -50,7 +48,7 @@ function unwrapJsonbField(raw: unknown): unknown {
50
48
  if (typeof raw === "object" && !Array.isArray(raw)) {
51
49
  const record = raw as Record<string, unknown>;
52
50
  if (record.type === "jsonb" && record.value != null) {
53
- return unwrapJsonbField(record.value);
51
+ return unwrapJsonbField(record.value, parseTipTapDoc);
54
52
  }
55
53
  if (record.type === "doc") {
56
54
  return record;
@@ -59,23 +57,29 @@ function unwrapJsonbField(raw: unknown): unknown {
59
57
  return raw;
60
58
  }
61
59
 
62
- function normalizeSectionRow(row: Record<string, unknown>): SectionRow {
60
+ function normalizeSectionRow(
61
+ row: Record<string, unknown>,
62
+ parseTipTapDoc: HostModuleContext['shell']['parseTipTapDoc'],
63
+ ): SectionRow {
63
64
  return {
64
65
  id: String(row.id ?? ""),
65
66
  sortOrder: Number(row.sortOrder ?? row.sort_order ?? 0),
66
67
  titleFr: String(row.titleFr ?? row.title_fr ?? ""),
67
68
  titleEn: String(row.titleEn ?? row.title_en ?? ""),
68
- contentFr: unwrapJsonbField(row.contentFr ?? row.content_fr),
69
- contentEn: unwrapJsonbField(row.contentEn ?? row.content_en),
69
+ contentFr: unwrapJsonbField(row.contentFr ?? row.content_fr, parseTipTapDoc),
70
+ contentEn: unwrapJsonbField(row.contentEn ?? row.content_en, parseTipTapDoc),
70
71
  };
71
72
  }
72
73
 
73
- function tipTapValue(raw: unknown): string {
74
- const doc = unwrapJsonbField(raw);
74
+ function tipTapValue(
75
+ raw: unknown,
76
+ shell: HostModuleContext['shell'],
77
+ ): string {
78
+ const doc = unwrapJsonbField(raw, shell.parseTipTapDoc);
75
79
  if (doc && typeof doc === "object") {
76
- return serializeTipTapDoc(doc as Record<string, unknown>);
80
+ return shell.serializeTipTapDoc(doc as Record<string, unknown>);
77
81
  }
78
- return serializeTipTapDoc(parseTipTapDoc(null));
82
+ return shell.serializeTipTapDoc(shell.parseTipTapDoc(null));
79
83
  }
80
84
 
81
85
  function SortableSectionItem({
@@ -129,14 +133,10 @@ function SortableSectionItem({
129
133
  );
130
134
  }
131
135
 
132
- export function SectionsHostWorkspace({
133
- ctx,
134
- design = DEFAULT_DESIGN,
135
- }: {
136
- ctx: HostModuleContext
137
- design?: SectionsEditorDesign
138
- }) {
136
+ export function SectionsHostWorkspace({ ctx }: { ctx: HostModuleContext }) {
137
+ const design = ctx.design as SectionsEditorDesign
139
138
  const { propertyId, gateway, shell } = ctx
139
+ const toasts = design.toasts
140
140
  const {
141
141
  NotionLikeEditor,
142
142
  HostEmptyPlaceholder,
@@ -173,7 +173,7 @@ export function SectionsHostWorkspace({
173
173
  setLoading(true);
174
174
  try {
175
175
  const data = await gateway.query<SectionRow[]>(gatewayOps.listQuery)
176
- const list = data.map((row) => normalizeSectionRow(row as Record<string, unknown>));
176
+ const list = data.map((row) => normalizeSectionRow(row as Record<string, unknown>, parseTipTapDoc));
177
177
  setSections(list);
178
178
  setSelectedId((prev) => {
179
179
  if (prev && list.some((s) => s.id === prev)) {
@@ -182,7 +182,7 @@ export function SectionsHostWorkspace({
182
182
  return list[0]?.id ?? null;
183
183
  });
184
184
  } catch {
185
- toast.error(sectionsToastCopy.loadError)
185
+ toast.error(toasts.loadError)
186
186
  } finally {
187
187
  setLoading(false);
188
188
  }
@@ -207,7 +207,7 @@ export function SectionsHostWorkspace({
207
207
  setLastSavedAt(new Date());
208
208
  await load();
209
209
  } catch {
210
- toast.error(sectionsToastCopy.saveError)
210
+ toast.error(toasts.saveError)
211
211
  } finally {
212
212
  setSaving(false);
213
213
  }
@@ -248,7 +248,7 @@ export function SectionsHostWorkspace({
248
248
  orderedIds: ordered.map((s) => s.id),
249
249
  });
250
250
  } catch {
251
- toast.error(sectionsToastCopy.reorderError)
251
+ toast.error(toasts.reorderError)
252
252
  await load();
253
253
  }
254
254
  },
@@ -280,7 +280,11 @@ export function SectionsHostWorkspace({
280
280
  setCreating(true);
281
281
  const sortOrder = sections.length;
282
282
  const id = crypto.randomUUID();
283
- const { contentFr, contentEn } = buildSectionTipTapPreset(payload.contentPreset);
283
+ const buildPreset = shell.buildSectionTipTapPreset
284
+ if (buildPreset == null) {
285
+ throw new Error('host_shell_missing_buildSectionTipTapPreset')
286
+ }
287
+ const { contentFr, contentEn } = buildPreset(payload.contentPreset)
284
288
  try {
285
289
  await gateway.command(gatewayOps.saveCommand, {
286
290
  id,
@@ -293,9 +297,9 @@ export function SectionsHostWorkspace({
293
297
  setCreateModalOpen(false);
294
298
  await load();
295
299
  setSelectedId(id);
296
- toast.success(sectionsToastCopy.createSuccess)
300
+ toast.success(toasts.createSuccess)
297
301
  } catch {
298
- toast.error(sectionsToastCopy.createError)
302
+ toast.error(toasts.createError)
299
303
  } finally {
300
304
  setCreating(false);
301
305
  }
@@ -311,10 +315,10 @@ export function SectionsHostWorkspace({
311
315
  await gateway.command(gatewayOps.deleteCommand, {
312
316
  id: selected.id,
313
317
  });
314
- toast.success(sectionsToastCopy.deleteSuccess)
318
+ toast.success(toasts.deleteSuccess)
315
319
  await load();
316
320
  } catch {
317
- toast.error(sectionsToastCopy.deleteError)
321
+ toast.error(toasts.deleteError)
318
322
  }
319
323
  }, [gateway, gatewayOps.deleteCommand, selected, load, toast])
320
324
 
@@ -350,8 +354,8 @@ export function SectionsHostWorkspace({
350
354
  selected == null
351
355
  ? serializeTipTapDoc(parseTipTapDoc(null))
352
356
  : lang === "fr"
353
- ? tipTapValue(selected.contentFr)
354
- : tipTapValue(selected.contentEn);
357
+ ? tipTapValue(selected.contentFr, shell)
358
+ : tipTapValue(selected.contentEn, shell);
355
359
 
356
360
  return (
357
361
  <div className="flex min-h-[min(70vh,720px)] flex-col gap-4 lg:flex-row">
@@ -50,4 +50,13 @@ export type SectionsEditorDesign = {
50
50
  }
51
51
  createModal: SectionCreateModalDesign
52
52
  emptyState: SectionsEditorEmptyStateDesign
53
+ toasts: {
54
+ loadError: string
55
+ saveError: string
56
+ reorderError: string
57
+ createSuccess: string
58
+ createError: string
59
+ deleteSuccess: string
60
+ deleteError: string
61
+ }
53
62
  }
@@ -1,6 +1,8 @@
1
1
  import type { HostModuleContext, HostModuleShellComponents } from '@portaki/sdk'
2
2
  import { createMockHostModuleContext } from '@portaki/sdk-test-support'
3
3
 
4
+ import type { SectionsEditorDesign } from '../host/sections-host.types'
5
+
4
6
  /** Stubs until published `@portaki/sdk-test-support` includes `shell` / `gateway`. */
5
7
  export const mockHostShell: HostModuleShellComponents = {
6
8
  NotionLikeEditor: () => null,
@@ -18,6 +20,47 @@ export const mockHostShell: HostModuleShellComponents = {
18
20
  toast: { success: () => {}, error: () => {} },
19
21
  parseTipTapDoc: () => ({ type: 'doc', content: [{ type: 'paragraph' }] }),
20
22
  serializeTipTapDoc: (doc) => JSON.stringify(doc),
23
+ buildSectionTipTapPreset: () => ({
24
+ contentFr: { type: 'doc', content: [] },
25
+ contentEn: { type: 'doc', content: [] },
26
+ }),
27
+ }
28
+
29
+ const mockSectionsDesign: SectionsEditorDesign = {
30
+ id: 'sections-editor-v1',
31
+ moduleId: 'sections',
32
+ layout: 'sections-split-panel',
33
+ gateway: {
34
+ listQuery: 'sections.list',
35
+ saveCommand: 'sections.section.save',
36
+ deleteCommand: 'sections.section.delete',
37
+ reorderCommand: 'sections.reorder',
38
+ },
39
+ createModal: {
40
+ titleFr: 'Nouvelle section',
41
+ subtitleFr: 'Modèle',
42
+ titleFieldLabelFr: 'Titre',
43
+ titleFieldHintFr: 'Hint',
44
+ footerHintFr: 'Footer',
45
+ cancelLabelFr: 'Annuler',
46
+ submitLabelFr: 'Créer',
47
+ defaultTemplateId: 'welcome',
48
+ templates: [],
49
+ },
50
+ emptyState: {
51
+ titleFr: 'Vide',
52
+ descriptionFr: 'Desc',
53
+ primaryActionLabelFr: 'Créer',
54
+ },
55
+ toasts: {
56
+ loadError: 'load',
57
+ saveError: 'save',
58
+ reorderError: 'reorder',
59
+ createSuccess: 'created',
60
+ createError: 'create err',
61
+ deleteSuccess: 'deleted',
62
+ deleteError: 'delete err',
63
+ },
21
64
  }
22
65
 
23
66
  export function createSectionsTestHostContext(
@@ -26,6 +69,7 @@ export function createSectionsTestHostContext(
26
69
  const base = createMockHostModuleContext(overrides)
27
70
  return {
28
71
  ...base,
72
+ design: overrides.design ?? mockSectionsDesign,
29
73
  gateway: overrides.gateway ?? base.gateway ?? {
30
74
  query: async <T,>() => [] as T,
31
75
  command: async () => {},
@@ -1,156 +0,0 @@
1
- import type { SectionContentPresetId } from "./sections-host.types";
2
-
3
- export type SectionTipTapPreset = {
4
- contentFr: Record<string, unknown>;
5
- contentEn: Record<string, unknown>;
6
- };
7
-
8
- function textNode(value: string): Record<string, unknown> {
9
- return { type: "text", text: value };
10
- }
11
-
12
- function paragraph(...parts: string[]): Record<string, unknown> {
13
- const joined = parts.filter(Boolean).join(" ");
14
- return {
15
- type: "paragraph",
16
- content: joined ? [textNode(joined)] : [],
17
- };
18
- }
19
-
20
- function heading(level: 2 | 3, text: string): Record<string, unknown> {
21
- return {
22
- type: "heading",
23
- attrs: { level },
24
- content: text ? [textNode(text)] : [],
25
- };
26
- }
27
-
28
- function bulletItem(text: string): Record<string, unknown> {
29
- return {
30
- type: "listItem",
31
- content: [paragraph(text)],
32
- };
33
- }
34
-
35
- function bulletList(items: string[]): Record<string, unknown> {
36
- return {
37
- type: "bulletList",
38
- content: items.map(bulletItem),
39
- };
40
- }
41
-
42
- function doc(...blocks: Record<string, unknown>[]): Record<string, unknown> {
43
- return { type: "doc", content: blocks };
44
- }
45
-
46
- const PRESETS: Record<SectionContentPresetId, SectionTipTapPreset> = {
47
- welcome: {
48
- contentFr: doc(
49
- paragraph(
50
- "Bienvenue ! Nous sommes ravis de vous accueillir. Voici quelques informations utiles pour profiter pleinement du logement.",
51
- ),
52
- paragraph(
53
- "N'hésitez pas à nous écrire ou à nous appeler si vous avez la moindre question pendant votre séjour.",
54
- ),
55
- ),
56
- contentEn: doc(
57
- paragraph(
58
- "Welcome! We're delighted to host you. Here is some useful information to help you make the most of your stay.",
59
- ),
60
- paragraph("Feel free to message or call us if you have any questions during your visit."),
61
- ),
62
- },
63
- beaches: {
64
- contentFr: doc(
65
- heading(2, "Plages & criques"),
66
- paragraph("Nos spots préférés aux alentours :"),
67
- bulletList([
68
- "Plage principale — accès facile, idéale en famille",
69
- "Crique sauvage — 10 min à pied, prévoir des chaussures",
70
- "Spot snorkeling — eau calme le matin",
71
- ]),
72
- ),
73
- contentEn: doc(
74
- heading(2, "Beaches & coves"),
75
- paragraph("Our favourite spots nearby:"),
76
- bulletList([
77
- "Main beach — easy access, great for families",
78
- "Hidden cove — 10 min walk, bring suitable shoes",
79
- "Snorkelling spot — calm water in the morning",
80
- ]),
81
- ),
82
- },
83
- restaurants: {
84
- contentFr: doc(
85
- heading(2, "Restaurants"),
86
- paragraph("Quelques adresses que nous recommandons, par budget :"),
87
- bulletList([
88
- "Bistrot du port — cuisine locale, réservation conseillée",
89
- "Pizzeria du village — décontracté, terrasse ombragée",
90
- "Marché du samedi matin — produits frais et spécialités",
91
- ]),
92
- ),
93
- contentEn: doc(
94
- heading(2, "Restaurants"),
95
- paragraph("A few places we recommend, by budget:"),
96
- bulletList([
97
- "Harbour bistro — local cuisine, booking recommended",
98
- "Village pizzeria — casual, shaded terrace",
99
- "Saturday market — fresh produce and regional treats",
100
- ]),
101
- ),
102
- },
103
- transport: {
104
- contentFr: doc(
105
- heading(2, "Venir & repartir"),
106
- paragraph("Pour vous déplacer facilement :"),
107
- bulletList([
108
- "Gare la plus proche — liaisons régulières vers les grandes villes",
109
- "Bus — arrêt à quelques minutes à pied",
110
- "Parking — place disponible sur la propriété",
111
- "Taxi / VTC — nous pouvons partager nos contacts",
112
- ]),
113
- ),
114
- contentEn: doc(
115
- heading(2, "Getting here & leaving"),
116
- paragraph("Getting around:"),
117
- bulletList([
118
- "Nearest train station — regular connections to major cities",
119
- "Bus — stop a few minutes' walk away",
120
- "Parking — space available on the property",
121
- "Taxi / ride-hailing — we can share our contacts",
122
- ]),
123
- ),
124
- },
125
- emergencies: {
126
- contentFr: doc(
127
- heading(2, "Urgences"),
128
- paragraph("En cas d'urgence :"),
129
- bulletList([
130
- "112 — numéro d'urgence européen",
131
- "Médecin / pharmacie de garde — contactez-nous",
132
- "Pompiers / police — composez le 112",
133
- ]),
134
- paragraph("Adresse du logement et codes d'accès : à compléter selon votre situation."),
135
- ),
136
- contentEn: doc(
137
- heading(2, "Emergencies"),
138
- paragraph("In an emergency:"),
139
- bulletList([
140
- "112 — European emergency number",
141
- "Doctor / on-call pharmacy — contact us",
142
- "Fire / police — dial 112",
143
- ]),
144
- paragraph("Property address and access codes: please complete for your guests."),
145
- ),
146
- },
147
- empty: {
148
- contentFr: doc(paragraph("")),
149
- contentEn: doc(paragraph("")),
150
- },
151
- };
152
-
153
- /** TipTap JSON presets keyed by {@link SectionContentPresetId} (sections editor design). */
154
- export function buildSectionTipTapPreset(preset: SectionContentPresetId): SectionTipTapPreset {
155
- return PRESETS[preset] ?? PRESETS.empty;
156
- }
@@ -1,79 +0,0 @@
1
- import type { SectionsEditorDesign } from './sections-host.types'
2
-
3
- export const sectionsEditorDesign = {
4
- id: 'sections-editor-v1',
5
- moduleId: 'sections',
6
- layout: 'sections-split-panel',
7
- gateway: {
8
- listQuery: 'sections.list',
9
- saveCommand: 'sections.section.save',
10
- deleteCommand: 'sections.section.delete',
11
- reorderCommand: 'sections.reorder',
12
- },
13
- createModal: {
14
- titleFr: 'Nouvelle section',
15
- subtitleFr: 'Choisissez un modèle pour partir vite, ou créez une section vide.',
16
- titleFieldLabelFr: 'Titre',
17
- titleFieldHintFr: 'Visible par le voyageur, en haut de la section.',
18
- footerHintFr: 'Vous pourrez renommer et réordonner ensuite.',
19
- cancelLabelFr: 'Annuler',
20
- submitLabelFr: 'Créer la section',
21
- defaultTemplateId: 'welcome',
22
- templates: [
23
- {
24
- id: 'welcome',
25
- icon: '👋',
26
- titleFr: 'Mot de bienvenue',
27
- titleEn: 'Welcome message',
28
- descriptionFr: "Un paragraphe d'accueil personnalisé",
29
- contentPreset: 'welcome',
30
- },
31
- {
32
- id: 'beaches',
33
- icon: '🏖️',
34
- titleFr: 'Plages & criques',
35
- titleEn: 'Beaches & coves',
36
- descriptionFr: 'Liste de plages avec accès & distance',
37
- contentPreset: 'beaches',
38
- },
39
- {
40
- id: 'restaurants',
41
- icon: '🍝',
42
- titleFr: 'Nos restaurants préférés',
43
- titleEn: 'Our favourite restaurants',
44
- descriptionFr: 'Recommandations par budget',
45
- contentPreset: 'restaurants',
46
- },
47
- {
48
- id: 'transport',
49
- icon: '🚂',
50
- titleFr: 'Comment venir & repartir',
51
- titleEn: 'Getting here & leaving',
52
- descriptionFr: 'Train, bus, voiture, taxi',
53
- contentPreset: 'transport',
54
- },
55
- {
56
- id: 'emergencies',
57
- icon: '🆘',
58
- titleFr: 'Urgences & numéros utiles',
59
- titleEn: 'Emergencies & useful numbers',
60
- descriptionFr: '112, médecin, police',
61
- contentPreset: 'emergencies',
62
- },
63
- {
64
- id: 'empty',
65
- icon: '📋',
66
- titleFr: 'Vide · personnalisée',
67
- titleEn: 'Empty · custom',
68
- descriptionFr: "Démarrer d'une page blanche",
69
- contentPreset: 'empty',
70
- },
71
- ],
72
- },
73
- emptyState: {
74
- titleFr: 'Aucune section pour l’instant',
75
- descriptionFr:
76
- 'Structurez le carnet d’accueil en blocs (plages, restaurants, consignes…) visibles par vos voyageurs dans le livret.',
77
- primaryActionLabelFr: 'Créer une section',
78
- },
79
- } satisfies SectionsEditorDesign
@@ -1,9 +0,0 @@
1
- export const sectionsToastCopy = {
2
- loadError: 'Impossible de charger les sections.',
3
- saveError: 'Enregistrement impossible.',
4
- reorderError: 'Réordonnancement impossible.',
5
- createSuccess: 'Section créée.',
6
- createError: 'Création impossible.',
7
- deleteSuccess: 'Section supprimée.',
8
- deleteError: 'Suppression impossible.',
9
- } as const