@proulr/sdui-components 0.1.88 → 0.1.90

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.
@@ -0,0 +1,275 @@
1
+ import type {
2
+ ColorToken,
3
+ ShapeToken,
4
+ SduiAvatarSizeToken,
5
+ SduiMediaAspectToken,
6
+ SduiMediaFitToken,
7
+ SduiSelectedFlag,
8
+ } from '@proulr/sdui-contract';
9
+ import type { BdcComponent } from '../jsx-runtime';
10
+
11
+ /**
12
+ * Blocos genéricos de mídia/estado para row templates e layouts BFF.
13
+ *
14
+ * Nenhum deles conhece produto: o **path de mídia** vem de dados (bindings
15
+ * `{{item.*}}` em row templates são resolvidos pelo app em materialize time)
16
+ * e a transformação path → URL (download autenticado) é capability genérica
17
+ * fornecida pelo app via `SduiMediaResolver` (`@proulr/sdui-components/renderer`).
18
+ * A lib nunca cai em path/URL de produto: sem resolver ou com path vazio,
19
+ * `content_image` renderiza `null`; `avatar` cai em iniciais/glyph genérico.
20
+ */
21
+
22
+ function definedProps(props: Record<string, unknown>): Record<string, unknown> {
23
+ const out: Record<string, unknown> = {};
24
+ for (const [key, value] of Object.entries(props)) {
25
+ if (key === 'children' || value === undefined) continue;
26
+ out[key] = value;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** Lê string aceitando a forma camel ou snake do mesmo prop (autoria BFF tolerante). */
32
+ function readStringAlias(props: Record<string, unknown>, ...keys: string[]): string | undefined {
33
+ for (const key of keys) {
34
+ const value = props[key];
35
+ if (typeof value === 'string') return value;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ type ContentImageProps = {
41
+ id?: string;
42
+ /** Storage path (ex.: `posts/2026/a.jpg`) ou binding `{{item.media_path}}` no row template. */
43
+ path?: string;
44
+ /** Alias de autoria: mesmo valor de `path` (compacto em rows). */
45
+ pathRef?: string;
46
+ path_ref?: string;
47
+ width?: number;
48
+ height?: number;
49
+ /** Token de proporção — `square` | `portrait` | `story` | `video` | `wide`. */
50
+ aspect?: SduiMediaAspectToken;
51
+ /** Token de raio do tema (sem px/hex) — default `cornerMedium`. */
52
+ corner?: ShapeToken;
53
+ /** `cover` (default) | `contain`. */
54
+ fit?: SduiMediaFitToken;
55
+ /** Preenche o frame atrás de mídia `contain` (blur-fill genérico; app pode refinar). */
56
+ blurFill?: boolean;
57
+ testId?: string;
58
+ };
59
+
60
+ /**
61
+ * `<ContentImage path="{{item.media_path}}" aspect="square" corner="cornerMedium" fit="cover" />`
62
+ * → wire `content_image`:
63
+ * `{ path, width?, height?, aspect?, corner?, fit?, blur_fill?, test_id? }`
64
+ */
65
+ export const ContentImage: BdcComponent = (props) => {
66
+ const raw = props as ContentImageProps & Record<string, unknown>;
67
+ const path = readStringAlias(raw, 'path', 'pathRef', 'path_ref');
68
+ const elementProps = definedProps({
69
+ id: raw.id,
70
+ width: raw.width,
71
+ height: raw.height,
72
+ aspect: raw.aspect,
73
+ corner: raw.corner,
74
+ fit: raw.fit,
75
+ blurFill: raw.blurFill ?? raw.blur_fill,
76
+ testId: raw.testId ?? raw.test_id,
77
+ });
78
+ if (path !== undefined) elementProps.path = path;
79
+ return { type: 'content_image', props: elementProps, children: [] };
80
+ };
81
+
82
+ type VideoProps = {
83
+ id?: string;
84
+ /** Storage path do vídeo (ex.: `clips/2026/a.mp4`) ou binding `{{item.video_path}}`. */
85
+ path?: string;
86
+ pathRef?: string;
87
+ path_ref?: string;
88
+ /** Storage path do poster/still (ou binding); exibido quando o app não pluga player. */
89
+ posterPath?: string;
90
+ poster_path?: string;
91
+ posterRef?: string;
92
+ poster_ref?: string;
93
+ width?: number;
94
+ height?: number;
95
+ /** Token de proporção — `square` | `portrait` | `story` | `video` | `wide`. */
96
+ aspect?: SduiMediaAspectToken;
97
+ /** Token de raio do tema (sem px/hex) — default `cornerMedium`. */
98
+ corner?: ShapeToken;
99
+ /** `cover` (default) | `contain`. */
100
+ fit?: SduiMediaFitToken;
101
+ testId?: string;
102
+ };
103
+
104
+ /**
105
+ * `<Video path="{{item.video_path}}" posterPath="{{item.poster_path}}" aspect="video" />`
106
+ * → wire `video`:
107
+ * `{ path, width?, height?, aspect?, corner?, fit?, poster_path?, test_id? }`
108
+ *
109
+ * O player em si é do **app** (expo-video): o renderer resolve `path`/
110
+ * `poster_path` via o mesmo `SduiMediaResolverContext` e delega a playback a
111
+ * um slot injetável (`SduiVideoPlayerProvider`). Sem slot, o bloco renderiza
112
+ * um **frame** com o poster (still) — nunca URL de produto hardcoded.
113
+ */
114
+ export const Video: BdcComponent = (props) => {
115
+ const raw = props as VideoProps & Record<string, unknown>;
116
+ const path = readStringAlias(raw, 'path', 'pathRef', 'path_ref');
117
+ const posterPath = readStringAlias(raw, 'posterPath', 'poster_path', 'posterRef', 'poster_ref');
118
+ const elementProps = definedProps({
119
+ id: raw.id,
120
+ width: raw.width,
121
+ height: raw.height,
122
+ aspect: raw.aspect,
123
+ corner: raw.corner,
124
+ fit: raw.fit,
125
+ testId: raw.testId ?? raw.test_id,
126
+ });
127
+ if (path !== undefined) elementProps.path = path;
128
+ if (posterPath !== undefined) elementProps.poster_path = posterPath;
129
+ return { type: 'video', props: elementProps, children: [] };
130
+ };
131
+
132
+ type RowOverflowMenuActionProps = {
133
+ /** Action id do wire; binding `{{item.*}}` ok (template-safe). */
134
+ actionId?: string;
135
+ action_id?: string;
136
+ /** Rótulo da entrada (copy via Rosetta no BFF; binding ok). */
137
+ label?: string;
138
+ /** AppIconName kebab-case (catalog) — ex.: `trash`, `bookmark`. */
139
+ icon?: string;
140
+ /** Tint `error` do tema nesta entrada. */
141
+ destructive?: boolean;
142
+ };
143
+
144
+ type RowOverflowMenuProps = {
145
+ id?: string;
146
+ /** Entradas do menu, em ordem. */
147
+ actions?: RowOverflowMenuActionProps[];
148
+ /** AppIconName do trigger (default no renderer: `overflow`). */
149
+ icon?: string;
150
+ testId?: string;
151
+ };
152
+
153
+ /**
154
+ * `<RowOverflowMenu actions={[{ actionId: 'delete_post_{{item.id}}', label: 'Excluir', icon: 'trash', destructive: true }]} />`
155
+ * → wire `row_overflow_menu`:
156
+ * `{ actions: [{ action_id, label, icon?, destructive? }], icon?, test_id? }`
157
+ *
158
+ * Overflow genérico por linha: aninhados **não** passam por camelToSnake no
159
+ * compile — o factory já normaliza `action_id`. Toda copy vem do chamador
160
+ * (BFF); a lib não inventa strings.
161
+ */
162
+ export const RowOverflowMenu: BdcComponent = (props) => {
163
+ const raw = props as RowOverflowMenuProps & Record<string, unknown>;
164
+ const actions = (raw.actions ?? []).map((action) => {
165
+ const entry = action as RowOverflowMenuActionProps;
166
+ return definedProps({
167
+ action_id: entry.actionId ?? entry.action_id,
168
+ label: entry.label,
169
+ icon: entry.icon,
170
+ destructive: entry.destructive,
171
+ });
172
+ });
173
+ const elementProps = definedProps({
174
+ id: raw.id,
175
+ icon: raw.icon,
176
+ testId: raw.testId ?? raw.test_id,
177
+ });
178
+ elementProps.actions = actions;
179
+ return { type: 'row_overflow_menu', props: elementProps, children: [] };
180
+ };
181
+
182
+ type AvatarProps = {
183
+ id?: string;
184
+ /** Storage path da foto (ou binding `{{item.author.avatar_path}}`). */
185
+ path?: string;
186
+ pathRef?: string;
187
+ path_ref?: string;
188
+ /** Nome de exibição — iniciais derivadas quando não há foto resolvível. */
189
+ name?: string;
190
+ /** Iniciais explícitas (override da derivação de `name`). */
191
+ initials?: string;
192
+ /** Token de tamanho — `xs` | `sm` | `md` | `lg` | `xl` (default `md`). */
193
+ size?: SduiAvatarSizeToken;
194
+ /** Token de raio — default `cornerFull` (círculo). */
195
+ corner?: ShapeToken;
196
+ testId?: string;
197
+ };
198
+
199
+ /**
200
+ * `<Avatar pathRef="{{item.author.avatar_path}}" name="{{item.author.name}}" size="sm" />`
201
+ * → wire `avatar`: `{ path?, name?, initials?, size?, corner?, test_id? }`
202
+ */
203
+ export const Avatar: BdcComponent = (props) => {
204
+ const raw = props as AvatarProps & Record<string, unknown>;
205
+ const path = readStringAlias(raw, 'path', 'pathRef', 'path_ref');
206
+ const elementProps = definedProps({
207
+ id: raw.id,
208
+ name: raw.name,
209
+ initials: raw.initials,
210
+ size: raw.size,
211
+ corner: raw.corner,
212
+ testId: raw.testId ?? raw.test_id,
213
+ });
214
+ if (path !== undefined) elementProps.path = path;
215
+ return { type: 'avatar', props: elementProps, children: [] };
216
+ };
217
+
218
+ type IconButtonProps = {
219
+ id?: string;
220
+ /** AppIconName kebab-case (catalog) — ex.: `heart`. */
221
+ icon: string;
222
+ /** AppIconName do estado selecionado (ex.: `heart-filled`); sem ele, só tint. */
223
+ selectedIcon?: string;
224
+ selected_icon?: string;
225
+ actionId?: string;
226
+ /**
227
+ * Estado selecionado: `true`/`false` literal ou binding `{{item.liked_by_me}}`.
228
+ * Em row templates, materialize resolve booleanos para as **strings**
229
+ * `'true'`/`'false'` — o renderer trata `'true'`/`true` como selecionado
230
+ * (regra stringly-typed documentada em `docs/components.md`).
231
+ */
232
+ selected?: SduiSelectedFlag;
233
+ /** Alias de autoria: mesmo destino de `selected`. */
234
+ selectedRef?: string;
235
+ selected_ref?: string;
236
+ /** Px do glyph (default `feedAction`). */
237
+ size?: number;
238
+ /** ColorToken do estado default (ex.: `onSurfaceVariant`). */
239
+ color?: ColorToken;
240
+ /** ColorToken do estado selecionado (ex.: `primary`). */
241
+ selectedColor?: ColorToken;
242
+ selected_color?: ColorToken;
243
+ /** Rótulo de acessibilidade (copy via Rosetta no BFF). */
244
+ label?: string;
245
+ disabled?: boolean;
246
+ testId?: string;
247
+ };
248
+
249
+ /**
250
+ * `<IconButton icon="heart" selectedIcon="heart-filled" actionId="like_post_{{item.id}}" selectedRef="{{item.liked_by_me}}" />`
251
+ * → wire `icon_button`:
252
+ * `{ icon, selected_icon?, action_id?, selected?, size?, color?, selected_color?, label?, disabled?, test_id? }`
253
+ */
254
+ export const IconButton: BdcComponent = (props) => {
255
+ const raw = props as IconButtonProps & Record<string, unknown>;
256
+ const selected = readStringAlias(raw, 'selected', 'selectedRef', 'selected_ref');
257
+ const elementProps = definedProps({
258
+ id: raw.id,
259
+ icon: raw.icon,
260
+ selectedIcon: readStringAlias(raw, 'selectedIcon', 'selected_icon'),
261
+ actionId: raw.actionId ?? raw.action_id,
262
+ size: raw.size,
263
+ color: raw.color,
264
+ selectedColor: readStringAlias(raw, 'selectedColor', 'selected_color'),
265
+ label: raw.label,
266
+ disabled: raw.disabled,
267
+ testId: raw.testId ?? raw.test_id,
268
+ });
269
+ if (selected !== undefined) {
270
+ elementProps.selected = selected;
271
+ } else if (typeof raw.selected === 'boolean') {
272
+ elementProps.selected = raw.selected;
273
+ }
274
+ return { type: 'icon_button', props: elementProps, children: [] };
275
+ };
@@ -12,15 +12,21 @@ type NativeBlockProps = {
12
12
  profileIdRef?: string;
13
13
  /** Wire `settings_page` — id da página filha (about, privacy, …). */
14
14
  page?: string;
15
+ children?: BdcElement[];
15
16
  };
16
17
 
17
18
  function nativeBlock(type: string, props: NativeBlockProps): BdcElement {
18
- return { type, props: { ...props }, children: [] };
19
+ const { children, ...rest } = props;
20
+ return { type, props: { ...rest }, children: children ?? [] };
19
21
  }
20
22
 
21
23
  export const ChatThread: BdcComponent = (props) => nativeBlock('chat_thread', props);
22
24
  export const SocialPostFeed: BdcComponent = (props) => nativeBlock('social_post_feed', props);
23
25
  export const ThoughtFeed: BdcComponent = (props) => nativeBlock('thought_feed', props);
26
+ /**
27
+ * Hero nativo genérico do perfil (fotos + vidro + fade). Aceita blocos BDC como
28
+ * filhos, que o híbrido monta na sua folha rolável sobre a foto fixa.
29
+ */
24
30
  export const ProfileView: BdcComponent = (props) => nativeBlock('profile_view', props);
25
31
  export const ProfileSelf: BdcComponent = (props) => nativeBlock('profile_self', props);
26
32
  export const ProfileSetup: BdcComponent = (props) => nativeBlock('profile_setup', props);
@@ -96,6 +96,31 @@ type SectionHeaderProps = LayoutProps & {
96
96
  export const SectionHeader: BdcComponent = (props) =>
97
97
  layoutBlock('section_header', props as SectionHeaderProps);
98
98
 
99
+ type StatTileRowProps = LayoutProps & {
100
+ /** Caminho em `screen.data` para o array de tiles: `{ icon, value, label }[]`. */
101
+ itemsRef?: string;
102
+ };
103
+
104
+ /** Linha genérica de tiles de métrica (ícone + valor + rótulo). Reusável em qualquer tela. */
105
+ export const StatTileRow: BdcComponent = (props) => layoutBlock('stat_tile_row', props as StatTileRowProps);
106
+
107
+ type DataGroupListProps = LayoutProps & {
108
+ /** Caminho em `screen.data`: `{ title, icon, rows: { label, value }[] }[]`. */
109
+ itemsRef?: string;
110
+ };
111
+
112
+ /** Grupos de detalhes (cabeçalho com ícone + linhas rótulo→valor). Reusável. */
113
+ export const DataGroupList: BdcComponent = (props) =>
114
+ layoutBlock('data_group_list', props as DataGroupListProps);
115
+
116
+ type VouchListProps = LayoutProps & {
117
+ /** Caminho em `screen.data`: `{ header, items: { quote, author_name, meta }[] }`. */
118
+ itemsRef?: string;
119
+ };
120
+
121
+ /** Cartões de citação/recomendação (Vouches/depoimentos). Reusável. */
122
+ export const VouchList: BdcComponent = (props) => layoutBlock('vouch_list', props as VouchListProps);
123
+
99
124
  type ListItemProps = LayoutProps & {
100
125
  title?: string;
101
126
  subtitle?: string;
package/src/bdc/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  Button,
4
4
  Card,
5
5
  Column,
6
+ DataGroupList,
6
7
  Icon,
7
8
  InfoBanner,
8
9
  ListItem,
@@ -13,11 +14,15 @@ export {
13
14
  Scroll,
14
15
  SectionHeader,
15
16
  Spacer,
17
+ StatTileRow,
16
18
  Text,
17
19
  ToggleRow,
20
+ VouchList,
18
21
  screenProps,
19
22
  } from './components/ui';
20
23
  export { Chip, ChipRow, DiscoverGrid, DiscoverMap, DiscoverProfileCard } from './components/discover';
24
+ export { Avatar, ContentImage, IconButton, RowOverflowMenu, Video } from './components/media';
25
+ export { ListRowTemplate } from './components/list';
21
26
  export {
22
27
  ChatInboxCapabilities,
23
28
  EmptyState,
@@ -9,6 +9,11 @@ export const SDUI_BLOCK_TYPES = [
9
9
  'spacer',
10
10
  'info_banner',
11
11
  'native_ad',
12
+ 'content_image',
13
+ 'video',
14
+ 'avatar',
15
+ 'icon_button',
16
+ 'row_overflow_menu',
12
17
  'inbox_hub_chrome',
13
18
  'select_row',
14
19
  ] as const;
@@ -0,0 +1,63 @@
1
+ import React, { useMemo } from 'react';
2
+ import type { SduiBlock, SduiBlockWire } from '@proulr/sdui-contract';
3
+ import { renderBlockTree } from './sdui-component-registry';
4
+ import { materializeRowTemplate, type SduiRowTemplateSource } from './lib/sdui-row-template';
5
+
6
+ type Props = {
7
+ /** Props do bloco hospedeiro (`{ row_template, item_key, section }`) ou array nu de blocos do template. */
8
+ template: SduiRowTemplateSource | null | undefined;
9
+ /** Item vivo do feed (`screen.data[...]`). */
10
+ item: unknown;
11
+ /** Índice do item — fallback do sufixo de id quando `item_key` não resolve. */
12
+ index?: number;
13
+ /** `screen.data` repassado ao renderer de blocos. */
14
+ data?: Record<string, unknown>;
15
+ onAction: (actionId: string, vars?: Record<string, string>) => void;
16
+ };
17
+
18
+ function toDomainBlock(block: SduiBlockWire): SduiBlock {
19
+ return {
20
+ id: block.id,
21
+ type: block.type,
22
+ props: block.props ?? {},
23
+ children: (block.children ?? []).map(toDomainBlock),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Renderiza **uma** linha de um row template wire-driven: materializa o subtree
29
+ * do bloco template para o item (bindings `{{item.*}}` + ids estáveis) e monta
30
+ * via registry de blocos do SDUI (`renderBlockTree`) — componentes genéricos,
31
+ * sem layout de linha hardcoded no híbrido.
32
+ *
33
+ * ```tsx
34
+ * {items.map((post, i) => (
35
+ * <SduiTemplateRow
36
+ * key={post.id}
37
+ * template={readSduiRowTemplate(block.props)}
38
+ * item={post}
39
+ * index={i}
40
+ * data={data}
41
+ * onAction={onAction}
42
+ * />
43
+ * ))}
44
+ * ```
45
+ */
46
+ export function SduiTemplateRow({ template, item, index = 0, data = {}, onAction }: Props) {
47
+ const blocks = useMemo(
48
+ () => (template ? materializeRowTemplate(template, item, index) : []),
49
+ [template, item, index],
50
+ );
51
+ return (
52
+ <>
53
+ {blocks.map((block) => {
54
+ const domainBlock = toDomainBlock(block);
55
+ return (
56
+ <React.Fragment key={domainBlock.id}>
57
+ {renderBlockTree(domainBlock, data, onAction)}
58
+ </React.Fragment>
59
+ );
60
+ })}
61
+ </>
62
+ );
63
+ }
@@ -0,0 +1,198 @@
1
+ import { StyleSheet, Text, View } from 'react-native';
2
+ import { fonts, m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
3
+ import { AppIcon } from '../ui/app-icon';
4
+ import { isAppIconName, type AppIconName } from '../../catalog/app-icon-names';
5
+ import { resolveDataRef } from '../lib/resolve-data-ref';
6
+ import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
7
+
8
+ function itemsFrom(
9
+ block: SduiBlockRenderProps['block'],
10
+ data: Record<string, unknown>,
11
+ fallback = '',
12
+ ): unknown[] {
13
+ const refProp = block.props.items_ref;
14
+ const ref = typeof refProp === 'string' && refProp ? refProp : fallback;
15
+ if (!ref) return [];
16
+ const raw = resolveDataRef(data, ref);
17
+ return Array.isArray(raw) ? raw : [];
18
+ }
19
+
20
+ function asRecord(item: unknown): Record<string, unknown> | null {
21
+ return item && typeof item === 'object' ? (item as Record<string, unknown>) : null;
22
+ }
23
+
24
+ function toText(value: unknown): string {
25
+ if (typeof value === 'string') return value;
26
+ if (value != null) return String(value);
27
+ return '';
28
+ }
29
+
30
+ function toIcon(value: unknown): AppIconName | undefined {
31
+ return typeof value === 'string' && isAppIconName(value) ? value : undefined;
32
+ }
33
+
34
+ /* ── stat_tile_row ─────────────────────────────────────────────────────────── */
35
+
36
+ type StatTile = { icon?: AppIconName; value: string; label: string };
37
+
38
+ /** Tiles genéricos de métrica (ícone + valor + rótulo) — layout vem do wire, regra do BFF. */
39
+ export function SduiStatTileRowBlock({ block, data }: SduiBlockRenderProps) {
40
+ const tiles: StatTile[] = itemsFrom(block, data, 'stats')
41
+ .map(asRecord)
42
+ .filter((row): row is Record<string, unknown> => row !== null)
43
+ .map((row) => ({ icon: toIcon(row.icon), value: toText(row.value), label: toText(row.label) }))
44
+ .filter((tile) => tile.value || tile.label);
45
+ if (tiles.length === 0) return null;
46
+
47
+ return (
48
+ <View style={styles.row} accessibilityRole="list">
49
+ {tiles.map((tile, index) => (
50
+ <View key={`${tile.label}-${index}`} style={styles.tile}>
51
+ {tile.icon ? <AppIcon name={tile.icon} size={17} color={m3Colors.onSurfaceVariant} /> : null}
52
+ <Text style={styles.value} numberOfLines={1}>{tile.value}</Text>
53
+ <Text style={styles.label} numberOfLines={1}>{tile.label}</Text>
54
+ </View>
55
+ ))}
56
+ </View>
57
+ );
58
+ }
59
+
60
+ /* ── data_group_list ──────────────────────────────────────────────────────── */
61
+
62
+ /** Grupos de detalhes (cabeçalho com ícone + linhas rótulo→valor). Reutilizável em qualquer tela. */
63
+ export function SduiDataGroupListBlock({ block, data }: SduiBlockRenderProps) {
64
+ const groups = itemsFrom(block, data)
65
+ .map(asRecord)
66
+ .filter((g): g is Record<string, unknown> => g !== null)
67
+ .map((group) => {
68
+ const rowsRaw = Array.isArray(group.rows) ? group.rows : [];
69
+ const rows = rowsRaw
70
+ .map(asRecord)
71
+ .filter((r): r is Record<string, unknown> => r !== null)
72
+ .map((row) => ({ label: toText(row.label), value: toText(row.value) }))
73
+ .filter((row) => row.label || row.value);
74
+ return {
75
+ icon: toIcon(group.icon),
76
+ title: toText(group.title),
77
+ rows,
78
+ };
79
+ })
80
+ .filter((group) => group.rows.length > 0);
81
+ if (groups.length === 0) return null;
82
+
83
+ return (
84
+ <View style={styles.groups}>
85
+ {groups.map((group, gIndex) => (
86
+ <View key={`${group.title}-${gIndex}`} style={styles.group}>
87
+ <View style={styles.groupHead} accessibilityRole="header">
88
+ {group.icon ? <AppIcon name={group.icon} size={16} color={m3Colors.onSurface} /> : null}
89
+ <Text style={styles.groupTitle}>{group.title}</Text>
90
+ </View>
91
+ {group.rows.map((row, rIndex) => (
92
+ <View key={`${row.label}-${rIndex}`} style={styles.dataRow}>
93
+ <Text style={styles.dataLabel} numberOfLines={1}>{row.label}</Text>
94
+ <Text style={styles.dataValue}>{row.value}</Text>
95
+ </View>
96
+ ))}
97
+ </View>
98
+ ))}
99
+ </View>
100
+ );
101
+ }
102
+
103
+ /* ── vouch_list ───────────────────────────────────────────────────────────── */
104
+
105
+ /** Cartões de citação/recomendação (cabeçalho + citações com autor). Reutilizável (depoimentos, reviews). */
106
+ export function SduiVouchListBlock({ block, data }: SduiBlockRenderProps) {
107
+ const itemsRefProp = block.props.items_ref;
108
+ const itemsRef = typeof itemsRefProp === 'string' && itemsRefProp ? itemsRefProp : 'vouches';
109
+ const container = asRecord(resolveDataRef(data, itemsRef));
110
+ const header = container ? toText(container.header) : '';
111
+ const items = (container && Array.isArray(container.items) ? container.items : [])
112
+ .map(asRecord)
113
+ .filter((i): i is Record<string, unknown> => i !== null)
114
+ .map((item) => ({
115
+ quote: toText(item.quote),
116
+ author: toText(item.author_name),
117
+ meta: toText(item.meta),
118
+ }))
119
+ .filter((item) => item.quote);
120
+ if (!header && items.length === 0) return null;
121
+
122
+ return (
123
+ <View style={styles.vouchWrap}>
124
+ {header ? <Text style={styles.vouchHeader}>{header}</Text> : null}
125
+ {items.map((item, index) => (
126
+ <View key={`vouch-${index}`} style={styles.vouchCard}>
127
+ <Text style={styles.vouchQuote}>“{item.quote}”</Text>
128
+ <View style={styles.vouchFooter}>
129
+ <Text style={styles.vouchAuthor}>{item.author}</Text>
130
+ {item.meta ? <Text style={styles.vouchMeta}>{item.meta}</Text> : null}
131
+ </View>
132
+ </View>
133
+ ))}
134
+ </View>
135
+ );
136
+ }
137
+
138
+ const styles = StyleSheet.create({
139
+ row: { flexDirection: 'row', gap: spacing.sm },
140
+ tile: {
141
+ flex: 1,
142
+ minHeight: 64,
143
+ borderRadius: m3Shape.cornerMedium,
144
+ backgroundColor: m3Colors.surfaceContainerHigh,
145
+ borderWidth: 1,
146
+ borderColor: m3Colors.outlineVariant,
147
+ alignItems: 'center',
148
+ justifyContent: 'center',
149
+ gap: 1,
150
+ paddingVertical: spacing.sm,
151
+ paddingHorizontal: spacing.xs,
152
+ },
153
+ value: { ...m3Type.title, color: m3Colors.onSurface, fontFamily: fonts.extrabold },
154
+ label: { ...m3Type.labelSmall, color: m3Colors.onSurfaceVariant },
155
+
156
+ groups: { gap: spacing.md },
157
+ group: {
158
+ borderRadius: m3Shape.cornerMedium,
159
+ backgroundColor: m3Colors.surfaceContainerHigh,
160
+ borderWidth: 1,
161
+ borderColor: m3Colors.outlineVariant,
162
+ overflow: 'hidden',
163
+ },
164
+ groupHead: {
165
+ flexDirection: 'row',
166
+ alignItems: 'center',
167
+ gap: spacing.sm,
168
+ backgroundColor: 'rgba(255,255,255,0.08)',
169
+ paddingHorizontal: spacing.md,
170
+ paddingVertical: spacing.sm,
171
+ },
172
+ groupTitle: { ...m3Type.label, color: m3Colors.onSurface, fontFamily: fonts.bold },
173
+ dataRow: {
174
+ flexDirection: 'row',
175
+ justifyContent: 'space-between',
176
+ alignItems: 'flex-start',
177
+ gap: spacing.md,
178
+ paddingHorizontal: spacing.md,
179
+ paddingVertical: spacing.sm,
180
+ },
181
+ dataLabel: { ...m3Type.bodySmall, color: m3Colors.onSurfaceVariant, flexShrink: 0 },
182
+ dataValue: { ...m3Type.bodySmall, color: m3Colors.onSurface, fontFamily: fonts.semibold, flex: 1, textAlign: 'right' },
183
+
184
+ vouchWrap: { gap: spacing.sm },
185
+ vouchHeader: { ...m3Type.bodySmall, color: m3Colors.onSurfaceVariant },
186
+ vouchCard: {
187
+ borderRadius: m3Shape.cornerMedium,
188
+ backgroundColor: m3Colors.surfaceContainerHigh,
189
+ borderWidth: 1,
190
+ borderColor: m3Colors.outlineVariant,
191
+ padding: spacing.md,
192
+ gap: spacing.sm,
193
+ },
194
+ vouchQuote: { ...m3Type.body, color: m3Colors.onSurface },
195
+ vouchFooter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm },
196
+ vouchAuthor: { ...m3Type.labelSmall, color: m3Colors.secondary, fontFamily: fonts.bold },
197
+ vouchMeta: { ...m3Type.labelSmall, color: m3Colors.onSurfaceVariant },
198
+ });