astro-tractstack 2.2.10 → 2.3.1
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/README.md +1 -1
- package/bin/create-tractstack.js +2 -2
- package/dist/index.js +177 -18
- package/package.json +4 -2
- package/templates/custom/minimal/CodeHook.astro +22 -5
- package/templates/custom/shopify/Cart.tsx +372 -0
- package/templates/custom/shopify/CartIcon.tsx +47 -0
- package/templates/custom/shopify/CartModal.tsx +63 -0
- package/templates/custom/shopify/CheckoutModal.tsx +576 -0
- package/templates/custom/shopify/NativeBookingCalendar.tsx +375 -0
- package/templates/custom/shopify/ShopifyCartManager.tsx +200 -0
- package/templates/custom/shopify/ShopifyCheckout.tsx +167 -0
- package/templates/custom/shopify/ShopifyProductGrid.tsx +247 -0
- package/templates/custom/shopify/ShopifyServiceList.tsx +135 -0
- package/templates/custom/shopify/cart.astro +23 -0
- package/templates/custom/with-examples/CodeHook.astro +17 -1
- package/templates/custom/with-examples/ProductGrid.astro +1 -1
- package/templates/src/client/app.js +4 -2
- package/templates/src/components/Footer.astro +4 -4
- package/templates/src/components/Header.astro +44 -12
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +3 -3
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +3 -3
- package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +2 -2
- package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +7 -7
- package/templates/src/components/form/advanced/APIConfigSection.tsx +407 -38
- package/templates/src/components/form/shopify/SchedulingSection.tsx +354 -0
- package/templates/src/components/storykeep/Dashboard.tsx +18 -4
- 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 +668 -0
- package/templates/src/components/storykeep/StoryKeepBackdrop.astro +43 -23
- package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +14 -5
- 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/KnownResourceTable.tsx +5 -2
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +4 -11
- package/templates/src/components/storykeep/controls/content/MenuTable.tsx +14 -5
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +333 -0
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +9 -5
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +108 -8
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +13 -4
- package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +14 -5
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +111 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +393 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Products.tsx +46 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Schedule.tsx +78 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Search.tsx +55 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Services.tsx +47 -0
- package/templates/src/lib/resources.ts +11 -21
- package/templates/src/pages/api/auth/lookup-lead.ts +72 -0
- package/templates/src/pages/api/booking/availability.ts +72 -0
- package/templates/src/pages/api/booking/cancel.ts +73 -0
- package/templates/src/pages/api/booking/confirm.ts +82 -0
- package/templates/src/pages/api/booking/hold.ts +75 -0
- package/templates/src/pages/api/booking/list.ts +66 -0
- package/templates/src/pages/api/booking/metrics.ts +60 -0
- package/templates/src/pages/api/booking/release.ts +76 -0
- package/templates/src/pages/api/sandbox.ts +2 -2
- package/templates/src/pages/api/shopify/createCart.ts +69 -0
- package/templates/src/pages/api/shopify/getProducts.ts +64 -0
- package/templates/src/pages/storykeep/login.astro +26 -24
- 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 +266 -0
- package/templates/src/types/tractstack.ts +75 -0
- package/templates/src/utils/api/advancedConfig.ts +7 -1
- package/templates/src/utils/api/advancedHelpers.ts +87 -7
- package/templates/src/utils/api/bookingHelpers.ts +125 -0
- package/templates/src/utils/api/brandHelpers.ts +14 -0
- package/templates/src/utils/api/resourceConfig.ts +13 -5
- package/templates/src/utils/auth.ts +29 -9
- package/templates/src/utils/compositor/aiGeneration.ts +3 -3
- package/templates/src/utils/compositor/aiPaneParser.ts +2 -2
- package/templates/src/utils/customHelpers.ts +49 -0
- package/templates/src/utils/helpers.ts +59 -0
- package/templates/src/utils/profileStorage.ts +5 -0
- package/templates/src/utils/tenantResolver.ts +2 -1
- package/utils/inject-files.ts +161 -2
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useStore } from '@nanostores/react';
|
|
3
|
+
import {
|
|
4
|
+
cartStore,
|
|
5
|
+
cartState,
|
|
6
|
+
CART_STATES,
|
|
7
|
+
isShopifyHandoff,
|
|
8
|
+
} from '@/stores/shopify';
|
|
9
|
+
import { calculateCartDuration } from '@/utils/customHelpers';
|
|
10
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
11
|
+
|
|
12
|
+
interface ShopifyCheckoutProps {
|
|
13
|
+
traceId: string;
|
|
14
|
+
email: string;
|
|
15
|
+
resources: ResourceNode[];
|
|
16
|
+
onError: (error: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function ShopifyCheckout({
|
|
20
|
+
traceId,
|
|
21
|
+
email,
|
|
22
|
+
resources = [],
|
|
23
|
+
onError,
|
|
24
|
+
}: ShopifyCheckoutProps) {
|
|
25
|
+
const cart = useStore(cartStore);
|
|
26
|
+
const [status, setStatus] = useState<'IDLE' | 'PROCESSING' | 'REDIRECTING'>(
|
|
27
|
+
'IDLE'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (status !== 'IDLE') return;
|
|
32
|
+
|
|
33
|
+
const initCheckout = async () => {
|
|
34
|
+
setStatus('PROCESSING');
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const cartItems = Object.values(cart);
|
|
38
|
+
|
|
39
|
+
// Determine if we are in "Pickup Mode" (Service exists in cart)
|
|
40
|
+
const duration = calculateCartDuration(cart, resources);
|
|
41
|
+
const isPickupMode = duration > 0;
|
|
42
|
+
|
|
43
|
+
const lines = cartItems
|
|
44
|
+
.map((item) => {
|
|
45
|
+
// 1. Resolve the ResourceNode for this item
|
|
46
|
+
const resource = resources.find((r) => r.id === item.resourceId);
|
|
47
|
+
|
|
48
|
+
if (!resource) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Determine the preferred Variant ID based on mode
|
|
53
|
+
const activeVariantId = isPickupMode
|
|
54
|
+
? item.variantIdPickup
|
|
55
|
+
: item.variantIdShipped;
|
|
56
|
+
|
|
57
|
+
// 3. Establish the specific ID to use from the cart state
|
|
58
|
+
let merchandiseId =
|
|
59
|
+
activeVariantId || item.variantIdPickup || item.variantId;
|
|
60
|
+
|
|
61
|
+
// 4. FALLBACK LOGIC (Mirrors Cart.tsx)
|
|
62
|
+
// If no specific variant ID is saved on the cart item,
|
|
63
|
+
// look up the Default Variant from the Resource data.
|
|
64
|
+
if (!merchandiseId && resource?.optionsPayload?.shopifyData) {
|
|
65
|
+
try {
|
|
66
|
+
const product = JSON.parse(resource.optionsPayload.shopifyData);
|
|
67
|
+
// If the product has variants, default to the first one
|
|
68
|
+
if (product.variants && product.variants.length > 0) {
|
|
69
|
+
merchandiseId = product.variants[0].id;
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.warn(
|
|
73
|
+
'ShopifyCheckout: Failed to parse shopifyData for fallback',
|
|
74
|
+
item.resourceId
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// If we still have no ID, we cannot add this item to the Shopify cart.
|
|
80
|
+
if (!merchandiseId) return null;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
merchandiseId,
|
|
84
|
+
quantity: item.quantity,
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
.filter((line) => line !== null) as Array<{
|
|
88
|
+
merchandiseId: string;
|
|
89
|
+
quantity: number;
|
|
90
|
+
}>;
|
|
91
|
+
|
|
92
|
+
if (lines.length === 0) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'No valid Shopify items found in cart. Please try removing and re-adding your items.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const payload = {
|
|
99
|
+
lines,
|
|
100
|
+
email,
|
|
101
|
+
attributes: [
|
|
102
|
+
{
|
|
103
|
+
key: 'Trace ID',
|
|
104
|
+
value: traceId,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const response = await fetch('/api/shopify/createCart', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify(payload),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await response.json();
|
|
118
|
+
|
|
119
|
+
if (!response.ok || result.error) {
|
|
120
|
+
throw new Error(result.error || 'Failed to create checkout');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (result.checkoutUrl) {
|
|
124
|
+
setStatus('REDIRECTING');
|
|
125
|
+
isShopifyHandoff.set(true);
|
|
126
|
+
cartStore.set({});
|
|
127
|
+
cartState.set(CART_STATES.READY);
|
|
128
|
+
window.location.href = result.checkoutUrl;
|
|
129
|
+
} else {
|
|
130
|
+
throw new Error('No checkout URL returned from Shopify');
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('Checkout Error:', err);
|
|
134
|
+
setStatus('IDLE');
|
|
135
|
+
onError(err instanceof Error ? err.message : 'Checkout failed');
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
initCheckout();
|
|
140
|
+
}, [cart, email, traceId, resources, status, onError]);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="flex h-64 flex-col items-center justify-center text-center">
|
|
144
|
+
{status === 'REDIRECTING' ? (
|
|
145
|
+
<>
|
|
146
|
+
<div className="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-500"></div>
|
|
147
|
+
<h3 className="mt-4 text-lg font-bold text-gray-900">
|
|
148
|
+
Redirecting to Payment...
|
|
149
|
+
</h3>
|
|
150
|
+
<p className="mt-2 text-sm text-gray-500">
|
|
151
|
+
Please wait while we transfer you to Shopify.
|
|
152
|
+
</p>
|
|
153
|
+
</>
|
|
154
|
+
) : (
|
|
155
|
+
<>
|
|
156
|
+
<div className="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-black"></div>
|
|
157
|
+
<h3 className="mt-4 text-lg font-bold text-gray-900">
|
|
158
|
+
Preparing your Invoice
|
|
159
|
+
</h3>
|
|
160
|
+
<p className="mt-2 text-sm text-gray-500">
|
|
161
|
+
Syncing booking details and calculating totals...
|
|
162
|
+
</p>
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useStore } from '@nanostores/react';
|
|
3
|
+
import { cartStore, addQueue, type CartAction } from '@/stores/shopify';
|
|
4
|
+
import { getShopifyImage } from '@/utils/helpers';
|
|
5
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
resources: Record<string, ResourceNode[]>;
|
|
9
|
+
options?: {
|
|
10
|
+
params?: {
|
|
11
|
+
options?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ShopifyOption {
|
|
17
|
+
name: string;
|
|
18
|
+
values: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ShopifyVariant {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
price: { amount: string; currencyCode: string };
|
|
25
|
+
compareAtPrice?: { amount: string; currencyCode: string };
|
|
26
|
+
selectedOptions: { name: string; value: string }[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ProductCardProps {
|
|
30
|
+
resource: ResourceNode;
|
|
31
|
+
allServices: ResourceNode[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ProductCard({ resource, allServices }: ProductCardProps) {
|
|
35
|
+
const cart = useStore(cartStore);
|
|
36
|
+
|
|
37
|
+
const serviceBoundSlug = resource.optionsPayload?.serviceBound as
|
|
38
|
+
| string
|
|
39
|
+
| undefined;
|
|
40
|
+
const boundServiceResource = serviceBoundSlug
|
|
41
|
+
? allServices.find((r) => r.slug === serviceBoundSlug)
|
|
42
|
+
: undefined;
|
|
43
|
+
|
|
44
|
+
let product: any = {};
|
|
45
|
+
try {
|
|
46
|
+
if (resource.optionsPayload?.shopifyData) {
|
|
47
|
+
product = JSON.parse(resource.optionsPayload.shopifyData);
|
|
48
|
+
}
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error('Failed to parse Shopify data', resource.id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const options: ShopifyOption[] = product?.options || [];
|
|
54
|
+
const variants: ShopifyVariant[] = product?.variants || [];
|
|
55
|
+
const vendor: string = product?.vendor || '';
|
|
56
|
+
|
|
57
|
+
const hasModeOption = options.some((o) => o.name === 'Mode');
|
|
58
|
+
const visibleOptions = options.filter(
|
|
59
|
+
(o) => o.name !== 'Mode' && o.name !== 'Title'
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const [selections, setSelections] = useState<Record<string, string>>(() => {
|
|
63
|
+
const initial: Record<string, string> = {};
|
|
64
|
+
visibleOptions.forEach((opt) => {
|
|
65
|
+
initial[opt.name] = opt.values[0];
|
|
66
|
+
});
|
|
67
|
+
return initial;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const getVariant = (targetMode: 'Shipped' | 'Pickup' | null) => {
|
|
71
|
+
if (targetMode && !hasModeOption) return undefined;
|
|
72
|
+
const found = variants.find((v) => {
|
|
73
|
+
const optionsMatch = visibleOptions.every((opt) => {
|
|
74
|
+
const variantOpt = v.selectedOptions.find((o) => o.name === opt.name);
|
|
75
|
+
return variantOpt?.value === selections[opt.name];
|
|
76
|
+
});
|
|
77
|
+
if (!optionsMatch) return false;
|
|
78
|
+
if (hasModeOption && targetMode) {
|
|
79
|
+
const modeOpt = v.selectedOptions.find((o) => o.name === 'Mode');
|
|
80
|
+
return modeOpt?.value === targetMode;
|
|
81
|
+
}
|
|
82
|
+
return !(hasModeOption && !targetMode);
|
|
83
|
+
});
|
|
84
|
+
return found;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const variantShipped = hasModeOption
|
|
88
|
+
? getVariant('Shipped')
|
|
89
|
+
: getVariant(null);
|
|
90
|
+
const variantPickup = hasModeOption ? getVariant('Pickup') : undefined;
|
|
91
|
+
|
|
92
|
+
const currentDisplayVariant = variantShipped || variantPickup || variants[0];
|
|
93
|
+
const price = currentDisplayVariant?.price?.amount;
|
|
94
|
+
const compareAtPrice = currentDisplayVariant?.compareAtPrice?.amount;
|
|
95
|
+
const currency = currentDisplayVariant?.price?.currencyCode || 'USD';
|
|
96
|
+
|
|
97
|
+
// High contrast rose badge calculation
|
|
98
|
+
let discountPercent = 0;
|
|
99
|
+
if (price && compareAtPrice) {
|
|
100
|
+
const p = parseFloat(price);
|
|
101
|
+
const cap = parseFloat(compareAtPrice);
|
|
102
|
+
if (cap > p) {
|
|
103
|
+
discountPercent = Math.round(((cap - p) / cap) * 100);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { src, srcSet } = getShopifyImage(
|
|
108
|
+
resource,
|
|
109
|
+
'600',
|
|
110
|
+
currentDisplayVariant?.id
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const handleAction = () => {
|
|
114
|
+
const queueUpdates: CartAction[] = [
|
|
115
|
+
{
|
|
116
|
+
resourceId: resource.id,
|
|
117
|
+
gid: product?.id,
|
|
118
|
+
variantIdShipped: variantShipped?.id,
|
|
119
|
+
variantIdPickup: variantPickup?.id,
|
|
120
|
+
action: 'add',
|
|
121
|
+
boundResourceId: boundServiceResource?.id,
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
addQueue.set([...addQueue.get(), ...queueUpdates]);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="group flex flex-col text-left font-main">
|
|
129
|
+
{/* Clickable Area: Image and Title */}
|
|
130
|
+
<button
|
|
131
|
+
onClick={handleAction}
|
|
132
|
+
className="text-left focus:outline-none"
|
|
133
|
+
aria-label={`Add ${resource.title} to cart`}
|
|
134
|
+
>
|
|
135
|
+
{/* Rounded-2xl Frame with Top-Left Badge */}
|
|
136
|
+
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-brand-8 transition-opacity group-hover:opacity-90">
|
|
137
|
+
<img
|
|
138
|
+
src={src}
|
|
139
|
+
srcSet={srcSet}
|
|
140
|
+
alt={resource.title}
|
|
141
|
+
className="h-full w-full object-cover object-center"
|
|
142
|
+
loading="lazy"
|
|
143
|
+
/>
|
|
144
|
+
{discountPercent > 0 && (
|
|
145
|
+
<div className="absolute left-4 top-4 flex h-12 w-12 items-center justify-center rounded-md bg-rose-600 text-xs font-bold text-white shadow-sm">
|
|
146
|
+
-{discountPercent}%
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="mt-6 flex flex-col">
|
|
152
|
+
{/* Vendor Label */}
|
|
153
|
+
{vendor && (
|
|
154
|
+
<span className="text-xs font-bold uppercase tracking-widest text-brand-6">
|
|
155
|
+
{vendor}
|
|
156
|
+
</span>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<h3 className="mt-1 text-2xl font-bold text-brand-1">
|
|
160
|
+
{resource.title}
|
|
161
|
+
</h3>
|
|
162
|
+
|
|
163
|
+
{/* Combined Price Baseline */}
|
|
164
|
+
<div className="mt-1 flex items-baseline space-x-2">
|
|
165
|
+
<span className="text-lg font-bold text-brand-1">
|
|
166
|
+
{price} {currency}
|
|
167
|
+
</span>
|
|
168
|
+
{discountPercent > 0 && (
|
|
169
|
+
<span className="text-sm text-brand-6 line-through">
|
|
170
|
+
{compareAtPrice} {currency}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<p className="mt-3 text-sm text-brand-7">{resource.oneliner}</p>
|
|
176
|
+
</div>
|
|
177
|
+
</button>
|
|
178
|
+
|
|
179
|
+
{/* Interactive Variant Selectors */}
|
|
180
|
+
{visibleOptions.length > 0 && (
|
|
181
|
+
<div className="mt-4 space-y-4">
|
|
182
|
+
{visibleOptions.map((opt) => (
|
|
183
|
+
<div key={opt.name} onClick={(e) => e.stopPropagation()}>
|
|
184
|
+
<label className="mb-1 block text-xs font-bold uppercase text-brand-7">
|
|
185
|
+
{opt.name}
|
|
186
|
+
</label>
|
|
187
|
+
<select
|
|
188
|
+
value={selections[opt.name]}
|
|
189
|
+
onChange={(e) =>
|
|
190
|
+
setSelections((prev) => ({
|
|
191
|
+
...prev,
|
|
192
|
+
[opt.name]: e.target.value,
|
|
193
|
+
}))
|
|
194
|
+
}
|
|
195
|
+
className="block w-full border-b border-brand-8 bg-transparent py-2 text-sm text-brand-1 focus:border-brand-1 focus:outline-none"
|
|
196
|
+
>
|
|
197
|
+
{opt.values.map((val) => (
|
|
198
|
+
<option key={val} value={val}>
|
|
199
|
+
{val}
|
|
200
|
+
</option>
|
|
201
|
+
))}
|
|
202
|
+
</select>
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{boundServiceResource && (
|
|
209
|
+
<div className="bg-brand-4/10 mt-4 w-fit rounded px-2 py-1 text-xs font-bold text-brand-4">
|
|
210
|
+
INCLUDES {boundServiceResource.title.toUpperCase()}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default function ShopifyProductGrid({ resources = {}, options }: Props) {
|
|
218
|
+
let products = resources['product'] || [];
|
|
219
|
+
const services = resources['service'] || [];
|
|
220
|
+
|
|
221
|
+
let group = '';
|
|
222
|
+
try {
|
|
223
|
+
const parsedOptions = JSON.parse(options?.params?.options || '{}');
|
|
224
|
+
group = parsedOptions.group || '';
|
|
225
|
+
} catch (e) {}
|
|
226
|
+
|
|
227
|
+
if (group) {
|
|
228
|
+
products = products.filter((p) => p.optionsPayload?.group === group);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (products.length === 0) return null;
|
|
232
|
+
|
|
233
|
+
// Grid with increased gaps matching the design
|
|
234
|
+
return (
|
|
235
|
+
<div className="mx-auto max-w-7xl px-4 md:px-8">
|
|
236
|
+
<div className="grid grid-cols-2 gap-x-8 gap-y-16 md:gap-x-12 xl:grid-cols-3">
|
|
237
|
+
{products.map((resource) => (
|
|
238
|
+
<ProductCard
|
|
239
|
+
key={resource.id}
|
|
240
|
+
resource={resource}
|
|
241
|
+
allServices={services}
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useStore } from '@nanostores/react';
|
|
2
|
+
import { cartStore, addQueue, type CartAction } from '@/stores/shopify';
|
|
3
|
+
import type { ResourceNode } from '@/types/compositorTypes';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
resources: Record<string, ResourceNode[]>;
|
|
7
|
+
options?: {
|
|
8
|
+
params?: {
|
|
9
|
+
options?: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function ShopifyServiceList({ resources = {}, options }: Props) {
|
|
15
|
+
const cart = useStore(cartStore);
|
|
16
|
+
|
|
17
|
+
const products = resources['product'] || [];
|
|
18
|
+
let services = resources['service'] || [];
|
|
19
|
+
|
|
20
|
+
let group = '';
|
|
21
|
+
try {
|
|
22
|
+
const parsedOptions = JSON.parse(options?.params?.options || '{}');
|
|
23
|
+
group = parsedOptions.group || '';
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// Ignore JSON parse errors
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (group) {
|
|
29
|
+
services = services.filter((s) => s.optionsPayload?.group === group);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const boundServiceSlugs = new Set(
|
|
33
|
+
products
|
|
34
|
+
.map((p) => p.optionsPayload?.serviceBound as string | undefined)
|
|
35
|
+
.filter((s): s is string => !!s)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const displayServices = services.filter(
|
|
39
|
+
(s) => !boundServiceSlugs.has(s.slug)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const getServiceVariantId = (resource: ResourceNode): string | undefined => {
|
|
43
|
+
try {
|
|
44
|
+
if (resource.optionsPayload?.shopifyData) {
|
|
45
|
+
const data = JSON.parse(resource.optionsPayload.shopifyData);
|
|
46
|
+
// Handle both raw product data and simplified product objects
|
|
47
|
+
const product = data.products?.[0] || data;
|
|
48
|
+
return product?.variants?.[0]?.id;
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleToggle = (resource: ResourceNode, currentQuantity: number) => {
|
|
57
|
+
const actionType = currentQuantity > 0 ? 'remove' : 'add';
|
|
58
|
+
|
|
59
|
+
const variantId = getServiceVariantId(resource);
|
|
60
|
+
let gid: string | undefined;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
if (resource.optionsPayload?.shopifyData) {
|
|
64
|
+
const data = JSON.parse(resource.optionsPayload.shopifyData);
|
|
65
|
+
const product = data.products?.[0] || data;
|
|
66
|
+
gid = product?.id;
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {}
|
|
69
|
+
|
|
70
|
+
const newAction: CartAction = {
|
|
71
|
+
resourceId: resource.id,
|
|
72
|
+
gid,
|
|
73
|
+
variantId,
|
|
74
|
+
action: actionType,
|
|
75
|
+
};
|
|
76
|
+
addQueue.set([...addQueue.get(), newAction]);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!displayServices || displayServices.length === 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="space-y-4">
|
|
85
|
+
{displayServices.map((resource) => {
|
|
86
|
+
const variantId = getServiceVariantId(resource);
|
|
87
|
+
const key = variantId || `${resource.id}_null_null`;
|
|
88
|
+
|
|
89
|
+
const cartItem = cart[key];
|
|
90
|
+
const isSelected = (cartItem?.quantity || 0) > 0;
|
|
91
|
+
const duration = resource.optionsPayload?.bookingLengthMinutes;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
key={resource.id}
|
|
96
|
+
className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
|
|
97
|
+
isSelected
|
|
98
|
+
? 'border-black bg-gray-50'
|
|
99
|
+
: 'border-gray-200 bg-white hover:border-gray-300'
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
<div className="flex-grow">
|
|
103
|
+
<div className="flex items-center gap-2">
|
|
104
|
+
<h3 className="font-bold text-gray-900">{resource.title}</h3>
|
|
105
|
+
{duration && (
|
|
106
|
+
<span className="inline-flex items-center rounded-sm bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-700">
|
|
107
|
+
{duration} mins
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
<p className="mt-1 text-sm text-gray-500">{resource.oneliner}</p>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="ml-4 flex-shrink-0">
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => handleToggle(resource, cartItem?.quantity || 0)}
|
|
117
|
+
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
|
118
|
+
isSelected ? 'bg-black' : 'bg-gray-200'
|
|
119
|
+
}`}
|
|
120
|
+
role="switch"
|
|
121
|
+
aria-checked={isSelected}
|
|
122
|
+
>
|
|
123
|
+
<span
|
|
124
|
+
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
125
|
+
isSelected ? 'translate-x-5' : 'translate-x-0'
|
|
126
|
+
}`}
|
|
127
|
+
/>
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from '@/layouts/Layout.astro';
|
|
3
|
+
import Cart from '@/custom/shopify/Cart';
|
|
4
|
+
import { getHeaderResources } from '@/lib/resources';
|
|
5
|
+
import { getBrandConfig } from '@/utils/api/brandConfig';
|
|
6
|
+
|
|
7
|
+
const tenantId =
|
|
8
|
+
Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
9
|
+
|
|
10
|
+
const brandConfig = await getBrandConfig(tenantId);
|
|
11
|
+
if (!brandConfig.HAS_SHOPIFY) {
|
|
12
|
+
return Astro.redirect('/storykeep');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const resourceCategories = ['product', 'service'];
|
|
16
|
+
const resources = await getHeaderResources(tenantId, resourceCategories);
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<Layout title="Your Cart" slug="cart">
|
|
20
|
+
<main class="mx-auto max-w-7xl px-4 py-16 md:px-6 xl:px-8">
|
|
21
|
+
<Cart resources={resources} embedded={true} client:only="react" />
|
|
22
|
+
</main>
|
|
23
|
+
</Layout>
|
|
@@ -8,6 +8,8 @@ import EpinetWrapper from '@/components/codehooks/EpinetWrapper';
|
|
|
8
8
|
import ProductCardWrapper from './ProductCardWrapper.astro';
|
|
9
9
|
import ProductGrid from './ProductGrid.astro';
|
|
10
10
|
import SandboxLauncher from './SandboxLauncher';
|
|
11
|
+
import ShopifyProductGrid from '@/custom/shopify/ShopifyProductGrid';
|
|
12
|
+
import ShopifyServiceList from '@/custom/shopify/ShopifyServiceList';
|
|
11
13
|
import type { FullContentMapItem } from '@/types/tractstack';
|
|
12
14
|
import type { ResourceNode } from '@/types/compositorTypes';
|
|
13
15
|
|
|
@@ -23,7 +25,7 @@ export interface Props {
|
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
const { target, options, fullContentMap, resourcesPayload } = Astro.props;
|
|
28
|
+
const { target, options, fullContentMap, resourcesPayload = {} } = Astro.props;
|
|
27
29
|
|
|
28
30
|
export const components = {
|
|
29
31
|
'custom-hero': true,
|
|
@@ -35,6 +37,8 @@ export const components = {
|
|
|
35
37
|
'get-crafting': true,
|
|
36
38
|
'bunny-video': import.meta.env.PUBLIC_ENABLE_BUNNY === 'true',
|
|
37
39
|
epinet: true,
|
|
40
|
+
'shopify-product-grid': true,
|
|
41
|
+
'shopify-service-list': true,
|
|
38
42
|
};
|
|
39
43
|
---
|
|
40
44
|
|
|
@@ -57,6 +61,18 @@ export const components = {
|
|
|
57
61
|
<CustomHero />
|
|
58
62
|
) : target === 'epinet' ? (
|
|
59
63
|
<EpinetWrapper fullContentMap={fullContentMap} client:only="react" />
|
|
64
|
+
) : target === 'shopify-product-grid' ? (
|
|
65
|
+
<ShopifyProductGrid
|
|
66
|
+
options={options}
|
|
67
|
+
resources={resourcesPayload}
|
|
68
|
+
client:only="react"
|
|
69
|
+
/>
|
|
70
|
+
) : target === 'shopify-service-list' ? (
|
|
71
|
+
<ShopifyServiceList
|
|
72
|
+
options={options}
|
|
73
|
+
resources={resourcesPayload}
|
|
74
|
+
client:only="react"
|
|
75
|
+
/>
|
|
60
76
|
) : (
|
|
61
77
|
<div class="rounded-lg bg-gray-50 p-8 text-center">
|
|
62
78
|
<p class="text-gray-600">CodeHook target "{target}" not found</p>
|
|
@@ -53,7 +53,7 @@ if (parsedOptions?.productType) {
|
|
|
53
53
|
</div>
|
|
54
54
|
) : (
|
|
55
55
|
<div class="rounded-lg border bg-yellow-50 p-6 text-center shadow-sm">
|
|
56
|
-
<p class="font-
|
|
56
|
+
<p class="font-bold text-yellow-800">No products to display.</p>
|
|
57
57
|
<p class="mt-1 text-sm text-yellow-700">
|
|
58
58
|
Check the grid configuration or ensure products match the specified
|
|
59
59
|
filters.
|
|
@@ -144,8 +144,10 @@ if (!window.TractStackApp) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
const { backendUrl, sessionId, storyfragmentId, tenantId } = this.config;
|
|
147
|
-
if (!sessionId || !tenantId) {
|
|
148
|
-
logError(
|
|
147
|
+
if (!sessionId || !tenantId || !storyfragmentId) {
|
|
148
|
+
logError(
|
|
149
|
+
'Cannot start SSE connection: missing sessionId or tenantId or storyfragmentId.'
|
|
150
|
+
);
|
|
149
151
|
return;
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -161,8 +161,8 @@ const createdDate = created ? new Date(created) : new Date();
|
|
|
161
161
|
<div
|
|
162
162
|
class="my-2 flex flex-row items-center justify-center text-myblue md:flex-col"
|
|
163
163
|
>
|
|
164
|
-
<div class="px-4 text-center text-
|
|
165
|
-
pressed with
|
|
164
|
+
<div class="px-4 text-center text-sm italic md:px-12">
|
|
165
|
+
This website has been pressed with
|
|
166
166
|
<a
|
|
167
167
|
href="https://tractstack.com/?utm_source=tractstack&utm_medium=www&utm_campaign=community"
|
|
168
168
|
class="font-bold underline hover:text-black"
|
|
@@ -171,12 +171,12 @@ const createdDate = created ? new Date(created) : new Date();
|
|
|
171
171
|
>
|
|
172
172
|
Tract Stack</a
|
|
173
173
|
>
|
|
174
|
-
|
|
174
|
+
by{` `}
|
|
175
175
|
<a
|
|
176
176
|
href="https://atriskmedia.com/?utm_source=tractstack&utm_medium=www&utm_campaign=community"
|
|
177
177
|
class="font-bold underline hover:text-black"
|
|
178
178
|
target="_blank">At Risk Media</a
|
|
179
|
-
|
|
179
|
+
>.
|
|
180
180
|
</div>
|
|
181
181
|
<br /><br /><br />
|
|
182
182
|
</div>
|