@liiift-studio/sanity-font-manager 2.7.0 → 2.8.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +2330 -7084
  4. package/dist/index.mjs +2331 -7085
  5. package/package.json +84 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -655
  7. package/src/components/BulkActions.jsx +99 -99
  8. package/src/components/ExistingDocumentResolver.jsx +152 -152
  9. package/src/components/FontReviewCard.jsx +455 -455
  10. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  11. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  12. package/src/components/KeyValueInput.jsx +95 -95
  13. package/src/components/KeyValueReferenceInput.jsx +267 -267
  14. package/src/components/NestedObjectArraySelector.jsx +146 -146
  15. package/src/components/PriceInput.jsx +26 -26
  16. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  17. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  18. package/src/components/SetOTF.jsx +87 -87
  19. package/src/components/SingleUploaderTool.jsx +674 -674
  20. package/src/components/StatusDisplay.jsx +26 -26
  21. package/src/components/StyleCountInput.jsx +16 -16
  22. package/src/components/UpdateScriptsComponent.jsx +76 -76
  23. package/src/components/UploadButton.jsx +43 -43
  24. package/src/components/UploadModal.jsx +309 -309
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -478
  28. package/src/components/UploadStep3Execute.jsx +234 -234
  29. package/src/components/UploadStep3bInstances.jsx +396 -396
  30. package/src/components/UploadSummary.jsx +196 -196
  31. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  32. package/src/hooks/useNestedObjects.js +92 -92
  33. package/src/hooks/useSanityClient.js +9 -9
  34. package/src/index.js +120 -120
  35. package/src/schema/openTypeField.js +1995 -1995
  36. package/src/schema/styleCountField.js +12 -12
  37. package/src/schema/stylesField.js +302 -302
  38. package/src/schema/stylisticSetField.js +301 -301
  39. package/src/utils/buildUploadPlan.js +326 -326
  40. package/src/utils/executeUploadPlan.js +430 -430
  41. package/src/utils/executionReducer.js +56 -56
  42. package/src/utils/fontHelpers.js +281 -281
  43. package/src/utils/generateCssFile.js +207 -207
  44. package/src/utils/generateFontData.js +98 -98
  45. package/src/utils/generateFontFile.js +38 -38
  46. package/src/utils/generateKeywords.js +185 -185
  47. package/src/utils/generateSubset.js +45 -45
  48. package/src/utils/getEmptyFontKit.js +101 -101
  49. package/src/utils/parseFont.js +56 -56
  50. package/src/utils/parseVariableFontInstances.js +301 -301
  51. package/src/utils/planReducer.js +531 -531
  52. package/src/utils/planTypes.js +183 -183
  53. package/src/utils/processFontFiles.js +530 -530
  54. package/src/utils/regenerateFontData.js +146 -146
  55. package/src/utils/resolveExistingFont.js +87 -87
  56. package/src/utils/retitleFontEntries.js +154 -154
  57. package/src/utils/sanitizeForSanityId.js +65 -65
  58. package/src/utils/setupDecompressors.js +27 -27
  59. package/src/utils/updateFontPrices.js +94 -94
  60. package/src/utils/updateTypefaceDocument.js +162 -162
  61. package/src/utils/uploadFontFiles.js +405 -405
  62. package/src/utils/utils.js +24 -24
@@ -1,146 +1,146 @@
1
- // Generic checkbox-list selector for nested objects from Sanity documents — configured via schema options
2
-
3
- import React, { useMemo, useState } from 'react';
4
- import { Stack, Card, Text, Checkbox, Box, Spinner, Flex } from '@sanity/ui';
5
- import { useNestedObjects } from '../hooks/useNestedObjects';
6
- import { set, unset } from 'sanity';
7
-
8
- /**
9
- * Sanity input component that renders a searchable checkbox list of items fetched
10
- * from nested arrays inside Sanity documents.
11
- *
12
- * Configure via schema options:
13
- * ```js
14
- * components: { input: NestedObjectArraySelector },
15
- * options: {
16
- * sourceType: 'licenseGroup', // document type to query
17
- * nestedField: 'sections', // array field to extract
18
- * titleField: 'title', // GROQ expression for display text
19
- * valueField: 'slug.current', // GROQ expression for stored value
20
- * filter: 'state == "published"', // optional GROQ filter
21
- * sortBy: 'title asc', // optional sort field + direction
22
- * emptyMessage: 'No options found', // optional empty state text
23
- * searchPlaceholder: 'Search...', // optional search input placeholder
24
- * }
25
- * ```
26
- */
27
- export const NestedObjectArraySelector = React.forwardRef((props, ref) => {
28
- const { value = [], onChange, schemaType } = props;
29
-
30
- const options = schemaType?.options || {};
31
- const {
32
- sourceType,
33
- nestedField,
34
- titleField,
35
- valueField,
36
- filter = '',
37
- sortBy = 'title asc',
38
- emptyMessage = 'No options found',
39
- searchPlaceholder = 'Search...',
40
- } = options;
41
-
42
- const [searchTerm, setSearchTerm] = useState('');
43
- const { objects, loading, error } = useNestedObjects({ sourceType, nestedField, titleField, valueField, filter, sortBy });
44
-
45
- const filteredObjects = useMemo(() => {
46
- if (!searchTerm) return objects;
47
- const lower = searchTerm.toLowerCase();
48
- return objects.filter(obj => obj.title?.toLowerCase().includes(lower));
49
- }, [objects, searchTerm]);
50
-
51
- const handleToggle = (itemValue) => {
52
- const currentValue = value || [];
53
- const isSelected = currentValue.includes(itemValue);
54
- if (isSelected) {
55
- const newValue = currentValue.filter(v => v !== itemValue);
56
- onChange(newValue.length > 0 ? set(newValue) : unset());
57
- } else {
58
- onChange(set([...currentValue, itemValue]));
59
- }
60
- };
61
-
62
- if (!sourceType || !nestedField || !titleField || !valueField) {
63
- return (
64
- <Card padding={3} tone="critical" border>
65
- <Text size={1}>Configuration error: Missing required options (sourceType, nestedField, titleField, or valueField)</Text>
66
- </Card>
67
- );
68
- }
69
-
70
- if (loading) {
71
- return (
72
- <Card padding={3} border>
73
- <Flex align="center" justify="center" padding={4}>
74
- <Spinner />
75
- <Box marginLeft={3}><Text size={1}>Loading options...</Text></Box>
76
- </Flex>
77
- </Card>
78
- );
79
- }
80
-
81
- if (error) {
82
- return (
83
- <Card padding={3} tone="critical" border>
84
- <Text size={1}>Error loading options: {error.message}</Text>
85
- </Card>
86
- );
87
- }
88
-
89
- if (objects.length === 0) {
90
- return (
91
- <Card padding={3} tone="transparent" border>
92
- <Text size={1} muted>{emptyMessage}</Text>
93
- </Card>
94
- );
95
- }
96
-
97
- return (
98
- <Card padding={0} border ref={ref}>
99
- {objects.length > 5 && (
100
- <Box padding={3} style={{ borderBottom: '1px solid var(--card-border-color)' }}>
101
- <input
102
- type="text"
103
- placeholder={searchPlaceholder}
104
- value={searchTerm}
105
- onChange={(e) => setSearchTerm(e.target.value)}
106
- style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--card-border-color)', borderRadius: '4px', fontSize: '13px', fontFamily: 'inherit' }}
107
- />
108
- </Box>
109
- )}
110
-
111
- <Stack space={0}>
112
- {filteredObjects.length === 0 ? (
113
- <Box padding={3}>
114
- <Text size={1} muted>No results found for "{searchTerm}"</Text>
115
- </Box>
116
- ) : (
117
- filteredObjects.map((obj, index) => {
118
- const isSelected = value?.includes(obj.value);
119
- const isLast = index === filteredObjects.length - 1;
120
- return (
121
- <Box
122
- key={obj.value}
123
- padding={3}
124
- style={{ borderBottom: isLast ? 'none' : '1px solid var(--card-border-color)', cursor: 'pointer', backgroundColor: isSelected ? 'var(--card-muted-fg-color)' : 'transparent', transition: 'background-color 0.2s' }}
125
- onClick={() => handleToggle(obj.value)}
126
- >
127
- <Flex align="center" gap={3}>
128
- <Checkbox checked={isSelected} readOnly style={{ pointerEvents: 'none' }} />
129
- <Text size={1} weight={isSelected ? 'semibold' : 'regular'}>{obj.title}</Text>
130
- </Flex>
131
- </Box>
132
- );
133
- })
134
- )}
135
- </Stack>
136
-
137
- {value?.length > 0 && (
138
- <Box padding={2} paddingX={3} style={{ borderTop: '1px solid var(--card-border-color)', backgroundColor: 'var(--card-muted-fg-color)' }}>
139
- <Text size={1} muted>{value.length} selected</Text>
140
- </Box>
141
- )}
142
- </Card>
143
- );
144
- });
145
-
146
- NestedObjectArraySelector.displayName = 'NestedObjectArraySelector';
1
+ // Generic checkbox-list selector for nested objects from Sanity documents — configured via schema options
2
+
3
+ import React, { useMemo, useState } from 'react';
4
+ import { Stack, Card, Text, Checkbox, Box, Spinner, Flex } from '@sanity/ui';
5
+ import { useNestedObjects } from '../hooks/useNestedObjects';
6
+ import { set, unset } from 'sanity';
7
+
8
+ /**
9
+ * Sanity input component that renders a searchable checkbox list of items fetched
10
+ * from nested arrays inside Sanity documents.
11
+ *
12
+ * Configure via schema options:
13
+ * ```js
14
+ * components: { input: NestedObjectArraySelector },
15
+ * options: {
16
+ * sourceType: 'licenseGroup', // document type to query
17
+ * nestedField: 'sections', // array field to extract
18
+ * titleField: 'title', // GROQ expression for display text
19
+ * valueField: 'slug.current', // GROQ expression for stored value
20
+ * filter: 'state == "published"', // optional GROQ filter
21
+ * sortBy: 'title asc', // optional sort field + direction
22
+ * emptyMessage: 'No options found', // optional empty state text
23
+ * searchPlaceholder: 'Search...', // optional search input placeholder
24
+ * }
25
+ * ```
26
+ */
27
+ export const NestedObjectArraySelector = React.forwardRef((props, ref) => {
28
+ const { value = [], onChange, schemaType } = props;
29
+
30
+ const options = schemaType?.options || {};
31
+ const {
32
+ sourceType,
33
+ nestedField,
34
+ titleField,
35
+ valueField,
36
+ filter = '',
37
+ sortBy = 'title asc',
38
+ emptyMessage = 'No options found',
39
+ searchPlaceholder = 'Search...',
40
+ } = options;
41
+
42
+ const [searchTerm, setSearchTerm] = useState('');
43
+ const { objects, loading, error } = useNestedObjects({ sourceType, nestedField, titleField, valueField, filter, sortBy });
44
+
45
+ const filteredObjects = useMemo(() => {
46
+ if (!searchTerm) return objects;
47
+ const lower = searchTerm.toLowerCase();
48
+ return objects.filter(obj => obj.title?.toLowerCase().includes(lower));
49
+ }, [objects, searchTerm]);
50
+
51
+ const handleToggle = (itemValue) => {
52
+ const currentValue = value || [];
53
+ const isSelected = currentValue.includes(itemValue);
54
+ if (isSelected) {
55
+ const newValue = currentValue.filter(v => v !== itemValue);
56
+ onChange(newValue.length > 0 ? set(newValue) : unset());
57
+ } else {
58
+ onChange(set([...currentValue, itemValue]));
59
+ }
60
+ };
61
+
62
+ if (!sourceType || !nestedField || !titleField || !valueField) {
63
+ return (
64
+ <Card padding={3} tone="critical" border>
65
+ <Text size={1}>Configuration error: Missing required options (sourceType, nestedField, titleField, or valueField)</Text>
66
+ </Card>
67
+ );
68
+ }
69
+
70
+ if (loading) {
71
+ return (
72
+ <Card padding={3} border>
73
+ <Flex align="center" justify="center" padding={4}>
74
+ <Spinner />
75
+ <Box marginLeft={3}><Text size={1}>Loading options...</Text></Box>
76
+ </Flex>
77
+ </Card>
78
+ );
79
+ }
80
+
81
+ if (error) {
82
+ return (
83
+ <Card padding={3} tone="critical" border>
84
+ <Text size={1}>Error loading options: {error.message}</Text>
85
+ </Card>
86
+ );
87
+ }
88
+
89
+ if (objects.length === 0) {
90
+ return (
91
+ <Card padding={3} tone="transparent" border>
92
+ <Text size={1} muted>{emptyMessage}</Text>
93
+ </Card>
94
+ );
95
+ }
96
+
97
+ return (
98
+ <Card padding={0} border ref={ref}>
99
+ {objects.length > 5 && (
100
+ <Box padding={3} style={{ borderBottom: '1px solid var(--card-border-color)' }}>
101
+ <input
102
+ type="text"
103
+ placeholder={searchPlaceholder}
104
+ value={searchTerm}
105
+ onChange={(e) => setSearchTerm(e.target.value)}
106
+ style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--card-border-color)', borderRadius: '4px', fontSize: '13px', fontFamily: 'inherit' }}
107
+ />
108
+ </Box>
109
+ )}
110
+
111
+ <Stack space={0}>
112
+ {filteredObjects.length === 0 ? (
113
+ <Box padding={3}>
114
+ <Text size={1} muted>No results found for "{searchTerm}"</Text>
115
+ </Box>
116
+ ) : (
117
+ filteredObjects.map((obj, index) => {
118
+ const isSelected = value?.includes(obj.value);
119
+ const isLast = index === filteredObjects.length - 1;
120
+ return (
121
+ <Box
122
+ key={obj.value}
123
+ padding={3}
124
+ style={{ borderBottom: isLast ? 'none' : '1px solid var(--card-border-color)', cursor: 'pointer', backgroundColor: isSelected ? 'var(--card-muted-fg-color)' : 'transparent', transition: 'background-color 0.2s' }}
125
+ onClick={() => handleToggle(obj.value)}
126
+ >
127
+ <Flex align="center" gap={3}>
128
+ <Checkbox checked={isSelected} readOnly style={{ pointerEvents: 'none' }} />
129
+ <Text size={1} weight={isSelected ? 'semibold' : 'regular'}>{obj.title}</Text>
130
+ </Flex>
131
+ </Box>
132
+ );
133
+ })
134
+ )}
135
+ </Stack>
136
+
137
+ {value?.length > 0 && (
138
+ <Box padding={2} paddingX={3} style={{ borderTop: '1px solid var(--card-border-color)', backgroundColor: 'var(--card-muted-fg-color)' }}>
139
+ <Text size={1} muted>{value.length} selected</Text>
140
+ </Box>
141
+ )}
142
+ </Card>
143
+ );
144
+ });
145
+
146
+ NestedObjectArraySelector.displayName = 'NestedObjectArraySelector';
@@ -1,26 +1,26 @@
1
- // Reusable $ + number input for collection and pair price fields
2
-
3
- import React from 'react';
4
- import { Flex, Text } from '@sanity/ui';
5
-
6
- /**
7
- * Renders an inline price field: "Price $ [input] per style"
8
- * @param {Object} props
9
- * @param {string} props.inputPrice - Current price value
10
- * @param {Function} props.handleInputChange - onChange handler
11
- */
12
- const PriceInput = ({ inputPrice, handleInputChange }) => (
13
- <Flex align="center" gap={2}>
14
- <Text size={1} muted>Price:</Text>
15
- <Text size={1} muted>$</Text>
16
- <input
17
- value={inputPrice}
18
- onChange={handleInputChange}
19
- type="number"
20
- style={{ textAlign: 'end', padding: '5px', maxWidth: '75px' }}
21
- />
22
- <Text size={1} muted>per style</Text>
23
- </Flex>
24
- );
25
-
26
- export default PriceInput;
1
+ // Reusable $ + number input for collection and pair price fields
2
+
3
+ import React from 'react';
4
+ import { Flex, Text } from '@sanity/ui';
5
+
6
+ /**
7
+ * Renders an inline price field: "Price $ [input] per style"
8
+ * @param {Object} props
9
+ * @param {string} props.inputPrice - Current price value
10
+ * @param {Function} props.handleInputChange - onChange handler
11
+ */
12
+ const PriceInput = ({ inputPrice, handleInputChange }) => (
13
+ <Flex align="center" gap={2}>
14
+ <Text size={1} muted>Price:</Text>
15
+ <Text size={1} muted>$</Text>
16
+ <input
17
+ value={inputPrice}
18
+ onChange={handleInputChange}
19
+ type="number"
20
+ style={{ textAlign: 'end', padding: '5px', maxWidth: '75px' }}
21
+ />
22
+ <Text size={1} muted>per style</Text>
23
+ </Flex>
24
+ );
25
+
26
+ export default PriceInput;
@@ -1,116 +1,116 @@
1
- // Generates a single primary full-family collection from a typeface's linked fonts and prepends it to the existing collections array
2
-
3
- import React, { useCallback, useState } from 'react';
4
- import { Stack, Flex, Text, Button, Card, Spinner } from '@sanity/ui';
5
- import { useFormValue } from 'sanity';
6
- import { nanoid } from 'nanoid';
7
-
8
- import { useSanityClient } from '../hooks/useSanityClient';
9
- import StatusDisplay from './StatusDisplay';
10
-
11
- /**
12
- * Generates a full-family collection document from the typeface's linked fonts
13
- * and prepends it to the existing styles.collections array.
14
- */
15
- export const PrimaryCollectionGeneratorTypeface = () => {
16
- const client = useSanityClient();
17
-
18
- const [status, setStatus] = useState('ready');
19
- const [ready, setReady] = useState(true);
20
- const [price, setPrice] = useState(
21
- process.env.SANITY_STUDIO_DEFAULT_COLLECTION_PRICE || 100
22
- );
23
-
24
- const fonts = useFormValue(['styles', 'fonts']);
25
- const title = useFormValue(['title']);
26
- const preferredStyle = useFormValue(['preferredStyle']);
27
- const docId = useFormValue(['_id']);
28
- const styles = useFormValue(['styles']);
29
-
30
- /** Creates or replaces the full-family collection document and prepends it to the typeface's collections array. */
31
- const generateCollection = useCallback(async () => {
32
- setStatus('Generating collection...');
33
- setReady(false);
34
-
35
- let id = title.toLowerCase().replace(/\s+/g, '-').slice(0, 200);
36
- if (!id.includes('collection')) id += '-collection';
37
-
38
- const colTitle = id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
39
-
40
- const collectionDoc = {
41
- _key: nanoid(),
42
- _id: id,
43
- title: colTitle,
44
- slug: { _type: 'slug', current: id },
45
- price: Number(price) || 0,
46
- fonts: Object.values(fonts),
47
- preferredStyle: preferredStyle,
48
- _type: 'collection',
49
- type: 'collection',
50
- };
51
-
52
- try {
53
- const sanityCollection = await client.createOrReplace(collectionDoc);
54
- const collections = styles.collections || [];
55
-
56
- await client.patch(docId)
57
- .setIfMissing({ styles: {} })
58
- .set({
59
- styles: {
60
- ...styles,
61
- collections: [{
62
- _type: 'reference',
63
- _key: nanoid(),
64
- _ref: sanityCollection._id,
65
- _weak: true,
66
- }, ...collections],
67
- },
68
- })
69
- .commit();
70
-
71
- setStatus('Collection generated');
72
- } catch (err) {
73
- console.error('Error creating collection:', err.message);
74
- setStatus('Error generating collection');
75
- }
76
-
77
- setReady(true);
78
- }, [docId, fonts, price, preferredStyle, styles, title, client]);
79
-
80
- if (!title || !fonts) return null;
81
-
82
- return (
83
- <Stack space={2}>
84
- <StatusDisplay status={status} error={false} />
85
- <Card border padding={2} shadow={1} radius={2}>
86
- {ready ? (
87
- <Stack space={3}>
88
- <Flex align="center" gap={2} marginTop={1} marginBottom={1}>
89
- <Text size={1} muted>Price</Text>
90
- <Text size={1} muted>$</Text>
91
- <input
92
- value={price}
93
- onChange={(e) => setPrice(e.target.value)}
94
- type="number"
95
- style={{ textAlign: 'end', padding: '5px', maxWidth: '75px' }}
96
- />
97
- <Text size={1} muted>per full family</Text>
98
- </Flex>
99
- <Button
100
- mode="ghost"
101
- tone="primary"
102
- style={{ width: '100%' }}
103
- onClick={generateCollection}
104
- text="Generate Full Family Collection"
105
- />
106
- </Stack>
107
- ) : (
108
- <Flex align="center" justify="center" gap={3} padding={4}>
109
- <Spinner />
110
- <Text muted size={1}>{status}</Text>
111
- </Flex>
112
- )}
113
- </Card>
114
- </Stack>
115
- );
116
- };
1
+ // Generates a single primary full-family collection from a typeface's linked fonts and prepends it to the existing collections array
2
+
3
+ import React, { useCallback, useState } from 'react';
4
+ import { Stack, Flex, Text, Button, Card, Spinner } from '@sanity/ui';
5
+ import { useFormValue } from 'sanity';
6
+ import { nanoid } from 'nanoid';
7
+
8
+ import { useSanityClient } from '../hooks/useSanityClient';
9
+ import StatusDisplay from './StatusDisplay';
10
+
11
+ /**
12
+ * Generates a full-family collection document from the typeface's linked fonts
13
+ * and prepends it to the existing styles.collections array.
14
+ */
15
+ export const PrimaryCollectionGeneratorTypeface = () => {
16
+ const client = useSanityClient();
17
+
18
+ const [status, setStatus] = useState('ready');
19
+ const [ready, setReady] = useState(true);
20
+ const [price, setPrice] = useState(
21
+ process.env.SANITY_STUDIO_DEFAULT_COLLECTION_PRICE || 100
22
+ );
23
+
24
+ const fonts = useFormValue(['styles', 'fonts']);
25
+ const title = useFormValue(['title']);
26
+ const preferredStyle = useFormValue(['preferredStyle']);
27
+ const docId = useFormValue(['_id']);
28
+ const styles = useFormValue(['styles']);
29
+
30
+ /** Creates or replaces the full-family collection document and prepends it to the typeface's collections array. */
31
+ const generateCollection = useCallback(async () => {
32
+ setStatus('Generating collection...');
33
+ setReady(false);
34
+
35
+ let id = title.toLowerCase().replace(/\s+/g, '-').slice(0, 200);
36
+ if (!id.includes('collection')) id += '-collection';
37
+
38
+ const colTitle = id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
39
+
40
+ const collectionDoc = {
41
+ _key: nanoid(),
42
+ _id: id,
43
+ title: colTitle,
44
+ slug: { _type: 'slug', current: id },
45
+ price: Number(price) || 0,
46
+ fonts: Object.values(fonts),
47
+ preferredStyle: preferredStyle,
48
+ _type: 'collection',
49
+ type: 'collection',
50
+ };
51
+
52
+ try {
53
+ const sanityCollection = await client.createOrReplace(collectionDoc);
54
+ const collections = styles.collections || [];
55
+
56
+ await client.patch(docId)
57
+ .setIfMissing({ styles: {} })
58
+ .set({
59
+ styles: {
60
+ ...styles,
61
+ collections: [{
62
+ _type: 'reference',
63
+ _key: nanoid(),
64
+ _ref: sanityCollection._id,
65
+ _weak: true,
66
+ }, ...collections],
67
+ },
68
+ })
69
+ .commit();
70
+
71
+ setStatus('Collection generated');
72
+ } catch (err) {
73
+ console.error('Error creating collection:', err.message);
74
+ setStatus('Error generating collection');
75
+ }
76
+
77
+ setReady(true);
78
+ }, [docId, fonts, price, preferredStyle, styles, title, client]);
79
+
80
+ if (!title || !fonts) return null;
81
+
82
+ return (
83
+ <Stack space={2}>
84
+ <StatusDisplay status={status} error={false} />
85
+ <Card border padding={2} shadow={1} radius={2}>
86
+ {ready ? (
87
+ <Stack space={3}>
88
+ <Flex align="center" gap={2} marginTop={1} marginBottom={1}>
89
+ <Text size={1} muted>Price</Text>
90
+ <Text size={1} muted>$</Text>
91
+ <input
92
+ value={price}
93
+ onChange={(e) => setPrice(e.target.value)}
94
+ type="number"
95
+ style={{ textAlign: 'end', padding: '5px', maxWidth: '75px' }}
96
+ />
97
+ <Text size={1} muted>per full family</Text>
98
+ </Flex>
99
+ <Button
100
+ mode="ghost"
101
+ tone="primary"
102
+ style={{ width: '100%' }}
103
+ onClick={generateCollection}
104
+ text="Generate Full Family Collection"
105
+ />
106
+ </Stack>
107
+ ) : (
108
+ <Flex align="center" justify="center" gap={3} padding={4}>
109
+ <Spinner />
110
+ <Text muted size={1}>{status}</Text>
111
+ </Flex>
112
+ )}
113
+ </Card>
114
+ </Stack>
115
+ );
116
+ };