astro-tractstack 2.3.1 → 2.3.3
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 +3 -3
- package/dist/index.js +69 -11
- package/package.json +1 -1
- package/templates/custom/shopify/Cart.tsx +99 -19
- package/templates/custom/shopify/CheckoutModal.tsx +196 -10
- package/templates/custom/shopify/ShopifyCartManager.tsx +79 -76
- package/templates/custom/shopify/ShopifyCheckout.tsx +4 -33
- package/templates/custom/shopify/ShopifyProductGrid.tsx +42 -14
- package/templates/custom/shopify/ShopifyServiceList.tsx +94 -50
- package/templates/custom/shopify/cart.astro +7 -1
- package/templates/src/components/Footer.astro +2 -2
- package/templates/src/components/Header.astro +17 -9
- package/templates/src/components/Menu.tsx +157 -135
- package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +27 -6
- package/templates/src/components/codehooks/EpinetTableView.tsx +153 -112
- package/templates/src/components/codehooks/EpinetWrapper.tsx +4 -1
- package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +8 -1
- package/templates/src/components/codehooks/ProductCardSetup.tsx +9 -1
- package/templates/src/components/codehooks/ProductGridSetup.tsx +9 -1
- package/templates/src/components/compositor/nodes/BgPaneWrapper.tsx +2 -1
- package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +1 -1
- package/templates/src/components/edit/ToolBar.tsx +2 -1
- package/templates/src/components/edit/context/ContextPaneConfig_slug.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +13 -0
- package/templates/src/components/edit/pane/AddPanePanel_newCustomCopy.tsx +2 -2
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +1 -1
- package/templates/src/components/edit/state/SaveModal.tsx +1 -1
- package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +8 -3
- package/templates/src/components/form/DateTimeInput.tsx +10 -3
- package/templates/src/components/form/FileUpload.tsx +11 -5
- package/templates/src/components/form/NumberInput.tsx +2 -2
- package/templates/src/components/form/advanced/APIConfigSection.tsx +221 -39
- package/templates/src/components/form/brand/SiteConfigSection.tsx +10 -0
- package/templates/src/components/form/shopify/SchedulingSection.tsx +44 -0
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +10 -1
- package/templates/src/components/storykeep/Dashboard_Shopify.tsx +16 -8
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +1 -0
- package/templates/src/components/storykeep/controls/content/ProductTable.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/ResourceBulkIngest.tsx +79 -51
- package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +80 -0
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -0
- package/templates/src/components/storykeep/email-builder/Blocks.tsx +169 -0
- package/templates/src/components/storykeep/email-builder/EmailBuilder.tsx +223 -0
- package/templates/src/components/storykeep/email-builder/PreviewModal.tsx +136 -0
- package/templates/src/components/storykeep/email-builder/PropertyPanel.tsx +154 -0
- package/templates/src/components/storykeep/shopify/ShopifyDashboard.tsx +1 -8
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Bookings.tsx +118 -14
- package/templates/src/components/storykeep/shopify/ShopifyDashboard_Emails.tsx +105 -0
- package/templates/src/constants.ts +2 -0
- package/templates/src/layouts/Layout.astro +8 -5
- package/templates/src/pages/api/google/oauth/callback.ts +50 -0
- package/templates/src/pages/api/google/oauth/disconnect.ts +32 -0
- package/templates/src/pages/api/google/oauth/start.ts +32 -0
- package/templates/src/pages/api/google/oauth/status.ts +32 -0
- package/templates/src/pages/privacy.astro +84 -0
- package/templates/src/pages/terms.astro +47 -0
- package/templates/src/stores/shopify.ts +21 -0
- package/templates/src/types/formTypes.ts +4 -2
- package/templates/src/types/tractstack.ts +35 -2
- package/templates/src/utils/api/advancedHelpers.ts +16 -0
- package/templates/src/utils/api/bookingHelpers.ts +3 -1
- package/templates/src/utils/api/brandConfig.ts +2 -0
- package/templates/src/utils/api/brandHelpers.ts +24 -1
- package/templates/src/utils/api/emailHelpers.ts +105 -0
- package/templates/src/utils/booking/appointmentMode.ts +135 -0
- package/templates/src/utils/customHelpers.ts +2 -0
- package/templates/src/utils/tenantResolver.ts +1 -1
- package/utils/inject-files.ts +63 -5
- package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +0 -101
- package/templates/src/utils/actions/actionButton.ts +0 -103
- package/templates/src/utils/actions/preParse_Clicked.ts +0 -87
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { emailHelpers, type EmailTemplate } from '@/utils/api/emailHelpers';
|
|
3
|
+
|
|
4
|
+
interface PreviewModalProps {
|
|
5
|
+
template: EmailTemplate;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function PreviewModal({ template, onClose }: PreviewModalProps) {
|
|
10
|
+
const [variables, setVariables] = useState<string[]>([]);
|
|
11
|
+
const [mockData, setMockData] = useState<Record<string, string>>({});
|
|
12
|
+
const [html, setHtml] = useState<string>('');
|
|
13
|
+
const [subject, setSubject] = useState<string>('');
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const rawString = JSON.stringify(template);
|
|
18
|
+
const regex = /\{\{\.([a-zA-Z0-9_]+)\}\}/g;
|
|
19
|
+
const found = new Set<string>();
|
|
20
|
+
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = regex.exec(rawString)) !== null) {
|
|
23
|
+
found.add(match[1]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setVariables(Array.from(found));
|
|
27
|
+
|
|
28
|
+
const initialData: Record<string, string> = {};
|
|
29
|
+
found.forEach((v) => {
|
|
30
|
+
initialData[v] = `[${v}]`;
|
|
31
|
+
});
|
|
32
|
+
setMockData(initialData);
|
|
33
|
+
}, [template]);
|
|
34
|
+
|
|
35
|
+
const handleGenerate = async () => {
|
|
36
|
+
try {
|
|
37
|
+
setError(null);
|
|
38
|
+
const res = await emailHelpers.previewTemplate(template, mockData);
|
|
39
|
+
setHtml(res.html);
|
|
40
|
+
setSubject(res.subject);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setError(
|
|
43
|
+
err instanceof Error ? err.message : 'Preview generation failed'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-75 p-4 backdrop-blur-sm">
|
|
50
|
+
<div
|
|
51
|
+
className="flex h-full w-full max-w-6xl flex-col overflow-hidden rounded-lg bg-white shadow-xl md:flex-row"
|
|
52
|
+
style={{ maxHeight: '90vh' }}
|
|
53
|
+
>
|
|
54
|
+
<div className="w-full border-r border-gray-200 bg-gray-50 p-6 md:w-80">
|
|
55
|
+
<div className="mb-6 flex items-center justify-between">
|
|
56
|
+
<h3 className="text-lg font-bold text-gray-900">Mock Data</h3>
|
|
57
|
+
<button
|
|
58
|
+
onClick={onClose}
|
|
59
|
+
className="text-gray-400 hover:text-gray-900 md:hidden"
|
|
60
|
+
>
|
|
61
|
+
×
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{variables.length === 0 ? (
|
|
66
|
+
<p className="text-sm text-gray-500">
|
|
67
|
+
No template variables found.
|
|
68
|
+
</p>
|
|
69
|
+
) : (
|
|
70
|
+
<div className="space-y-4">
|
|
71
|
+
{variables.map((v) => (
|
|
72
|
+
<div key={v}>
|
|
73
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
74
|
+
{v}
|
|
75
|
+
</label>
|
|
76
|
+
<input
|
|
77
|
+
type="text"
|
|
78
|
+
value={mockData[v] || ''}
|
|
79
|
+
onChange={(e) =>
|
|
80
|
+
setMockData({ ...mockData, [v]: e.target.value })
|
|
81
|
+
}
|
|
82
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
<button
|
|
90
|
+
onClick={handleGenerate}
|
|
91
|
+
className="mt-8 w-full rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white hover:bg-cyan-500"
|
|
92
|
+
>
|
|
93
|
+
Generate Preview
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
{error && (
|
|
97
|
+
<p className="mt-4 text-xs font-bold text-red-600">{error}</p>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="flex flex-1 flex-col overflow-hidden bg-white">
|
|
102
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
|
103
|
+
<div className="flex flex-col">
|
|
104
|
+
<span className="text-xs font-bold text-gray-500">Subject</span>
|
|
105
|
+
<span className="text-sm font-bold text-gray-900">
|
|
106
|
+
{subject || 'Generate preview to view subject...'}
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
<button
|
|
110
|
+
onClick={onClose}
|
|
111
|
+
className="hidden text-gray-400 hover:text-gray-900 md:block"
|
|
112
|
+
>
|
|
113
|
+
× Close
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex-1 bg-gray-100 p-8">
|
|
118
|
+
<div className="mx-auto h-full w-full max-w-2xl overflow-hidden rounded bg-white shadow-lg">
|
|
119
|
+
{html ? (
|
|
120
|
+
<iframe
|
|
121
|
+
srcDoc={html}
|
|
122
|
+
className="h-full w-full border-0"
|
|
123
|
+
title="Email Preview"
|
|
124
|
+
/>
|
|
125
|
+
) : (
|
|
126
|
+
<div className="flex h-full items-center justify-center text-sm font-bold text-gray-400">
|
|
127
|
+
Awaiting preview generation...
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { EmailBlock } from '@/utils/api/emailHelpers';
|
|
2
|
+
|
|
3
|
+
interface PropertyPanelProps {
|
|
4
|
+
block: EmailBlock;
|
|
5
|
+
onChange: (block: EmailBlock) => void;
|
|
6
|
+
onDelete: () => void;
|
|
7
|
+
onMoveUp: () => void;
|
|
8
|
+
onMoveDown: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function PropertyPanel({
|
|
12
|
+
block,
|
|
13
|
+
onChange,
|
|
14
|
+
onDelete,
|
|
15
|
+
onMoveUp,
|
|
16
|
+
onMoveDown,
|
|
17
|
+
}: PropertyPanelProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="space-y-6">
|
|
20
|
+
<div className="flex min-w-0 items-center justify-between border-b border-gray-200 pb-4">
|
|
21
|
+
<h3 className="min-w-0 flex-1 truncate text-sm font-bold capitalize text-gray-900">
|
|
22
|
+
{block.type} Settings
|
|
23
|
+
</h3>
|
|
24
|
+
<div className="flex shrink-0 gap-2 text-gray-400">
|
|
25
|
+
<button onClick={onMoveUp} className="hover:text-gray-900">
|
|
26
|
+
↑
|
|
27
|
+
</button>
|
|
28
|
+
<button onClick={onMoveDown} className="hover:text-gray-900">
|
|
29
|
+
↓
|
|
30
|
+
</button>
|
|
31
|
+
<button onClick={onDelete} className="hover:text-red-600">
|
|
32
|
+
×
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{block.type === 'text' && (
|
|
38
|
+
<div className="space-y-4">
|
|
39
|
+
<div>
|
|
40
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
41
|
+
Alignment
|
|
42
|
+
</label>
|
|
43
|
+
<select
|
|
44
|
+
value={block.align}
|
|
45
|
+
onChange={(e) =>
|
|
46
|
+
onChange({ ...block, align: e.target.value as any })
|
|
47
|
+
}
|
|
48
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
49
|
+
>
|
|
50
|
+
<option value="left">Left</option>
|
|
51
|
+
<option value="center">Center</option>
|
|
52
|
+
<option value="right">Right</option>
|
|
53
|
+
</select>
|
|
54
|
+
</div>
|
|
55
|
+
<div>
|
|
56
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
57
|
+
Text Color
|
|
58
|
+
</label>
|
|
59
|
+
<div className="flex gap-2">
|
|
60
|
+
<input
|
|
61
|
+
type="color"
|
|
62
|
+
value={block.color}
|
|
63
|
+
onChange={(e) => onChange({ ...block, color: e.target.value })}
|
|
64
|
+
className="h-8 w-8 cursor-pointer rounded"
|
|
65
|
+
/>
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
value={block.color}
|
|
69
|
+
onChange={(e) => onChange({ ...block, color: e.target.value })}
|
|
70
|
+
className="flex-1 rounded-md border border-gray-300 px-3 py-1 text-sm"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<input
|
|
76
|
+
type="checkbox"
|
|
77
|
+
checked={block.isBold}
|
|
78
|
+
onChange={(e) => onChange({ ...block, isBold: e.target.checked })}
|
|
79
|
+
className="rounded border-gray-300 text-cyan-600 focus:ring-cyan-500"
|
|
80
|
+
/>
|
|
81
|
+
<label className="text-sm font-bold text-gray-700">Bold Text</label>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{block.type === 'button' && (
|
|
87
|
+
<div className="space-y-4">
|
|
88
|
+
<div>
|
|
89
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
90
|
+
Label
|
|
91
|
+
</label>
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
value={block.label}
|
|
95
|
+
onChange={(e) => onChange({ ...block, label: e.target.value })}
|
|
96
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
<div>
|
|
100
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
101
|
+
URL
|
|
102
|
+
</label>
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
value={block.url}
|
|
106
|
+
onChange={(e) => onChange({ ...block, url: e.target.value })}
|
|
107
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-cyan-500 focus:ring-cyan-500"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div>
|
|
111
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
112
|
+
Background Color
|
|
113
|
+
</label>
|
|
114
|
+
<input
|
|
115
|
+
type="color"
|
|
116
|
+
value={block.bgColor}
|
|
117
|
+
onChange={(e) => onChange({ ...block, bgColor: e.target.value })}
|
|
118
|
+
className="h-8 w-full cursor-pointer rounded"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div>
|
|
122
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
123
|
+
Text Color
|
|
124
|
+
</label>
|
|
125
|
+
<input
|
|
126
|
+
type="color"
|
|
127
|
+
value={block.textColor}
|
|
128
|
+
onChange={(e) =>
|
|
129
|
+
onChange({ ...block, textColor: e.target.value })
|
|
130
|
+
}
|
|
131
|
+
className="h-8 w-full cursor-pointer rounded"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{block.type === 'divider' && (
|
|
138
|
+
<div className="space-y-4">
|
|
139
|
+
<div>
|
|
140
|
+
<label className="mb-1 block text-xs font-bold text-gray-700">
|
|
141
|
+
Line Color
|
|
142
|
+
</label>
|
|
143
|
+
<input
|
|
144
|
+
type="color"
|
|
145
|
+
value={block.color}
|
|
146
|
+
onChange={(e) => onChange({ ...block, color: e.target.value })}
|
|
147
|
+
className="h-8 w-full cursor-pointer rounded"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { bookingHelpers } from '@/utils/api/bookingHelpers';
|
|
3
3
|
import type { BookingMetricsResponse } from '@/types/tractstack';
|
|
4
|
-
import type { ResourceNode } from '@/types/compositorTypes';
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
existingResources: ResourceNode[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export default function ShopifyDashboard({
|
|
11
|
-
existingResources,
|
|
12
|
-
}: ShopifyDashboardProps) {
|
|
5
|
+
export default function ShopifyDashboard({}) {
|
|
13
6
|
const [metrics, setMetrics] = useState<BookingMetricsResponse | null>(null);
|
|
14
7
|
const [isLoading, setIsLoading] = useState(true);
|
|
15
8
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useCallback,
|
|
6
|
+
type ChangeEvent,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import { Toggle } from '@ark-ui/react/toggle';
|
|
3
9
|
import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon';
|
|
4
10
|
import TableCellsIcon from '@heroicons/react/24/outline/TableCellsIcon';
|
|
@@ -68,7 +74,7 @@ export default function ShopifyDashboard_Bookings({
|
|
|
68
74
|
setCurrentPage(newPage);
|
|
69
75
|
};
|
|
70
76
|
|
|
71
|
-
const handleStatusChange = (e:
|
|
77
|
+
const handleStatusChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
72
78
|
setStatusFilter(e.target.value);
|
|
73
79
|
setCurrentPage(0);
|
|
74
80
|
};
|
|
@@ -112,6 +118,26 @@ export default function ShopifyDashboard_Bookings({
|
|
|
112
118
|
}
|
|
113
119
|
};
|
|
114
120
|
|
|
121
|
+
const getModeColor = (mode?: string) => {
|
|
122
|
+
if (mode === 'REMOTE') return 'bg-violet-100 text-violet-800';
|
|
123
|
+
return 'bg-slate-100 text-slate-700';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const getSyncColor = (syncStatus?: string) => {
|
|
127
|
+
switch (syncStatus) {
|
|
128
|
+
case 'SYNCED':
|
|
129
|
+
case 'DELETE_SYNCED':
|
|
130
|
+
return 'bg-green-100 text-green-800';
|
|
131
|
+
case 'FAILED':
|
|
132
|
+
return 'bg-red-100 text-red-800';
|
|
133
|
+
case 'PENDING':
|
|
134
|
+
case 'DELETE_PENDING':
|
|
135
|
+
return 'bg-amber-100 text-amber-800';
|
|
136
|
+
default:
|
|
137
|
+
return 'bg-gray-100 text-gray-700';
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
115
141
|
const todayStr = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`;
|
|
116
142
|
|
|
117
143
|
const renderCustomerInfo = (booking: BookingEntity) => {
|
|
@@ -207,15 +233,31 @@ export default function ShopifyDashboard_Bookings({
|
|
|
207
233
|
dayBookings.map((booking) => (
|
|
208
234
|
<div
|
|
209
235
|
key={booking.id}
|
|
210
|
-
className=
|
|
236
|
+
className={`rounded-lg border p-4 shadow-sm transition-colors hover:border-cyan-200 ${
|
|
237
|
+
booking.appointmentMode === 'REMOTE'
|
|
238
|
+
? 'border-violet-200 bg-violet-50'
|
|
239
|
+
: 'border-gray-200 bg-white'
|
|
240
|
+
}`}
|
|
211
241
|
>
|
|
212
242
|
<div className="flex items-start justify-between">
|
|
213
243
|
<div className="space-y-1">
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
244
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
245
|
+
<span
|
|
246
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getStatusColor(booking.status)}`}
|
|
247
|
+
>
|
|
248
|
+
{booking.status}
|
|
249
|
+
</span>
|
|
250
|
+
<span
|
|
251
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getModeColor(booking.appointmentMode)}`}
|
|
252
|
+
>
|
|
253
|
+
{booking.appointmentMode || 'IN_PERSON'}
|
|
254
|
+
</span>
|
|
255
|
+
<span
|
|
256
|
+
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-bold ${getSyncColor(booking.googleSyncStatus)}`}
|
|
257
|
+
>
|
|
258
|
+
{booking.googleSyncStatus || 'NOT_SYNCED'}
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
219
261
|
<div className="text-sm font-bold text-gray-900">
|
|
220
262
|
{new Date(booking.startTime).toLocaleTimeString(
|
|
221
263
|
'en-US',
|
|
@@ -247,16 +289,41 @@ export default function ShopifyDashboard_Bookings({
|
|
|
247
289
|
)}
|
|
248
290
|
</div>
|
|
249
291
|
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
|
250
|
-
<div className="
|
|
251
|
-
{renderCustomerInfo(booking)}
|
|
292
|
+
<div className="flex items-center justify-between text-gray-900">
|
|
293
|
+
<span>{renderCustomerInfo(booking)}</span>
|
|
294
|
+
{booking.shopifyOrderId && (
|
|
295
|
+
<a
|
|
296
|
+
href={`https://admin.shopify.com/orders/${booking.shopifyOrderId}`}
|
|
297
|
+
target="_blank"
|
|
298
|
+
rel="noopener noreferrer"
|
|
299
|
+
className="text-cyan-600 hover:text-cyan-800 hover:underline"
|
|
300
|
+
>
|
|
301
|
+
Order #{booking.shopifyOrderId}
|
|
302
|
+
</a>
|
|
303
|
+
)}
|
|
252
304
|
</div>
|
|
253
|
-
<div className="
|
|
305
|
+
<div className="text-gray-700">
|
|
254
306
|
{booking.resourceIds
|
|
255
307
|
.map(
|
|
256
308
|
(id) => resourceMap.get(id) || 'Unknown Service'
|
|
257
309
|
)
|
|
258
310
|
.join(', ')}
|
|
259
311
|
</div>
|
|
312
|
+
{booking.googleMeetURL && (
|
|
313
|
+
<a
|
|
314
|
+
className="text-cyan-700 underline"
|
|
315
|
+
href={booking.googleMeetURL}
|
|
316
|
+
target="_blank"
|
|
317
|
+
rel="noreferrer"
|
|
318
|
+
>
|
|
319
|
+
Open Meet Link
|
|
320
|
+
</a>
|
|
321
|
+
)}
|
|
322
|
+
{booking.googleLastError && (
|
|
323
|
+
<div className="text-xs font-bold text-red-700">
|
|
324
|
+
Google sync error: {booking.googleLastError}
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
260
327
|
</div>
|
|
261
328
|
</div>
|
|
262
329
|
))
|
|
@@ -279,6 +346,9 @@ export default function ShopifyDashboard_Bookings({
|
|
|
279
346
|
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
280
347
|
Status
|
|
281
348
|
</th>
|
|
349
|
+
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
350
|
+
Mode / Sync
|
|
351
|
+
</th>
|
|
282
352
|
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider text-gray-500">
|
|
283
353
|
Service(s)
|
|
284
354
|
</th>
|
|
@@ -296,13 +366,13 @@ export default function ShopifyDashboard_Bookings({
|
|
|
296
366
|
<tbody className="divide-y divide-gray-200 bg-white">
|
|
297
367
|
{isLoading ? (
|
|
298
368
|
<tr>
|
|
299
|
-
<td colSpan={
|
|
369
|
+
<td colSpan={6} className="py-12 text-center">
|
|
300
370
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
301
371
|
</td>
|
|
302
372
|
</tr>
|
|
303
373
|
) : bookings.length === 0 ? (
|
|
304
374
|
<tr>
|
|
305
|
-
<td colSpan={
|
|
375
|
+
<td colSpan={6} className="py-12 text-center text-gray-500">
|
|
306
376
|
No bookings found.
|
|
307
377
|
</td>
|
|
308
378
|
</tr>
|
|
@@ -318,13 +388,47 @@ export default function ShopifyDashboard_Bookings({
|
|
|
318
388
|
{booking.status}
|
|
319
389
|
</span>
|
|
320
390
|
</td>
|
|
391
|
+
<td className="whitespace-nowrap px-6 py-4 text-xs">
|
|
392
|
+
<div className="flex flex-col gap-1">
|
|
393
|
+
<span
|
|
394
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getModeColor(booking.appointmentMode)}`}
|
|
395
|
+
>
|
|
396
|
+
{booking.appointmentMode || 'IN_PERSON'}
|
|
397
|
+
</span>
|
|
398
|
+
<span
|
|
399
|
+
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 font-bold ${getSyncColor(booking.googleSyncStatus)}`}
|
|
400
|
+
>
|
|
401
|
+
{booking.googleSyncStatus || 'NOT_SYNCED'}
|
|
402
|
+
</span>
|
|
403
|
+
{booking.googleMeetURL && (
|
|
404
|
+
<a
|
|
405
|
+
className="text-cyan-700 underline"
|
|
406
|
+
href={booking.googleMeetURL}
|
|
407
|
+
target="_blank"
|
|
408
|
+
rel="noreferrer"
|
|
409
|
+
>
|
|
410
|
+
Meet link
|
|
411
|
+
</a>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
</td>
|
|
321
415
|
<td className="px-6 py-4 text-sm text-gray-900">
|
|
322
416
|
{booking.resourceIds
|
|
323
417
|
.map((id) => resourceMap.get(id) || 'Unknown Service')
|
|
324
418
|
.join(', ')}
|
|
325
419
|
</td>
|
|
326
420
|
<td className="px-6 py-4 text-sm text-gray-500">
|
|
327
|
-
{renderCustomerInfo(booking)}
|
|
421
|
+
<div>{renderCustomerInfo(booking)}</div>
|
|
422
|
+
{booking.shopifyOrderId && (
|
|
423
|
+
<a
|
|
424
|
+
href={`https://admin.shopify.com/orders/${booking.shopifyOrderId}`}
|
|
425
|
+
target="_blank"
|
|
426
|
+
rel="noopener noreferrer"
|
|
427
|
+
className="mt-1 inline-block text-xs font-bold text-cyan-600 hover:text-cyan-800 hover:underline"
|
|
428
|
+
>
|
|
429
|
+
Order #{booking.shopifyOrderId}
|
|
430
|
+
</a>
|
|
431
|
+
)}
|
|
328
432
|
</td>
|
|
329
433
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
|
330
434
|
<div>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
emailHelpers,
|
|
4
|
+
type EmailTemplateListEntry,
|
|
5
|
+
} from '@/utils/api/emailHelpers';
|
|
6
|
+
import EmailBuilder from '../email-builder/EmailBuilder';
|
|
7
|
+
|
|
8
|
+
export default function ShopifyDashboard_Emails() {
|
|
9
|
+
const [templates, setTemplates] = useState<
|
|
10
|
+
Record<string, EmailTemplateListEntry[]>
|
|
11
|
+
>({});
|
|
12
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [editingTemplate, setEditingTemplate] = useState<{
|
|
15
|
+
category: string;
|
|
16
|
+
name: string;
|
|
17
|
+
} | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
loadTemplates();
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
const loadTemplates = async () => {
|
|
24
|
+
try {
|
|
25
|
+
setIsLoading(true);
|
|
26
|
+
setError(null);
|
|
27
|
+
const data = await emailHelpers.getTemplates();
|
|
28
|
+
setTemplates(data);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
setError(err instanceof Error ? err.message : 'Failed to load templates');
|
|
31
|
+
} finally {
|
|
32
|
+
setIsLoading(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (editingTemplate) {
|
|
37
|
+
return (
|
|
38
|
+
<EmailBuilder
|
|
39
|
+
category={editingTemplate.category}
|
|
40
|
+
templateName={editingTemplate.name}
|
|
41
|
+
onClose={() => setEditingTemplate(null)}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isLoading) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex h-48 items-center justify-center">
|
|
49
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-cyan-600" />
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (error) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700">
|
|
57
|
+
{error}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="space-y-8">
|
|
64
|
+
{Object.entries(templates).map(([category, entries]) => (
|
|
65
|
+
<div
|
|
66
|
+
key={category}
|
|
67
|
+
className="rounded-lg border border-gray-200 bg-white"
|
|
68
|
+
>
|
|
69
|
+
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
|
|
70
|
+
<h3 className="text-sm font-bold capitalize text-gray-900">
|
|
71
|
+
{category}
|
|
72
|
+
</h3>
|
|
73
|
+
</div>
|
|
74
|
+
<ul className="divide-y divide-gray-200">
|
|
75
|
+
{entries.map((entry) => (
|
|
76
|
+
<li
|
|
77
|
+
key={entry.name}
|
|
78
|
+
className="flex items-center justify-between px-4 py-4 md:px-6"
|
|
79
|
+
>
|
|
80
|
+
<div className="flex min-w-0 flex-col">
|
|
81
|
+
<p className="truncate text-sm font-bold text-gray-900">
|
|
82
|
+
{entry.adminTitle}
|
|
83
|
+
</p>
|
|
84
|
+
<p className="truncate text-xs text-gray-500">
|
|
85
|
+
{entry.name}.json
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="ml-4 flex flex-shrink-0">
|
|
89
|
+
<button
|
|
90
|
+
onClick={() =>
|
|
91
|
+
setEditingTemplate({ category, name: entry.name })
|
|
92
|
+
}
|
|
93
|
+
className="rounded-md bg-white font-bold text-cyan-600 hover:text-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
|
|
94
|
+
>
|
|
95
|
+
Edit
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</li>
|
|
99
|
+
))}
|
|
100
|
+
</ul>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -50,15 +50,18 @@ const {
|
|
|
50
50
|
|
|
51
51
|
const isInitialized = !freshInstallStore.get().needsSetup;
|
|
52
52
|
const goBackend = import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
53
|
+
const isMultiTenant = import.meta.env.PUBLIC_ENABLE_MULTI_TENANT === 'true';
|
|
54
|
+
const tenantId = isMultiTenant
|
|
55
|
+
? (await resolveTenantId(Astro.request)).id
|
|
56
|
+
: import.meta.env.PUBLIC_TENANTID || 'default';
|
|
57
|
+
|
|
56
58
|
if (!Astro.locals.tenant) {
|
|
57
59
|
Astro.locals.tenant = {
|
|
58
60
|
id: tenantId,
|
|
59
61
|
domain: Astro.url.hostname,
|
|
60
|
-
isMultiTenant:
|
|
61
|
-
isLocalhost:
|
|
62
|
+
isMultiTenant: isMultiTenant,
|
|
63
|
+
isLocalhost:
|
|
64
|
+
Astro.url.hostname === 'localhost' || Astro.url.hostname === '127.0.0.1',
|
|
62
65
|
};
|
|
63
66
|
}
|
|
64
67
|
const brandConfig = propBrandConfig || (await getBrandConfig(tenantId));
|