@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -2
  3. package/dist/adapter-Y9yB3-q3.d.ts +14 -0
  4. package/dist/adapter-fFZPsINL.d.ts +14 -0
  5. package/dist/chunk-7B66QEMY.js +536 -0
  6. package/dist/chunk-7B66QEMY.js.map +1 -0
  7. package/dist/chunk-FM64UDKJ.js +893 -0
  8. package/dist/chunk-FM64UDKJ.js.map +1 -0
  9. package/dist/chunk-VC6XLWM3.js +176 -0
  10. package/dist/chunk-VC6XLWM3.js.map +1 -0
  11. package/dist/chunk-ZBVQGMR4.js +583 -0
  12. package/dist/chunk-ZBVQGMR4.js.map +1 -0
  13. package/dist/cli/index.js +1335 -0
  14. package/dist/core/index.d.ts +273 -0
  15. package/dist/core/index.js +71 -0
  16. package/dist/core/index.js.map +1 -0
  17. package/dist/expo/index.d.ts +189 -0
  18. package/dist/expo/index.js +39 -0
  19. package/dist/expo/index.js.map +1 -0
  20. package/dist/expo-router/index.d.ts +18 -0
  21. package/dist/expo-router/index.js +46 -0
  22. package/dist/expo-router/index.js.map +1 -0
  23. package/dist/next/index.d.ts +18 -0
  24. package/dist/next/index.js +49 -0
  25. package/dist/next/index.js.map +1 -0
  26. package/dist/presentation-CClsPRT6.d.ts +12 -0
  27. package/dist/react/index.d.ts +35 -0
  28. package/dist/react/index.js +29 -0
  29. package/dist/react/index.js.map +1 -0
  30. package/dist/rtk/index.d.ts +751 -0
  31. package/dist/rtk/index.js +114 -0
  32. package/dist/rtk/index.js.map +1 -0
  33. package/dist/stepCount-CTYYFWiV.d.ts +57 -0
  34. package/dist/types-CHD2pB5X.d.ts +192 -0
  35. package/dist/web/index.d.ts +112 -0
  36. package/dist/web/index.js +34 -0
  37. package/dist/web/index.js.map +1 -0
  38. package/package.json +147 -3
  39. package/templates/expo/AnnouncementModal.tsx +102 -0
  40. package/templates/expo/TourCard.tsx +112 -0
  41. package/templates/expo/WhatsNewButton.tsx +87 -0
  42. package/templates/expo/WhatsNewPanel.tsx +167 -0
  43. package/templates/mantine/AnnouncementModal.tsx +66 -0
  44. package/templates/mantine/TourCard.tsx +84 -0
  45. package/templates/mantine/WhatsNewButton.tsx +62 -0
  46. package/templates/mantine/WhatsNewPanel.tsx +129 -0
  47. package/templates/tailwind/AnnouncementModal.tsx +100 -0
  48. package/templates/tailwind/TourCard.tsx +69 -0
  49. package/templates/tailwind/WhatsNewButton.tsx +56 -0
  50. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }