@liiift-studio/sanity-font-manager 2.3.19 → 2.5.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/README.md +437 -437
  2. package/dist/UploadModal-6LIX7XOK.js +6 -0
  3. package/dist/UploadModal-NME2W53V.mjs +6 -0
  4. package/dist/chunk-646WCBRR.mjs +7276 -0
  5. package/dist/chunk-FH4QKHOH.js +7276 -0
  6. package/dist/index.js +747 -1675
  7. package/dist/index.mjs +400 -1237
  8. package/package.json +85 -85
  9. package/src/components/BatchUploadFonts.jsx +653 -639
  10. package/src/components/BulkActions.jsx +99 -0
  11. package/src/components/ExistingDocumentResolver.jsx +152 -0
  12. package/src/components/FontReviewCard.jsx +415 -0
  13. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  14. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  15. package/src/components/KeyValueInput.jsx +95 -95
  16. package/src/components/KeyValueReferenceInput.jsx +254 -254
  17. package/src/components/NestedObjectArraySelector.jsx +146 -146
  18. package/src/components/PriceInput.jsx +26 -26
  19. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  20. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  21. package/src/components/SetOTF.jsx +87 -87
  22. package/src/components/SingleUploaderTool.jsx +672 -673
  23. package/src/components/StatusDisplay.jsx +26 -26
  24. package/src/components/StyleCountInput.jsx +16 -16
  25. package/src/components/UpdateScriptsComponent.jsx +76 -76
  26. package/src/components/UploadButton.jsx +43 -43
  27. package/src/components/UploadModal.jsx +268 -0
  28. package/src/components/UploadScriptsComponent.jsx +539 -537
  29. package/src/components/UploadStep1Settings.jsx +272 -0
  30. package/src/components/UploadStep2Review.jsx +472 -0
  31. package/src/components/UploadStep3Execute.jsx +234 -0
  32. package/src/components/UploadSummary.jsx +196 -0
  33. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  34. package/src/hooks/useNestedObjects.js +92 -92
  35. package/src/hooks/useSanityClient.js +9 -9
  36. package/src/index.js +115 -70
  37. package/src/schema/openTypeField.js +1945 -1945
  38. package/src/schema/styleCountField.js +12 -12
  39. package/src/schema/stylesField.js +268 -268
  40. package/src/schema/stylisticSetField.js +301 -301
  41. package/src/utils/buildUploadPlan.js +325 -0
  42. package/src/utils/executeUploadPlan.js +437 -0
  43. package/src/utils/executionReducer.js +56 -0
  44. package/src/utils/fontHelpers.js +267 -0
  45. package/src/utils/generateCssFile.js +207 -205
  46. package/src/utils/generateFontData.js +98 -145
  47. package/src/utils/generateFontFile.js +38 -38
  48. package/src/utils/generateKeywords.js +185 -185
  49. package/src/utils/generateSubset.js +45 -45
  50. package/src/utils/getEmptyFontKit.js +101 -99
  51. package/src/utils/parseFont.js +55 -0
  52. package/src/utils/parseVariableFontInstances.js +211 -211
  53. package/src/utils/planReducer.js +517 -0
  54. package/src/utils/planTypes.js +183 -0
  55. package/src/utils/processFontFiles.js +529 -477
  56. package/src/utils/regenerateFontData.js +146 -146
  57. package/src/utils/resolveExistingFont.js +87 -0
  58. package/src/utils/sanitizeForSanityId.js +65 -65
  59. package/src/utils/updateFontPrices.js +94 -94
  60. package/src/utils/updateTypefaceDocument.js +149 -160
  61. package/src/utils/uploadFontFiles.js +405 -316
  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
+ };