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
@@ -348,7 +348,7 @@ PUBLIC_ENABLE_BUNNY="${finalResponses.enableBunny ? 'true' : 'false'}"
348
348
 
349
349
  // Install UI components
350
350
  execSync(
351
- `${addCommand} @ark-ui/react@^5.30.0 @heroicons/react@^2.1.1 @internationalized/date@^3.10.1`,
351
+ `${addCommand} @ark-ui/react@^5.30.0 @heroicons/react@^2.1.1 @internationalized/date@3.10.0 @calcom/embed-react@^1.4.1`,
352
352
  {
353
353
  stdio: 'inherit',
354
354
  }
@@ -382,7 +382,7 @@ PUBLIC_ENABLE_BUNNY="${finalResponses.enableBunny ? 'true' : 'false'}"
382
382
  console.log('Please run manually:');
383
383
  console.log(
384
384
  kleur.cyan(
385
- `${addCommand} react@^19.0.0 react-dom@^19.0.0 @astrojs/react@^4.4.2 @astrojs/node@^9.4.3 @nanostores/react@^1.0.0 nanostores@^1.0.1 @nanostores/persistent ulid@^3.0.1 @ark-ui/react@^5.30.0 @heroicons/react@^2.1.1 @internationalized/date@^3.10.1 d3@^7.9.0 d3-sankey@^0.12.3 recharts@^3.1.2 player.js@^0.1.0 tinycolor2@1.6.0 html-to-image@^1.11.13 path-to-regexp@^8.0.0 postcss postcss-selector-parser`
385
+ `${addCommand} react@^19.0.0 react-dom@^19.0.0 @astrojs/react@^4.4.2 @astrojs/node@^9.4.3 @nanostores/react@^1.0.0 nanostores@^1.0.1 @nanostores/persistent ulid@^3.0.1 @ark-ui/react@^5.30.0 @heroicons/react@^2.1.1 @internationalized/date@3.10.0 @calcom/embed-react@^1.4.1 d3@^7.9.0 d3-sankey@^0.12.3 recharts@^3.1.2 player.js@^0.1.0 tinycolor2@1.6.0 html-to-image@^1.11.13 path-to-regexp@^8.0.0 postcss postcss-selector-parser`
386
386
  )
387
387
  );
388
388
  console.log(
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ function g(t, e) {
21
21
  }
22
22
  return r ? /^[a-zA-Z0-9_-]+$/.test(r) ? e.info(`Tenant ID validated: ${r}`) : e.error(`PUBLIC_TENANTID contains invalid characters: ${r}`) : e.warn("PUBLIC_TENANTID not set - this will be required at runtime"), t;
23
23
  }
24
- async function w(t, e, c) {
24
+ async function y(t, e, c) {
25
25
  e.info("TractStack: Injecting template files");
26
26
  const r = [
27
27
  // Core Configuration
@@ -586,6 +586,10 @@ async function w(t, e, c) {
586
586
  src: t("../templates/src/stores/resources.ts"),
587
587
  dest: "src/stores/resources.ts"
588
588
  },
589
+ {
590
+ src: t("../templates/src/stores/shopify.ts"),
591
+ dest: "src/stores/shopify.ts"
592
+ },
589
593
  // Compositor stores
590
594
  {
591
595
  src: t("../templates/src/stores/nodes.ts"),
@@ -856,8 +860,12 @@ async function w(t, e, c) {
856
860
  dest: "src/pages/storykeep.astro"
857
861
  },
858
862
  {
859
- src: t("../templates/src/pages/storykeep/content.astro"),
860
- dest: "src/pages/storykeep/content.astro"
863
+ src: t("../templates/src/pages/storykeep/pages.astro"),
864
+ dest: "src/pages/storykeep/pages.astro"
865
+ },
866
+ {
867
+ src: t("../templates/src/pages/storykeep/manage.astro"),
868
+ dest: "src/pages/storykeep/manage.astro"
861
869
  },
862
870
  {
863
871
  src: t("../templates/src/pages/storykeep/branding.astro"),
@@ -867,10 +875,18 @@ async function w(t, e, c) {
867
875
  src: t("../templates/src/pages/storykeep/advanced.astro"),
868
876
  dest: "src/pages/storykeep/advanced.astro"
869
877
  },
878
+ {
879
+ src: t("../templates/src/pages/storykeep/shopify.astro"),
880
+ dest: "src/pages/storykeep/shopify.astro"
881
+ },
870
882
  {
871
883
  src: t("../templates/src/pages/maint.astro"),
872
884
  dest: "src/pages/maint.astro"
873
885
  },
886
+ {
887
+ src: t("../templates/custom/shopify/cart.astro"),
888
+ dest: "src/pages/cart.astro"
889
+ },
874
890
  {
875
891
  src: t("../templates/src/pages/404.astro"),
876
892
  dest: "src/pages/404.astro"
@@ -887,6 +903,14 @@ async function w(t, e, c) {
887
903
  src: t("../templates/src/pages/sitemap.xml.ts"),
888
904
  dest: "src/pages/sitemap.xml.ts"
889
905
  },
906
+ {
907
+ src: t("../templates/src/pages/api/shopify/getProducts.ts"),
908
+ dest: "src/pages/api/shopify/getProducts.ts"
909
+ },
910
+ {
911
+ src: t("../templates/src/pages/api/shopify/createCart.ts"),
912
+ dest: "src/pages/api/shopify/createCart.ts"
913
+ },
890
914
  {
891
915
  src: t("../templates/src/pages/api/tailwind.ts"),
892
916
  dest: "src/pages/api/tailwind.ts"
@@ -1140,6 +1164,12 @@ async function w(t, e, c) {
1140
1164
  ),
1141
1165
  dest: "src/components/storykeep/Dashboard_Advanced.tsx"
1142
1166
  },
1167
+ {
1168
+ src: t(
1169
+ "../templates/src/components/storykeep/Dashboard_Shopify.tsx"
1170
+ ),
1171
+ dest: "src/components/storykeep/Dashboard_Shopify.tsx"
1172
+ },
1143
1173
  {
1144
1174
  src: t(
1145
1175
  "../templates/src/components/storykeep/Dashboard_Analytics.tsx"
@@ -1195,6 +1225,12 @@ async function w(t, e, c) {
1195
1225
  ),
1196
1226
  dest: "src/components/storykeep/controls/content/PaneTable.tsx"
1197
1227
  },
1228
+ {
1229
+ src: t(
1230
+ "../templates/src/components/storykeep/controls/content/ProductTable.tsx"
1231
+ ),
1232
+ dest: "src/components/storykeep/controls/content/ProductTable.tsx"
1233
+ },
1198
1234
  {
1199
1235
  src: t(
1200
1236
  "../templates/src/components/storykeep/controls/content/ContentBrowser.tsx"
@@ -2159,6 +2195,51 @@ async function w(t, e, c) {
2159
2195
  dest: "src/utils/customHelpers.ts",
2160
2196
  protected: !0
2161
2197
  },
2198
+ {
2199
+ src: t("../templates/custom/shopify/ShopifyProductGrid.tsx"),
2200
+ dest: "src/custom/shopify/ShopifyProductGrid.tsx",
2201
+ protected: !0
2202
+ },
2203
+ {
2204
+ src: t("../templates/custom/shopify/ShopifyServiceList.tsx"),
2205
+ dest: "src/custom/shopify/ShopifyServiceList.tsx",
2206
+ protected: !0
2207
+ },
2208
+ {
2209
+ src: t("../templates/custom/shopify/CartIcon.tsx"),
2210
+ dest: "src/custom/shopify/CartIcon.tsx",
2211
+ protected: !0
2212
+ },
2213
+ {
2214
+ src: t("../templates/custom/shopify/ShopifyCartManager.tsx"),
2215
+ dest: "src/custom/shopify/ShopifyCartManager.tsx",
2216
+ protected: !0
2217
+ },
2218
+ {
2219
+ src: t("../templates/custom/shopify/CartModal.tsx"),
2220
+ dest: "src/custom/shopify/CartModal.tsx",
2221
+ protected: !0
2222
+ },
2223
+ {
2224
+ src: t("../templates/custom/shopify/CheckoutModal.tsx"),
2225
+ dest: "src/custom/shopify/CheckoutModal.tsx",
2226
+ protected: !0
2227
+ },
2228
+ {
2229
+ src: t("../templates/custom/shopify/Cart.tsx"),
2230
+ dest: "src/custom/shopify/Cart.tsx",
2231
+ protected: !0
2232
+ },
2233
+ {
2234
+ src: t("../templates/custom/shopify/CalDotComBooking.tsx"),
2235
+ dest: "src/custom/shopify/CalDotComBooking.tsx",
2236
+ protected: !0
2237
+ },
2238
+ {
2239
+ src: t("../templates/custom/shopify/ShopifyCheckout.tsx"),
2240
+ dest: "src/custom/shopify/ShopifyCheckout.tsx",
2241
+ protected: !0
2242
+ },
2162
2243
  // Example Components (Conditional)
2163
2244
  ...c?.includeExamples ? [
2164
2245
  {
@@ -2213,7 +2294,7 @@ async function w(t, e, c) {
2213
2294
  if (n(s.src))
2214
2295
  k(s.src, s.dest), e.info(`Updated ${s.dest}`);
2215
2296
  else {
2216
- const m = y(s.dest);
2297
+ const m = w(s.dest);
2217
2298
  u(s.dest, m), e.info(`Created placeholder ${s.dest}`);
2218
2299
  }
2219
2300
  else s.protected ? e.info(`Protected: ${s.dest} (skipped overwrite)`) : e.info(`Skipped existing ${s.dest}`);
@@ -2222,7 +2303,7 @@ async function w(t, e, c) {
2222
2303
  e.error(`Failed to create ${s.dest}: ${o}`);
2223
2304
  }
2224
2305
  }
2225
- function y(t) {
2306
+ function w(t) {
2226
2307
  return t.endsWith(".astro") ? `---
2227
2308
  // TractStack placeholder component
2228
2309
  ---
@@ -2233,7 +2314,7 @@ export default function Placeholder() {
2233
2314
  }` : t.endsWith(".ts") ? `// TractStack placeholder utility
2234
2315
  export const placeholder = "${t}";` : `# TractStack placeholder: ${t}`;
2235
2316
  }
2236
- function C(t = {}) {
2317
+ function h(t = {}) {
2237
2318
  const { resolve: e } = b(import.meta.url);
2238
2319
  return {
2239
2320
  name: "astro-tractstack",
@@ -2243,7 +2324,7 @@ function C(t = {}) {
2243
2324
  const p = t.enableMultiTenant || !1;
2244
2325
  if (s.info(
2245
2326
  `DEBUG: enableMultiTenant = ${p}, process.env.PUBLIC_ENABLE_MULTI_TENANT = ${process.env.PUBLIC_ENABLE_MULTI_TENANT}`
2246
- ), s.info("TractStack: Starting file injection..."), await w(e, s, {
2327
+ ), s.info("TractStack: Starting file injection..."), await y(e, s, {
2247
2328
  includeExamples: t.includeExamples,
2248
2329
  enableMultiTenant: p
2249
2330
  }), s.info("TractStack: File injection complete."), c.output !== "server")
@@ -2315,5 +2396,5 @@ function C(t = {}) {
2315
2396
  };
2316
2397
  }
2317
2398
  export {
2318
- C as default
2399
+ h as default
2319
2400
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.2.10",
3
+ "version": "2.3.0",
4
4
  "description": "Astro integration for TractStack - the free web press by At Risk Media",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,6 +24,7 @@
24
24
  "build": "vite build && tsc -p tsconfig.dts.json",
25
25
  "format": "prettier --write .",
26
26
  "dev": "vite build --watch",
27
+ "lint": "prettier --check .",
27
28
  "prepublishOnly": "pnpm format && pnpm tsc && pnpm build"
28
29
  },
29
30
  "keywords": [
@@ -50,6 +51,7 @@
50
51
  "react-dom": "^19.0.0"
51
52
  },
52
53
  "dependencies": {
54
+ "@calcom/embed-react": "^1.5.3",
53
55
  "kleur": "^4.1.5",
54
56
  "prompts": "^2.4.2"
55
57
  },
@@ -4,9 +4,12 @@ import ListContent from '@/components/codehooks/ListContent.astro';
4
4
  import SearchWidget from '@/components/codehooks/SearchWidget.tsx';
5
5
  import BunnyVideoWrapper from '@/components/codehooks/BunnyVideoWrapper.astro';
6
6
  import EpinetWrapper from '@/components/codehooks/EpinetWrapper';
7
+ import ShopifyProductGrid from '@/custom/shopify/ShopifyProductGrid';
8
+ import ShopifyServiceList from '@/custom/shopify/ShopifyServiceList';
7
9
  import type { FullContentMapItem } from '@/types/tractstack';
8
10
  import type { ResourceNode } from '@/types/compositorTypes';
9
- // import CustomHero from './CustomHero.astro'; // Uncomment to add custom components
11
+ // import CustomHero from './CustomHero.astro';
12
+ // Uncomment to add custom components
10
13
 
11
14
  export interface Props {
12
15
  target: string;
@@ -20,7 +23,7 @@ export interface Props {
20
23
  };
21
24
  }
22
25
 
23
- const { target, options, fullContentMap /*, resourcesPayload */ } = Astro.props;
26
+ const { target, options, fullContentMap, resourcesPayload = {} } = Astro.props;
24
27
 
25
28
  export const components = {
26
29
  'featured-article': true,
@@ -29,6 +32,8 @@ export const components = {
29
32
  'search-widget': true,
30
33
  'bunny-video': import.meta.env.PUBLIC_ENABLE_BUNNY === 'true',
31
34
  epinet: true,
35
+ 'shopify-product-grid': true,
36
+ 'shopify-service-list': true,
32
37
  // "custom-hero": true, // Uncomment when you create CustomHero.astro
33
38
  };
34
39
  ---
@@ -43,11 +48,15 @@ export const components = {
43
48
  ) : target === 'bunny-video' && import.meta.env.PUBLIC_ENABLE_BUNNY ? (
44
49
  <BunnyVideoWrapper options={options} />
45
50
  ) : target === 'epinet' ? (
46
- <EpinetWrapper fullContentMap={fullContentMap} client:only="react" /> /*
47
- : target === "custom-hero" ? (
51
+ <EpinetWrapper fullContentMap={fullContentMap} client:only="react" />
52
+ ) : target === 'shopify-product-grid' ? (
53
+ <ShopifyProductGrid resources={resourcesPayload} client:only="react" />
54
+ ) : target === 'shopify-service-list' ? (
55
+ <ShopifyServiceList resources={resourcesPayload} client:only="react" />
56
+ ) : (
57
+ /* : target === "custom-hero" ? (
48
58
  <CustomHero />
49
59
  ) */
50
- ) : (
51
60
  <div class="rounded-lg bg-gray-50 p-8 text-center">
52
61
  <p class="text-gray-600">CodeHook target "{target}" not found</p>
53
62
  </div>
@@ -0,0 +1,44 @@
1
+ import { useEffect } from 'react';
2
+ import Cal, { getCalApi } from '@calcom/embed-react';
3
+
4
+ interface CalDotComBookingProps {
5
+ calSlug: string;
6
+ traceId: string;
7
+ name: string;
8
+ email: string;
9
+ onSuccess: () => void;
10
+ }
11
+
12
+ export default function CalDotComBooking({
13
+ calSlug,
14
+ traceId,
15
+ name,
16
+ email,
17
+ onSuccess,
18
+ }: CalDotComBookingProps) {
19
+ useEffect(() => {
20
+ (async function () {
21
+ const cal = await getCalApi();
22
+ cal('on', {
23
+ action: 'bookingSuccessful',
24
+ callback: () => {
25
+ onSuccess();
26
+ },
27
+ });
28
+ })();
29
+ }, [onSuccess]);
30
+
31
+ return (
32
+ <Cal
33
+ calLink={calSlug}
34
+ style={{ width: '100%', height: '100%', overflow: 'hidden' }}
35
+ config={{
36
+ name,
37
+ email,
38
+ metadata: {
39
+ traceId,
40
+ },
41
+ }}
42
+ />
43
+ );
44
+ }
@@ -0,0 +1,345 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import {
4
+ addQueue,
5
+ cartStore,
6
+ cartState,
7
+ CART_STATES,
8
+ isShopifyHandoff,
9
+ type CartAction,
10
+ type CartItemState,
11
+ } from '@/stores/shopify';
12
+ import { getShopifyImage } from '@/utils/helpers';
13
+ import type { ResourceNode } from '@/types/compositorTypes';
14
+
15
+ interface CartProps {
16
+ resources: ResourceNode[];
17
+ }
18
+
19
+ const getCleanVariantTitle = (variant: any) => {
20
+ if (variant?.selectedOptions) {
21
+ return variant.selectedOptions
22
+ .filter((o: any) => o.name !== 'Mode')
23
+ .map((o: any) => o.value)
24
+ .join(' / ');
25
+ }
26
+ return variant?.title || '';
27
+ };
28
+
29
+ export default function Cart({ resources = [] }: CartProps) {
30
+ const cart = useStore(cartStore);
31
+ const isHandoff = useStore(isShopifyHandoff);
32
+ const [pickupEnabled, setPickupEnabled] = useState(false);
33
+
34
+ const cartValues = Object.values(cart);
35
+
36
+ const boundServiceIds = new Set(
37
+ cartValues
38
+ .map((item) => item.boundResourceId)
39
+ .filter((id) => !!id) as string[]
40
+ );
41
+
42
+ const hasServiceBoundProduct = boundServiceIds.size > 0;
43
+
44
+ const displayableItems = cartValues.filter(
45
+ (item) => !boundServiceIds.has(item.resourceId)
46
+ );
47
+
48
+ const groupedItems = displayableItems.reduce(
49
+ (acc, item) => {
50
+ if (!acc[item.resourceId]) {
51
+ acc[item.resourceId] = [];
52
+ }
53
+ acc[item.resourceId].push(item);
54
+ return acc;
55
+ },
56
+ {} as Record<string, CartItemState[]>
57
+ );
58
+
59
+ const hasService = cartValues.some((item) => {
60
+ const resource = resources.find((r) => r.id === item.resourceId);
61
+ return !!resource?.optionsPayload?.bookingLengthMinutes;
62
+ });
63
+
64
+ const hasPhysicalProductWithPickup = cartValues.some(
65
+ (item) => !!item.variantIdPickup
66
+ );
67
+
68
+ const canPickup = hasService && hasPhysicalProductWithPickup;
69
+
70
+ useEffect(() => {
71
+ if (hasServiceBoundProduct) {
72
+ setPickupEnabled(true);
73
+ } else if (canPickup) {
74
+ setPickupEnabled(true);
75
+ } else {
76
+ setPickupEnabled(false);
77
+ }
78
+ }, [canPickup, hasServiceBoundProduct]);
79
+
80
+ const isPickupMode = (canPickup || hasServiceBoundProduct) && pickupEnabled;
81
+
82
+ const dispatchDualAction = (
83
+ item: CartItemState,
84
+ action: 'add' | 'remove'
85
+ ) => {
86
+ const queueUpdates: CartAction[] = [];
87
+
88
+ queueUpdates.push({
89
+ resourceId: item.resourceId,
90
+ action,
91
+ variantId: item.variantId,
92
+ variantIdShipped: item.variantIdShipped,
93
+ variantIdPickup: item.variantIdPickup,
94
+ boundResourceId: item.boundResourceId,
95
+ suppressModal: action === 'add' ? true : undefined,
96
+ });
97
+
98
+ if (item.boundResourceId) {
99
+ queueUpdates.push({
100
+ resourceId: item.boundResourceId,
101
+ action,
102
+ suppressModal: action === 'add' ? true : undefined,
103
+ });
104
+ }
105
+
106
+ addQueue.set([...addQueue.get(), ...queueUpdates]);
107
+ };
108
+
109
+ if (cartValues.length === 0) {
110
+ return (
111
+ <div className="relative">
112
+ {isHandoff && (
113
+ <div className="absolute inset-0 z-103 flex flex-col items-center justify-center rounded-lg bg-black bg-opacity-75 backdrop-blur-md">
114
+ <div className="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-black"></div>
115
+ <h3 className="mt-4 text-lg font-bold text-gray-900">
116
+ Finalizing Handoff...
117
+ </h3>
118
+ </div>
119
+ )}
120
+ <div className="rounded-lg border bg-gray-50 p-8 text-center">
121
+ <h2 className="text-xl font-bold">Your cart is empty</h2>
122
+ <p className="mt-2 text-gray-600">Add some items to get started.</p>
123
+ </div>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ return (
129
+ <div className="rounded-lg bg-white shadow">
130
+ <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
131
+ <h2 className="text-xl font-bold text-gray-800">Shopping Cart</h2>
132
+ {canPickup && (
133
+ <label className="flex items-center space-x-2 text-sm font-bold text-gray-900">
134
+ <input
135
+ type="checkbox"
136
+ checked={pickupEnabled}
137
+ onChange={(e) => setPickupEnabled(e.target.checked)}
138
+ className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
139
+ />
140
+ <span>Pick up at Store</span>
141
+ </label>
142
+ )}
143
+ </div>
144
+
145
+ <ul className="divide-y divide-gray-200">
146
+ {Object.keys(groupedItems).map((resourceId) => {
147
+ const items = groupedItems[resourceId];
148
+ const resource = resources.find((r) => r.id === resourceId);
149
+ if (!resource || items.length === 0) return null;
150
+
151
+ const isService = !!resource.optionsPayload?.bookingLengthMinutes;
152
+ const serviceDuration = resource.optionsPayload?.bookingLengthMinutes;
153
+
154
+ const firstItem = items[0];
155
+ const boundServiceId = firstItem.boundResourceId;
156
+ const boundServiceResource = boundServiceId
157
+ ? resources.find((r) => r.id === boundServiceId)
158
+ : null;
159
+
160
+ const activeVariantIdFirst = isPickupMode
161
+ ? firstItem.variantIdPickup
162
+ : firstItem.variantIdShipped;
163
+ const displayIdFirst =
164
+ activeVariantIdFirst ||
165
+ firstItem.variantIdPickup ||
166
+ firstItem.variantId;
167
+ const { src, srcSet } = getShopifyImage(
168
+ resource,
169
+ '600',
170
+ displayIdFirst
171
+ );
172
+
173
+ let productData: any = {};
174
+ try {
175
+ if (resource.optionsPayload?.shopifyData) {
176
+ productData = JSON.parse(resource.optionsPayload.shopifyData);
177
+ }
178
+ } catch (e) {
179
+ console.error('Failed to parse Shopify data', resource.id);
180
+ }
181
+ const variants = productData?.variants || [];
182
+
183
+ return (
184
+ <li key={resourceId} className="p-6">
185
+ <div className="flex items-start">
186
+ {!isService && (
187
+ <div className="h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border border-gray-200">
188
+ <img
189
+ src={src}
190
+ srcSet={srcSet}
191
+ alt={resource.title}
192
+ className="aspect-square h-full w-full object-cover object-center"
193
+ loading="lazy"
194
+ />
195
+ </div>
196
+ )}
197
+ <div className="ml-4 flex-1">
198
+ <div className="flex justify-between">
199
+ <div>
200
+ <div className="flex items-center gap-2">
201
+ <h3 className="text-base font-bold text-gray-900">
202
+ {resource.title}
203
+ </h3>
204
+ {isService && (
205
+ <span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-700">
206
+ {serviceDuration} mins
207
+ </span>
208
+ )}
209
+ </div>
210
+ {boundServiceResource && (
211
+ <p className="flex items-center text-xs font-bold text-blue-600">
212
+ <span className="mr-1 inline-block h-2 w-2 rounded-full bg-blue-500"></span>
213
+ Includes Booking: {boundServiceResource.title}
214
+ </p>
215
+ )}
216
+ <p className="mt-1 text-sm text-gray-500">
217
+ {resource.oneliner}
218
+ </p>
219
+ </div>
220
+ </div>
221
+
222
+ <div className="mt-4 space-y-4 border-t border-gray-100 pt-4">
223
+ {items.map((item) => {
224
+ const activeVariantId = isPickupMode
225
+ ? item.variantIdPickup
226
+ : item.variantIdShipped;
227
+
228
+ const displayId =
229
+ activeVariantId ||
230
+ item.variantIdPickup ||
231
+ item.variantId;
232
+
233
+ let price = '0.00';
234
+ let currency = 'USD';
235
+ let variantTitle = '';
236
+
237
+ const variant = variants.find(
238
+ (v: any) => v.id === displayId
239
+ );
240
+
241
+ if (variant) {
242
+ price = variant.price?.amount || '0.00';
243
+ currency = variant.price?.currencyCode || 'USD';
244
+ variantTitle = getCleanVariantTitle(variant);
245
+ } else if (variants.length > 0 && !variantTitle) {
246
+ const v = variants[0];
247
+ price = v.price?.amount || '0.00';
248
+ currency = v.price?.currencyCode || 'USD';
249
+ variantTitle = getCleanVariantTitle(v);
250
+ }
251
+
252
+ return (
253
+ <div
254
+ key={`${item.resourceId}_${displayId}`}
255
+ className="flex items-center justify-between"
256
+ >
257
+ <div className="flex items-center gap-2">
258
+ <div className="text-sm font-bold text-gray-700">
259
+ {variantTitle &&
260
+ variantTitle !== 'Default Title' && (
261
+ <span>{variantTitle}</span>
262
+ )}
263
+ </div>
264
+ {isPickupMode && !isService && (
265
+ <span className="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-bold text-gray-800">
266
+ Store Pickup
267
+ </span>
268
+ )}
269
+ </div>
270
+
271
+ <div className="flex items-center">
272
+ <div className="mr-6 text-right">
273
+ <p className="text-sm font-bold text-gray-900">
274
+ {price && parseFloat(price) > 0
275
+ ? `${(parseFloat(price) * item.quantity).toFixed(2)} ${currency}`
276
+ : 'No Charge'}
277
+ </p>
278
+ </div>
279
+
280
+ {isService ? (
281
+ <button
282
+ onClick={() =>
283
+ addQueue.set([
284
+ ...addQueue.get(),
285
+ {
286
+ resourceId: item.resourceId,
287
+ action: 'remove',
288
+ variantId: item.variantId,
289
+ },
290
+ ])
291
+ }
292
+ className="rounded-md border border-gray-300 px-3 py-1 text-sm font-bold text-gray-600 hover:bg-gray-100"
293
+ >
294
+ Remove
295
+ </button>
296
+ ) : (
297
+ <div className="flex items-center rounded-md border border-gray-300">
298
+ <button
299
+ onClick={() =>
300
+ dispatchDualAction(item, 'remove')
301
+ }
302
+ className="px-3 py-1 text-gray-600 hover:bg-gray-100"
303
+ >
304
+ -
305
+ </button>
306
+ <span className="border-l border-r border-gray-300 px-3 py-1 text-gray-900">
307
+ {item.quantity}
308
+ </span>
309
+ <button
310
+ onClick={() =>
311
+ dispatchDualAction(item, 'add')
312
+ }
313
+ className="px-3 py-1 text-gray-600 hover:bg-gray-100"
314
+ >
315
+ +
316
+ </button>
317
+ </div>
318
+ )}
319
+ </div>
320
+ </div>
321
+ );
322
+ })}
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </li>
327
+ );
328
+ })}
329
+ </ul>
330
+
331
+ <div className="rounded-b-lg border-t border-gray-200 bg-gray-50 px-6 py-6">
332
+ <div className="flex justify-end">
333
+ <button
334
+ className="rounded-lg bg-black px-6 py-3 font-bold text-white transition-colors hover:bg-gray-800"
335
+ onClick={() => {
336
+ cartState.set(CART_STATES.CHECKOUT);
337
+ }}
338
+ >
339
+ Proceed to Checkout
340
+ </button>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ );
345
+ }