@opndev/react-native-events 0.0.10

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/README.md ADDED
@@ -0,0 +1,320 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
3
+ SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
4
+
5
+ SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
6
+ -->
7
+
8
+ # Welcome to @opndev/opndev-react-native-events
9
+
10
+ Reusable React Native / Expo components for event-style apps.
11
+
12
+ Focus:
13
+ - fast to build
14
+ - minimal dependencies
15
+ - reusable across projects
16
+ - no app-specific assumptions
17
+
18
+ This package provides a small set of building blocks for:
19
+
20
+ - hero / parallax screens
21
+ - vendor grids
22
+ - menu-style content
23
+ - tile-based navigation
24
+ - external link / app launching
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ npm install @opndev/opndev-react-native-events
30
+ ```
31
+
32
+ Peer dependencies are expected to be installed by the consuming app.
33
+
34
+ ## Components
35
+
36
+ ### HeroScreen
37
+
38
+ Base layout for screens with:
39
+ - parallax header image
40
+ - optional overlay (title, back button, etc)
41
+ - scrollable content
42
+
43
+ ```jsx
44
+ <HeroScreen
45
+ headerImage={<Image ... />}
46
+ headerOverlay={<Text>Title</Text>}
47
+ >
48
+ {children}
49
+ </HeroScreen>
50
+ ```
51
+
52
+ ### ParallaxScrollView
53
+
54
+ Low-level scroll + animation component used by `HeroScreen`.
55
+
56
+ Theme-agnostic. Consumers inject:
57
+ - colors
58
+ - content wrapper
59
+ - styling overrides
60
+
61
+ ### FoodVendorScreen
62
+
63
+ Grid of vendor tiles.
64
+
65
+ ```jsx
66
+ <FoodVendorScreen
67
+ title="Food vendors"
68
+ headerImage={<Image ... />}
69
+ vendors={[
70
+ { key: 'bar', label: 'Bar' },
71
+ ]}
72
+ onSelectVendor={(vendor) => {
73
+ // navigation handled by app
74
+ }}
75
+ />
76
+ ```
77
+
78
+ ### FoodMenuScreen
79
+
80
+ Menu-style screen with categories and items.
81
+
82
+ ```jsx
83
+ <FoodMenuScreen
84
+ title="Bar"
85
+ headerImage={<Image ... />}
86
+ menu={vendor.menu}
87
+ onBack={() => router.back()}
88
+ />
89
+ ```
90
+
91
+ ### Tile / GradientTile
92
+
93
+ Reusable tile components.
94
+
95
+ ```jsx
96
+ <Tile
97
+ label="Info"
98
+ onPress={...}
99
+ />
100
+
101
+ <GradientTile
102
+ label="Bar"
103
+ colors={['#F4D645', '#9FDEED']}
104
+ onPress={...}
105
+ />
106
+ ```
107
+
108
+ ## QR Code Screen
109
+
110
+ A generic, configurable QR code form + result screen.
111
+
112
+ ### Basic Usage
113
+
114
+ ```jsx
115
+ <QrCodeScreen
116
+ TextComponent={Text}
117
+ endpoint={endpoint}
118
+ fields={fields}
119
+ title="Check-in QR"
120
+
121
+ renderAboveQRCode={renderAboveQRCode}
122
+ renderBelowQRCode={renderBelowQRCode}
123
+
124
+ successButtons={['refresh', 'reset', 'back']}
125
+ onBack={() => navigation.goBack()}
126
+ />
127
+ ```
128
+
129
+ ### Data Contract
130
+
131
+ ```json
132
+ {
133
+ "token": "...",
134
+ "status": "pending",
135
+ "error": null
136
+ }
137
+ ```
138
+
139
+ - HTTP always returns 200
140
+ - logical errors come via `error`
141
+ - component treats `error` as failure
142
+
143
+ ### Render Hooks
144
+
145
+ #### renderAboveQRCode
146
+
147
+ ```jsx
148
+ const renderAboveQRCode = ({ response }) => {
149
+ if (response.status !== 'pending') {
150
+ return null;
151
+ }
152
+
153
+ return (
154
+ <>
155
+ <Text>Awaiting Check-in</Text>
156
+ <Text>Present this QR code at the entry desk</Text>
157
+ </>
158
+ );
159
+ };
160
+ ```
161
+
162
+ #### renderBelowQRCode
163
+
164
+ ```jsx
165
+ const renderBelowQRCode = ({ fields, values }) => {
166
+ return (
167
+ <View>
168
+ {fields.map((field) => (
169
+ <View key={field.key}>
170
+ <Text>{field.label}:</Text>
171
+ <Text>{values[field.key]}</Text>
172
+ </View>
173
+ ))}
174
+ </View>
175
+ );
176
+ };
177
+ ```
178
+
179
+ ### Multi-step Status Example
180
+
181
+ ```jsx
182
+ const steps = [
183
+ { key: 'pending', label: 'Pending' },
184
+ { key: 'checked_in', label: 'Checked-in' },
185
+ { key: 'goodie_bag_received', label: 'Goodie-bag-received' },
186
+ ];
187
+
188
+ const renderAboveQRCode = ({ response }) => {
189
+ const currentIndex = steps.findIndex(
190
+ (step) => step.key === response.status
191
+ );
192
+
193
+ return (
194
+ <>
195
+ <Text>Event Status</Text>
196
+ <View>
197
+ {steps.map((step, idx) => {
198
+ const isCurrent = idx === currentIndex;
199
+ const isDone = idx < currentIndex;
200
+
201
+ return (
202
+ <Text key={step.key}>
203
+ {step.label}
204
+ {isCurrent ? ' (current)' : ''}
205
+ {isDone ? ' (done)' : ''}
206
+ </Text>
207
+ );
208
+ })}
209
+ </View>
210
+ </>
211
+ );
212
+ };
213
+ ```
214
+
215
+ ### Success Buttons
216
+
217
+ ```jsx
218
+ successButtons={['refresh', 'reset', 'back']}
219
+ ```
220
+
221
+ Supported:
222
+ - refresh
223
+ - reset
224
+ - back
225
+
226
+ Default:
227
+ ```jsx
228
+ ['refresh', 'reset']
229
+ ```
230
+
231
+ ### Responsibilities
232
+
233
+ Component:
234
+ - form state
235
+ - persistence
236
+ - API calls
237
+ - error handling
238
+ - QR rendering
239
+ - success actions
240
+
241
+ Caller:
242
+ - status UI
243
+ - layout above/below QR
244
+ - mapping status to UI
245
+
246
+ ## Utils
247
+
248
+ ### openExternal
249
+
250
+ Helper for opening:
251
+ - URLs
252
+ - apps (deep links)
253
+ - Play Store / App Store fallback
254
+
255
+ ```js
256
+ import { openExternal } from '@opndev/opndev-react-native-events';
257
+
258
+ openExternal({
259
+ url: 'instagram://user?username=...',
260
+ packageName: 'com.instagram.android',
261
+ appStoreUrl: 'https://apps.apple.com/app/instagram',
262
+ });
263
+ ```
264
+
265
+ Internal navigation is intentionally **not handled** by this package.
266
+
267
+ ## Development
268
+
269
+ Try to keep functions small and try not to depend on any outside functions. We
270
+ aim to keep the dependency graph as small as possible. The use of `esm` is
271
+ welcomed and writing tests is encouraged.
272
+
273
+ ### jsdoc
274
+
275
+ You can build the documentation by running `npm run jsdoc`.
276
+
277
+ ### eslint
278
+
279
+ The ES Linting profile is flexible and does not try to enforce much. There is
280
+ more than one way to do it ([TIMTOWTDI](https://en.wikipedia.org/wiki/There%27s_more_than_one_way_to_do_it)).
281
+
282
+ Try to stay constistent, but forcing a programming style upon others is bad.
283
+
284
+ ## Code of Conduct
285
+
286
+ Be human.
287
+
288
+ ## Semver
289
+
290
+ This project does not adhere to semver and one should not rely on the version
291
+ x.y.z notation to infer stability or reliability. Read the Changes file to see
292
+ any updates a version may bring. The fact that this module sits currently at
293
+ 0.x.z ranges does not indicate alpha or beta or even unstable associations. It
294
+ is just a number and we started at 0.0.1.
295
+
296
+ In general the following hard guarantee will be given: We will not break your
297
+ code. In case we do happen to cause breakage: we will fix it accordingly.
298
+
299
+ In case we foresee breaking changes we'll add deprecation warnings. Giving you
300
+ time to fix things before a breaking change will be introduced. When a change
301
+ will be introduced is communicated in the Changes file. Security fixes may
302
+ cause breakage at any given time without notice.
303
+
304
+ This package is released by `@opndev/rzilla`, changes to `package.json` will be
305
+ overridden. In addition to a little bit of promotion, this also means that
306
+ version numbers are autoincremented at release time and bumped in all relevant
307
+ files: Versioning for humans, not machines.
308
+
309
+
310
+ ### License
311
+
312
+ Please be aware that this package is GPL-3.0-or-later with exceptions. Please
313
+ refer to the LICENSES directory for more on this.
314
+
315
+ ### Code contributions
316
+
317
+ This project does not accept pull, merge or patches from others.
318
+
319
+ Due to the license and how copyright law works, this module will not accept
320
+ code written by people who are not employed by opndev.io.
@@ -0,0 +1,100 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ /**
6
+ * News API cache time-to-live.
7
+ */
8
+ const TTL_MS = 15 * 60 * 1000;
9
+
10
+ /**
11
+ * In-memory cache for news API responses.
12
+ */
13
+ const cache = new Map();
14
+
15
+ /**
16
+ * Builds a cache key for a given URI and bearer token.
17
+ *
18
+ * @param {string} uri
19
+ * @param {string} [bearerToken]
20
+ *
21
+ * @returns {string}
22
+ */
23
+ function getCacheKey(uri, bearerToken) {
24
+ return `${uri}::${bearerToken || ''}`;
25
+ }
26
+
27
+ /**
28
+ * Returns true when a cache entry is still fresh.
29
+ *
30
+ * @param {object} entry
31
+ *
32
+ * @returns {boolean}
33
+ */
34
+ function isFresh(entry) {
35
+ if (!entry) {
36
+ return false;
37
+ }
38
+
39
+ return (Date.now() - entry.fetchedAt) < TTL_MS;
40
+ }
41
+
42
+ /**
43
+ * Fetches JSON from the news API with optional bearer token support.
44
+ *
45
+ * Cached responses are reused for 15 minutes unless `force` is set.
46
+ *
47
+ * @param {object} args
48
+ * @param {string} args.uri
49
+ * @param {string} [args.bearerToken]
50
+ * @param {boolean} [args.force]
51
+ *
52
+ * @returns {Promise<any>}
53
+ */
54
+ export async function fetchNewsJson({
55
+ uri,
56
+ bearerToken,
57
+ force = false,
58
+ }) {
59
+ const key = getCacheKey(uri, bearerToken);
60
+ const entry = cache.get(key);
61
+
62
+ if (!force && isFresh(entry)) {
63
+ return entry.data;
64
+ }
65
+
66
+ const headers = {};
67
+ if (bearerToken) {
68
+ headers.Authorization = `Bearer ${bearerToken}`;
69
+ }
70
+
71
+ const res = await fetch(uri, {
72
+ method: 'GET',
73
+ headers,
74
+ });
75
+
76
+ const data = await res.json();
77
+
78
+ cache.set(key, {
79
+ data,
80
+ fetchedAt: Date.now(),
81
+ });
82
+
83
+ return data;
84
+ }
85
+
86
+ /**
87
+ * Clears the cached response for a specific URI.
88
+ *
89
+ * @param {object} args
90
+ * @param {string} args.uri
91
+ * @param {string} [args.bearerToken]
92
+ *
93
+ * @returns {void}
94
+ */
95
+ export function clearNewsCache({
96
+ uri,
97
+ bearerToken,
98
+ }) {
99
+ cache.delete(getCacheKey(uri, bearerToken));
100
+ }
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ /**
6
+ * QRCodeAction
7
+ *
8
+ * Small helper for QR form persistence + request handling.
9
+ */
10
+
11
+ import AsyncStorage from '@react-native-async-storage/async-storage';
12
+
13
+ export default class QRCodeAction {
14
+
15
+ /**
16
+ * @param {object} props
17
+ * @param {{url: string, method?: string, headers?: object}} props.endpoint
18
+ * @param {string} [props.storageKey]
19
+ * @param {(values: object) => any} [props.buildPayload]
20
+ * @param {(response: any) => string} [props.extractToken]
21
+ */
22
+ constructor({
23
+ endpoint,
24
+ storageKey = 'qr-form',
25
+ buildPayload,
26
+ extractToken,
27
+ }) {
28
+ this.endpoint = endpoint;
29
+ this.storageKey = storageKey;
30
+ this.buildPayload = buildPayload;
31
+ this.extractToken = extractToken;
32
+ }
33
+
34
+ /**
35
+ * @returns {Promise<object>}
36
+ */
37
+ async loadValues() {
38
+ const stored = await AsyncStorage.getItem(this.storageKey);
39
+
40
+ if (!stored) {
41
+ return {};
42
+ }
43
+
44
+ return JSON.parse(stored);
45
+ }
46
+
47
+ /**
48
+ * @param {object} values
49
+ * @returns {Promise<void>}
50
+ */
51
+ async persistValues(values) {
52
+ await AsyncStorage.setItem(
53
+ this.storageKey,
54
+ JSON.stringify(values)
55
+ );
56
+ }
57
+
58
+ /**
59
+ * @param {object} values
60
+ * @returns {Promise<any>}
61
+ */
62
+ async fetchResponse(values) {
63
+ await this.persistValues(values);
64
+
65
+ const payload = this.buildPayload
66
+ ? this.buildPayload(values)
67
+ : values;
68
+
69
+ console.log(this.endpoint);
70
+ console.log(payload);
71
+
72
+ const res = await fetch(this.endpoint.url, {
73
+ method: this.endpoint.method || 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ ...(this.endpoint.headers || {}),
77
+ },
78
+ body: JSON.stringify(payload),
79
+ });
80
+
81
+ return await res.json();
82
+ }
83
+
84
+ getResponseError(response) {
85
+ if (!response) {
86
+ return undefined;
87
+ }
88
+
89
+ if (response.status === 'error') {
90
+ return response.error || response.message || 'Something went wrong.';
91
+ }
92
+
93
+ if (response.error) {
94
+ return response.error;
95
+ }
96
+
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * @param {any} response
102
+ * @returns {string|undefined}
103
+ */
104
+ getToken(response) {
105
+ if (!response) {
106
+ return undefined;
107
+ }
108
+
109
+ if (this.extractToken) {
110
+ return this.extractToken(response);
111
+ }
112
+
113
+ return response.token;
114
+ }
115
+ }
@@ -0,0 +1,49 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import React from 'react';
6
+ import { StyleSheet } from 'react-native';
7
+ import { LinearGradient } from 'expo-linear-gradient';
8
+
9
+ import TileBase from './tile-base';
10
+
11
+ const defaultStyle = StyleSheet.create({
12
+ background: {
13
+ ...StyleSheet.absoluteFillObject,
14
+ },
15
+ });
16
+
17
+ const DEFAULT_START = { x: 0, y: 0 };
18
+ const DEFAULT_END = { x: 1, y: 1 };
19
+
20
+ /**
21
+ * Tile with a linear gradient background.
22
+ *
23
+ * @param {object} props
24
+ * @param {string[]} props.colors
25
+ * @param {object} [props.start]
26
+ * @param {object} [props.end]
27
+ */
28
+ export default function GradientTile({
29
+ colors,
30
+ start = DEFAULT_START,
31
+ end = DEFAULT_END,
32
+ ...props
33
+ }) {
34
+ const background = (
35
+ <LinearGradient
36
+ colors={colors}
37
+ start={start}
38
+ end={end}
39
+ style={defaultStyle.background}
40
+ />
41
+ );
42
+
43
+ return (
44
+ <TileBase
45
+ {...props}
46
+ background={background}
47
+ />
48
+ );
49
+ }
@@ -0,0 +1,109 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import { View, StyleSheet, Image } from 'react-native';
6
+ import ParallaxScrollView from './parallax-scroll-view';
7
+
8
+ const defaultStyle = StyleSheet.create({
9
+ headerImageWrap: {
10
+ width: '100%',
11
+ height: 220,
12
+ bottom: 0,
13
+ left: 0,
14
+ position: 'absolute',
15
+ },
16
+ headerImage: {
17
+ width: '100%',
18
+ height: '100%',
19
+ },
20
+ titleWrap: {
21
+ paddingHorizontal: 8,
22
+ paddingTop: 8,
23
+ paddingBottom: 8,
24
+ },
25
+ });
26
+
27
+ /**
28
+ * HeroScreen
29
+ *
30
+ * Thin wrapper around ParallaxScrollView that:
31
+ * - renders a full-bleed header image
32
+ * - renders optional title/header content below the image
33
+ * - renders children below that
34
+ *
35
+ * @param {object} props
36
+ * @param {React.ReactNode} props.children
37
+ * @param {object} [props.headerImage]
38
+ * Header image config:
39
+ * - source: React Native image source
40
+ * - style: optional image style
41
+ * - fit: optional resize mode, defaults to 'cover'
42
+ * @param {React.ReactNode} [props.headerOverlay]
43
+ * Optional content rendered below the header image.
44
+ * @param {string} [props.backgroundColor]
45
+ * @param {string} [props.headerBackgroundColor]
46
+ * @param {number} [props.headerHeight]
47
+ * @param {React.ComponentType<{style?: any, children?: React.ReactNode}>}
48
+ * [props.ContentComponent]
49
+ * @param {object} [props.containerStyle]
50
+ * @param {object} [props.headerStyle]
51
+ * @param {object} [props.contentStyle]
52
+ *
53
+ * @returns {JSX.Element}
54
+ */
55
+ export default function HeroScreen({
56
+ children,
57
+ headerImage,
58
+ headerOverlay,
59
+
60
+ backgroundColor,
61
+ headerBackgroundColor,
62
+ headerHeight,
63
+
64
+ ContentComponent,
65
+ containerStyle,
66
+ headerStyle,
67
+ contentStyle,
68
+ }) {
69
+ let hi;
70
+
71
+ if (headerImage) {
72
+ const fit = headerImage.fit || 'cover';
73
+ const style = headerImage.style || defaultStyle.headerImage;
74
+
75
+ hi = (
76
+ <Image
77
+ source={headerImage.source}
78
+ style={style}
79
+ resizeMode={fit}
80
+ />
81
+ );
82
+ }
83
+
84
+ return (
85
+ <ParallaxScrollView
86
+ backgroundColor={backgroundColor}
87
+ headerBackgroundColor={headerBackgroundColor}
88
+ headerHeight={headerHeight}
89
+ ContentComponent={ContentComponent}
90
+ containerStyle={containerStyle}
91
+ headerStyle={headerStyle}
92
+ contentStyle={contentStyle}
93
+ headerImage={
94
+ <View style={defaultStyle.headerImageWrap}>
95
+ {hi}
96
+ </View>
97
+ }
98
+ >
99
+ {headerOverlay ? (
100
+ <View style={defaultStyle.titleWrap}>
101
+ {headerOverlay}
102
+ </View>
103
+ ) : null}
104
+
105
+ {children}
106
+ </ParallaxScrollView>
107
+ );
108
+ }
109
+