astro-tractstack 2.2.10 → 2.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.
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +89 -8
- package/package.json +3 -1
- package/templates/custom/minimal/CodeHook.astro +14 -5
- package/templates/custom/shopify/CalDotComBooking.tsx +44 -0
- package/templates/custom/shopify/Cart.tsx +345 -0
- package/templates/custom/shopify/CartIcon.tsx +47 -0
- package/templates/custom/shopify/CartModal.tsx +63 -0
- package/templates/custom/shopify/CheckoutModal.tsx +187 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +145 -0
- package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
- package/templates/custom/shopify/ShopifyProductGrid.tsx +281 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +118 -0
- package/templates/custom/shopify/cart.astro +23 -0
- package/templates/custom/with-examples/CodeHook.astro +9 -1
- package/templates/custom/with-examples/ProductGrid.astro +1 -1
- package/templates/src/client/app.js +4 -2
- package/templates/src/components/Header.astro +37 -11
- package/templates/src/components/form/advanced/APIConfigSection.tsx +165 -38
- package/templates/src/components/storykeep/Dashboard.tsx +17 -3
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
- package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +525 -0
- package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
- package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
- package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +254 -0
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
- package/templates/src/lib/resources.ts +11 -21
- package/templates/src/pages/api/shopify/createCart.ts +73 -0
- package/templates/src/pages/api/shopify/getProducts.ts +64 -0
- package/templates/src/pages/storykeep/login.astro +5 -10
- package/templates/src/pages/storykeep/logout.astro +1 -10
- package/templates/src/pages/storykeep/manage.astro +69 -0
- package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
- package/templates/src/pages/storykeep/shopify.astro +101 -0
- package/templates/src/stores/navigation.ts +3 -42
- package/templates/src/stores/nodes.ts +3 -1
- package/templates/src/stores/resources.ts +7 -10
- package/templates/src/stores/shopify.ts +210 -0
- package/templates/src/types/tractstack.ts +21 -0
- package/templates/src/utils/api/advancedConfig.ts +5 -1
- package/templates/src/utils/api/advancedHelpers.ts +48 -5
- package/templates/src/utils/api/brandHelpers.ts +4 -0
- package/templates/src/utils/api/resourceConfig.ts +13 -5
- package/templates/src/utils/customHelpers.ts +70 -0
- package/templates/src/utils/helpers.ts +59 -0
- package/utils/inject-files.ts +83 -2
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo } from 'react';
|
|
2
|
+
import { useStore } from '@nanostores/react';
|
|
3
|
+
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
|
|
4
|
+
import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
|
|
5
|
+
import CheckIcon from '@heroicons/react/24/outline/CheckIcon';
|
|
6
|
+
import {
|
|
7
|
+
shopifyData,
|
|
8
|
+
shopifyStatus,
|
|
9
|
+
fetchShopifyProducts,
|
|
10
|
+
type ShopifyProduct,
|
|
11
|
+
} from '@/stores/shopify';
|
|
12
|
+
import ProductTable from './controls/content/ProductTable';
|
|
13
|
+
import ResourceForm from './controls/content/ResourceForm';
|
|
14
|
+
import { saveBrandConfigWithStateUpdate } from '@/utils/api/brandConfig';
|
|
15
|
+
import {
|
|
16
|
+
deleteResource,
|
|
17
|
+
getResourcesByCategory,
|
|
18
|
+
} from '@/utils/api/resourceConfig';
|
|
19
|
+
import { convertToLocalState } from '@/utils/api/brandHelpers';
|
|
20
|
+
import BooleanToggle from '@/components/form/BooleanToggle';
|
|
21
|
+
import type { BrandConfig, BrandConfigState } from '@/types/tractstack';
|
|
22
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
23
|
+
import type { ResourceConfig } from '@/types/tractstack';
|
|
24
|
+
|
|
25
|
+
interface DashboardShopifyProps {
|
|
26
|
+
brandConfig: BrandConfig;
|
|
27
|
+
existingResources: ResourceNode[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type MachineState = 'INIT' | 'CONFIG' | 'UPDATE' | 'READY';
|
|
31
|
+
|
|
32
|
+
export default function StoryKeepDashboard_Shopify({
|
|
33
|
+
brandConfig,
|
|
34
|
+
existingResources,
|
|
35
|
+
}: DashboardShopifyProps) {
|
|
36
|
+
const data = useStore(shopifyData);
|
|
37
|
+
const status = useStore(shopifyStatus);
|
|
38
|
+
const [selectedProduct, setSelectedProduct] = useState<ShopifyProduct | null>(
|
|
39
|
+
null
|
|
40
|
+
);
|
|
41
|
+
const [copied, setCopied] = useState(false);
|
|
42
|
+
|
|
43
|
+
const [resources, setResources] = useState<ResourceNode[]>(existingResources);
|
|
44
|
+
|
|
45
|
+
const [draftResource, setDraftResource] =
|
|
46
|
+
useState<Partial<ResourceConfig> | null>(null);
|
|
47
|
+
const [showResourceModal, setShowResourceModal] = useState(false);
|
|
48
|
+
const [isCreateMode, setIsCreateMode] = useState(true);
|
|
49
|
+
const [targetProduct, setTargetProduct] = useState<ShopifyProduct | null>(
|
|
50
|
+
null
|
|
51
|
+
);
|
|
52
|
+
const [showTypeSelector, setShowTypeSelector] = useState(false);
|
|
53
|
+
|
|
54
|
+
const [machineState, setMachineState] = useState<MachineState>('INIT');
|
|
55
|
+
const [internalBrandConfig, setInternalBrandConfig] =
|
|
56
|
+
useState<BrandConfigState | null>(null);
|
|
57
|
+
|
|
58
|
+
const [wantProduct, setWantProduct] = useState(true);
|
|
59
|
+
const [wantService, setWantService] = useState(true);
|
|
60
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (brandConfig) {
|
|
64
|
+
const localState = convertToLocalState(brandConfig);
|
|
65
|
+
setInternalBrandConfig(localState);
|
|
66
|
+
|
|
67
|
+
const hasProduct = !!localState.knownResources['product'];
|
|
68
|
+
if (hasProduct) {
|
|
69
|
+
setMachineState('READY');
|
|
70
|
+
} else {
|
|
71
|
+
setMachineState('CONFIG');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, [brandConfig]);
|
|
75
|
+
|
|
76
|
+
// Operational Effect: Fetch products only when READY
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (machineState === 'READY') {
|
|
79
|
+
fetchShopifyProducts();
|
|
80
|
+
}
|
|
81
|
+
}, [machineState]);
|
|
82
|
+
|
|
83
|
+
// Memoize the lookup map for performance (gid -> ResourceNode)
|
|
84
|
+
const linkedResourceMap = useMemo(() => {
|
|
85
|
+
const map = new Map<string, ResourceNode>();
|
|
86
|
+
resources.forEach((r) => {
|
|
87
|
+
if (r.optionsPayload?.gid) {
|
|
88
|
+
map.set(r.optionsPayload.gid, r);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return map;
|
|
92
|
+
}, [resources]);
|
|
93
|
+
|
|
94
|
+
const handleRefresh = () => {
|
|
95
|
+
fetchShopifyProducts();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const refreshResources = async () => {
|
|
99
|
+
const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
|
|
100
|
+
try {
|
|
101
|
+
// Fetch both categories if configured, then merge unique
|
|
102
|
+
const promises = [];
|
|
103
|
+
if (internalBrandConfig?.knownResources['product']) {
|
|
104
|
+
promises.push(getResourcesByCategory(tenantId, 'product'));
|
|
105
|
+
}
|
|
106
|
+
if (internalBrandConfig?.knownResources['service']) {
|
|
107
|
+
promises.push(getResourcesByCategory(tenantId, 'service'));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const results = await Promise.all(promises);
|
|
111
|
+
const flattened = results.flat() as ResourceNode[];
|
|
112
|
+
|
|
113
|
+
setResources((prev) => {
|
|
114
|
+
const otherTypes = prev.filter(
|
|
115
|
+
(r) => r.categorySlug !== 'product' && r.categorySlug !== 'service'
|
|
116
|
+
);
|
|
117
|
+
return [...otherTypes, ...flattened];
|
|
118
|
+
});
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error('Failed to refresh resources', e);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const handleLink = (product: ShopifyProduct) => {
|
|
125
|
+
const hasProductSchema = !!internalBrandConfig?.knownResources['product'];
|
|
126
|
+
const hasServiceSchema = !!internalBrandConfig?.knownResources['service'];
|
|
127
|
+
|
|
128
|
+
if (hasProductSchema && hasServiceSchema) {
|
|
129
|
+
setTargetProduct(product);
|
|
130
|
+
setShowTypeSelector(true);
|
|
131
|
+
} else if (hasServiceSchema) {
|
|
132
|
+
startCreateFlow('service', product);
|
|
133
|
+
} else {
|
|
134
|
+
startCreateFlow('product', product);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const handleEdit = (_product: ShopifyProduct, resource: ResourceNode) => {
|
|
139
|
+
setDraftResource(resource as any);
|
|
140
|
+
setIsCreateMode(false);
|
|
141
|
+
setShowResourceModal(true);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const startCreateFlow = (category: string, product: ShopifyProduct) => {
|
|
145
|
+
const schema = internalBrandConfig?.knownResources[category] || {};
|
|
146
|
+
const mergedOptions: Record<string, any> = {
|
|
147
|
+
gid: product.id,
|
|
148
|
+
shopifyData: JSON.stringify(product),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Apply schema defaults for missing fields
|
|
152
|
+
Object.entries(schema).forEach(([key, def]) => {
|
|
153
|
+
if (mergedOptions[key] === undefined) {
|
|
154
|
+
if (def.type === 'number') {
|
|
155
|
+
mergedOptions[key] = def.defaultValue ?? def.minNumber ?? 0;
|
|
156
|
+
} else if (def.type === 'boolean') {
|
|
157
|
+
mergedOptions[key] = def.defaultValue ?? false;
|
|
158
|
+
} else if (def.type === 'string') {
|
|
159
|
+
mergedOptions[key] = def.defaultValue ?? '';
|
|
160
|
+
} else if (def.type === 'multi') {
|
|
161
|
+
mergedOptions[key] = def.defaultValue ?? [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
setDraftResource({
|
|
167
|
+
title: product.title,
|
|
168
|
+
oneliner: product.description || '',
|
|
169
|
+
slug: `${category}-${product.handle}`.toLowerCase(),
|
|
170
|
+
categorySlug: category,
|
|
171
|
+
optionsPayload: mergedOptions,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
setIsCreateMode(true);
|
|
175
|
+
setShowTypeSelector(false);
|
|
176
|
+
setShowResourceModal(true);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleUnlink = async (resourceId: string) => {
|
|
180
|
+
if (
|
|
181
|
+
!confirm(
|
|
182
|
+
'Are you sure you want to unlink this resource? Content on your site relying on this link may break.'
|
|
183
|
+
)
|
|
184
|
+
) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await deleteResource(
|
|
190
|
+
window.TRACTSTACK_CONFIG?.tenantId || 'default',
|
|
191
|
+
resourceId
|
|
192
|
+
);
|
|
193
|
+
// Optimistic update
|
|
194
|
+
setResources((prev) => prev.filter((r) => r.id !== resourceId));
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error('Unlink failed', error);
|
|
197
|
+
alert('Failed to delete resource');
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleCopy = () => {
|
|
202
|
+
if (selectedProduct) {
|
|
203
|
+
navigator.clipboard.writeText(JSON.stringify(selectedProduct, null, 2));
|
|
204
|
+
setCopied(true);
|
|
205
|
+
setTimeout(() => setCopied(false), 2000);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const handleConfigContinue = async () => {
|
|
210
|
+
if (!internalBrandConfig) return;
|
|
211
|
+
setIsSaving(true);
|
|
212
|
+
setMachineState('UPDATE');
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const updatedKnownResources = { ...internalBrandConfig.knownResources };
|
|
216
|
+
|
|
217
|
+
// 1. Product Schema
|
|
218
|
+
if (wantProduct) {
|
|
219
|
+
updatedKnownResources['product'] = {
|
|
220
|
+
gid: { type: 'string', optional: false },
|
|
221
|
+
allowMultiple: { type: 'boolean', optional: false },
|
|
222
|
+
shopifyData: { type: 'string', optional: false },
|
|
223
|
+
shopifyImage: { type: 'string', optional: true, defaultValue: '{}' },
|
|
224
|
+
...(wantService
|
|
225
|
+
? {
|
|
226
|
+
serviceBound: {
|
|
227
|
+
type: 'string',
|
|
228
|
+
optional: true,
|
|
229
|
+
belongsToCategory: 'service',
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
: {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 2. Service Schema
|
|
237
|
+
if (wantService) {
|
|
238
|
+
updatedKnownResources['service'] = {
|
|
239
|
+
gid: { type: 'string', optional: true },
|
|
240
|
+
shopifyData: { type: 'string', optional: true },
|
|
241
|
+
shopifyImage: { type: 'string', optional: true, defaultValue: '{}' },
|
|
242
|
+
bookingLengthMinutes: {
|
|
243
|
+
type: 'number',
|
|
244
|
+
optional: false,
|
|
245
|
+
minNumber: 15,
|
|
246
|
+
maxNumber: 120,
|
|
247
|
+
defaultValue: 15,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const updatedState = {
|
|
253
|
+
...internalBrandConfig,
|
|
254
|
+
knownResources: updatedKnownResources,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const freshConfig = await saveBrandConfigWithStateUpdate(
|
|
258
|
+
window.TRACTSTACK_CONFIG?.tenantId || 'default',
|
|
259
|
+
updatedState
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
setInternalBrandConfig(freshConfig);
|
|
263
|
+
setMachineState('READY');
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('Failed to configure Shopify resources:', error);
|
|
266
|
+
setMachineState('CONFIG');
|
|
267
|
+
} finally {
|
|
268
|
+
setIsSaving(false);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (machineState === 'INIT') {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// CONFIG State
|
|
277
|
+
if (machineState === 'CONFIG' || machineState === 'UPDATE') {
|
|
278
|
+
return (
|
|
279
|
+
<div className="mt-8 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
280
|
+
<div className="mb-6 border-b border-gray-100 pb-4">
|
|
281
|
+
<h2 className="text-xl font-bold text-gray-900">Shopify Setup</h2>
|
|
282
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
283
|
+
Configure how your store interacts with StoryKeep content.
|
|
284
|
+
</p>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div className="space-y-6">
|
|
288
|
+
<div className="rounded-md bg-blue-50 p-4">
|
|
289
|
+
<div className="flex">
|
|
290
|
+
<div className="flex-shrink-0">
|
|
291
|
+
<span className="text-blue-400">ℹ️</span>
|
|
292
|
+
</div>
|
|
293
|
+
<div className="ml-3">
|
|
294
|
+
<h3 className="text-sm font-bold text-blue-800">
|
|
295
|
+
Resource Configuration
|
|
296
|
+
</h3>
|
|
297
|
+
<div className="mt-2 text-sm text-blue-700">
|
|
298
|
+
<p>
|
|
299
|
+
We need to create content definitions for your store. Select
|
|
300
|
+
the types of content you plan to manage.
|
|
301
|
+
</p>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div className="space-y-4">
|
|
308
|
+
<BooleanToggle
|
|
309
|
+
label="Enable Products"
|
|
310
|
+
description="Creates a 'product' resource type to map Shopify items."
|
|
311
|
+
value={wantProduct}
|
|
312
|
+
onChange={setWantProduct}
|
|
313
|
+
disabled={isSaving}
|
|
314
|
+
/>
|
|
315
|
+
|
|
316
|
+
<BooleanToggle
|
|
317
|
+
label="Enable Services"
|
|
318
|
+
description="Creates a 'service' resource type for bookings and appointments."
|
|
319
|
+
value={wantService}
|
|
320
|
+
onChange={setWantService}
|
|
321
|
+
disabled={isSaving}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<div className="pt-4">
|
|
326
|
+
<button
|
|
327
|
+
onClick={handleConfigContinue}
|
|
328
|
+
disabled={(!wantProduct && !wantService) || isSaving}
|
|
329
|
+
className="inline-flex items-center rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 disabled:opacity-50"
|
|
330
|
+
>
|
|
331
|
+
{isSaving ? 'Configuring...' : 'Continue'}
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// READY State
|
|
340
|
+
return (
|
|
341
|
+
<div className="mt-8 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
|
342
|
+
<div className="mb-6 border-b border-gray-100 pb-4">
|
|
343
|
+
<h2 className="text-xl font-bold text-gray-900">Shopify Integration</h2>
|
|
344
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
345
|
+
Manage your connected Shopify store.
|
|
346
|
+
</p>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{status.error && (
|
|
350
|
+
<div className="mb-6 rounded-md bg-red-50 p-4">
|
|
351
|
+
<div className="flex">
|
|
352
|
+
<div className="flex-shrink-0">
|
|
353
|
+
<span className="text-red-400">⚠️</span>
|
|
354
|
+
</div>
|
|
355
|
+
<div className="ml-3">
|
|
356
|
+
<h3 className="text-sm font-bold text-red-800">Error</h3>
|
|
357
|
+
<div className="mt-2 text-sm text-red-700">
|
|
358
|
+
<p>{status.error}</p>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{status.isLoading && data.products.length === 0 ? (
|
|
366
|
+
<div className="flex h-64 items-center justify-center">
|
|
367
|
+
<div className="text-center">
|
|
368
|
+
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-cyan-600"></div>
|
|
369
|
+
<p className="mt-2 text-sm text-gray-500">
|
|
370
|
+
Loading products from Shopify...
|
|
371
|
+
</p>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
) : (
|
|
375
|
+
<ProductTable
|
|
376
|
+
products={data.products}
|
|
377
|
+
linkedResourceMap={linkedResourceMap}
|
|
378
|
+
onRefresh={handleRefresh}
|
|
379
|
+
isRefreshing={status.isLoading}
|
|
380
|
+
onSelectProduct={setSelectedProduct}
|
|
381
|
+
onLink={handleLink}
|
|
382
|
+
onUnlink={handleUnlink}
|
|
383
|
+
onEdit={handleEdit}
|
|
384
|
+
/>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{/* Product Inspector Modal */}
|
|
388
|
+
{selectedProduct && (
|
|
389
|
+
<div className="relative z-50" aria-modal="true">
|
|
390
|
+
<div className="fixed inset-0 bg-black bg-opacity-75" />
|
|
391
|
+
<div className="fixed inset-0 flex items-end justify-center p-4 md:items-center">
|
|
392
|
+
<div
|
|
393
|
+
className="flex w-full flex-col overflow-hidden rounded-lg bg-white shadow-xl md:max-w-4xl"
|
|
394
|
+
style={{ maxHeight: '90vh' }}
|
|
395
|
+
>
|
|
396
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
|
397
|
+
<h3 className="text-lg font-bold text-gray-900">
|
|
398
|
+
{selectedProduct.title}
|
|
399
|
+
</h3>
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
className="rounded-md bg-white text-gray-400 hover:text-gray-500"
|
|
403
|
+
onClick={() => setSelectedProduct(null)}
|
|
404
|
+
>
|
|
405
|
+
<XMarkIcon className="h-6 w-6" />
|
|
406
|
+
</button>
|
|
407
|
+
</div>
|
|
408
|
+
<div className="overflow-y-auto p-6">
|
|
409
|
+
<div className="mb-4">
|
|
410
|
+
<h4 className="mb-2 text-sm font-bold text-gray-900">
|
|
411
|
+
Product Details
|
|
412
|
+
</h4>
|
|
413
|
+
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
|
414
|
+
<div className="md:col-span-1">
|
|
415
|
+
<dt className="text-sm font-bold text-gray-500">
|
|
416
|
+
Handle
|
|
417
|
+
</dt>
|
|
418
|
+
<dd className="mt-1 text-sm text-gray-900">
|
|
419
|
+
{selectedProduct.handle}
|
|
420
|
+
</dd>
|
|
421
|
+
</div>
|
|
422
|
+
</dl>
|
|
423
|
+
</div>
|
|
424
|
+
<div>
|
|
425
|
+
<div className="mb-2 flex items-center justify-between">
|
|
426
|
+
<h4 className="text-sm font-bold text-gray-900">
|
|
427
|
+
Raw API Data
|
|
428
|
+
</h4>
|
|
429
|
+
<button
|
|
430
|
+
onClick={handleCopy}
|
|
431
|
+
className="flex items-center gap-1 text-xs text-cyan-600 hover:text-cyan-800"
|
|
432
|
+
>
|
|
433
|
+
{copied ? (
|
|
434
|
+
<CheckIcon className="h-4 w-4" />
|
|
435
|
+
) : (
|
|
436
|
+
<ClipboardDocumentIcon className="h-4 w-4" />
|
|
437
|
+
)}
|
|
438
|
+
{copied ? 'Copied' : 'Copy JSON'}
|
|
439
|
+
</button>
|
|
440
|
+
</div>
|
|
441
|
+
<pre
|
|
442
|
+
className="overflow-auto rounded-md bg-gray-50 p-4 text-xs text-gray-800"
|
|
443
|
+
style={{ maxHeight: '40vh' }}
|
|
444
|
+
>
|
|
445
|
+
{JSON.stringify(selectedProduct, null, 2)}
|
|
446
|
+
</pre>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
|
|
454
|
+
{/* Type Selector Modal */}
|
|
455
|
+
{showTypeSelector && targetProduct && (
|
|
456
|
+
<div className="relative z-50" aria-modal="true">
|
|
457
|
+
<div className="fixed inset-0 bg-black bg-opacity-50" />
|
|
458
|
+
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
459
|
+
<div className="w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-xl">
|
|
460
|
+
<div className="px-6 py-4">
|
|
461
|
+
<h3 className="text-lg font-bold text-gray-900">
|
|
462
|
+
Import as...
|
|
463
|
+
</h3>
|
|
464
|
+
<p className="mt-2 text-sm text-gray-500">
|
|
465
|
+
Should "{targetProduct.title}" be imported as a Product or
|
|
466
|
+
Service?
|
|
467
|
+
</p>
|
|
468
|
+
<div className="mt-6 flex flex-col gap-3">
|
|
469
|
+
<button
|
|
470
|
+
onClick={() => startCreateFlow('product', targetProduct)}
|
|
471
|
+
className="flex w-full items-center justify-center rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-cyan-500"
|
|
472
|
+
>
|
|
473
|
+
Product
|
|
474
|
+
</button>
|
|
475
|
+
<button
|
|
476
|
+
onClick={() => startCreateFlow('service', targetProduct)}
|
|
477
|
+
className="flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 shadow-sm hover:bg-gray-50"
|
|
478
|
+
>
|
|
479
|
+
Service (Bookable)
|
|
480
|
+
</button>
|
|
481
|
+
</div>
|
|
482
|
+
<div className="mt-4 border-t pt-4">
|
|
483
|
+
<button
|
|
484
|
+
onClick={() => setShowTypeSelector(false)}
|
|
485
|
+
className="w-full text-center text-sm text-gray-500 hover:text-gray-700"
|
|
486
|
+
>
|
|
487
|
+
Cancel
|
|
488
|
+
</button>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
{/* Resource Form Modal */}
|
|
497
|
+
{showResourceModal && draftResource && (
|
|
498
|
+
<div className="fixed inset-0 z-50 overflow-y-auto bg-gray-900 bg-opacity-50 backdrop-blur-sm">
|
|
499
|
+
<div className="flex min-h-full items-center justify-center p-4">
|
|
500
|
+
<div className="w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
|
|
501
|
+
<ResourceForm
|
|
502
|
+
resourceData={draftResource as any}
|
|
503
|
+
fullContentMap={resources as any} // Use local resources for slug uniqueness check
|
|
504
|
+
categorySlug={draftResource.categorySlug || ''}
|
|
505
|
+
categorySchema={
|
|
506
|
+
internalBrandConfig?.knownResources[
|
|
507
|
+
draftResource.categorySlug || ''
|
|
508
|
+
] || {}
|
|
509
|
+
}
|
|
510
|
+
isCreate={isCreateMode}
|
|
511
|
+
onClose={(saved) => {
|
|
512
|
+
setShowResourceModal(false);
|
|
513
|
+
setIsCreateMode(true);
|
|
514
|
+
if (saved) {
|
|
515
|
+
refreshResources();
|
|
516
|
+
}
|
|
517
|
+
}}
|
|
518
|
+
/>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
)}
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
@@ -25,26 +25,21 @@ if (MODE === `wordmark`)
|
|
|
25
25
|
assetUrl = getAssetPath(brandConfig?.WORDMARK, '/brand/wordmark.svg');
|
|
26
26
|
else assetUrl = getAssetPath(brandConfig?.LOGO, '/brand/logo.svg');
|
|
27
27
|
|
|
28
|
-
// Generate positions programmatically for triple density
|
|
29
28
|
const generatePositions = () => {
|
|
30
29
|
const positions = [];
|
|
31
|
-
const rows = 15;
|
|
32
|
-
const cols = 12;
|
|
30
|
+
const rows = 15;
|
|
31
|
+
const cols = 12;
|
|
33
32
|
|
|
34
33
|
for (let row = 0; row < rows; row++) {
|
|
35
34
|
for (let col = 0; col < cols; col++) {
|
|
36
|
-
// Skip some positions for natural spacing
|
|
37
35
|
if ((row + col) % 3 !== 0) continue;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const top = (row / (rows - 1)) * 120 - 10; // Extend 10% beyond top/bottom
|
|
41
|
-
const left = (col / (cols - 1)) * 120 - 10; // Extend 10% beyond left/right
|
|
36
|
+
const top = (row / (rows - 1)) * 120 - 10;
|
|
37
|
+
const left = (col / (cols - 1)) * 120 - 10;
|
|
42
38
|
const rotation = -45 + Math.random() * 90;
|
|
43
|
-
|
|
44
39
|
positions.push({
|
|
45
40
|
top: `${top}%`,
|
|
46
41
|
left: `${left}%`,
|
|
47
|
-
rotation:
|
|
42
|
+
rotation: rotation,
|
|
48
43
|
});
|
|
49
44
|
}
|
|
50
45
|
}
|
|
@@ -69,19 +64,44 @@ const logoPositions = generatePositions();
|
|
|
69
64
|
border: '2px dashed rgba(0, 0, 0, 1)',
|
|
70
65
|
}}
|
|
71
66
|
>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
height
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
67
|
+
<svg
|
|
68
|
+
width="100%"
|
|
69
|
+
height="100%"
|
|
70
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
71
|
+
style="overflow: visible;"
|
|
72
|
+
>
|
|
73
|
+
<defs>
|
|
74
|
+
<symbol id="brand-logo-symbol" viewBox="0 0 120 120">
|
|
75
|
+
<image href={assetUrl} width="120" height="120" />
|
|
76
|
+
</symbol>
|
|
77
|
+
</defs>
|
|
78
|
+
{logoPositions.map((pos) => (
|
|
79
|
+
<g
|
|
80
|
+
transform={`translate(${parseFloat(pos.left) * 0.01 * 100} ${parseFloat(pos.top) * 0.01 * 100})`}
|
|
81
|
+
>
|
|
82
|
+
<use
|
|
83
|
+
href="#brand-logo-symbol"
|
|
84
|
+
x={pos.left}
|
|
85
|
+
y={pos.top}
|
|
86
|
+
width="120"
|
|
87
|
+
height="120"
|
|
88
|
+
transform={`rotate(${pos.rotation}, 60, 60)`}
|
|
89
|
+
transform-origin="center"
|
|
90
|
+
/>
|
|
91
|
+
</g>
|
|
92
|
+
))}
|
|
93
|
+
{/* Simplified positioning to ensure browser compatibility with percentages */}
|
|
94
|
+
{logoPositions.map((pos) => (
|
|
95
|
+
<use
|
|
96
|
+
href="#brand-logo-symbol"
|
|
97
|
+
x={pos.left}
|
|
98
|
+
y={pos.top}
|
|
99
|
+
width="120"
|
|
100
|
+
height="120"
|
|
101
|
+
style={`transform: rotate(${pos.rotation}deg); transform-origin: center; transform-box: fill-box;`}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</svg>
|
|
85
105
|
</div>
|
|
86
106
|
)
|
|
87
107
|
}
|
|
@@ -171,20 +171,6 @@ const ContentBrowser = ({
|
|
|
171
171
|
|
|
172
172
|
return (
|
|
173
173
|
<div className="w-full">
|
|
174
|
-
<div className="mb-6">
|
|
175
|
-
<h2 className="font-action text-2xl font-bold text-gray-900">
|
|
176
|
-
Content Management
|
|
177
|
-
{(analytics.isLoading || analytics.status === 'loading') && (
|
|
178
|
-
<span className="ml-2 text-sm font-normal text-gray-500">
|
|
179
|
-
(Loading data...)
|
|
180
|
-
</span>
|
|
181
|
-
)}
|
|
182
|
-
</h2>
|
|
183
|
-
<p className="mt-1 text-sm text-gray-600">
|
|
184
|
-
Browse and manage your StoryKeep content pages
|
|
185
|
-
</p>
|
|
186
|
-
</div>
|
|
187
|
-
|
|
188
174
|
{analytics.error && (
|
|
189
175
|
<div className="mb-6 rounded-lg bg-red-50 p-4 text-red-800">
|
|
190
176
|
<h4 className="font-bold">Content Error</h4>
|