@laconius/ui 0.1.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 (85) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +22 -0
  3. package/lib/module/button.js +76 -0
  4. package/lib/module/button.js.map +1 -0
  5. package/lib/module/config.js +33 -0
  6. package/lib/module/config.js.map +1 -0
  7. package/lib/module/feedback.js +81 -0
  8. package/lib/module/feedback.js.map +1 -0
  9. package/lib/module/hooks.js +107 -0
  10. package/lib/module/hooks.js.map +1 -0
  11. package/lib/module/index.js +18 -0
  12. package/lib/module/index.js.map +1 -0
  13. package/lib/module/kratos.js +130 -0
  14. package/lib/module/kratos.js.map +1 -0
  15. package/lib/module/media.js +61 -0
  16. package/lib/module/media.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/module/price.js +34 -0
  19. package/lib/module/price.js.map +1 -0
  20. package/lib/module/quantity-stepper.js +60 -0
  21. package/lib/module/quantity-stepper.js.map +1 -0
  22. package/lib/module/store.js +25 -0
  23. package/lib/module/store.js.map +1 -0
  24. package/lib/module/text.js +55 -0
  25. package/lib/module/text.js.map +1 -0
  26. package/lib/module/theme.js +40 -0
  27. package/lib/module/theme.js.map +1 -0
  28. package/lib/module/translations.js +15 -0
  29. package/lib/module/translations.js.map +1 -0
  30. package/lib/module/use-gallery.js +22 -0
  31. package/lib/module/use-gallery.js.map +1 -0
  32. package/lib/module/use-product-card.js +36 -0
  33. package/lib/module/use-product-card.js.map +1 -0
  34. package/lib/module/use-quantity-stepper.js +20 -0
  35. package/lib/module/use-quantity-stepper.js.map +1 -0
  36. package/lib/typescript/package.json +1 -0
  37. package/lib/typescript/src/button.d.ts +18 -0
  38. package/lib/typescript/src/button.d.ts.map +1 -0
  39. package/lib/typescript/src/config.d.ts +40 -0
  40. package/lib/typescript/src/config.d.ts.map +1 -0
  41. package/lib/typescript/src/feedback.d.ts +24 -0
  42. package/lib/typescript/src/feedback.d.ts.map +1 -0
  43. package/lib/typescript/src/hooks.d.ts +31 -0
  44. package/lib/typescript/src/hooks.d.ts.map +1 -0
  45. package/lib/typescript/src/index.d.ts +16 -0
  46. package/lib/typescript/src/index.d.ts.map +1 -0
  47. package/lib/typescript/src/kratos.d.ts +8 -0
  48. package/lib/typescript/src/kratos.d.ts.map +1 -0
  49. package/lib/typescript/src/media.d.ts +37 -0
  50. package/lib/typescript/src/media.d.ts.map +1 -0
  51. package/lib/typescript/src/price.d.ts +15 -0
  52. package/lib/typescript/src/price.d.ts.map +1 -0
  53. package/lib/typescript/src/quantity-stepper.d.ts +13 -0
  54. package/lib/typescript/src/quantity-stepper.d.ts.map +1 -0
  55. package/lib/typescript/src/store.d.ts +27 -0
  56. package/lib/typescript/src/store.d.ts.map +1 -0
  57. package/lib/typescript/src/text.d.ts +15 -0
  58. package/lib/typescript/src/text.d.ts.map +1 -0
  59. package/lib/typescript/src/theme.d.ts +71 -0
  60. package/lib/typescript/src/theme.d.ts.map +1 -0
  61. package/lib/typescript/src/translations.d.ts +13 -0
  62. package/lib/typescript/src/translations.d.ts.map +1 -0
  63. package/lib/typescript/src/use-gallery.d.ts +13 -0
  64. package/lib/typescript/src/use-gallery.d.ts.map +1 -0
  65. package/lib/typescript/src/use-product-card.d.ts +24 -0
  66. package/lib/typescript/src/use-product-card.d.ts.map +1 -0
  67. package/lib/typescript/src/use-quantity-stepper.d.ts +18 -0
  68. package/lib/typescript/src/use-quantity-stepper.d.ts.map +1 -0
  69. package/package.json +73 -0
  70. package/src/button.tsx +95 -0
  71. package/src/config.ts +53 -0
  72. package/src/feedback.tsx +95 -0
  73. package/src/hooks.ts +130 -0
  74. package/src/index.ts +62 -0
  75. package/src/kratos.ts +86 -0
  76. package/src/media.tsx +96 -0
  77. package/src/price.tsx +30 -0
  78. package/src/quantity-stepper.tsx +70 -0
  79. package/src/store.ts +35 -0
  80. package/src/text.tsx +49 -0
  81. package/src/theme.ts +90 -0
  82. package/src/translations.ts +12 -0
  83. package/src/use-gallery.ts +35 -0
  84. package/src/use-product-card.ts +47 -0
  85. package/src/use-quantity-stepper.ts +35 -0
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ export {
2
+ defaultUiConfig,
3
+ type LaconiusUiComponent,
4
+ type LaconiusUiComponents,
5
+ } from './config';
6
+
7
+ export {
8
+ defineTheme,
9
+ extendTheme,
10
+ type ColorSchemePreference,
11
+ type Theme,
12
+ type ThemeColors,
13
+ type ThemeEntry,
14
+ type ThemeOverrides,
15
+ type TypographyRole,
16
+ type TypographyToken,
17
+ } from './theme';
18
+
19
+ export { kratos, kratosDark, kratosLight } from './kratos';
20
+
21
+ export { useThemeStore, type ThemeState } from './store';
22
+
23
+ export {
24
+ useStyles,
25
+ useTheme,
26
+ useThemeSwitcher,
27
+ useUiComponent,
28
+ type ThemeSwitcher,
29
+ } from './hooks';
30
+
31
+ export { defaultUiTranslations } from './translations';
32
+
33
+ export { Text, type TextProps } from './text';
34
+ export { Button, type ButtonProps, type ButtonVariant } from './button';
35
+ export {
36
+ Media,
37
+ useMedia,
38
+ type MediaProps,
39
+ type UseMediaOptions,
40
+ type UseMediaResult,
41
+ } from './media';
42
+ export { Price, formatPrice, type PriceProps } from './price';
43
+ export {
44
+ Divider,
45
+ EmptyState,
46
+ Spinner,
47
+ type DividerProps,
48
+ type EmptyStateProps,
49
+ type SpinnerProps,
50
+ } from './feedback';
51
+ export { QuantityStepper, type QuantityStepperProps } from './quantity-stepper';
52
+ export {
53
+ useProductCard,
54
+ type ProductCardBadge,
55
+ type ProductCardView,
56
+ } from './use-product-card';
57
+ export { useGallery, type GalleryApi } from './use-gallery';
58
+ export {
59
+ useQuantityStepper,
60
+ type QuantityStepperApi,
61
+ type QuantityStepperOptions,
62
+ } from './use-quantity-stepper';
package/src/kratos.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { defineTheme, type Theme, type ThemeEntry } from './theme';
2
+
3
+ const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32 };
4
+
5
+ const typography = {
6
+ display: { fontSize: 32, lineHeight: 38, fontWeight: '700' },
7
+ heading: { fontSize: 24, lineHeight: 30, fontWeight: '700' },
8
+ subheading: { fontSize: 18, lineHeight: 24, fontWeight: '600' },
9
+ body: { fontSize: 16, lineHeight: 22, fontWeight: '400' },
10
+ label: { fontSize: 14, lineHeight: 18, fontWeight: '600', letterSpacing: 0.2 },
11
+ caption: { fontSize: 12, lineHeight: 16, fontWeight: '400' },
12
+ } satisfies Theme['typography'];
13
+
14
+ const radii = { sm: 4, md: 8, pill: 999 };
15
+
16
+ const media = { aspectRatio: { product: 1, banner: 16 / 9, thumbnail: 1 } };
17
+
18
+ function elevation(shadowColor: string, opacity: number): Theme['elevation'] {
19
+ return {
20
+ card: {
21
+ shadowColor,
22
+ shadowOffset: { width: 0, height: 1 },
23
+ shadowOpacity: opacity,
24
+ shadowRadius: 3,
25
+ elevation: 2,
26
+ },
27
+ modal: {
28
+ shadowColor,
29
+ shadowOffset: { width: 0, height: 6 },
30
+ shadowOpacity: opacity * 1.6,
31
+ shadowRadius: 16,
32
+ elevation: 12,
33
+ },
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Every foreground/background pair below clears WCAG AA (4.5:1), verified by `kratos.test.ts`.
39
+ */
40
+ export const kratosLight = defineTheme({
41
+ colors: {
42
+ background: '#FFFFFF',
43
+ surface: '#FFFFFF',
44
+ surfaceMuted: '#F4F4F5',
45
+ text: '#18181B',
46
+ textMuted: '#52525B',
47
+ textInverse: '#FFFFFF',
48
+ border: '#D4D4D8',
49
+ primary: '#1A56DB',
50
+ onPrimary: '#FFFFFF',
51
+ danger: '#B3261E',
52
+ success: '#146C43',
53
+ },
54
+ spacing,
55
+ typography,
56
+ radii,
57
+ elevation: elevation('#000000', 0.12),
58
+ media,
59
+ });
60
+
61
+ export const kratosDark = defineTheme({
62
+ colors: {
63
+ background: '#0B0B0F',
64
+ surface: '#17171B',
65
+ surfaceMuted: '#27272A',
66
+ text: '#F4F4F5',
67
+ textMuted: '#A1A1AA',
68
+ textInverse: '#18181B',
69
+ border: '#3F3F46',
70
+ primary: '#93B4FF',
71
+ onPrimary: '#0B0B0F',
72
+ danger: '#FF8A80',
73
+ success: '#6EE7A8',
74
+ },
75
+ spacing,
76
+ typography,
77
+ radii,
78
+ elevation: elevation('#000000', 0.5),
79
+ media,
80
+ });
81
+
82
+ export const kratos: ThemeEntry = {
83
+ name: 'Kratos',
84
+ light: kratosLight,
85
+ dark: kratosDark,
86
+ };
package/src/media.tsx ADDED
@@ -0,0 +1,96 @@
1
+ import {
2
+ getMediaBaseUrl,
3
+ resolveMediaCandidates,
4
+ useLaconiusConfig,
5
+ type MediaContainer,
6
+ type MediaSource,
7
+ } from '@laconius/core';
8
+ import { Image, type ImageProps } from 'expo-image';
9
+ import { useMemo } from 'react';
10
+ import type { ImageStyle, StyleProp } from 'react-native';
11
+
12
+ import { useTheme } from './hooks';
13
+ import type { Theme } from './theme';
14
+
15
+ export type UseMediaOptions = {
16
+ /** Caps the candidate set. It does not select one asset. */
17
+ role?: string;
18
+ /** Explicit cap, overriding the one the role implies. */
19
+ maxFormat?: string;
20
+ };
21
+
22
+ export type UseMediaResult = {
23
+ /** Every format the container carries, widths attached, ascending. */
24
+ sources: MediaSource[];
25
+ altText?: string;
26
+ };
27
+
28
+ /**
29
+ * `expo-image` implements srcset semantics against the measured container, so Laconius hands it
30
+ * the whole candidate set and never picks a format for a device class.
31
+ */
32
+ export function useMedia(
33
+ media: MediaContainer,
34
+ options: UseMediaOptions = {},
35
+ ): UseMediaResult {
36
+ const config = useLaconiusConfig();
37
+ const { role, maxFormat } = options;
38
+ return useMemo(
39
+ () => ({
40
+ sources: resolveMediaCandidates(media, {
41
+ baseUrl: getMediaBaseUrl(config),
42
+ role,
43
+ maxFormat,
44
+ formats: config.ui?.media?.formats,
45
+ roles: config.ui?.media?.roles,
46
+ }),
47
+ altText: (media as { altText?: string } | undefined)?.altText,
48
+ }),
49
+ [config, media, role, maxFormat],
50
+ );
51
+ }
52
+
53
+ export type MediaProps = Omit<ImageProps, 'source' | 'style'> & {
54
+ media: MediaContainer;
55
+ /**
56
+ * The `useMedia` role. Named `mediaRole` because React Native's own `role` (the ARIA one)
57
+ * is already on every view prop type, and a primitive spreads the platform props.
58
+ */
59
+ mediaRole?: string;
60
+ /** Explicit cap, overriding the one the role implies. */
61
+ maxFormat?: string;
62
+ /** A theme aspect-ratio role, or a number. Unset leaves layout to the caller. */
63
+ aspectRatio?: keyof Theme['media']['aspectRatio'] | number;
64
+ /** Hides the image from screen readers, for decorative content. */
65
+ decorative?: boolean;
66
+ style?: StyleProp<ImageStyle>;
67
+ };
68
+
69
+ export function Media({
70
+ media,
71
+ mediaRole,
72
+ maxFormat,
73
+ aspectRatio,
74
+ decorative = false,
75
+ style,
76
+ ...rest
77
+ }: MediaProps) {
78
+ const theme = useTheme();
79
+ const { sources, altText } = useMedia(media, { role: mediaRole, maxFormat });
80
+ const ratio =
81
+ typeof aspectRatio === 'string' ? theme.media.aspectRatio[aspectRatio] : aspectRatio;
82
+
83
+ return (
84
+ <Image
85
+ source={sources}
86
+ contentFit="contain"
87
+ accessible={!decorative}
88
+ accessibilityRole={decorative ? undefined : 'image'}
89
+ accessibilityLabel={decorative ? undefined : altText}
90
+ accessibilityElementsHidden={decorative}
91
+ importantForAccessibility={decorative ? 'no-hide-descendants' : 'auto'}
92
+ style={[ratio === undefined ? null : { aspectRatio: ratio }, style]}
93
+ {...rest}
94
+ />
95
+ );
96
+ }
package/src/price.tsx ADDED
@@ -0,0 +1,30 @@
1
+ import { useSiteContext, type Price as PriceModel } from '@laconius/core';
2
+
3
+ import { Text, type TextProps } from './text';
4
+
5
+ export type PriceProps = Omit<TextProps, 'children'> & {
6
+ price?: PriceModel;
7
+ /** Rendered when the price carries neither a formatted value nor a numeric one. */
8
+ fallback?: string;
9
+ };
10
+
11
+ /**
12
+ * OCC's `formattedValue` arrives in the request's language and currency, so it follows a
13
+ * language switch on its own. `Intl` is the fallback for the endpoints that omit it — Hermes
14
+ * ships it, so there is no dependency here.
15
+ */
16
+ export function formatPrice(price: PriceModel | undefined, locale: string): string | undefined {
17
+ if (!price) return undefined;
18
+ if (price.formattedValue) return price.formattedValue;
19
+ if (price.value === undefined) return undefined;
20
+ if (!price.currencyIso) return String(price.value);
21
+ return new Intl.NumberFormat(locale, {
22
+ style: 'currency',
23
+ currency: price.currencyIso,
24
+ }).format(price.value);
25
+ }
26
+
27
+ export function Price({ price, fallback = '', ...rest }: PriceProps) {
28
+ const { language } = useSiteContext();
29
+ return <Text {...rest}>{formatPrice(price, language ?? 'en') ?? fallback}</Text>;
30
+ }
@@ -0,0 +1,70 @@
1
+ import { useTranslation } from '@laconius/core';
2
+ import { StyleSheet, View, type StyleProp, type TextStyle, type ViewStyle } from 'react-native';
3
+
4
+ import { Button } from './button';
5
+ import { useStyles, useUiComponent } from './hooks';
6
+ import { useQuantityStepper, type QuantityStepperOptions } from './use-quantity-stepper';
7
+ import { Text } from './text';
8
+ import type { Theme } from './theme';
9
+
10
+ export type QuantityStepperProps = QuantityStepperOptions & {
11
+ disabled?: boolean;
12
+ style?: StyleProp<ViewStyle>;
13
+ styles?: {
14
+ container?: StyleProp<ViewStyle>;
15
+ value?: StyleProp<TextStyle>;
16
+ button?: StyleProp<ViewStyle>;
17
+ };
18
+ };
19
+
20
+ const createStyles = (theme: Theme) =>
21
+ StyleSheet.create({
22
+ root: {
23
+ flexDirection: 'row',
24
+ alignItems: 'center',
25
+ gap: theme.spacing.sm,
26
+ alignSelf: 'flex-start',
27
+ },
28
+ button: { paddingHorizontal: theme.spacing.md, minWidth: 44 },
29
+ value: { minWidth: 32, textAlign: 'center' },
30
+ });
31
+
32
+ export function QuantityStepper({
33
+ disabled = false,
34
+ style,
35
+ styles: parts,
36
+ ...options
37
+ }: QuantityStepperProps) {
38
+ const t = useTranslation();
39
+ const styles = useStyles(createStyles);
40
+ const Label = useUiComponent('Text', Text);
41
+ const Step = useUiComponent('Button', Button);
42
+ const stepper = useQuantityStepper(options);
43
+
44
+ return (
45
+ <View style={[styles.root, parts?.container, style]}>
46
+ <Step
47
+ variant="secondary"
48
+ title="−"
49
+ accessibilityLabel={t('ui.quantity.decrease')}
50
+ disabled={disabled || !stepper.canDecrement}
51
+ onPress={stepper.decrement}
52
+ style={[styles.button, parts?.button]}
53
+ />
54
+ <Label
55
+ accessibilityLabel={`${t('ui.quantity.label')} ${stepper.value}`}
56
+ style={[styles.value, parts?.value]}
57
+ >
58
+ {stepper.value}
59
+ </Label>
60
+ <Step
61
+ variant="secondary"
62
+ title="+"
63
+ accessibilityLabel={t('ui.quantity.increase')}
64
+ disabled={disabled || !stepper.canIncrement}
65
+ onPress={stepper.increment}
66
+ style={[styles.button, parts?.button]}
67
+ />
68
+ </View>
69
+ );
70
+ }
package/src/store.ts ADDED
@@ -0,0 +1,35 @@
1
+ import AsyncStorage from 'expo-sqlite/kv-store';
2
+ import { create } from 'zustand';
3
+ import { createJSONStorage, persist } from 'zustand/middleware';
4
+
5
+ import type { ColorSchemePreference } from './theme';
6
+
7
+ export type ThemeState = {
8
+ /** Unset means "whatever `ui.defaultTheme` says". */
9
+ activeThemeId?: string;
10
+ /** Unset means "whatever `ui.colorScheme` says". */
11
+ colorScheme?: ColorSchemePreference;
12
+ setTheme: (themeId: string) => void;
13
+ setColorScheme: (scheme: ColorSchemePreference) => void;
14
+ };
15
+
16
+ /**
17
+ * The runtime half of Spartacus' two-layer theming. React Native has no build-time SCSS swap, so
18
+ * the choice is state, and it survives a restart like any other preference.
19
+ */
20
+ export const useThemeStore = create<ThemeState>()(
21
+ persist(
22
+ (set) => ({
23
+ setTheme: (activeThemeId) => set({ activeThemeId }),
24
+ setColorScheme: (colorScheme) => set({ colorScheme }),
25
+ }),
26
+ {
27
+ name: 'laconius.theme',
28
+ storage: createJSONStorage(() => AsyncStorage),
29
+ partialize: (state) => ({
30
+ activeThemeId: state.activeThemeId,
31
+ colorScheme: state.colorScheme,
32
+ }),
33
+ },
34
+ ),
35
+ );
package/src/text.tsx ADDED
@@ -0,0 +1,49 @@
1
+ import { StyleSheet, Text as RNText, type TextProps as RNTextProps } from 'react-native';
2
+
3
+ import { useStyles, useTheme } from './hooks';
4
+ import type { Theme, TypographyRole } from './theme';
5
+
6
+ /**
7
+ * Primitives extend the platform prop type and spread the rest: a closed prop set broke on the
8
+ * first `numberOfLines` written in an app file.
9
+ */
10
+ export type TextProps = RNTextProps & {
11
+ variant?: TypographyRole;
12
+ muted?: boolean;
13
+ /** For text sitting on `colors.primary`. */
14
+ inverse?: boolean;
15
+ color?: string;
16
+ };
17
+
18
+ const createStyles = (theme: Theme) =>
19
+ StyleSheet.create({
20
+ display: { ...theme.typography.display, fontFamily: theme.fontFamily?.bold },
21
+ heading: { ...theme.typography.heading, fontFamily: theme.fontFamily?.bold },
22
+ subheading: {
23
+ ...theme.typography.subheading,
24
+ fontFamily: theme.fontFamily?.medium,
25
+ },
26
+ body: { ...theme.typography.body, fontFamily: theme.fontFamily?.regular },
27
+ label: { ...theme.typography.label, fontFamily: theme.fontFamily?.medium },
28
+ caption: { ...theme.typography.caption, fontFamily: theme.fontFamily?.regular },
29
+ });
30
+
31
+ export function Text({
32
+ variant = 'body',
33
+ muted,
34
+ inverse,
35
+ color,
36
+ style,
37
+ ...rest
38
+ }: TextProps) {
39
+ const theme = useTheme();
40
+ const styles = useStyles(createStyles);
41
+ const resolved =
42
+ color ??
43
+ (inverse
44
+ ? theme.colors.textInverse
45
+ : muted
46
+ ? theme.colors.textMuted
47
+ : theme.colors.text);
48
+ return <RNText style={[styles[variant], { color: resolved }, style]} {...rest} />;
49
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { TextStyle, ViewStyle } from 'react-native';
2
+
3
+ export type ThemeColors = {
4
+ background: string;
5
+ surface: string;
6
+ surfaceMuted: string;
7
+ text: string;
8
+ textMuted: string;
9
+ textInverse: string;
10
+ border: string;
11
+ primary: string;
12
+ onPrimary: string;
13
+ danger: string;
14
+ success: string;
15
+ };
16
+
17
+ export type TypographyRole =
18
+ | 'display'
19
+ | 'heading'
20
+ | 'subheading'
21
+ | 'body'
22
+ | 'label'
23
+ | 'caption';
24
+
25
+ export type TypographyToken = Pick<
26
+ TextStyle,
27
+ 'fontSize' | 'lineHeight' | 'fontWeight' | 'letterSpacing'
28
+ >;
29
+
30
+ /** Roles only, no numeric palette: the brand already has one and remapping it is work nobody asked for. */
31
+ export type Theme = {
32
+ colors: ThemeColors;
33
+ spacing: { xs: number; sm: number; md: number; lg: number; xl: number; xxl: number };
34
+ typography: Record<TypographyRole, TypographyToken>;
35
+ radii: { sm: number; md: number; pill: number };
36
+ /** Two levels. `shadow*` on iOS, `elevation` on Android; both are set on each. */
37
+ elevation: { card: ViewStyle; modal: ViewStyle };
38
+ media: { aspectRatio: { product: number; banner: number; thumbnail: number } };
39
+ fontFamily?: { regular: string; medium: string; bold: string };
40
+ };
41
+
42
+ /** One theme, two schemes. `dark` is optional and falls back to `light`. */
43
+ export type ThemeEntry = { name: string; light: Theme; dark?: Theme };
44
+
45
+ export type ColorSchemePreference = 'system' | 'light' | 'dark';
46
+
47
+ /**
48
+ * A complete theme, inheriting nothing. The compiler enumerates what is missing, which is why
49
+ * this is a separate function rather than a flag on `extendTheme`.
50
+ */
51
+ export function defineTheme(theme: Theme): Theme {
52
+ return theme;
53
+ }
54
+
55
+ /** Deep merge onto an existing theme. */
56
+ export function extendTheme(base: Theme, overrides: ThemeOverrides): Theme {
57
+ return mergeTheme(base, overrides) as Theme;
58
+ }
59
+
60
+ export type ThemeOverrides = {
61
+ [K in keyof Theme]?: Theme[K] extends object
62
+ ? { [P in keyof Theme[K]]?: Partial<Theme[K][P]> | Theme[K][P] }
63
+ : Theme[K];
64
+ };
65
+
66
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
67
+ return (
68
+ typeof value === 'object' &&
69
+ value !== null &&
70
+ !Array.isArray(value) &&
71
+ Object.getPrototypeOf(value) === Object.prototype
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Core's `deepMerge` semantics, kept local so this module imports nothing from a React Native
77
+ * runtime — a theme is the one thing worth building and testing outside a device.
78
+ */
79
+ function mergeTheme(base: unknown, override: unknown): unknown {
80
+ if (override === undefined) return base;
81
+ if (!isPlainObject(base) || !isPlainObject(override)) return override;
82
+
83
+ const result: Record<string, unknown> = { ...base };
84
+ for (const key of Object.keys(override)) {
85
+ const next = override[key];
86
+ if (next === undefined) continue;
87
+ result[key] = isPlainObject(result[key]) ? mergeTheme(result[key], next) : next;
88
+ }
89
+ return result;
90
+ }
@@ -0,0 +1,12 @@
1
+ /** English strings for the defaults in this package. Other languages are the app's. */
2
+ export const defaultUiTranslations = {
3
+ en: {
4
+ ui: {
5
+ quantity: {
6
+ label: 'Quantity',
7
+ increase: 'Increase quantity',
8
+ decrease: 'Decrease quantity',
9
+ },
10
+ },
11
+ },
12
+ };
@@ -0,0 +1,35 @@
1
+ import { getGalleryImages, type ImageGroup, type Images } from '@laconius/core';
2
+ import { useCallback, useMemo, useState } from 'react';
3
+
4
+ export type GalleryApi = {
5
+ /** One entry per slide, each a group keyed by format code. */
6
+ items: ImageGroup[];
7
+ index: number;
8
+ selected: ImageGroup | undefined;
9
+ select: (index: number) => void;
10
+ next: () => void;
11
+ previous: () => void;
12
+ };
13
+
14
+ /** The selected slide — the only state a gallery owns. Paging is the caller's layout decision. */
15
+ export function useGallery(images: Images | undefined): GalleryApi {
16
+ const items = useMemo(() => getGalleryImages(images), [images]);
17
+ const [index, setIndex] = useState(0);
18
+
19
+ // A product switch shortens the list; clamping on read avoids an effect that fights the render.
20
+ const current = items.length === 0 ? 0 : Math.min(index, items.length - 1);
21
+
22
+ const select = useCallback(
23
+ (next: number) => setIndex(Math.max(0, next)),
24
+ [],
25
+ );
26
+
27
+ return {
28
+ items,
29
+ index: current,
30
+ selected: items[current],
31
+ select,
32
+ next: () => select(Math.min(current + 1, items.length - 1)),
33
+ previous: () => select(current - 1),
34
+ };
35
+ }
@@ -0,0 +1,47 @@
1
+ import { getPrimaryImage, type MediaContainer, type Price, type Product } from '@laconius/core';
2
+ import { useMemo } from 'react';
3
+
4
+ /**
5
+ * Semantic, not a translation key: the key space belongs to `@laconius/product`, and this hook
6
+ * lives in the UI kit, which never reaches into a feature package's namespace.
7
+ */
8
+ export type ProductCardBadge = 'outOfStock' | 'lowStock';
9
+
10
+ export type ProductCardView = {
11
+ code?: string;
12
+ title: string;
13
+ price?: Price;
14
+ media: MediaContainer;
15
+ badge?: ProductCardBadge;
16
+ averageRating?: number;
17
+ numberOfReviews?: number;
18
+ };
19
+
20
+ function badgeFor(product: Product): ProductCardBadge | undefined {
21
+ const status = product.stock?.stockLevelStatus;
22
+ if (product.purchasable === false || status === 'outOfStock') return 'outOfStock';
23
+ if (status === 'lowStock') return 'lowStock';
24
+ return undefined;
25
+ }
26
+
27
+ /**
28
+ * Derivation only — no fetch, no navigation. A card is rendered by the row of a `FlatList`, so
29
+ * everything it needs comes off the product it was handed.
30
+ *
31
+ * `priceRange.minPrice` covers the multidimensional products whose `price` is absent, which is
32
+ * what the search endpoint returns for apparel.
33
+ */
34
+ export function useProductCard(product: Product | undefined): ProductCardView {
35
+ return useMemo(
36
+ () => ({
37
+ code: product?.code,
38
+ title: product?.name ?? '',
39
+ price: product?.price ?? product?.priceRange?.minPrice,
40
+ media: getPrimaryImage(product?.images),
41
+ badge: product ? badgeFor(product) : undefined,
42
+ averageRating: product?.averageRating,
43
+ numberOfReviews: product?.numberOfReviews,
44
+ }),
45
+ [product],
46
+ );
47
+ }
@@ -0,0 +1,35 @@
1
+ export type QuantityStepperOptions = {
2
+ value: number;
3
+ /** Defaults to 1: zero quantity is a removal, which is the cart's decision, not the stepper's. */
4
+ min?: number;
5
+ max?: number;
6
+ onChange: (value: number) => void;
7
+ };
8
+
9
+ export type QuantityStepperApi = {
10
+ value: number;
11
+ canIncrement: boolean;
12
+ canDecrement: boolean;
13
+ increment: () => void;
14
+ decrement: () => void;
15
+ setValue: (value: number) => void;
16
+ };
17
+
18
+ /** Clamping and the disabled edges — the only state a stepper owns. It does not know what a cart is. */
19
+ export function useQuantityStepper({
20
+ value,
21
+ min = 1,
22
+ max,
23
+ onChange,
24
+ }: QuantityStepperOptions): QuantityStepperApi {
25
+ const clamp = (next: number) =>
26
+ Math.min(Math.max(Math.round(next), min), max ?? Number.POSITIVE_INFINITY);
27
+ return {
28
+ value,
29
+ canIncrement: max === undefined || value < max,
30
+ canDecrement: value > min,
31
+ increment: () => onChange(clamp(value + 1)),
32
+ decrement: () => onChange(clamp(value - 1)),
33
+ setValue: (next: number) => onChange(clamp(next)),
34
+ };
35
+ }