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.
Files changed (49) hide show
  1. package/bin/create-tractstack.js +2 -2
  2. package/dist/index.js +89 -8
  3. package/package.json +3 -1
  4. package/templates/custom/minimal/CodeHook.astro +14 -5
  5. package/templates/custom/shopify/CalDotComBooking.tsx +44 -0
  6. package/templates/custom/shopify/Cart.tsx +345 -0
  7. package/templates/custom/shopify/CartIcon.tsx +47 -0
  8. package/templates/custom/shopify/CartModal.tsx +63 -0
  9. package/templates/custom/shopify/CheckoutModal.tsx +187 -0
  10. package/templates/custom/shopify/ShopifyCartManager.tsx +145 -0
  11. package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
  12. package/templates/custom/shopify/ShopifyProductGrid.tsx +281 -0
  13. package/templates/custom/shopify/ShopifyServiceList.tsx +118 -0
  14. package/templates/custom/shopify/cart.astro +23 -0
  15. package/templates/custom/with-examples/CodeHook.astro +9 -1
  16. package/templates/custom/with-examples/ProductGrid.astro +1 -1
  17. package/templates/src/client/app.js +4 -2
  18. package/templates/src/components/Header.astro +37 -11
  19. package/templates/src/components/form/advanced/APIConfigSection.tsx +165 -38
  20. package/templates/src/components/storykeep/Dashboard.tsx +17 -3
  21. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -0
  22. package/templates/src/components/storykeep/Dashboard_Content.tsx +5 -96
  23. package/templates/src/components/storykeep/Dashboard_Shopify.tsx +525 -0
  24. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
  25. package/templates/src/components/storykeep/controls/content/ContentBrowser.tsx +0 -14
  26. package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +36 -13
  27. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
  28. package/templates/src/components/storykeep/controls/content/ProductTable.tsx +254 -0
  29. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
  30. package/templates/src/lib/resources.ts +11 -21
  31. package/templates/src/pages/api/shopify/createCart.ts +73 -0
  32. package/templates/src/pages/api/shopify/getProducts.ts +64 -0
  33. package/templates/src/pages/storykeep/login.astro +5 -10
  34. package/templates/src/pages/storykeep/logout.astro +1 -10
  35. package/templates/src/pages/storykeep/manage.astro +69 -0
  36. package/templates/src/pages/storykeep/{content.astro → pages.astro} +4 -8
  37. package/templates/src/pages/storykeep/shopify.astro +101 -0
  38. package/templates/src/stores/navigation.ts +3 -42
  39. package/templates/src/stores/nodes.ts +3 -1
  40. package/templates/src/stores/resources.ts +7 -10
  41. package/templates/src/stores/shopify.ts +210 -0
  42. package/templates/src/types/tractstack.ts +21 -0
  43. package/templates/src/utils/api/advancedConfig.ts +5 -1
  44. package/templates/src/utils/api/advancedHelpers.ts +48 -5
  45. package/templates/src/utils/api/brandHelpers.ts +4 -0
  46. package/templates/src/utils/api/resourceConfig.ts +13 -5
  47. package/templates/src/utils/customHelpers.ts +70 -0
  48. package/templates/src/utils/helpers.ts +59 -0
  49. 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; // More rows to extend beyond boundaries
32
- const cols = 12; // More cols to extend beyond boundaries
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
- // Allow logos to extend beyond container edges (no margins)
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: `${rotation}deg`,
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
- {logoPositions.map((position) => (
73
- <img
74
- src={assetUrl}
75
- style={{
76
- position: 'absolute',
77
- top: position.top,
78
- left: position.left,
79
- width: '120px',
80
- height: 'auto',
81
- transform: `rotate(${position.rotation})`,
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>