@thelol3882/sapar 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +380 -2
- package/dist/adapter-Y9yB3-q3.d.ts +14 -0
- package/dist/adapter-fFZPsINL.d.ts +14 -0
- package/dist/chunk-7B66QEMY.js +536 -0
- package/dist/chunk-7B66QEMY.js.map +1 -0
- package/dist/chunk-FM64UDKJ.js +893 -0
- package/dist/chunk-FM64UDKJ.js.map +1 -0
- package/dist/chunk-VC6XLWM3.js +176 -0
- package/dist/chunk-VC6XLWM3.js.map +1 -0
- package/dist/chunk-ZBVQGMR4.js +583 -0
- package/dist/chunk-ZBVQGMR4.js.map +1 -0
- package/dist/cli/index.js +1335 -0
- package/dist/core/index.d.ts +273 -0
- package/dist/core/index.js +71 -0
- package/dist/core/index.js.map +1 -0
- package/dist/expo/index.d.ts +189 -0
- package/dist/expo/index.js +39 -0
- package/dist/expo/index.js.map +1 -0
- package/dist/expo-router/index.d.ts +18 -0
- package/dist/expo-router/index.js +46 -0
- package/dist/expo-router/index.js.map +1 -0
- package/dist/next/index.d.ts +18 -0
- package/dist/next/index.js +49 -0
- package/dist/next/index.js.map +1 -0
- package/dist/presentation-CClsPRT6.d.ts +12 -0
- package/dist/react/index.d.ts +35 -0
- package/dist/react/index.js +29 -0
- package/dist/react/index.js.map +1 -0
- package/dist/rtk/index.d.ts +751 -0
- package/dist/rtk/index.js +114 -0
- package/dist/rtk/index.js.map +1 -0
- package/dist/stepCount-CTYYFWiV.d.ts +57 -0
- package/dist/types-CHD2pB5X.d.ts +192 -0
- package/dist/web/index.d.ts +112 -0
- package/dist/web/index.js +34 -0
- package/dist/web/index.js.map +1 -0
- package/package.json +147 -3
- package/templates/expo/AnnouncementModal.tsx +102 -0
- package/templates/expo/TourCard.tsx +112 -0
- package/templates/expo/WhatsNewButton.tsx +87 -0
- package/templates/expo/WhatsNewPanel.tsx +167 -0
- package/templates/mantine/AnnouncementModal.tsx +66 -0
- package/templates/mantine/TourCard.tsx +84 -0
- package/templates/mantine/WhatsNewButton.tsx +62 -0
- package/templates/mantine/WhatsNewPanel.tsx +129 -0
- package/templates/tailwind/AnnouncementModal.tsx +100 -0
- package/templates/tailwind/TourCard.tsx +69 -0
- package/templates/tailwind/WhatsNewButton.tsx +56 -0
- package/templates/tailwind/WhatsNewPanel.tsx +174 -0
package/package.json
CHANGED
|
@@ -1,6 +1,150 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thelol3882/sapar",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Sapar: headless product tours and a What's New feed for React, Next.js and Expo, with an in-memory or HTTP source.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"product-tour",
|
|
7
|
+
"onboarding",
|
|
8
|
+
"walkthrough",
|
|
9
|
+
"whats-new",
|
|
10
|
+
"announcements",
|
|
11
|
+
"react",
|
|
12
|
+
"nextjs",
|
|
13
|
+
"expo",
|
|
14
|
+
"react-native",
|
|
15
|
+
"headless"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Dias Shabarov",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/thelol3882/sapar.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/thelol3882/sapar#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/thelol3882/sapar/issues"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"templates",
|
|
32
|
+
"!templates/**/*.test.*"
|
|
33
|
+
],
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/core/index.d.ts",
|
|
37
|
+
"import": "./dist/core/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./react": {
|
|
40
|
+
"types": "./dist/react/index.d.ts",
|
|
41
|
+
"import": "./dist/react/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./web": {
|
|
44
|
+
"types": "./dist/web/index.d.ts",
|
|
45
|
+
"import": "./dist/web/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./next": {
|
|
48
|
+
"types": "./dist/next/index.d.ts",
|
|
49
|
+
"import": "./dist/next/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./rtk": {
|
|
52
|
+
"types": "./dist/rtk/index.d.ts",
|
|
53
|
+
"import": "./dist/rtk/index.js"
|
|
54
|
+
},
|
|
55
|
+
"./expo": {
|
|
56
|
+
"types": "./dist/expo/index.d.ts",
|
|
57
|
+
"import": "./dist/expo/index.js"
|
|
58
|
+
},
|
|
59
|
+
"./expo-router": {
|
|
60
|
+
"types": "./dist/expo-router/index.d.ts",
|
|
61
|
+
"import": "./dist/expo-router/index.js"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"@floating-ui/react-dom": "^2.1.6"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@react-native-async-storage/async-storage": ">=2 <4",
|
|
69
|
+
"@reduxjs/toolkit": ">=2",
|
|
70
|
+
"next": ">=15",
|
|
71
|
+
"react": ">=18.3",
|
|
72
|
+
"react-dom": ">=18.3",
|
|
73
|
+
"react-redux": ">=9",
|
|
74
|
+
"react-native": ">=0.79",
|
|
75
|
+
"react-native-svg": ">=15",
|
|
76
|
+
"expo-router": ">=5",
|
|
77
|
+
"@mantine/core": ">=7",
|
|
78
|
+
"typescript": ">=5"
|
|
79
|
+
},
|
|
80
|
+
"peerDependenciesMeta": {
|
|
81
|
+
"@react-native-async-storage/async-storage": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
84
|
+
"@reduxjs/toolkit": {
|
|
85
|
+
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"next": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"react-dom": {
|
|
91
|
+
"optional": true
|
|
92
|
+
},
|
|
93
|
+
"react-redux": {
|
|
94
|
+
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"react-native": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"react-native-svg": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"expo-router": {
|
|
103
|
+
"optional": true
|
|
104
|
+
},
|
|
105
|
+
"@mantine/core": {
|
|
106
|
+
"optional": true
|
|
107
|
+
},
|
|
108
|
+
"typescript": {
|
|
109
|
+
"optional": true
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"devDependencies": {
|
|
113
|
+
"@mantine/core": "^9.8.0",
|
|
114
|
+
"@mantine/hooks": "^9.8.0",
|
|
115
|
+
"@react-native-async-storage/async-storage": "^3.1.1",
|
|
116
|
+
"@reduxjs/toolkit": "^2.11.1",
|
|
117
|
+
"@testing-library/dom": "^10.4.1",
|
|
118
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
119
|
+
"@testing-library/react": "^16.3.2",
|
|
120
|
+
"@testing-library/user-event": "^14.6.1",
|
|
121
|
+
"@types/node": "^24.10.1",
|
|
122
|
+
"@types/react": "^19.2.17",
|
|
123
|
+
"@types/react-dom": "^19.2.3",
|
|
124
|
+
"@vitejs/plugin-react": "^5.0.4",
|
|
125
|
+
"async-storage-v2": "npm:@react-native-async-storage/async-storage@2.2.0",
|
|
126
|
+
"expo-router": "^58.0.30",
|
|
127
|
+
"jsdom": "^29.1.1",
|
|
128
|
+
"next": "^16.2.6",
|
|
129
|
+
"react": "^19.2.1",
|
|
130
|
+
"react-dom": "^19.2.1",
|
|
131
|
+
"react-native": "^0.88.1",
|
|
132
|
+
"react-native-svg": "^15.16.1",
|
|
133
|
+
"react-redux": "^9.2.0",
|
|
134
|
+
"tsup": "^8.5.0",
|
|
135
|
+
"typescript": "^5.9.3",
|
|
136
|
+
"vitest": "^4.1.5"
|
|
137
|
+
},
|
|
138
|
+
"bin": {
|
|
139
|
+
"sapar": "./dist/cli/index.js"
|
|
140
|
+
},
|
|
141
|
+
"publishConfig": {
|
|
142
|
+
"access": "public"
|
|
143
|
+
},
|
|
144
|
+
"scripts": {
|
|
145
|
+
"build": "rm -rf dist && tsup",
|
|
146
|
+
"test": "vitest run",
|
|
147
|
+
"test:watch": "vitest",
|
|
148
|
+
"typecheck": "tsc --noEmit"
|
|
149
|
+
}
|
|
6
150
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
2
|
+
import { Image, Linking, Modal, Pressable, StyleSheet, Text, useColorScheme, View } from 'react-native';
|
|
3
|
+
import { useAnnouncementInterruption, useWhatsNew } from '../../src/react';
|
|
4
|
+
|
|
5
|
+
export interface AnnouncementModalLabels {
|
|
6
|
+
acknowledge: string;
|
|
7
|
+
takeTour: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LABELS: AnnouncementModalLabels = {
|
|
11
|
+
acknowledge: 'Got it',
|
|
12
|
+
takeTour: 'Take the tour',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Replace these with your design tokens.
|
|
16
|
+
const palette = {
|
|
17
|
+
light: { surface: '#ffffff', text: '#171717', muted: '#525252', accent: '#4f46e5', onAccent: '#ffffff', pressed: '#f5f5f5', backdrop: 'rgba(0, 0, 0, 0.5)' },
|
|
18
|
+
dark: { surface: '#171717', text: '#f5f5f5', muted: '#d4d4d4', accent: '#818cf8', onAccent: '#ffffff', pressed: '#262626', backdrop: 'rgba(0, 0, 0, 0.5)' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TourColors = Record<keyof (typeof palette)['light'], string>;
|
|
22
|
+
|
|
23
|
+
function useColors(overrides: Partial<TourColors> | undefined): TourColors {
|
|
24
|
+
return { ...palette[useColorScheme() === 'dark' ? 'dark' : 'light'], ...overrides };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const styles = StyleSheet.create({
|
|
28
|
+
root: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 },
|
|
29
|
+
backdrop: StyleSheet.absoluteFill,
|
|
30
|
+
card: { width: '100%', maxWidth: 448, gap: 12, padding: 24, borderRadius: 12 },
|
|
31
|
+
image: { width: '100%', height: 160, borderRadius: 8 },
|
|
32
|
+
title: { fontSize: 18, fontWeight: '600' },
|
|
33
|
+
body: { fontSize: 14, lineHeight: 20 },
|
|
34
|
+
link: { fontSize: 14, fontWeight: '500' },
|
|
35
|
+
actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, paddingTop: 8 },
|
|
36
|
+
button: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
|
37
|
+
buttonLabel: { fontSize: 14, fontWeight: '500' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export interface AnnouncementModalProps {
|
|
41
|
+
labels?: Partial<AnnouncementModalLabels>;
|
|
42
|
+
/** Overrides the palette of the current colour scheme. */
|
|
43
|
+
colors?: Partial<TourColors>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function AnnouncementModal({ labels, colors: colorOverrides }: AnnouncementModalProps) {
|
|
47
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
48
|
+
const colors = useColors(colorOverrides);
|
|
49
|
+
const { announcement, dismiss, acknowledge } = useAnnouncementInterruption();
|
|
50
|
+
const { recordClick, startTour } = useWhatsNew();
|
|
51
|
+
|
|
52
|
+
if (!announcement) return null;
|
|
53
|
+
const { key, tourKey, cta } = announcement;
|
|
54
|
+
|
|
55
|
+
async function takeTour(key: string): Promise<void> {
|
|
56
|
+
await acknowledge();
|
|
57
|
+
await startTour(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function openCta(url: string): void {
|
|
61
|
+
recordClick(key);
|
|
62
|
+
void Linking.openURL(url);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Modal visible transparent animationType="fade" onRequestClose={() => void dismiss()}>
|
|
67
|
+
<View style={styles.root} accessibilityViewIsModal>
|
|
68
|
+
<Pressable accessible={false} style={[styles.backdrop, { backgroundColor: colors.backdrop }]} onPress={() => void dismiss()} />
|
|
69
|
+
<View role="dialog" style={[styles.card, { backgroundColor: colors.surface }]}>
|
|
70
|
+
{announcement.imageUrl && <Image source={{ uri: announcement.imageUrl }} style={styles.image} />}
|
|
71
|
+
<Text role="heading" style={[styles.title, { color: colors.text }]}>
|
|
72
|
+
{announcement.title}
|
|
73
|
+
</Text>
|
|
74
|
+
<Text style={[styles.body, { color: colors.muted }]}>{announcement.body}</Text>
|
|
75
|
+
{cta && (
|
|
76
|
+
<Pressable role="link" onPress={() => openCta(cta.url)}>
|
|
77
|
+
<Text style={[styles.link, { color: colors.accent }]}>{cta.label}</Text>
|
|
78
|
+
</Pressable>
|
|
79
|
+
)}
|
|
80
|
+
<View style={styles.actions}>
|
|
81
|
+
{tourKey && (
|
|
82
|
+
<Pressable
|
|
83
|
+
role="button"
|
|
84
|
+
onPress={() => void takeTour(tourKey)}
|
|
85
|
+
style={({ pressed }) => [styles.button, pressed && { backgroundColor: colors.pressed }]}
|
|
86
|
+
>
|
|
87
|
+
<Text style={[styles.buttonLabel, { color: colors.text }]}>{text.takeTour}</Text>
|
|
88
|
+
</Pressable>
|
|
89
|
+
)}
|
|
90
|
+
<Pressable
|
|
91
|
+
role="button"
|
|
92
|
+
onPress={() => void acknowledge()}
|
|
93
|
+
style={[styles.button, { backgroundColor: colors.accent }]}
|
|
94
|
+
>
|
|
95
|
+
<Text style={[styles.buttonLabel, { color: colors.onAccent }]}>{text.acknowledge}</Text>
|
|
96
|
+
</Pressable>
|
|
97
|
+
</View>
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
</Modal>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
2
|
+
import { StyleSheet, useColorScheme, useWindowDimensions, View } from 'react-native';
|
|
3
|
+
import { TourOverlay, TourStep, type TourPopoverProps } from '../../src/expo';
|
|
4
|
+
import type { StepAnnouncement, StepCount } from '../../src/react';
|
|
5
|
+
|
|
6
|
+
export interface TourCardLabels {
|
|
7
|
+
next: string;
|
|
8
|
+
back: string;
|
|
9
|
+
skip: string;
|
|
10
|
+
done: string;
|
|
11
|
+
close: string;
|
|
12
|
+
progress: ({ position, total }: StepCount) => string;
|
|
13
|
+
announceStep: ({ position, total, title }: StepAnnouncement) => string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_LABELS: TourCardLabels = {
|
|
17
|
+
next: 'Next',
|
|
18
|
+
back: 'Back',
|
|
19
|
+
skip: 'Skip tour',
|
|
20
|
+
done: 'Done',
|
|
21
|
+
close: 'Close tour',
|
|
22
|
+
progress: ({ position, total }) => `${position} / ${total}`,
|
|
23
|
+
announceStep: ({ position, total, title }) => `Step ${position} of ${total}: ${title}`,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Replace these with your design tokens.
|
|
27
|
+
const palette = {
|
|
28
|
+
light: { surface: '#ffffff', border: '#e5e5e5', text: '#171717', muted: '#525252', accent: '#4f46e5', onAccent: '#ffffff', pressed: '#f5f5f5' },
|
|
29
|
+
dark: { surface: '#171717', border: '#404040', text: '#f5f5f5', muted: '#d4d4d4', accent: '#6366f1', onAccent: '#ffffff', pressed: '#262626' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TourColors = Record<keyof (typeof palette)['light'], string>;
|
|
33
|
+
|
|
34
|
+
function useColors(overrides: Partial<TourColors> | undefined): TourColors {
|
|
35
|
+
return { ...palette[useColorScheme() === 'dark' ? 'dark' : 'light'], ...overrides };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CARD_WIDTH = 320;
|
|
39
|
+
const CARD_MARGIN = 16;
|
|
40
|
+
|
|
41
|
+
const styles = StyleSheet.create({
|
|
42
|
+
card: { alignSelf: 'center', padding: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth },
|
|
43
|
+
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
44
|
+
progress: { fontSize: 12, fontWeight: '500' },
|
|
45
|
+
closeLabel: { fontSize: 20, lineHeight: 24 },
|
|
46
|
+
title: { marginTop: 8, fontSize: 16, fontWeight: '600' },
|
|
47
|
+
body: { marginTop: 4, fontSize: 14, lineHeight: 20 },
|
|
48
|
+
footer: { marginTop: 16, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
49
|
+
actions: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
50
|
+
button: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
|
51
|
+
buttonLabel: { fontSize: 14, fontWeight: '500' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export interface TourCardProps extends Pick<TourPopoverProps, 'insets' | 'presentation'> {
|
|
55
|
+
labels?: Partial<TourCardLabels>;
|
|
56
|
+
/** Overrides the palette of the current colour scheme. */
|
|
57
|
+
colors?: Partial<TourColors>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TourCard({ labels, colors: colorOverrides, insets, presentation }: TourCardProps) {
|
|
61
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
62
|
+
const colors = useColors(colorOverrides);
|
|
63
|
+
const { width } = useWindowDimensions();
|
|
64
|
+
|
|
65
|
+
const secondary = ({ pressed }: { pressed: boolean }) => [styles.button, pressed && { backgroundColor: colors.pressed }];
|
|
66
|
+
const secondaryLabel = [styles.buttonLabel, { color: colors.muted }];
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<TourOverlay insets={insets} presentation={presentation}>
|
|
70
|
+
<TourStep.Root
|
|
71
|
+
announceStep={text.announceStep}
|
|
72
|
+
style={[
|
|
73
|
+
styles.card,
|
|
74
|
+
{
|
|
75
|
+
width: Math.min(CARD_WIDTH, width - CARD_MARGIN * 2),
|
|
76
|
+
backgroundColor: colors.surface,
|
|
77
|
+
borderColor: colors.border,
|
|
78
|
+
},
|
|
79
|
+
]}
|
|
80
|
+
>
|
|
81
|
+
<View style={styles.header}>
|
|
82
|
+
<TourStep.Progress style={[styles.progress, { color: colors.muted }]}>
|
|
83
|
+
{text.progress}
|
|
84
|
+
</TourStep.Progress>
|
|
85
|
+
<TourStep.Close
|
|
86
|
+
accessibilityLabel={text.close}
|
|
87
|
+
style={secondary}
|
|
88
|
+
textStyle={[styles.closeLabel, { color: colors.muted }]}
|
|
89
|
+
/>
|
|
90
|
+
</View>
|
|
91
|
+
<TourStep.Title style={[styles.title, { color: colors.text }]} />
|
|
92
|
+
<TourStep.Body style={[styles.body, { color: colors.muted }]} />
|
|
93
|
+
<View style={styles.footer}>
|
|
94
|
+
<TourStep.Skip style={secondary} textStyle={secondaryLabel}>
|
|
95
|
+
{text.skip}
|
|
96
|
+
</TourStep.Skip>
|
|
97
|
+
<View style={styles.actions}>
|
|
98
|
+
<TourStep.Back style={secondary} textStyle={secondaryLabel}>
|
|
99
|
+
{text.back}
|
|
100
|
+
</TourStep.Back>
|
|
101
|
+
<TourStep.Next
|
|
102
|
+
nextLabel={text.next}
|
|
103
|
+
doneLabel={text.done}
|
|
104
|
+
style={[styles.button, { backgroundColor: colors.accent }]}
|
|
105
|
+
textStyle={[styles.buttonLabel, { color: colors.onAccent }]}
|
|
106
|
+
/>
|
|
107
|
+
</View>
|
|
108
|
+
</View>
|
|
109
|
+
</TourStep.Root>
|
|
110
|
+
</TourOverlay>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Pressable, StyleSheet, Text, useColorScheme, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
|
+
import Svg, { Path } from 'react-native-svg';
|
|
5
|
+
import { useWhatsNew } from '../../src/react';
|
|
6
|
+
import { WhatsNewPanel, type WhatsNewPanelProps } from './WhatsNewPanel';
|
|
7
|
+
|
|
8
|
+
export interface WhatsNewButtonProps extends Pick<WhatsNewPanelProps, 'labels' | 'locale' | 'formatDate'> {
|
|
9
|
+
style?: StyleProp<ViewStyle>;
|
|
10
|
+
/** Accessible name; the unread count is appended. */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** Overrides the palette of the current colour scheme. */
|
|
13
|
+
colors?: Partial<TourColors>;
|
|
14
|
+
/** Overrides the palette of the panel the button opens. */
|
|
15
|
+
panelColors?: WhatsNewPanelProps['colors'];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Replace these with your design tokens.
|
|
19
|
+
const palette = {
|
|
20
|
+
light: { icon: '#525252', pressed: '#f5f5f5', badge: '#ef4444', onBadge: '#ffffff' },
|
|
21
|
+
dark: { icon: '#d4d4d4', pressed: '#262626', badge: '#ef4444', onBadge: '#ffffff' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type TourColors = Record<keyof (typeof palette)['light'], string>;
|
|
25
|
+
|
|
26
|
+
function useColors(overrides: Partial<TourColors> | undefined): TourColors {
|
|
27
|
+
return { ...palette[useColorScheme() === 'dark' ? 'dark' : 'light'], ...overrides };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SPARKLE_PATH =
|
|
31
|
+
'M9.9 15.5a2 2 0 0 0-1.4-1.4l-6.1-1.6a.5.5 0 0 1 0-1l6.1-1.6a2 2 0 0 0 1.4-1.4l1.6-6.1a.5.5 0 0 1 1 0l1.6 6.1a2 2 0 0 0 1.4 1.4l6.1 1.6a.5.5 0 0 1 0 1l-6.1 1.6a2 2 0 0 0-1.4 1.4l-1.6 6.1a.5.5 0 0 1-1 0zM20 3v4M22 5h-4M4 17v2M5 18H3';
|
|
32
|
+
|
|
33
|
+
const styles = StyleSheet.create({
|
|
34
|
+
button: { padding: 8, borderRadius: 999 },
|
|
35
|
+
badge: {
|
|
36
|
+
position: 'absolute',
|
|
37
|
+
top: 0,
|
|
38
|
+
right: 0,
|
|
39
|
+
minWidth: 16,
|
|
40
|
+
paddingHorizontal: 4,
|
|
41
|
+
borderRadius: 999,
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
},
|
|
44
|
+
badgeLabel: { fontSize: 10, fontWeight: '600', lineHeight: 16 },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export function WhatsNewButton({
|
|
48
|
+
style,
|
|
49
|
+
label = "What's new",
|
|
50
|
+
labels,
|
|
51
|
+
locale,
|
|
52
|
+
formatDate,
|
|
53
|
+
colors: colorOverrides,
|
|
54
|
+
panelColors,
|
|
55
|
+
}: WhatsNewButtonProps) {
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const { unreadCount } = useWhatsNew();
|
|
58
|
+
const colors = useColors(colorOverrides);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<Pressable
|
|
63
|
+
role="button"
|
|
64
|
+
accessibilityLabel={unreadCount > 0 ? `${label} (${unreadCount})` : label}
|
|
65
|
+
onPress={() => setOpen(true)}
|
|
66
|
+
style={({ pressed }) => [styles.button, pressed && { backgroundColor: colors.pressed }, style]}
|
|
67
|
+
>
|
|
68
|
+
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" accessibilityElementsHidden>
|
|
69
|
+
<Path d={SPARKLE_PATH} stroke={colors.icon} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
70
|
+
</Svg>
|
|
71
|
+
{unreadCount > 0 && (
|
|
72
|
+
<View style={[styles.badge, { backgroundColor: colors.badge }]}>
|
|
73
|
+
<Text style={[styles.badgeLabel, { color: colors.onBadge }]}>{unreadCount}</Text>
|
|
74
|
+
</View>
|
|
75
|
+
)}
|
|
76
|
+
</Pressable>
|
|
77
|
+
<WhatsNewPanel
|
|
78
|
+
open={open}
|
|
79
|
+
onClose={() => setOpen(false)}
|
|
80
|
+
labels={labels}
|
|
81
|
+
locale={locale}
|
|
82
|
+
formatDate={formatDate}
|
|
83
|
+
colors={panelColors}
|
|
84
|
+
/>
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
2
|
+
import { FlatList, Image, Linking, Modal, Pressable, StyleSheet, Text, useColorScheme, View } from 'react-native';
|
|
3
|
+
import { useWhatsNewPanel, type WhatsNewPanelItem } from '../../src/react';
|
|
4
|
+
|
|
5
|
+
export interface WhatsNewPanelLabels {
|
|
6
|
+
title: string;
|
|
7
|
+
empty: string;
|
|
8
|
+
close: string;
|
|
9
|
+
takeTour: string;
|
|
10
|
+
new: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LABELS: WhatsNewPanelLabels = {
|
|
14
|
+
title: "What's new",
|
|
15
|
+
empty: 'Nothing new yet.',
|
|
16
|
+
close: 'Close',
|
|
17
|
+
takeTour: 'Take the tour',
|
|
18
|
+
new: 'New',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface WhatsNewPanelProps {
|
|
22
|
+
open: boolean;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
labels?: Partial<WhatsNewPanelLabels>;
|
|
25
|
+
/** BCP 47 tag for the default date format. Defaults to the device locale. */
|
|
26
|
+
locale?: string;
|
|
27
|
+
/** Replaces the default date format; the template then never uses `Intl`. */
|
|
28
|
+
formatDate?: (isoDate: string) => string;
|
|
29
|
+
/** Overrides the palette of the current colour scheme. */
|
|
30
|
+
colors?: Partial<TourColors>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Replace these with your design tokens.
|
|
34
|
+
const palette = {
|
|
35
|
+
light: { surface: '#ffffff', border: '#e5e5e5', text: '#171717', muted: '#525252', accent: '#4f46e5', onAccent: '#ffffff' },
|
|
36
|
+
dark: { surface: '#171717', border: '#404040', text: '#f5f5f5', muted: '#d4d4d4', accent: '#818cf8', onAccent: '#ffffff' },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type TourColors = Record<keyof (typeof palette)['light'], string>;
|
|
40
|
+
|
|
41
|
+
function useColors(overrides: Partial<TourColors> | undefined): TourColors {
|
|
42
|
+
return { ...palette[useColorScheme() === 'dark' ? 'dark' : 'light'], ...overrides };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const styles = StyleSheet.create({
|
|
46
|
+
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 20, borderBottomWidth: StyleSheet.hairlineWidth },
|
|
47
|
+
heading: { fontSize: 16, fontWeight: '600' },
|
|
48
|
+
closeLabel: { paddingHorizontal: 8, fontSize: 24, lineHeight: 28 },
|
|
49
|
+
list: { padding: 20, gap: 24 },
|
|
50
|
+
entry: { gap: 8, paddingBottom: 24, borderBottomWidth: StyleSheet.hairlineWidth },
|
|
51
|
+
entryHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
52
|
+
entryTitle: { fontSize: 14, fontWeight: '600' },
|
|
53
|
+
newBadge: { paddingHorizontal: 8, borderRadius: 999 },
|
|
54
|
+
newBadgeLabel: { fontSize: 10, fontWeight: '600', lineHeight: 16, textTransform: 'uppercase' },
|
|
55
|
+
date: { fontSize: 12 },
|
|
56
|
+
image: { width: '100%', height: 160, borderRadius: 8 },
|
|
57
|
+
body: { fontSize: 14, lineHeight: 20 },
|
|
58
|
+
actions: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 12 },
|
|
59
|
+
tourButton: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
|
60
|
+
tourButtonLabel: { fontSize: 14, fontWeight: '500' },
|
|
61
|
+
link: { fontSize: 14, fontWeight: '500' },
|
|
62
|
+
empty: { fontSize: 14 },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function formatDateWithIntl(isoDate: string, locale: string | undefined): string {
|
|
66
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(isoDate));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Entry({
|
|
70
|
+
item,
|
|
71
|
+
labels,
|
|
72
|
+
colors,
|
|
73
|
+
formatDate,
|
|
74
|
+
onCtaClick,
|
|
75
|
+
onTakeTour,
|
|
76
|
+
}: {
|
|
77
|
+
item: WhatsNewPanelItem;
|
|
78
|
+
labels: WhatsNewPanelLabels;
|
|
79
|
+
colors: TourColors;
|
|
80
|
+
formatDate: (isoDate: string) => string;
|
|
81
|
+
onCtaClick: (key: string) => void;
|
|
82
|
+
onTakeTour: (tourKey: string) => void;
|
|
83
|
+
}) {
|
|
84
|
+
const { tourKey, cta } = item;
|
|
85
|
+
|
|
86
|
+
function openCta(url: string): void {
|
|
87
|
+
onCtaClick(item.key);
|
|
88
|
+
void Linking.openURL(url);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<View style={[styles.entry, { borderColor: colors.border }]}>
|
|
93
|
+
<View style={styles.entryHeader}>
|
|
94
|
+
<Text role="heading" style={[styles.entryTitle, { color: colors.text }]}>
|
|
95
|
+
{item.title}
|
|
96
|
+
</Text>
|
|
97
|
+
{item.isNew && (
|
|
98
|
+
<View style={[styles.newBadge, { backgroundColor: colors.accent }]}>
|
|
99
|
+
<Text style={[styles.newBadgeLabel, { color: colors.onAccent }]}>{labels.new}</Text>
|
|
100
|
+
</View>
|
|
101
|
+
)}
|
|
102
|
+
</View>
|
|
103
|
+
{item.publishedAt && <Text style={[styles.date, { color: colors.muted }]}>{formatDate(item.publishedAt)}</Text>}
|
|
104
|
+
{item.imageUrl && <Image source={{ uri: item.imageUrl }} style={styles.image} />}
|
|
105
|
+
<Text style={[styles.body, { color: colors.muted }]}>{item.body}</Text>
|
|
106
|
+
<View style={styles.actions}>
|
|
107
|
+
{tourKey && (
|
|
108
|
+
<Pressable
|
|
109
|
+
role="button"
|
|
110
|
+
onPress={() => onTakeTour(tourKey)}
|
|
111
|
+
style={[styles.tourButton, { backgroundColor: colors.accent }]}
|
|
112
|
+
>
|
|
113
|
+
<Text style={[styles.tourButtonLabel, { color: colors.onAccent }]}>{labels.takeTour}</Text>
|
|
114
|
+
</Pressable>
|
|
115
|
+
)}
|
|
116
|
+
{cta && (
|
|
117
|
+
<Pressable role="link" onPress={() => openCta(cta.url)}>
|
|
118
|
+
<Text style={[styles.link, { color: colors.accent }]}>{cta.label}</Text>
|
|
119
|
+
</Pressable>
|
|
120
|
+
)}
|
|
121
|
+
</View>
|
|
122
|
+
</View>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function WhatsNewPanel({ open, onClose, labels, locale, formatDate, colors: colorOverrides }: WhatsNewPanelProps) {
|
|
127
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
128
|
+
const colors = useColors(colorOverrides);
|
|
129
|
+
const { items, recordClick, startTour } = useWhatsNewPanel(open);
|
|
130
|
+
const format = formatDate ?? ((isoDate: string) => formatDateWithIntl(isoDate, locale));
|
|
131
|
+
|
|
132
|
+
function takeTour(tourKey: string): void {
|
|
133
|
+
onClose();
|
|
134
|
+
void startTour(tourKey);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Modal visible={open} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
|
139
|
+
<View style={{ flex: 1, backgroundColor: colors.surface }}>
|
|
140
|
+
<View style={[styles.header, { borderColor: colors.border }]}>
|
|
141
|
+
<Text role="heading" style={[styles.heading, { color: colors.text }]}>
|
|
142
|
+
{text.title}
|
|
143
|
+
</Text>
|
|
144
|
+
<Pressable role="button" accessibilityLabel={text.close} onPress={onClose}>
|
|
145
|
+
<Text style={[styles.closeLabel, { color: colors.muted }]}>×</Text>
|
|
146
|
+
</Pressable>
|
|
147
|
+
</View>
|
|
148
|
+
<FlatList
|
|
149
|
+
data={items}
|
|
150
|
+
keyExtractor={(item) => item.key}
|
|
151
|
+
contentContainerStyle={styles.list}
|
|
152
|
+
ListEmptyComponent={<Text style={[styles.empty, { color: colors.muted }]}>{text.empty}</Text>}
|
|
153
|
+
renderItem={({ item }) => (
|
|
154
|
+
<Entry
|
|
155
|
+
item={item}
|
|
156
|
+
labels={text}
|
|
157
|
+
colors={colors}
|
|
158
|
+
formatDate={format}
|
|
159
|
+
onCtaClick={recordClick}
|
|
160
|
+
onTakeTour={takeTour}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
/>
|
|
164
|
+
</View>
|
|
165
|
+
</Modal>
|
|
166
|
+
);
|
|
167
|
+
}
|