@xetwa/design-system 1.0.48 → 1.0.49
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 +1 -1
- package/src/components/DataDisplay/Stamp/Stamp.stories.tsx +153 -0
- package/src/components/DataDisplay/Stamp/Stamp.tsx +173 -0
- package/src/components/DataDisplay/StampCard/StampCard.stories.tsx +275 -0
- package/src/components/DataDisplay/StampCard/StampCard.tsx +196 -0
- package/src/components/Headers/RouteHeader/RouteHeader.stories.tsx +28 -0
- package/src/components/Headers/RouteHeader/RouteHeader.tsx +84 -15
- package/src/components/Navigation/PaginationDots/PaginationDots.stories.tsx +20 -4
- package/src/components/Navigation/PaginationDots/PaginationDots.tsx +23 -9
- package/src/index.ts +2 -0
- package/src/styles/theme.ts +34 -0
- package/src/utils/iconMap.ts +3 -1
package/package.json
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { Stamp } from './Stamp';
|
|
5
|
+
import { theme } from '../../../styles/theme';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/DataDisplay/Stamp',
|
|
9
|
+
component: Stamp,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'centered',
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component: `O componente **Stamp** reproduz visualmente um "carimbo de passaporte". É usado no ecrã da rota para representar módulos que estão em curso, concluídos, ou localidades visitadas.
|
|
15
|
+
|
|
16
|
+
### Características
|
|
17
|
+
- **Cores Livres:** Pode passar a propriedade \`color\` com qualquer cor (HEX, RGB) e o fundo é gerado automaticamente.
|
|
18
|
+
- **Textos Flexíveis:** O carimbo aceita \`topLabel\`, \`title\` e \`bottomLabels\` para escrever o que quiser.
|
|
19
|
+
## Notas de Uso
|
|
20
|
+
- O componente possui três tamanhos pré-definidos: **sm** (100px), **md** (140px, padrão), e **lg** (180px).
|
|
21
|
+
- Cada tamanho tem a sua própria escala de tipografia matematicamente calculada para garantir que as letras nunca ficam desproporcionais ao círculo.
|
|
22
|
+
- Se a palavra do título for demasiado comprida, o tamanho de letra ajusta-se automaticamente (\`adjustsFontSizeToFit\`) sem quebrar o carimbo.
|
|
23
|
+
|
|
24
|
+
### Exemplo de Uso
|
|
25
|
+
\`\`\`tsx
|
|
26
|
+
import { Stamp } from '@xetwa/design-system';
|
|
27
|
+
|
|
28
|
+
// Carimbo estilo Paris (Azul)
|
|
29
|
+
<Stamp
|
|
30
|
+
topLabel="REPUBLIQUE"
|
|
31
|
+
title="PARIS"
|
|
32
|
+
bottomLabels={['NIVEAU A1', 'MAR 2026']}
|
|
33
|
+
color="#2a6fc2"
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
// Carimbo estilo Dakar com inclinação à direita
|
|
37
|
+
<Stamp
|
|
38
|
+
topLabel="SÉNÉGAL"
|
|
39
|
+
title="DAKAR"
|
|
40
|
+
bottomLabels={['NIVEAU B1', 'EM CURSO']}
|
|
41
|
+
color="#f2974b"
|
|
42
|
+
position="right"
|
|
43
|
+
/>
|
|
44
|
+
\`\`\`
|
|
45
|
+
`,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
decorators: [
|
|
50
|
+
(Story) => (
|
|
51
|
+
<View style={{ padding: 40, backgroundColor: theme.colors.brown[50], alignItems: 'center', justifyContent: 'center' }}>
|
|
52
|
+
<Story />
|
|
53
|
+
</View>
|
|
54
|
+
),
|
|
55
|
+
],
|
|
56
|
+
tags: ['autodocs'],
|
|
57
|
+
argTypes: {
|
|
58
|
+
color: {
|
|
59
|
+
control: 'color',
|
|
60
|
+
description: 'Cor principal do carimbo (texto e borda).',
|
|
61
|
+
},
|
|
62
|
+
size: {
|
|
63
|
+
control: 'radio',
|
|
64
|
+
options: ['sm', 'md', 'lg'],
|
|
65
|
+
description: 'Tamanho pré-definido do carimbo.',
|
|
66
|
+
},
|
|
67
|
+
bgColor: {
|
|
68
|
+
control: 'color',
|
|
69
|
+
description: 'Cor de fundo do círculo interno (opcional).',
|
|
70
|
+
},
|
|
71
|
+
position: {
|
|
72
|
+
control: 'radio',
|
|
73
|
+
options: ['left', 'center', 'right'],
|
|
74
|
+
description: 'Inclinação do carimbo.',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
} satisfies Meta<typeof Stamp>;
|
|
78
|
+
|
|
79
|
+
export default meta;
|
|
80
|
+
type Story = StoryObj<typeof meta>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Carimbo Laranja, estilo Dakar (Em curso)
|
|
84
|
+
*/
|
|
85
|
+
export const Dakar: Story = {
|
|
86
|
+
render: (args) => <Stamp {...args} />,
|
|
87
|
+
args: {
|
|
88
|
+
topLabel: 'SÉNÉGAL',
|
|
89
|
+
title: 'DAKAR',
|
|
90
|
+
bottomLabels: ['NIVEAU B1', 'EM CURSO'],
|
|
91
|
+
color: theme.colors.primary,
|
|
92
|
+
position: 'left',
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Carimbo Azul, estilo Paris (Concluído antigo)
|
|
98
|
+
*/
|
|
99
|
+
export const Paris: Story = {
|
|
100
|
+
render: (args) => <Stamp {...args} />,
|
|
101
|
+
args: {
|
|
102
|
+
topLabel: 'REPUBLIQUE',
|
|
103
|
+
title: 'PARIS',
|
|
104
|
+
bottomLabels: ['NIVEAU A1', 'MAR 2026'],
|
|
105
|
+
color: theme.colors.info,
|
|
106
|
+
position: 'right',
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Carimbo Vermelho, estilo Lyon (Concluído recente)
|
|
112
|
+
*/
|
|
113
|
+
export const Lyon: Story = {
|
|
114
|
+
render: (args) => <Stamp {...args} />,
|
|
115
|
+
args: {
|
|
116
|
+
topLabel: 'FRANCE',
|
|
117
|
+
title: 'LYON',
|
|
118
|
+
bottomLabels: ['NIVEAU A1', 'ABR 2026'],
|
|
119
|
+
color: theme.colors.error,
|
|
120
|
+
position: 'center',
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Demonstra a capacidade do componente se ajustar perfeitamente a diferentes escalas.
|
|
126
|
+
*/
|
|
127
|
+
export const Scalability: Story = {
|
|
128
|
+
render: () => (
|
|
129
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, justifyContent: 'center', alignItems: 'center' }}>
|
|
130
|
+
<Stamp
|
|
131
|
+
color="#8e44ad"
|
|
132
|
+
title="GENEVE"
|
|
133
|
+
topLabel="SUISSE"
|
|
134
|
+
size="sm"
|
|
135
|
+
bottomLabels={['NIVEAU A2']}
|
|
136
|
+
/>
|
|
137
|
+
<Stamp
|
|
138
|
+
color="#d35400"
|
|
139
|
+
title="MONTREAL"
|
|
140
|
+
topLabel="CANADA"
|
|
141
|
+
size="md"
|
|
142
|
+
bottomLabels={['NIVEAU B1']}
|
|
143
|
+
/>
|
|
144
|
+
<Stamp
|
|
145
|
+
color="#16a085"
|
|
146
|
+
title="KINSHASA"
|
|
147
|
+
topLabel="RD CONGO"
|
|
148
|
+
size="lg"
|
|
149
|
+
bottomLabels={['NIVEAU C2']}
|
|
150
|
+
/>
|
|
151
|
+
</View>
|
|
152
|
+
),
|
|
153
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, Platform } from 'react-native';
|
|
3
|
+
import type { ViewStyle } from 'react-native';
|
|
4
|
+
import { Typography } from '../../Typography/Typography';
|
|
5
|
+
|
|
6
|
+
export interface StampProps {
|
|
7
|
+
/**
|
|
8
|
+
* Texto superior (ex: 'REPUBLIQUE', 'FRANCE', 'SÉNÉGAL').
|
|
9
|
+
*/
|
|
10
|
+
topLabel?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Título central em destaque (ex: 'PARIS', 'LYON', 'DAKAR').
|
|
13
|
+
*/
|
|
14
|
+
title: string;
|
|
15
|
+
/**
|
|
16
|
+
* Textos inferiores, normalmente nível e data (ex: ['NIVEAU A1', 'MAR 2026']).
|
|
17
|
+
*/
|
|
18
|
+
bottomLabels?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Cor principal do carimbo (aplicada ao texto e borda).
|
|
21
|
+
*/
|
|
22
|
+
color: string;
|
|
23
|
+
/**
|
|
24
|
+
* Cor de fundo do círculo interno. Se não for fornecida,
|
|
25
|
+
* calculará automaticamente 10% de opacidade se a cor principal for HEX.
|
|
26
|
+
*/
|
|
27
|
+
bgColor?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Tamanho do carimbo.
|
|
30
|
+
* - `sm`: 100px (ideal para ícones/badges)
|
|
31
|
+
* - `md`: 140px (tamanho padrão)
|
|
32
|
+
* - `lg`: 180px (grande destaque)
|
|
33
|
+
* @default 'md'
|
|
34
|
+
*/
|
|
35
|
+
size?: 'sm' | 'md' | 'lg';
|
|
36
|
+
/**
|
|
37
|
+
* Posição (inclinação) do carimbo.
|
|
38
|
+
* - `left`: Inclinado para a esquerda (-8 graus)
|
|
39
|
+
* - `center`: Direito (0 graus)
|
|
40
|
+
* - `right`: Inclinado para a direita (8 graus)
|
|
41
|
+
* @default 'left'
|
|
42
|
+
*/
|
|
43
|
+
position?: 'left' | 'center' | 'right';
|
|
44
|
+
/**
|
|
45
|
+
* Estilos adicionais para o contentor exterior.
|
|
46
|
+
*/
|
|
47
|
+
style?: ViewStyle | ViewStyle[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* **Stamp (Carimbo)**
|
|
52
|
+
*
|
|
53
|
+
* Componente visual para representar módulos ou cidades concluídas/em curso na rota de aprendizagem.
|
|
54
|
+
* Possui um aspeto autêntico de carimbo de passaporte, com borda tracejada e textos proporcionais.
|
|
55
|
+
*/
|
|
56
|
+
export const Stamp = ({
|
|
57
|
+
topLabel,
|
|
58
|
+
title,
|
|
59
|
+
bottomLabels = [],
|
|
60
|
+
color,
|
|
61
|
+
bgColor,
|
|
62
|
+
size = 'md',
|
|
63
|
+
position = 'left',
|
|
64
|
+
style,
|
|
65
|
+
}: StampProps) => {
|
|
66
|
+
|
|
67
|
+
const SIZE_MAP = {
|
|
68
|
+
sm: { base: 100, top: 9, title: 18, bottom1: 7, bottom2: 6 },
|
|
69
|
+
md: { base: 140, top: 11, title: 22, bottom1: 9, bottom2: 7 },
|
|
70
|
+
lg: { base: 180, top: 14, title: 26, bottom1: 11, bottom2: 8 },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const currentSize = SIZE_MAP[size];
|
|
74
|
+
const BASE_SIZE = currentSize.base;
|
|
75
|
+
|
|
76
|
+
// Determinar inclinação: aplicamos a rotação ao carimbo inteiro!
|
|
77
|
+
const rotation = position === 'right' ? 12 : position === 'center' ? 0 : -12;
|
|
78
|
+
|
|
79
|
+
// Determinar cor de fundo (injetar 10% de opacidade se for HEX)
|
|
80
|
+
const isHex = color.startsWith('#') && (color.length === 7 || color.length === 4);
|
|
81
|
+
const calculatedBgColor = bgColor || (isHex ? `${color}1A` : 'transparent');
|
|
82
|
+
|
|
83
|
+
// Cálculos dinâmicos de proporção baseados no tamanho
|
|
84
|
+
const innerSize = BASE_SIZE * 0.82;
|
|
85
|
+
const borderWidth = Math.max(2.5, BASE_SIZE * 0.035);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<View style={[{ alignSelf: 'flex-start', transform: [{ rotate: `${rotation}deg` }] }, style]}>
|
|
89
|
+
<View
|
|
90
|
+
style={[
|
|
91
|
+
styles.outerCircle,
|
|
92
|
+
{
|
|
93
|
+
width: BASE_SIZE,
|
|
94
|
+
height: BASE_SIZE,
|
|
95
|
+
borderRadius: BASE_SIZE / 2,
|
|
96
|
+
borderColor: color,
|
|
97
|
+
borderWidth: borderWidth,
|
|
98
|
+
},
|
|
99
|
+
]}
|
|
100
|
+
>
|
|
101
|
+
<View style={[
|
|
102
|
+
styles.innerCircle,
|
|
103
|
+
{
|
|
104
|
+
width: innerSize,
|
|
105
|
+
height: innerSize,
|
|
106
|
+
borderRadius: innerSize / 2,
|
|
107
|
+
backgroundColor: calculatedBgColor,
|
|
108
|
+
}
|
|
109
|
+
]}>
|
|
110
|
+
|
|
111
|
+
{topLabel ? (
|
|
112
|
+
<View style={{ position: 'absolute', top: '22%', width: '100%', alignItems: 'center' }}>
|
|
113
|
+
<Typography
|
|
114
|
+
color={color}
|
|
115
|
+
variant="stampTop"
|
|
116
|
+
style={{ textAlign: 'center', fontSize: currentSize.top, lineHeight: currentSize.top + 2 }}
|
|
117
|
+
>
|
|
118
|
+
{topLabel}
|
|
119
|
+
</Typography>
|
|
120
|
+
</View>
|
|
121
|
+
) : null}
|
|
122
|
+
|
|
123
|
+
<View style={[styles.contentWrapper, { paddingHorizontal: BASE_SIZE * 0.2 }]}>
|
|
124
|
+
<Typography
|
|
125
|
+
color={color}
|
|
126
|
+
variant="stampTitle"
|
|
127
|
+
style={{ textAlign: 'center', fontSize: currentSize.title, lineHeight: currentSize.title + 4 }}
|
|
128
|
+
>
|
|
129
|
+
{title}
|
|
130
|
+
</Typography>
|
|
131
|
+
</View>
|
|
132
|
+
|
|
133
|
+
{bottomLabels.length > 0 ? (
|
|
134
|
+
<View style={{ position: 'absolute', bottom: '15%', width: '100%', alignItems: 'center' }}>
|
|
135
|
+
{bottomLabels.map((label, index) => (
|
|
136
|
+
<Typography
|
|
137
|
+
key={index}
|
|
138
|
+
color={color}
|
|
139
|
+
variant={index === 0 ? "stampBottom1" : "stampBottom2"}
|
|
140
|
+
style={{
|
|
141
|
+
marginTop: index > 0 ? 2 : 0,
|
|
142
|
+
textAlign: 'center',
|
|
143
|
+
fontSize: index === 0 ? currentSize.bottom1 : currentSize.bottom2,
|
|
144
|
+
lineHeight: index === 0 ? currentSize.bottom1 + 2 : currentSize.bottom2 + 2,
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{label}
|
|
148
|
+
</Typography>
|
|
149
|
+
))}
|
|
150
|
+
</View>
|
|
151
|
+
) : null}
|
|
152
|
+
|
|
153
|
+
</View>
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const styles = StyleSheet.create({
|
|
160
|
+
outerCircle: {
|
|
161
|
+
borderStyle: 'dashed',
|
|
162
|
+
justifyContent: 'center',
|
|
163
|
+
alignItems: 'center',
|
|
164
|
+
},
|
|
165
|
+
innerCircle: {
|
|
166
|
+
overflow: 'hidden',
|
|
167
|
+
},
|
|
168
|
+
contentWrapper: {
|
|
169
|
+
flex: 1,
|
|
170
|
+
justifyContent: 'center',
|
|
171
|
+
alignItems: 'center',
|
|
172
|
+
}
|
|
173
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import { StampCard } from './StampCard';
|
|
5
|
+
import { theme } from '../../../styles/theme';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* \`StampCard\` é um contentor elegante que exibe um \`Stamp\` no topo e
|
|
9
|
+
* informações de contexto (Bandeira, Local, e Subtítulo) na base.
|
|
10
|
+
* É perfeito para ser usado como card de conquista ou progresso numa grelha.
|
|
11
|
+
*
|
|
12
|
+
* ### Características
|
|
13
|
+
* - **Modular:** Recebe diretamente as propriedades do \`Stamp\` interno.
|
|
14
|
+
* - **Design Alinhado:** A cor do local mapeia automaticamente com a cor do carimbo.
|
|
15
|
+
* - **Container Clean:** Borda redonda e fundo sólido.
|
|
16
|
+
*/
|
|
17
|
+
const meta = {
|
|
18
|
+
title: 'Components/Cards/StampCard',
|
|
19
|
+
component: StampCard,
|
|
20
|
+
parameters: {
|
|
21
|
+
layout: 'centered',
|
|
22
|
+
docs: {
|
|
23
|
+
description: {
|
|
24
|
+
component: `O \`StampCard\` é um contentor elegante que exibe um \`Stamp\` no topo e informações de contexto (Bandeira, Local, e Subtítulo) na base.
|
|
25
|
+
É perfeito para ser usado como card de conquista ou progresso numa grelha.
|
|
26
|
+
|
|
27
|
+
### Características
|
|
28
|
+
- **Modular:** Recebe diretamente as propriedades do \`Stamp\` interno.
|
|
29
|
+
- **Design Alinhado:** A cor do local mapeia automaticamente com a cor do carimbo.
|
|
30
|
+
- **Container Clean:** Borda redonda e fundo sólido.
|
|
31
|
+
|
|
32
|
+
### Exemplo de Uso
|
|
33
|
+
\`\`\`tsx
|
|
34
|
+
import { StampCard } from '@/components/DataDisplay/StampCard/StampCard';
|
|
35
|
+
import { theme } from '@/styles/theme';
|
|
36
|
+
|
|
37
|
+
<StampCard
|
|
38
|
+
stamp={{
|
|
39
|
+
color: theme.colors.error,
|
|
40
|
+
title: 'PARIS',
|
|
41
|
+
topLabel: 'FRANCE',
|
|
42
|
+
bottomLabels: ['NIVEAU A1', 'ABR 2026'],
|
|
43
|
+
size: 'md',
|
|
44
|
+
position: 'center',
|
|
45
|
+
}}
|
|
46
|
+
countryCode="FR"
|
|
47
|
+
locationName="Paris"
|
|
48
|
+
subtitle="A1 • concluído"
|
|
49
|
+
variant="warning"
|
|
50
|
+
warningText="Visto a expirar!"
|
|
51
|
+
/>
|
|
52
|
+
\`\`\`
|
|
53
|
+
`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
tags: ['autodocs'],
|
|
58
|
+
argTypes: {
|
|
59
|
+
width: {
|
|
60
|
+
control: 'text',
|
|
61
|
+
description: 'Largura do cartão (ex: 320 ou "100%").',
|
|
62
|
+
},
|
|
63
|
+
height: {
|
|
64
|
+
control: 'text',
|
|
65
|
+
description: 'Altura do cartão.',
|
|
66
|
+
},
|
|
67
|
+
variant: {
|
|
68
|
+
control: 'radio',
|
|
69
|
+
options: ['default', 'highlight', 'warning'],
|
|
70
|
+
description: 'Variante visual do cartão.',
|
|
71
|
+
},
|
|
72
|
+
warningText: {
|
|
73
|
+
control: 'text',
|
|
74
|
+
description: 'Texto exibido no topo quando variant="warning".',
|
|
75
|
+
if: { arg: 'variant', eq: 'warning' },
|
|
76
|
+
},
|
|
77
|
+
countryCode: {
|
|
78
|
+
control: 'text',
|
|
79
|
+
description: 'Código ISO ou nome do país para gerar a bandeira.',
|
|
80
|
+
},
|
|
81
|
+
locationName: {
|
|
82
|
+
control: 'text',
|
|
83
|
+
description: 'Nome do local em destaque.',
|
|
84
|
+
},
|
|
85
|
+
subtitle: {
|
|
86
|
+
control: 'text',
|
|
87
|
+
description: 'Subtítulo cinzento/castanho inferior.',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
} satisfies Meta<typeof StampCard>;
|
|
91
|
+
|
|
92
|
+
export default meta;
|
|
93
|
+
type Story = StoryObj<typeof meta>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Exemplo padrão conforme a especificação do design.
|
|
97
|
+
*/
|
|
98
|
+
export const Default: Story = {
|
|
99
|
+
args: {
|
|
100
|
+
stamp: {
|
|
101
|
+
color: theme.colors.error,
|
|
102
|
+
title: 'LYON',
|
|
103
|
+
topLabel: 'FRANCE',
|
|
104
|
+
bottomLabels: ['NIVEAU A1', 'ABR 2026'],
|
|
105
|
+
size: 'md',
|
|
106
|
+
position: 'center',
|
|
107
|
+
},
|
|
108
|
+
countryCode: 'FR',
|
|
109
|
+
locationName: 'Lyon',
|
|
110
|
+
subtitle: 'A1 • concluído',
|
|
111
|
+
variant: 'default',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Exemplo de um cartão em destaque (highlight) com a borda espessa
|
|
117
|
+
* que herda a cor do carimbo interno.
|
|
118
|
+
*/
|
|
119
|
+
export const Highlight: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
stamp: {
|
|
122
|
+
color: theme.colors.primary,
|
|
123
|
+
title: 'DAKAR',
|
|
124
|
+
topLabel: 'SÉNÉGAL',
|
|
125
|
+
bottomLabels: ['NIVEAU B1', 'EM CURSO'],
|
|
126
|
+
size: 'md',
|
|
127
|
+
position: 'center',
|
|
128
|
+
},
|
|
129
|
+
countryCode: 'SN',
|
|
130
|
+
locationName: 'Dakar',
|
|
131
|
+
subtitle: 'B1 • em curso',
|
|
132
|
+
variant: 'highlight',
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Exemplo de um cartão com aviso crítico (warning),
|
|
138
|
+
* sobrepondo uma badge no topo.
|
|
139
|
+
*/
|
|
140
|
+
export const Warning: Story = {
|
|
141
|
+
args: {
|
|
142
|
+
stamp: {
|
|
143
|
+
color: theme.colors.primaryDark,
|
|
144
|
+
title: 'BRUXELLES',
|
|
145
|
+
topLabel: 'BELGIQUE',
|
|
146
|
+
bottomLabels: ['NIVEAU A2', 'JUL 2026'],
|
|
147
|
+
size: 'md',
|
|
148
|
+
position: 'left',
|
|
149
|
+
},
|
|
150
|
+
countryCode: 'BE',
|
|
151
|
+
locationName: 'Bruxelles',
|
|
152
|
+
subtitle: 'A2 • renovar visto',
|
|
153
|
+
variant: 'warning',
|
|
154
|
+
warningText: 'Visto a expirar!',
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Exemplo demonstrando o card num estado de progresso contínuo
|
|
160
|
+
* (Ex: ainda em curso).
|
|
161
|
+
*/
|
|
162
|
+
export const InProgress: Story = {
|
|
163
|
+
args: {
|
|
164
|
+
stamp: {
|
|
165
|
+
color: theme.colors.primary,
|
|
166
|
+
title: 'DAKAR',
|
|
167
|
+
topLabel: 'SÉNÉGAL',
|
|
168
|
+
bottomLabels: ['NIVEAU B1', 'EM CURSO'],
|
|
169
|
+
size: 'md',
|
|
170
|
+
position: 'left',
|
|
171
|
+
},
|
|
172
|
+
countryCode: 'SN',
|
|
173
|
+
locationName: 'Dakar',
|
|
174
|
+
subtitle: 'B1 • a decorrer',
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Grelha de vários cards para testar a escalabilidade visual
|
|
180
|
+
* e mostrar como se comportam lado a lado.
|
|
181
|
+
*/
|
|
182
|
+
export const Grid: Story = {
|
|
183
|
+
render: () => (
|
|
184
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, justifyContent: 'center' }}>
|
|
185
|
+
<StampCard
|
|
186
|
+
stamp={{
|
|
187
|
+
color: theme.colors.info,
|
|
188
|
+
title: 'PARIS',
|
|
189
|
+
topLabel: 'FRANCE',
|
|
190
|
+
bottomLabels: ['NIVEAU A2'],
|
|
191
|
+
size: 'md',
|
|
192
|
+
position: 'right',
|
|
193
|
+
}}
|
|
194
|
+
countryCode="FR"
|
|
195
|
+
locationName="Paris"
|
|
196
|
+
subtitle="A2 • concluído"
|
|
197
|
+
/>
|
|
198
|
+
<StampCard
|
|
199
|
+
stamp={{
|
|
200
|
+
color: theme.colors.success,
|
|
201
|
+
title: 'GENEVE',
|
|
202
|
+
topLabel: 'SUISSE',
|
|
203
|
+
bottomLabels: ['NIVEAU C1'],
|
|
204
|
+
size: 'md',
|
|
205
|
+
position: 'left',
|
|
206
|
+
}}
|
|
207
|
+
countryCode="CH"
|
|
208
|
+
locationName="Geneve"
|
|
209
|
+
subtitle="C1 • em curso"
|
|
210
|
+
variant="highlight"
|
|
211
|
+
/>
|
|
212
|
+
<StampCard
|
|
213
|
+
stamp={{
|
|
214
|
+
color: theme.colors.error,
|
|
215
|
+
title: 'ROMA',
|
|
216
|
+
topLabel: 'ITALIA',
|
|
217
|
+
bottomLabels: ['NIVEAU B2'],
|
|
218
|
+
size: 'md',
|
|
219
|
+
position: 'center',
|
|
220
|
+
}}
|
|
221
|
+
countryCode="IT"
|
|
222
|
+
locationName="Roma"
|
|
223
|
+
subtitle="B2 • atrasado"
|
|
224
|
+
variant="warning"
|
|
225
|
+
warningText="Atenção necessária"
|
|
226
|
+
/>
|
|
227
|
+
</View>
|
|
228
|
+
),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Exemplo demonstrando a passagem de uma \`width\` customizada.
|
|
233
|
+
* O cartão estica-se para preencher a largura fornecida, mantendo
|
|
234
|
+
* os elementos internos perfeitamente centrados.
|
|
235
|
+
*/
|
|
236
|
+
export const CustomWidth: Story = {
|
|
237
|
+
args: {
|
|
238
|
+
width: 320,
|
|
239
|
+
stamp: {
|
|
240
|
+
color: theme.colors.primary,
|
|
241
|
+
title: 'LISBOA',
|
|
242
|
+
topLabel: 'PORTUGAL',
|
|
243
|
+
bottomLabels: ['NIVEAU C2'],
|
|
244
|
+
size: 'lg',
|
|
245
|
+
position: 'center',
|
|
246
|
+
},
|
|
247
|
+
countryCode: 'PT',
|
|
248
|
+
locationName: 'Lisboa',
|
|
249
|
+
subtitle: 'C2 • concluído',
|
|
250
|
+
variant: 'default',
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Exemplo demonstrando a passagem de uma \`height\` customizada.
|
|
256
|
+
* O cartão estica-se verticalmente, e o carimbo afasta-se das informações
|
|
257
|
+
* graças ao \`justifyContent: 'space-between'\`.
|
|
258
|
+
*/
|
|
259
|
+
export const CustomHeight: Story = {
|
|
260
|
+
args: {
|
|
261
|
+
height: 450,
|
|
262
|
+
stamp: {
|
|
263
|
+
color: theme.colors.success,
|
|
264
|
+
title: 'ZURICH',
|
|
265
|
+
topLabel: 'SUISSE',
|
|
266
|
+
bottomLabels: ['NIVEAU B2'],
|
|
267
|
+
size: 'lg',
|
|
268
|
+
position: 'right',
|
|
269
|
+
},
|
|
270
|
+
countryCode: 'CH',
|
|
271
|
+
locationName: 'Zurich',
|
|
272
|
+
subtitle: 'B2 • concluído',
|
|
273
|
+
variant: 'default',
|
|
274
|
+
},
|
|
275
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { View, StyleSheet } from 'react-native';
|
|
2
|
+
import type { ViewStyle, DimensionValue } from 'react-native';
|
|
3
|
+
import { Typography } from '../../Typography/Typography';
|
|
4
|
+
import { Stamp } from '../Stamp/Stamp';
|
|
5
|
+
import { Flag } from '../Flag/Flag';
|
|
6
|
+
import { BaseCard } from '../../Cards/BaseCard/BaseCard';
|
|
7
|
+
import { theme } from '../../../styles/theme';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* O \`StampCard\` é um cartão desenhado para exibir o progresso ou conquista num destino (carimbo).
|
|
11
|
+
* Usa o \`BaseCard\` como base, herda bordas e fundos do Design System, e disponibiliza 3 variantes visuais.
|
|
12
|
+
*
|
|
13
|
+
* ### Exemplo de Uso
|
|
14
|
+
* \`\`\`tsx
|
|
15
|
+
* import { StampCard } from '@/components/DataDisplay/StampCard/StampCard';
|
|
16
|
+
* import { theme } from '@/styles/theme';
|
|
17
|
+
*
|
|
18
|
+
* <StampCard
|
|
19
|
+
* stamp={{
|
|
20
|
+
* color: theme.colors.error,
|
|
21
|
+
* title: 'PARIS',
|
|
22
|
+
* topLabel: 'FRANCE',
|
|
23
|
+
* bottomLabels: ['NIVEAU A1', 'ABR 2026'],
|
|
24
|
+
* size: 'md',
|
|
25
|
+
* position: 'center',
|
|
26
|
+
* }}
|
|
27
|
+
* countryCode="FR"
|
|
28
|
+
* locationName="Paris"
|
|
29
|
+
* subtitle="A1 • concluído"
|
|
30
|
+
* variant="warning"
|
|
31
|
+
* warningText="Visto a expirar!"
|
|
32
|
+
* />
|
|
33
|
+
* \`\`\`
|
|
34
|
+
*/
|
|
35
|
+
export interface StampCardProps {
|
|
36
|
+
/**
|
|
37
|
+
* Propriedades para configurar o carimbo que aparece dentro do card.
|
|
38
|
+
*/
|
|
39
|
+
stamp: {
|
|
40
|
+
color: string;
|
|
41
|
+
title: string;
|
|
42
|
+
topLabel?: string;
|
|
43
|
+
bottomLabels?: string[];
|
|
44
|
+
size?: 'sm' | 'md' | 'lg';
|
|
45
|
+
position?: 'left' | 'center' | 'right';
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Código ISO do país ou nome para exibir a bandeira (ex: 'FR', 'France').
|
|
49
|
+
* Usa a componente Flag do sistema.
|
|
50
|
+
*/
|
|
51
|
+
countryCode?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Nome do local que aparece ao lado da bandeira (ex: 'Lyon').
|
|
54
|
+
* Fica com a mesma cor principal do carimbo.
|
|
55
|
+
*/
|
|
56
|
+
locationName: string;
|
|
57
|
+
/**
|
|
58
|
+
* Subtítulo informativo (ex: 'A1 • concluído').
|
|
59
|
+
*/
|
|
60
|
+
subtitle: string;
|
|
61
|
+
/**
|
|
62
|
+
* Largura customizada para o card.
|
|
63
|
+
*/
|
|
64
|
+
width?: DimensionValue;
|
|
65
|
+
/**
|
|
66
|
+
* Altura customizada para o card.
|
|
67
|
+
*/
|
|
68
|
+
height?: DimensionValue;
|
|
69
|
+
/**
|
|
70
|
+
* Variante visual do cartão:
|
|
71
|
+
* - `default`: Borda subtil neutra.
|
|
72
|
+
* - `highlight`: Borda espessa com a mesma cor do carimbo.
|
|
73
|
+
* - `warning`: Idêntico ao highlight, mas com badge a sobrepor no topo.
|
|
74
|
+
*/
|
|
75
|
+
variant?: 'default' | 'highlight' | 'warning';
|
|
76
|
+
/**
|
|
77
|
+
* Texto para a badge de aviso (apenas renderizada se variant='warning').
|
|
78
|
+
* Opcional, por defeito será 'Visto a expirar!'.
|
|
79
|
+
*/
|
|
80
|
+
warningText?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Estilo customizado para o card (opcional).
|
|
83
|
+
*/
|
|
84
|
+
style?: ViewStyle;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const StampCard = ({
|
|
88
|
+
stamp,
|
|
89
|
+
countryCode,
|
|
90
|
+
locationName,
|
|
91
|
+
subtitle,
|
|
92
|
+
width,
|
|
93
|
+
height,
|
|
94
|
+
variant = 'default',
|
|
95
|
+
warningText = 'Visto a expirar!',
|
|
96
|
+
style,
|
|
97
|
+
}: StampCardProps) => {
|
|
98
|
+
|
|
99
|
+
const isHighlight = variant === 'highlight' || variant === 'warning';
|
|
100
|
+
const resolvedBorderColor = isHighlight ? stamp.color : theme.colors.brown[100];
|
|
101
|
+
const resolvedBorderWidth = isHighlight ? 4 : 2;
|
|
102
|
+
|
|
103
|
+
// Proteção contra inputs inválidos do painel do Storybook (ex: objetos vazios ou strings vazias)
|
|
104
|
+
const safeWidth = (typeof width === 'object' || width === '') ? undefined : width as DimensionValue;
|
|
105
|
+
const safeHeight = (typeof height === 'object' || height === '') ? undefined : height as DimensionValue;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<BaseCard
|
|
109
|
+
width={safeWidth}
|
|
110
|
+
height={safeHeight}
|
|
111
|
+
borderColor={resolvedBorderColor}
|
|
112
|
+
borderWidth={resolvedBorderWidth}
|
|
113
|
+
borderRadius={theme.radius.xxl}
|
|
114
|
+
overflow="visible"
|
|
115
|
+
style={[
|
|
116
|
+
styles.card,
|
|
117
|
+
variant === 'warning' && { paddingTop: 44 }, // Espaço extra para compensar a tag flutuante
|
|
118
|
+
style
|
|
119
|
+
]}
|
|
120
|
+
>
|
|
121
|
+
|
|
122
|
+
{variant === 'warning' ? (
|
|
123
|
+
<View style={[styles.warningBadge, { backgroundColor: stamp.color }]}>
|
|
124
|
+
<Typography variant="labelHeavy" color={theme.colors.white} style={{ textTransform: 'none' }}>
|
|
125
|
+
{warningText}
|
|
126
|
+
</Typography>
|
|
127
|
+
</View>
|
|
128
|
+
) : null}
|
|
129
|
+
|
|
130
|
+
{/* Stamp Container */}
|
|
131
|
+
<View style={styles.stampContainer}>
|
|
132
|
+
<Stamp {...stamp} />
|
|
133
|
+
</View>
|
|
134
|
+
|
|
135
|
+
{/* Info Container */}
|
|
136
|
+
<View style={styles.infoContainer}>
|
|
137
|
+
|
|
138
|
+
<View style={styles.locationRow}>
|
|
139
|
+
{countryCode ? (
|
|
140
|
+
<Flag
|
|
141
|
+
countryCode={countryCode}
|
|
142
|
+
size={20}
|
|
143
|
+
style={{ marginRight: theme.spacing.xs }}
|
|
144
|
+
/>
|
|
145
|
+
) : null}
|
|
146
|
+
<Typography
|
|
147
|
+
variant="h5"
|
|
148
|
+
color={stamp.color}
|
|
149
|
+
>
|
|
150
|
+
{locationName}
|
|
151
|
+
</Typography>
|
|
152
|
+
</View>
|
|
153
|
+
|
|
154
|
+
<Typography
|
|
155
|
+
variant="subtitle1"
|
|
156
|
+
color={theme.colors.brown[500]}
|
|
157
|
+
style={{ marginTop: 2 }}
|
|
158
|
+
>
|
|
159
|
+
{subtitle}
|
|
160
|
+
</Typography>
|
|
161
|
+
|
|
162
|
+
</View>
|
|
163
|
+
|
|
164
|
+
</BaseCard>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const styles = StyleSheet.create({
|
|
169
|
+
card: {
|
|
170
|
+
padding: theme.spacing.space8,
|
|
171
|
+
alignItems: 'center',
|
|
172
|
+
justifyContent: 'space-between',
|
|
173
|
+
alignSelf: 'flex-start', // Adapta-se ao conteúdo por defeito
|
|
174
|
+
},
|
|
175
|
+
warningBadge: {
|
|
176
|
+
position: 'absolute',
|
|
177
|
+
top: -18, // Sobe para quebrar a borda superior (ajustado para a nova borda branca)
|
|
178
|
+
alignSelf: 'center',
|
|
179
|
+
paddingHorizontal: 16,
|
|
180
|
+
paddingVertical: 6,
|
|
181
|
+
borderRadius: theme.radius.full,
|
|
182
|
+
zIndex: 10,
|
|
183
|
+
borderWidth: 4,
|
|
184
|
+
borderColor: theme.colors.white,
|
|
185
|
+
},
|
|
186
|
+
stampContainer: {
|
|
187
|
+
marginBottom: theme.spacing.space6,
|
|
188
|
+
},
|
|
189
|
+
infoContainer: {
|
|
190
|
+
alignItems: 'center',
|
|
191
|
+
},
|
|
192
|
+
locationRow: {
|
|
193
|
+
flexDirection: 'row',
|
|
194
|
+
alignItems: 'center',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
@@ -67,6 +67,13 @@ export const MapScreen = () => {
|
|
|
67
67
|
control: 'radio',
|
|
68
68
|
options: ['continuous', 'segmented'],
|
|
69
69
|
},
|
|
70
|
+
variant: {
|
|
71
|
+
control: 'radio',
|
|
72
|
+
options: ['default', 'minimal'],
|
|
73
|
+
},
|
|
74
|
+
notificationCount: {
|
|
75
|
+
control: 'number',
|
|
76
|
+
},
|
|
70
77
|
},
|
|
71
78
|
} satisfies Meta<typeof RouteHeader>;
|
|
72
79
|
|
|
@@ -74,6 +81,7 @@ export default meta;
|
|
|
74
81
|
type Story = StoryObj<typeof meta>;
|
|
75
82
|
|
|
76
83
|
export const Continuous: Story = {
|
|
84
|
+
render: (args) => <RouteHeader {...args} />,
|
|
77
85
|
args: {
|
|
78
86
|
routeLabel: 'ROTA DA FRANCOFONIA',
|
|
79
87
|
countryCode: 'SN',
|
|
@@ -88,6 +96,7 @@ export const Continuous: Story = {
|
|
|
88
96
|
};
|
|
89
97
|
|
|
90
98
|
export const Segmented: Story = {
|
|
99
|
+
render: (args) => <RouteHeader {...args} />,
|
|
91
100
|
args: {
|
|
92
101
|
routeLabel: 'ROTA DA FRANCOFONIA',
|
|
93
102
|
stepLabel: 'paragem 5 de 8',
|
|
@@ -102,3 +111,22 @@ export const Segmented: Story = {
|
|
|
102
111
|
safeAreaTop: 20,
|
|
103
112
|
},
|
|
104
113
|
};
|
|
114
|
+
|
|
115
|
+
export const Minimal: Story = {
|
|
116
|
+
render: (args) => <RouteHeader {...args} />,
|
|
117
|
+
args: {
|
|
118
|
+
routeLabel: 'Francofonia',
|
|
119
|
+
stepLabel: '5/8',
|
|
120
|
+
countryCode: 'SN',
|
|
121
|
+
title: 'Dakar',
|
|
122
|
+
subtitle: 'B2',
|
|
123
|
+
progressText: '',
|
|
124
|
+
progressSubText: '',
|
|
125
|
+
progress: 0.625,
|
|
126
|
+
progressType: 'segmented',
|
|
127
|
+
totalSegments: 8,
|
|
128
|
+
variant: 'minimal',
|
|
129
|
+
notificationCount: 3,
|
|
130
|
+
safeAreaTop: 20,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
@@ -5,6 +5,7 @@ import { theme } from '../../../styles/theme';
|
|
|
5
5
|
import { Flag } from '../../DataDisplay/Flag/Flag';
|
|
6
6
|
import { ProgressBar } from '../../DataDisplay/ProgressBar/ProgressBar';
|
|
7
7
|
import { Typography } from '../../Typography/Typography';
|
|
8
|
+
import { IconButton } from '../../Buttons/IconButton/IconButton';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Propriedades para o componente RouteHeader.
|
|
@@ -69,6 +70,19 @@ export interface RouteHeaderProps {
|
|
|
69
70
|
* Deve receber o `insets.top` devolvido pelo hook `useSafeAreaInsets()`.
|
|
70
71
|
*/
|
|
71
72
|
safeAreaTop?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Variante visual do cabeçalho.
|
|
75
|
+
* - `default`: Mostra o progresso em texto gigante à direita (ex: 63%).
|
|
76
|
+
* - `minimal`: Oculta o progresso em texto e pode mostrar ações contextuais à direita (ex: sino e globo).
|
|
77
|
+
* @default 'default'
|
|
78
|
+
*/
|
|
79
|
+
variant?: 'default' | 'minimal';
|
|
80
|
+
/** Contagem de notificações não lidas. Requer variant="minimal" */
|
|
81
|
+
notificationCount?: number;
|
|
82
|
+
/** Callback ao clicar no sino de notificações. Requer variant="minimal" */
|
|
83
|
+
onNotificationPress?: () => void;
|
|
84
|
+
/** Callback ao clicar no mapa/globo. Requer variant="minimal" */
|
|
85
|
+
onGlobePress?: () => void;
|
|
72
86
|
/**
|
|
73
87
|
* Estilos adicionais para o contentor exterior (ex: zIndex, position absolute).
|
|
74
88
|
*/
|
|
@@ -98,6 +112,10 @@ export const RouteHeader = ({
|
|
|
98
112
|
progress,
|
|
99
113
|
totalSegments = 1,
|
|
100
114
|
progressType = 'continuous',
|
|
115
|
+
variant = 'default',
|
|
116
|
+
notificationCount = 0,
|
|
117
|
+
onNotificationPress,
|
|
118
|
+
onGlobePress,
|
|
101
119
|
safeAreaTop = 0,
|
|
102
120
|
style,
|
|
103
121
|
}: RouteHeaderProps) => {
|
|
@@ -109,14 +127,21 @@ export const RouteHeader = ({
|
|
|
109
127
|
<View style={[styles.container, { paddingTop: safeAreaTop + scale(12), paddingHorizontal: scale(20), paddingBottom: scale(20) }, style]}>
|
|
110
128
|
{/* Top Row: Labels */}
|
|
111
129
|
<View style={[styles.topRow, { marginBottom: scale(10) }]}>
|
|
112
|
-
<
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
{stepLabel ? (
|
|
116
|
-
<Typography variant="smallHeavy" color={theme.colors.brown[500]}>
|
|
117
|
-
{stepLabel}
|
|
130
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
131
|
+
<Typography variant="bodySmHeavy" color={theme.colors.brown[500]} style={{ textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
132
|
+
{routeLabel}
|
|
118
133
|
</Typography>
|
|
119
|
-
|
|
134
|
+
{stepLabel ? (
|
|
135
|
+
<>
|
|
136
|
+
<Typography variant="bodySmHeavy" color={theme.colors.brown[400]} style={{ marginHorizontal: scale(6) }}>
|
|
137
|
+
•
|
|
138
|
+
</Typography>
|
|
139
|
+
<Typography variant="bodySmHeavy" color={theme.colors.brown[500]}>
|
|
140
|
+
{stepLabel}
|
|
141
|
+
</Typography>
|
|
142
|
+
</>
|
|
143
|
+
) : null}
|
|
144
|
+
</View>
|
|
120
145
|
</View>
|
|
121
146
|
|
|
122
147
|
{/* Middle Row: Flag, Title, Progress Text */}
|
|
@@ -135,14 +160,42 @@ export const RouteHeader = ({
|
|
|
135
160
|
</Typography>
|
|
136
161
|
</View>
|
|
137
162
|
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
{
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
|
|
163
|
+
{variant === 'default' && (
|
|
164
|
+
<View style={styles.progressTextContainer}>
|
|
165
|
+
<Typography variant="h4" color={theme.colors.primary}>
|
|
166
|
+
{progressText}
|
|
167
|
+
</Typography>
|
|
168
|
+
<Typography variant="smallHeavy" color={theme.colors.brown[500]}>
|
|
169
|
+
{progressSubText}
|
|
170
|
+
</Typography>
|
|
171
|
+
</View>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{variant === 'minimal' && (
|
|
175
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: scale(8) }}>
|
|
176
|
+
<View style={{ zIndex: 10, elevation: 10 }}>
|
|
177
|
+
<IconButton
|
|
178
|
+
iconName="Bell"
|
|
179
|
+
variant="secondary"
|
|
180
|
+
size="sm"
|
|
181
|
+
iconColor={theme.colors.primary}
|
|
182
|
+
onPress={onNotificationPress}
|
|
183
|
+
/>
|
|
184
|
+
{!!notificationCount && notificationCount > 0 && (
|
|
185
|
+
<View style={styles.badgeContainer}>
|
|
186
|
+
<Typography variant="smallHeavy" color={theme.colors.white}>{notificationCount > 99 ? '99+' : notificationCount}</Typography>
|
|
187
|
+
</View>
|
|
188
|
+
)}
|
|
189
|
+
</View>
|
|
190
|
+
<IconButton
|
|
191
|
+
iconName="Globe"
|
|
192
|
+
variant="secondary"
|
|
193
|
+
size="sm"
|
|
194
|
+
iconColor={theme.colors.brown[800]}
|
|
195
|
+
onPress={onGlobePress}
|
|
196
|
+
/>
|
|
197
|
+
</View>
|
|
198
|
+
)}
|
|
146
199
|
</View>
|
|
147
200
|
|
|
148
201
|
{/* Bottom Row: Progress Bar */}
|
|
@@ -217,4 +270,20 @@ const styles = StyleSheet.create({
|
|
|
217
270
|
alignItems: 'flex-end',
|
|
218
271
|
justifyContent: 'center',
|
|
219
272
|
},
|
|
273
|
+
badgeContainer: {
|
|
274
|
+
position: 'absolute',
|
|
275
|
+
top: -4,
|
|
276
|
+
right: -4,
|
|
277
|
+
backgroundColor: theme.colors.error,
|
|
278
|
+
borderRadius: 10,
|
|
279
|
+
minWidth: 18,
|
|
280
|
+
height: 18,
|
|
281
|
+
justifyContent: 'center',
|
|
282
|
+
alignItems: 'center',
|
|
283
|
+
paddingHorizontal: 4,
|
|
284
|
+
borderWidth: 1.5,
|
|
285
|
+
borderColor: theme.colors.white,
|
|
286
|
+
zIndex: 10,
|
|
287
|
+
elevation: 10,
|
|
288
|
+
}
|
|
220
289
|
});
|
|
@@ -17,6 +17,13 @@ const meta = {
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
tags: ['autodocs'],
|
|
20
|
+
argTypes: {
|
|
21
|
+
variant: {
|
|
22
|
+
control: 'radio',
|
|
23
|
+
options: ['default', 'brown'],
|
|
24
|
+
description: 'Variante visual dos pontos.',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
20
27
|
decorators: [
|
|
21
28
|
(Story) => (
|
|
22
29
|
<View style={{ padding: 40, backgroundColor: theme.colors.cream }}>
|
|
@@ -30,16 +37,21 @@ export default meta;
|
|
|
30
37
|
type Story = StoryObj<any>;
|
|
31
38
|
|
|
32
39
|
export const Default: Story = {
|
|
33
|
-
args: { totalPages: 4, currentPage: 1 } as any,
|
|
40
|
+
args: { totalPages: 4, currentPage: 1, variant: 'default' } as any,
|
|
41
|
+
render: (args: any) => <PaginationDots {...args} />,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const Brown: Story = {
|
|
45
|
+
args: { totalPages: 4, currentPage: 1, variant: 'brown' } as any,
|
|
34
46
|
render: (args: any) => <PaginationDots {...args} />,
|
|
35
47
|
};
|
|
36
48
|
|
|
37
|
-
const InteractiveDots = ({ totalPages }: { totalPages: number }) => {
|
|
49
|
+
const InteractiveDots = ({ totalPages, variant }: { totalPages: number, variant?: 'default' | 'brown' }) => {
|
|
38
50
|
const [currentPage, setCurrentStep] = useState(0);
|
|
39
51
|
|
|
40
52
|
return (
|
|
41
53
|
<View style={{ alignItems: 'center' }}>
|
|
42
|
-
<PaginationDots totalPages={totalPages} currentPage={currentPage} />
|
|
54
|
+
<PaginationDots totalPages={totalPages} currentPage={currentPage} variant={variant} />
|
|
43
55
|
|
|
44
56
|
<View style={{ flexDirection: 'row', gap: 20, marginTop: 20 }}>
|
|
45
57
|
<Button
|
|
@@ -58,5 +70,9 @@ const InteractiveDots = ({ totalPages }: { totalPages: number }) => {
|
|
|
58
70
|
};
|
|
59
71
|
|
|
60
72
|
export const Interactive: Story = {
|
|
61
|
-
render: () => <InteractiveDots totalPages={4} />,
|
|
73
|
+
render: () => <InteractiveDots totalPages={4} variant="default" />,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const InteractiveBrown: Story = {
|
|
77
|
+
render: () => <InteractiveDots totalPages={4} variant="brown" />,
|
|
62
78
|
};
|
|
@@ -17,27 +17,41 @@ export interface PaginationDotsProps {
|
|
|
17
17
|
inactiveColor?: string;
|
|
18
18
|
/** Cor customizada do ponto ativo (opcional). */
|
|
19
19
|
activeColor?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Variante visual dos pontos.
|
|
22
|
+
* - `default`: Ponto ativo longo (primário), inativos redondos.
|
|
23
|
+
* - `brown`: Cores da paleta castanha, pontos inativos mais largos (em formato de traço curto).
|
|
24
|
+
*/
|
|
25
|
+
variant?: 'default' | 'brown';
|
|
20
26
|
style?: ViewStyle | ViewStyle[];
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
interface DotProps {
|
|
24
30
|
isActive: boolean;
|
|
31
|
+
variant: 'default' | 'brown';
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
const Dot = ({ isActive }: DotProps) => {
|
|
34
|
+
const Dot = ({ isActive, variant }: DotProps) => {
|
|
28
35
|
const { scale } = useResponsive();
|
|
29
36
|
|
|
30
|
-
//
|
|
31
|
-
const
|
|
37
|
+
// Se for 'brown', os inativos são mais largos (traços curtos em vez de círculos perfeitos)
|
|
38
|
+
const inactiveWidth = variant === 'brown' ? scale(12) : scale(6);
|
|
39
|
+
const activeWidth = scale(20);
|
|
40
|
+
|
|
41
|
+
const activeColor = variant === 'brown' ? theme.colors.brown[500] : theme.colors.primary;
|
|
42
|
+
const inactiveColor = theme.colors.brown[100]; // Bolinhas cinzas inativas mantêm a cor padrão (brown[100])
|
|
43
|
+
|
|
44
|
+
// Animação da largura
|
|
45
|
+
const widthAnim = useRef(new Animated.Value(isActive ? activeWidth : inactiveWidth)).current;
|
|
32
46
|
|
|
33
47
|
useEffect(() => {
|
|
34
48
|
Animated.timing(widthAnim, {
|
|
35
|
-
toValue: isActive ?
|
|
49
|
+
toValue: isActive ? activeWidth : inactiveWidth,
|
|
36
50
|
duration: 250,
|
|
37
|
-
easing: Easing.bezier(0.175, 0.885, 0.32, 1.275), // Efeito "ease-bounce"
|
|
51
|
+
easing: Easing.bezier(0.175, 0.885, 0.32, 1.275), // Efeito "ease-bounce"
|
|
38
52
|
useNativeDriver: false, // Animating width requires useNativeDriver: false
|
|
39
53
|
}).start();
|
|
40
|
-
}, [isActive, widthAnim,
|
|
54
|
+
}, [isActive, widthAnim, activeWidth, inactiveWidth]);
|
|
41
55
|
|
|
42
56
|
return (
|
|
43
57
|
<Animated.View
|
|
@@ -47,14 +61,14 @@ const Dot = ({ isActive }: DotProps) => {
|
|
|
47
61
|
width: widthAnim,
|
|
48
62
|
height: scale(6),
|
|
49
63
|
borderRadius: scale(3),
|
|
50
|
-
backgroundColor: isActive ?
|
|
64
|
+
backgroundColor: isActive ? activeColor : inactiveColor,
|
|
51
65
|
},
|
|
52
66
|
]}
|
|
53
67
|
/>
|
|
54
68
|
);
|
|
55
69
|
};
|
|
56
70
|
|
|
57
|
-
export const PaginationDots = ({ totalPages, currentPage, style }: PaginationDotsProps) => {
|
|
71
|
+
export const PaginationDots = ({ totalPages, currentPage, variant = 'default', style }: PaginationDotsProps) => {
|
|
58
72
|
const { scale } = useResponsive();
|
|
59
73
|
|
|
60
74
|
// Garantir que não crasha caso passem steps negativos
|
|
@@ -63,7 +77,7 @@ export const PaginationDots = ({ totalPages, currentPage, style }: PaginationDot
|
|
|
63
77
|
return (
|
|
64
78
|
<View style={[styles.container, { gap: scale(7), marginBottom: scale(24) }, style]}>
|
|
65
79
|
{Array.from({ length: totalPages }).map((_, index) => (
|
|
66
|
-
<Dot key={index} isActive={index === safeCurrentStep} />
|
|
80
|
+
<Dot key={index} isActive={index === safeCurrentStep} variant={variant} />
|
|
67
81
|
))}
|
|
68
82
|
</View>
|
|
69
83
|
);
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export * from './components/DataDisplay/InstructionBubble/InstructionBubble';
|
|
|
27
27
|
export * from './components/DataDisplay/LevelBadge/LevelBadge';
|
|
28
28
|
export * from './components/DataDisplay/NumberBadge/NumberBadge';
|
|
29
29
|
export * from './components/DataDisplay/ProgressBar/ProgressBar';
|
|
30
|
+
export * from './components/DataDisplay/Stamp/Stamp';
|
|
31
|
+
export * from './components/DataDisplay/StampCard/StampCard';
|
|
30
32
|
export * from './components/DataDisplay/StatBadge/StatBadge';
|
|
31
33
|
export * from './components/DataDisplay/Tag/Tag';
|
|
32
34
|
export * from './components/Feedback/BottomSheet/BottomSheet';
|
package/src/styles/theme.ts
CHANGED
|
@@ -234,6 +234,40 @@ export const theme = {
|
|
|
234
234
|
desktopFontSize: 16,
|
|
235
235
|
},
|
|
236
236
|
|
|
237
|
+
// FAMÍLIA SERIF (Elementos Clássicos, Carimbos)
|
|
238
|
+
stampTop: {
|
|
239
|
+
...font('IM Fell English', '400'),
|
|
240
|
+
fontStyle: 'italic',
|
|
241
|
+
fontSize: 12,
|
|
242
|
+
lineHeight: 14,
|
|
243
|
+
letterSpacing: 2.5,
|
|
244
|
+
textTransform: 'uppercase',
|
|
245
|
+
desktopFontSize: 16,
|
|
246
|
+
},
|
|
247
|
+
stampTitle: {
|
|
248
|
+
...font('Fredoka-SemiBold', '600'),
|
|
249
|
+
fontSize: 24,
|
|
250
|
+
lineHeight: 28,
|
|
251
|
+
textTransform: 'uppercase',
|
|
252
|
+
desktopFontSize: 28,
|
|
253
|
+
},
|
|
254
|
+
stampBottom1: {
|
|
255
|
+
...font('Nunito-Regular', '400'),
|
|
256
|
+
fontSize: 9,
|
|
257
|
+
lineHeight: 12,
|
|
258
|
+
letterSpacing: 2,
|
|
259
|
+
textTransform: 'uppercase',
|
|
260
|
+
desktopFontSize: 10,
|
|
261
|
+
},
|
|
262
|
+
stampBottom2: {
|
|
263
|
+
...font('Nunito-Regular', '400'),
|
|
264
|
+
fontSize: 7,
|
|
265
|
+
lineHeight: 10,
|
|
266
|
+
letterSpacing: 0.5,
|
|
267
|
+
textTransform: 'uppercase',
|
|
268
|
+
desktopFontSize: 8,
|
|
269
|
+
},
|
|
270
|
+
|
|
237
271
|
// FAMÍLIA NUNITO (Corpo, Labels, Listas e Infos)
|
|
238
272
|
bodyLgHeavy: {
|
|
239
273
|
...font('Nunito-Black', '900'),
|
package/src/utils/iconMap.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Play, Volume2, Volume1, ArrowRight, X, Check, ChevronRight, ChevronLeft, Info, AlertTriangle, Book, Mic, Home, Compass, GraduationCap, FolderOpen, User, ShoppingCart, FileText, Wallet } from 'lucide-react-native';
|
|
1
|
+
import { Play, Volume2, Volume1, ArrowRight, X, Check, ChevronRight, ChevronLeft, Info, AlertTriangle, Book, Mic, Home, Compass, GraduationCap, FolderOpen, User, ShoppingCart, FileText, Wallet, Bell, Globe } from 'lucide-react-native';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Para evitar que o Vite e o Metro (React Native) tentem empacotar os 1400+ ícones
|
|
@@ -29,4 +29,6 @@ export const IconMap: Record<string, any> = {
|
|
|
29
29
|
ShoppingCart,
|
|
30
30
|
FileText,
|
|
31
31
|
Wallet,
|
|
32
|
+
Bell,
|
|
33
|
+
Globe,
|
|
32
34
|
};
|