@tower_74/cms-app 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,51 +1,51 @@
1
1
  {
2
- "name": "@tower_74/cms-app",
3
- "version": "0.5.0",
4
- "description": "Front-end app shell for the Base CMS — Inertia/Vue pages, layouts, components and composables. Ships source; consumed by client sites as a versioned dependency (ADR-0023) and overridable per site.",
5
- "type": "module",
6
- "license": "UNLICENSED",
7
- "private": false,
8
- "files": [
9
- "src"
10
- ],
11
- "exports": {
12
- ".": "./src/index.ts",
13
- "./pages": "./src/pages.ts"
14
- },
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/JMancuso79/cms-app.git"
18
- },
19
- "engines": {
20
- "node": ">=18"
21
- },
22
- "scripts": {
23
- "lint": "eslint . --fix",
24
- "format": "prettier --write src/",
25
- "format:check": "prettier --check src/"
26
- },
27
- "peerDependencies": {
28
- "@inertiajs/vue3": "^2.0.0-beta.3",
29
- "@tower_74/cms-ui": "^0.13.0",
30
- "@vueuse/core": "^12.0.0",
31
- "laravel-vite-plugin": "^1.0",
32
- "lucide-vue-next": "^0.468.0",
33
- "pinia": "^3.0.4",
34
- "radix-vue": "^1.9.11",
35
- "vue": "^3.5.13",
36
- "ziggy-js": "^2.4.2"
37
- },
38
- "devDependencies": {
39
- "@eslint/js": "^9.19.0",
40
- "@vue/eslint-config-typescript": "^14.3.0",
41
- "eslint": "^9.17.0",
42
- "eslint-config-prettier": "^10.0.1",
43
- "eslint-plugin-vue": "^9.32.0",
44
- "prettier": "^3.4.2",
45
- "prettier-plugin-organize-imports": "^4.3.0",
46
- "typescript": "^5.2.2",
47
- "typescript-eslint": "^8.23.0",
48
- "vue": "^3.5.13",
49
- "vue-tsc": "^3.3.5"
50
- }
2
+ "name": "@tower_74/cms-app",
3
+ "version": "0.6.0",
4
+ "description": "Front-end app shell for the Base CMS — Inertia/Vue pages, layouts, components and composables. Ships source; consumed by client sites as a versioned dependency (ADR-0023) and overridable per site.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "private": false,
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./pages": "./src/pages.ts"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/JMancuso79/cms-app.git"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "lint": "eslint . --fix",
24
+ "format": "prettier --write src/",
25
+ "format:check": "prettier --check src/"
26
+ },
27
+ "peerDependencies": {
28
+ "@inertiajs/vue3": "^2.0.0-beta.3",
29
+ "@tower_74/cms-ui": "^0.13.0",
30
+ "@vueuse/core": "^12.0.0",
31
+ "laravel-vite-plugin": "^1.0",
32
+ "lucide-vue-next": "^0.468.0",
33
+ "pinia": "^3.0.4",
34
+ "radix-vue": "^1.9.11",
35
+ "vue": "^3.5.13",
36
+ "ziggy-js": "^2.4.2"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^9.19.0",
40
+ "@vue/eslint-config-typescript": "^14.3.0",
41
+ "eslint": "^9.17.0",
42
+ "eslint-config-prettier": "^10.0.1",
43
+ "eslint-plugin-vue": "^9.32.0",
44
+ "prettier": "^3.4.2",
45
+ "prettier-plugin-organize-imports": "^4.3.0",
46
+ "typescript": "^5.2.2",
47
+ "typescript-eslint": "^8.23.0",
48
+ "vue": "^3.5.13",
49
+ "vue-tsc": "^3.3.5"
50
+ }
51
51
  }
@@ -18,23 +18,7 @@ import {
18
18
  } from '@/components/ui/sidebar';
19
19
  import { pluginIcons } from '@/plugins';
20
20
  import { Link, usePage } from '@inertiajs/vue3';
21
- import {
22
- Box,
23
- FileStack,
24
- FileText,
25
- Folder,
26
- Image,
27
- Inbox,
28
- LayoutGrid,
29
- ListOrdered,
30
- Menu,
31
- Paintbrush,
32
- Puzzle,
33
- Settings,
34
- ShoppingBag,
35
- Tag,
36
- Users,
37
- } from 'lucide-vue-next';
21
+ import { Box, FileStack, FileText, Folder, Image, Inbox, LayoutGrid, Menu, Paintbrush, Puzzle, Settings, Tag, Users } from 'lucide-vue-next';
38
22
  import { type Component, computed, ref, watch } from 'vue';
39
23
 
40
24
  interface NavItem {
@@ -54,8 +38,6 @@ const nav: NavItem[] = [
54
38
  { label: 'Tags', href: '/admin/taxonomy/post_tag', icon: Tag, permission: 'taxonomy' },
55
39
  { label: 'Media', href: '/admin/media', icon: Image, permission: 'media' },
56
40
  { label: 'Forms', href: '/admin/forms', icon: Inbox, permission: 'forms' },
57
- { label: 'Products', href: '/admin/commerce/products', icon: ShoppingBag, permission: 'products' },
58
- { label: 'Orders', href: '/admin/commerce/orders', icon: ListOrdered, permission: 'orders' },
59
41
  { label: 'Menus', href: '/admin/menus', icon: Menu, permission: 'menus' },
60
42
  { label: 'Users', href: '/admin/users', icon: Users, permission: 'users' },
61
43
  { label: 'Theme', href: '/admin/appearance/theme', icon: Paintbrush, permission: 'theme' },
@@ -1,55 +0,0 @@
1
- <script setup lang="ts">
2
- import { Button, Input } from '@tower_74/cms-ui';
3
-
4
- interface Option {
5
- name: string;
6
- values: string[];
7
- }
8
-
9
- const props = defineProps<{ modelValue: Option[] }>();
10
- const emit = defineEmits<{ 'update:modelValue': [value: Option[]] }>();
11
-
12
- const commit = (options: Option[]) => emit('update:modelValue', options);
13
-
14
- const add = () => commit([...props.modelValue, { name: '', values: [] }]);
15
- const remove = (i: number) => {
16
- const options = [...props.modelValue];
17
- options.splice(i, 1);
18
- commit(options);
19
- };
20
- const setName = (i: number, name: string) => {
21
- const options = [...props.modelValue];
22
- options[i] = { ...options[i], name };
23
- commit(options);
24
- };
25
- const setValues = (i: number, csv: string) => {
26
- const options = [...props.modelValue];
27
- options[i] = {
28
- ...options[i],
29
- values: csv
30
- .split(',')
31
- .map((s) => s.trim())
32
- .filter(Boolean),
33
- };
34
- commit(options);
35
- };
36
- const valuesText = (option: Option) => option.values.join(', ');
37
- </script>
38
-
39
- <template>
40
- <div class="space-y-3">
41
- <div v-for="(option, i) in modelValue" :key="i" class="flex items-end gap-2 rounded-lg border border-border bg-background p-3">
42
- <div class="w-48">
43
- <label class="mb-1 block text-xs font-medium text-muted">Option</label>
44
- <Input :model-value="option.name" placeholder="Size" @update:model-value="setName(i, $event)" />
45
- </div>
46
- <div class="flex-1">
47
- <label class="mb-1 block text-xs font-medium text-muted">Values (comma-separated)</label>
48
- <Input :model-value="valuesText(option)" placeholder="S, M, L" @update:model-value="setValues(i, $event)" />
49
- </div>
50
- <Button type="button" variant="danger" size="sm" @click="remove(i)">✕</Button>
51
- </div>
52
-
53
- <Button type="button" variant="secondary" @click="add">Add option</Button>
54
- </div>
55
- </template>
@@ -1,71 +0,0 @@
1
- <script setup lang="ts">
2
- import { Button, Input, Select } from '@tower_74/cms-ui';
3
-
4
- interface Option {
5
- name: string;
6
- values: string[];
7
- }
8
- interface Variant {
9
- sku: string;
10
- price: string;
11
- stock: number | string;
12
- options: Record<string, string>;
13
- }
14
-
15
- const props = defineProps<{ modelValue: Variant[]; options: Option[]; currencySymbol: string }>();
16
- const emit = defineEmits<{ 'update:modelValue': [value: Variant[]] }>();
17
-
18
- const commit = (variants: Variant[]) => emit('update:modelValue', variants);
19
-
20
- const add = () => {
21
- const options: Record<string, string> = {};
22
- props.options.forEach((o) => (options[o.name] = o.values[0] ?? ''));
23
- commit([...props.modelValue, { sku: '', price: '', stock: 0, options }]);
24
- };
25
- const remove = (i: number) => {
26
- const variants = [...props.modelValue];
27
- variants.splice(i, 1);
28
- commit(variants);
29
- };
30
- const setField = (i: number, key: 'sku' | 'price' | 'stock', value: string) => {
31
- const variants = [...props.modelValue];
32
- variants[i] = { ...variants[i], [key]: value };
33
- commit(variants);
34
- };
35
- const setOption = (i: number, name: string, value: string) => {
36
- const variants = [...props.modelValue];
37
- variants[i] = { ...variants[i], options: { ...variants[i].options, [name]: value } };
38
- commit(variants);
39
- };
40
- const optionsFor = (option: Option) => option.values.map((v) => ({ label: v, value: v }));
41
- </script>
42
-
43
- <template>
44
- <div class="space-y-3">
45
- <div v-for="(variant, i) in modelValue" :key="i" class="flex flex-wrap items-end gap-2 rounded-lg border border-border bg-background p-3">
46
- <div v-for="option in options" :key="option.name" class="w-32">
47
- <label class="mb-1 block text-xs font-medium text-muted">{{ option.name }}</label>
48
- <Select
49
- :model-value="variant.options[option.name] ?? ''"
50
- :options="optionsFor(option)"
51
- @update:model-value="setOption(i, option.name, $event)"
52
- />
53
- </div>
54
- <div class="w-40">
55
- <label class="mb-1 block text-xs font-medium text-muted">SKU</label>
56
- <Input :model-value="variant.sku" placeholder="ABC-001" @update:model-value="setField(i, 'sku', $event)" />
57
- </div>
58
- <div class="w-28">
59
- <label class="mb-1 block text-xs font-medium text-muted">Price ({{ currencySymbol }})</label>
60
- <Input :model-value="String(variant.price)" type="number" @update:model-value="setField(i, 'price', $event)" />
61
- </div>
62
- <div class="w-24">
63
- <label class="mb-1 block text-xs font-medium text-muted">Stock</label>
64
- <Input :model-value="String(variant.stock)" type="number" @update:model-value="setField(i, 'stock', $event)" />
65
- </div>
66
- <Button type="button" variant="danger" size="sm" @click="remove(i)">✕</Button>
67
- </div>
68
-
69
- <Button type="button" variant="secondary" @click="add">Add variant</Button>
70
- </div>
71
- </template>
@@ -1,80 +0,0 @@
1
- <script setup lang="ts">
2
- import Pagination from '@/components/Pagination.vue';
3
- import AdminLayout from '@/layouts/AdminLayout.vue';
4
- import { Link } from '@inertiajs/vue3';
5
- import { Badge, Button, DataTable } from '@tower_74/cms-ui';
6
-
7
- interface OrderRow {
8
- id: number;
9
- number: string;
10
- date: string | null;
11
- email: string;
12
- status: string;
13
- payment_status: string;
14
- total: string;
15
- }
16
- interface PageLink {
17
- url: string | null;
18
- label: string;
19
- active: boolean;
20
- }
21
-
22
- defineProps<{ orders: { data: OrderRow[]; links: PageLink[] } }>();
23
-
24
- const columns = [
25
- { key: 'number', label: 'Order' },
26
- { key: 'date', label: 'Date' },
27
- { key: 'email', label: 'Customer' },
28
- { key: 'status', label: 'Status' },
29
- { key: 'payment_status', label: 'Payment' },
30
- { key: 'total', label: 'Total', align: 'right' as const },
31
- { key: 'actions', label: '', align: 'right' as const },
32
- ];
33
-
34
- const statusVariant = (status: string) =>
35
- ({
36
- completed: 'success',
37
- processing: 'info',
38
- on_hold: 'warning',
39
- cancelled: 'neutral',
40
- failed: 'danger',
41
- refunded: 'neutral',
42
- pending: 'neutral',
43
- })[status] ?? 'neutral';
44
-
45
- const paymentVariant = (status: string) =>
46
- ({
47
- paid: 'success',
48
- unpaid: 'neutral',
49
- authorized: 'info',
50
- partially_refunded: 'warning',
51
- refunded: 'neutral',
52
- failed: 'danger',
53
- })[status] ?? 'neutral';
54
-
55
- const label = (value: string) => value.replace(/_/g, ' ');
56
- </script>
57
-
58
- <template>
59
- <AdminLayout>
60
- <template #title>Orders</template>
61
-
62
- <h2 class="mb-4 text-lg font-semibold">Orders</h2>
63
-
64
- <DataTable :columns="columns" :rows="orders.data" empty="No orders yet.">
65
- <template #cell-status="{ value }">
66
- <Badge :variant="statusVariant(value as string)">{{ label(value as string) }}</Badge>
67
- </template>
68
- <template #cell-payment_status="{ value }">
69
- <Badge :variant="paymentVariant(value as string)">{{ label(value as string) }}</Badge>
70
- </template>
71
- <template #cell-actions="{ row }">
72
- <Link :href="`/admin/commerce/orders/${(row as OrderRow).number}`">
73
- <Button variant="ghost" size="sm">View</Button>
74
- </Link>
75
- </template>
76
- </DataTable>
77
-
78
- <Pagination :links="orders.links" />
79
- </AdminLayout>
80
- </template>
@@ -1,200 +0,0 @@
1
- <script setup lang="ts">
2
- import AdminLayout from '@/layouts/AdminLayout.vue';
3
- import { Link, router } from '@inertiajs/vue3';
4
- import { Badge, Button, Card, Select } from '@tower_74/cms-ui';
5
- import { ref } from 'vue';
6
-
7
- interface OrderItem {
8
- name: string;
9
- variant: string | null;
10
- sku: string | null;
11
- quantity: number;
12
- unit: string;
13
- total: string;
14
- }
15
- interface Address {
16
- first_name?: string;
17
- last_name?: string;
18
- line1?: string;
19
- line2?: string;
20
- city?: string;
21
- region?: string;
22
- postal_code?: string;
23
- country?: string;
24
- phone?: string;
25
- }
26
- interface Order {
27
- id: number;
28
- number: string;
29
- date: string | null;
30
- email: string;
31
- status: string;
32
- paymentStatus: string;
33
- billing: Address | null;
34
- shipping: Address | null;
35
- shippingMethod: string | null;
36
- note: string | null;
37
- isPaid: boolean;
38
- items: OrderItem[];
39
- totals: { subtotal: string; discount?: string | null; shipping?: string | null; tax?: string | null; total: string };
40
- payments: Array<{ gateway: string; transactionId: string | null; amount: string; status: string }>;
41
- }
42
-
43
- const props = defineProps<{
44
- order: Order;
45
- fulfillmentStatuses: string[];
46
- canEdit: boolean;
47
- canRefund: boolean;
48
- canDelete: boolean;
49
- }>();
50
-
51
- const label = (value: string) => value.replace(/_/g, ' ');
52
-
53
- const statusVariant = (status: string) =>
54
- ({ completed: 'success', processing: 'info', on_hold: 'warning', failed: 'danger', refunded: 'neutral' })[status] ?? 'neutral';
55
-
56
- const newStatus = ref(props.order.status);
57
-
58
- const statusOptions = props.fulfillmentStatuses.map((value) => ({ label: label(value), value }));
59
-
60
- const updateStatus = () => router.patch(`/admin/commerce/orders/${props.order.number}/status`, { status: newStatus.value }, { preserveScroll: true });
61
-
62
- const refund = () => {
63
- if (confirm('Issue a refund for this order? The gateway will confirm it shortly.')) {
64
- router.post(`/admin/commerce/orders/${props.order.number}/refund`, {}, { preserveScroll: true });
65
- }
66
- };
67
-
68
- const destroy = () => {
69
- if (confirm(`Delete order ${props.order.number}? This cannot be undone.`)) {
70
- router.delete(`/admin/commerce/orders/${props.order.number}`);
71
- }
72
- };
73
-
74
- const formatAddress = (a: Address | null) =>
75
- a
76
- ? [
77
- `${a.first_name ?? ''} ${a.last_name ?? ''}`.trim(),
78
- a.line1,
79
- a.line2,
80
- [a.city, a.region, a.postal_code].filter(Boolean).join(' '),
81
- a.country,
82
- a.phone,
83
- ].filter(Boolean)
84
- : [];
85
- </script>
86
-
87
- <template>
88
- <AdminLayout>
89
- <template #title>Order {{ order.number }}</template>
90
-
91
- <div class="mb-6 flex flex-wrap items-center justify-between gap-3">
92
- <div class="flex items-center gap-3">
93
- <Link href="/admin/commerce/orders" class="text-sm text-muted hover:underline">← Orders</Link>
94
- <h2 class="text-lg font-semibold">{{ order.number }}</h2>
95
- <Badge :variant="statusVariant(order.status)">{{ label(order.status) }}</Badge>
96
- <Badge :variant="order.paymentStatus === 'paid' ? 'success' : 'neutral'">{{ label(order.paymentStatus) }}</Badge>
97
- </div>
98
- <a :href="`/admin/commerce/orders/${order.number}/invoice`">
99
- <Button variant="secondary" size="sm">Download invoice</Button>
100
- </a>
101
- </div>
102
-
103
- <div class="grid gap-6 lg:grid-cols-[1fr_320px]">
104
- <div class="space-y-6">
105
- <Card title="Items">
106
- <table class="w-full text-sm">
107
- <thead>
108
- <tr class="border-b border-border text-left text-muted">
109
- <th class="py-2">Item</th>
110
- <th class="py-2 text-right">Qty</th>
111
- <th class="py-2 text-right">Unit</th>
112
- <th class="py-2 text-right">Total</th>
113
- </tr>
114
- </thead>
115
- <tbody>
116
- <tr v-for="(item, i) in order.items" :key="i" class="border-b border-border last:border-0">
117
- <td class="py-2">
118
- {{ item.name }}
119
- <span v-if="item.variant" class="block text-xs text-muted">{{ item.variant }}</span>
120
- </td>
121
- <td class="py-2 text-right">{{ item.quantity }}</td>
122
- <td class="py-2 text-right">{{ item.unit }}</td>
123
- <td class="py-2 text-right">{{ item.total }}</td>
124
- </tr>
125
- </tbody>
126
- </table>
127
-
128
- <dl class="mt-4 space-y-1 border-t border-border pt-4 text-sm">
129
- <div class="flex justify-between">
130
- <dt class="text-muted">Subtotal</dt>
131
- <dd>{{ order.totals.subtotal }}</dd>
132
- </div>
133
- <div v-if="order.totals.discount" class="flex justify-between">
134
- <dt class="text-muted">Discount</dt>
135
- <dd>-{{ order.totals.discount }}</dd>
136
- </div>
137
- <div v-if="order.totals.shipping" class="flex justify-between">
138
- <dt class="text-muted">Shipping</dt>
139
- <dd>{{ order.totals.shipping }}</dd>
140
- </div>
141
- <div v-if="order.totals.tax" class="flex justify-between">
142
- <dt class="text-muted">Tax</dt>
143
- <dd>{{ order.totals.tax }}</dd>
144
- </div>
145
- <div class="flex justify-between border-t border-border pt-2 font-semibold">
146
- <dt>Total</dt>
147
- <dd>{{ order.totals.total }}</dd>
148
- </div>
149
- </dl>
150
- </Card>
151
-
152
- <Card title="Payments">
153
- <p v-if="!order.payments.length" class="text-sm text-muted">No payments recorded.</p>
154
- <ul v-else class="space-y-2 text-sm">
155
- <li v-for="(p, i) in order.payments" :key="i" class="flex items-center justify-between">
156
- <span>
157
- <span class="font-medium">{{ p.amount }}</span>
158
- <span class="text-muted"> · {{ p.gateway }} · {{ label(p.status) }}</span>
159
- </span>
160
- <span class="font-mono text-xs text-muted">{{ p.transactionId }}</span>
161
- </li>
162
- </ul>
163
- </Card>
164
- </div>
165
-
166
- <div class="space-y-6">
167
- <Card title="Customer">
168
- <p class="text-sm">{{ order.email }}</p>
169
- <p class="mt-1 text-xs text-muted">Placed {{ order.date }}</p>
170
- </Card>
171
-
172
- <Card title="Shipping address">
173
- <p v-for="(line, i) in formatAddress(order.shipping)" :key="i" class="text-sm">{{ line }}</p>
174
- <p v-if="order.shippingMethod" class="mt-2 text-xs text-muted">Method: {{ order.shippingMethod }}</p>
175
- </Card>
176
-
177
- <Card title="Billing address">
178
- <p v-for="(line, i) in formatAddress(order.billing)" :key="i" class="text-sm">{{ line }}</p>
179
- </Card>
180
-
181
- <Card v-if="order.note" title="Customer note">
182
- <p class="text-sm">{{ order.note }}</p>
183
- </Card>
184
-
185
- <Card title="Manage">
186
- <div v-if="canEdit" class="space-y-2">
187
- <label class="text-xs font-medium text-muted">Fulfillment status</label>
188
- <Select v-model="newStatus" :options="statusOptions" />
189
- <Button size="sm" class="w-full" @click="updateStatus">Update status</Button>
190
- </div>
191
-
192
- <div class="mt-4 space-y-2 border-t border-border pt-4">
193
- <Button v-if="canRefund && order.isPaid" variant="secondary" size="sm" class="w-full" @click="refund"> Refund order </Button>
194
- <Button v-if="canDelete" variant="danger" size="sm" class="w-full" @click="destroy"> Delete order </Button>
195
- </div>
196
- </Card>
197
- </div>
198
- </div>
199
- </AdminLayout>
200
- </template>
@@ -1,167 +0,0 @@
1
- <script setup lang="ts">
2
- import BlockEditor from '@/components/BlockEditor.vue';
3
- import OptionsEditor from '@/components/commerce/OptionsEditor.vue';
4
- import VariantsEditor from '@/components/commerce/VariantsEditor.vue';
5
- import AdminLayout from '@/layouts/AdminLayout.vue';
6
- import { Link, useForm } from '@inertiajs/vue3';
7
- import { Button, FormBuilder, FormField, MediaPicker, type MediaItem, type MediaSelection } from '@tower_74/cms-ui';
8
- import axios from 'axios';
9
- import { computed, ref } from 'vue';
10
-
11
- interface Option {
12
- name: string;
13
- values: string[];
14
- }
15
- interface Variant {
16
- sku: string;
17
- price: string;
18
- stock: number | string;
19
- options: Record<string, string>;
20
- }
21
- interface Block {
22
- type: string;
23
- data: Record<string, unknown>;
24
- }
25
- interface BlockDef {
26
- type: string;
27
- label: string;
28
- fields: Array<Record<string, unknown>>;
29
- defaults: Record<string, unknown>;
30
- }
31
- interface ProductData {
32
- id: number;
33
- name: string;
34
- description: string | null;
35
- status: string;
36
- tax_class: string;
37
- featured_media_id: number | null;
38
- options: Option[];
39
- variants: Variant[];
40
- }
41
-
42
- const props = defineProps<{
43
- product: ProductData | null;
44
- currencySymbol: string;
45
- featuredMedia: MediaItem | null;
46
- mediaItems: MediaItem[];
47
- blocks: Block[];
48
- blockRegistry: BlockDef[];
49
- }>();
50
-
51
- const isEdit = computed(() => props.product !== null);
52
-
53
- const fields = [
54
- { name: 'name', label: 'Name', required: true },
55
- {
56
- name: 'status',
57
- label: 'Status',
58
- type: 'select' as const,
59
- options: [
60
- { label: 'Draft', value: 'draft' },
61
- { label: 'Published', value: 'published' },
62
- ],
63
- },
64
- {
65
- name: 'tax_class',
66
- label: 'Tax class',
67
- type: 'select' as const,
68
- options: [
69
- { label: 'Standard', value: 'standard' },
70
- { label: 'Reduced', value: 'reduced' },
71
- { label: 'Zero-rated', value: 'zero' },
72
- ],
73
- },
74
- { name: 'description', label: 'Description', type: 'textarea' as const },
75
- ];
76
-
77
- const form = useForm({
78
- name: props.product?.name ?? '',
79
- status: props.product?.status ?? 'draft',
80
- tax_class: props.product?.tax_class ?? 'standard',
81
- description: props.product?.description ?? '',
82
- featured_media_id: props.product?.featured_media_id ?? null,
83
- options: (props.product?.options ?? []) as Option[],
84
- variants: (props.product?.variants ?? []) as Variant[],
85
- blocks: (props.blocks ?? []) as Block[],
86
- });
87
-
88
- const model = computed({
89
- get: () => ({ name: form.name, status: form.status, tax_class: form.tax_class, description: form.description }),
90
- set: (value: Record<string, unknown>) => {
91
- form.name = (value.name as string) ?? '';
92
- form.status = (value.status as string) ?? 'draft';
93
- form.tax_class = (value.tax_class as string) ?? 'standard';
94
- form.description = (value.description as string) ?? '';
95
- },
96
- });
97
-
98
- const optionsModel = computed({ get: () => form.options, set: (value: Option[]) => (form.options = value) });
99
- const variantsModel = computed({ get: () => form.variants, set: (value: Variant[]) => (form.variants = value) });
100
- const blocksModel = computed({ get: () => form.blocks, set: (value: Block[]) => (form.blocks = value) });
101
-
102
- // Shared media library for the featured-image picker and any block galleries.
103
- const mediaItems = ref<MediaItem[]>([...props.mediaItems]);
104
- const onUpload = async (file: File) => {
105
- const data = new FormData();
106
- data.append('file', file);
107
- const { data: res } = await axios.post('/admin/media/upload', data);
108
- mediaItems.value = [res.item, ...mediaItems.value];
109
- };
110
-
111
- const featured = ref<MediaSelection | null>(
112
- props.featuredMedia ? { id: props.featuredMedia.id, src: props.featuredMedia.url, alt: props.featuredMedia.name ?? '' } : null,
113
- );
114
- const onFeatured = (value: MediaSelection | MediaSelection[] | null) => {
115
- featured.value = (value as MediaSelection | null) ?? null;
116
- form.featured_media_id = featured.value?.id ?? null;
117
- };
118
-
119
- const submit = () => {
120
- if (isEdit.value && props.product) {
121
- form.put(`/admin/commerce/products/${props.product.id}`);
122
- } else {
123
- form.post('/admin/commerce/products');
124
- }
125
- };
126
- </script>
127
-
128
- <template>
129
- <AdminLayout>
130
- <template #title>{{ isEdit ? 'Edit product' : 'New product' }}</template>
131
-
132
- <form class="max-w-4xl" @submit.prevent="submit">
133
- <div class="max-w-xl">
134
- <FormBuilder v-model="model" :fields="fields" :errors="form.errors" />
135
-
136
- <div class="mt-4">
137
- <FormField label="Featured image" :error="form.errors.featured_media_id" help="Shown on the product card and page.">
138
- <MediaPicker :model-value="featured" :items="mediaItems" @update:model-value="onFeatured" @upload="onUpload" />
139
- </FormField>
140
- </div>
141
- </div>
142
-
143
- <div class="mt-8">
144
- <h3 class="mb-3 text-base font-semibold">Options</h3>
145
- <OptionsEditor v-model="optionsModel" />
146
- </div>
147
-
148
- <div class="mt-8">
149
- <h3 class="mb-3 text-base font-semibold">Variants</h3>
150
- <p v-if="form.errors.variants" class="mb-2 text-sm text-danger">{{ form.errors.variants }}</p>
151
- <VariantsEditor v-model="variantsModel" :options="form.options" :currency-symbol="currencySymbol" />
152
- </div>
153
-
154
- <div class="mt-8">
155
- <h3 class="mb-3 text-base font-semibold">Product page blocks</h3>
156
- <BlockEditor v-model="blocksModel" :registry="blockRegistry" :media-items="mediaItems" @upload="onUpload" />
157
- </div>
158
-
159
- <div class="mt-8 flex items-center gap-2">
160
- <Button type="submit" :disabled="form.processing">
161
- {{ isEdit ? 'Save changes' : 'Create product' }}
162
- </Button>
163
- <Link href="/admin/commerce/products"><Button variant="ghost" type="button">Cancel</Button></Link>
164
- </div>
165
- </form>
166
- </AdminLayout>
167
- </template>
@@ -1,65 +0,0 @@
1
- <script setup lang="ts">
2
- import Pagination from '@/components/Pagination.vue';
3
- import AdminLayout from '@/layouts/AdminLayout.vue';
4
- import { Link, router } from '@inertiajs/vue3';
5
- import { Badge, Button, DataTable } from '@tower_74/cms-ui';
6
-
7
- interface ProductRow {
8
- id: number;
9
- name: string;
10
- status: string;
11
- variants_count: number;
12
- price_from: string | null;
13
- }
14
- interface PageLink {
15
- url: string | null;
16
- label: string;
17
- active: boolean;
18
- }
19
-
20
- defineProps<{ products: { data: ProductRow[]; links: PageLink[] } }>();
21
-
22
- const columns = [
23
- { key: 'name', label: 'Name' },
24
- { key: 'status', label: 'Status' },
25
- { key: 'variants_count', label: 'Variants', align: 'right' as const },
26
- { key: 'price_from', label: 'From', align: 'right' as const },
27
- { key: 'actions', label: '', align: 'right' as const },
28
- ];
29
-
30
- const destroy = (row: ProductRow) => {
31
- if (confirm(`Delete “${row.name}”?`)) {
32
- router.delete(`/admin/commerce/products/${row.id}`, { preserveScroll: true });
33
- }
34
- };
35
- </script>
36
-
37
- <template>
38
- <AdminLayout>
39
- <template #title>Products</template>
40
-
41
- <div class="mb-4 flex items-center justify-between">
42
- <h2 class="text-lg font-semibold">Products</h2>
43
- <Link href="/admin/commerce/products/create"><Button>New product</Button></Link>
44
- </div>
45
-
46
- <DataTable :columns="columns" :rows="products.data" empty="No products yet.">
47
- <template #cell-status="{ value }">
48
- <Badge :variant="value === 'published' ? 'success' : 'neutral'">{{ value }}</Badge>
49
- </template>
50
- <template #cell-price_from="{ value }">
51
- <span class="text-muted">{{ value ?? '—' }}</span>
52
- </template>
53
- <template #cell-actions="{ row }">
54
- <div class="flex justify-end gap-2">
55
- <Link :href="`/admin/commerce/products/${(row as ProductRow).id}/edit`">
56
- <Button variant="ghost" size="sm">Edit</Button>
57
- </Link>
58
- <Button variant="danger" size="sm" @click="destroy(row as ProductRow)">Delete</Button>
59
- </div>
60
- </template>
61
- </DataTable>
62
-
63
- <Pagination :links="products.links" />
64
- </AdminLayout>
65
- </template>
@@ -1,108 +0,0 @@
1
- <script setup lang="ts">
2
- import Seo from '@/components/Seo.vue';
3
- import PublicLayout from '@/layouts/PublicLayout.vue';
4
- import { Link, router } from '@inertiajs/vue3';
5
- import { type Block, Button, CartLineItem, CartSummary } from '@tower_74/cms-ui';
6
-
7
- interface CartItem {
8
- variant_id: number;
9
- name: string;
10
- variant: string | null;
11
- price: string;
12
- quantity: number;
13
- line_total: string;
14
- max: number;
15
- image: { src: string; alt?: string } | null;
16
- }
17
- interface ShippingOption {
18
- id: string;
19
- label: string;
20
- cost: string;
21
- }
22
-
23
- defineProps<{
24
- site: { name: string };
25
- menu: Array<{ label: string; url: string }>;
26
- footerWidgets: Block[];
27
- cartCount?: number | null;
28
- items: CartItem[];
29
- shippingOptions: ShippingOption[];
30
- selectedShipping: string | null;
31
- summary: { subtotal: string; shipping?: string | null; tax?: string | null; total: string };
32
- seo: { title: string; description?: string | null; url?: string | null; image?: string | null; type?: string | null };
33
- }>();
34
-
35
- const updateQty = (variantId: number, quantity: number) => router.patch(`/cart/${variantId}`, { quantity }, { preserveScroll: true });
36
-
37
- const removeItem = (variantId: number) => router.delete(`/cart/${variantId}`, { preserveScroll: true });
38
-
39
- const chooseShipping = (method: string) => router.post('/cart/shipping', { method }, { preserveScroll: true });
40
- </script>
41
-
42
- <template>
43
- <Seo :seo="seo" />
44
-
45
- <PublicLayout :site="site" :menu="menu" :footer-widgets="footerWidgets" :cart-count="cartCount">
46
- <div class="mx-auto max-w-5xl px-4 py-12 sm:px-6">
47
- <h1 class="mb-8 text-3xl font-bold tracking-tight">Cart</h1>
48
-
49
- <div v-if="items.length" class="grid gap-10 lg:grid-cols-[1fr_320px]">
50
- <div>
51
- <CartLineItem
52
- v-for="item in items"
53
- :key="item.variant_id"
54
- :name="item.name"
55
- :variant="item.variant ?? undefined"
56
- :image="item.image ?? undefined"
57
- :price="item.price"
58
- :quantity="item.quantity"
59
- :line-total="item.line_total"
60
- :max="item.max"
61
- @update:quantity="updateQty(item.variant_id, $event)"
62
- @remove="removeItem(item.variant_id)"
63
- />
64
-
65
- <div v-if="shippingOptions.length" class="mt-6">
66
- <h2 class="mb-2 text-sm font-medium">Shipping</h2>
67
- <label
68
- v-for="option in shippingOptions"
69
- :key="option.id"
70
- class="flex cursor-pointer items-center justify-between rounded border border-border px-3 py-2 text-sm"
71
- :class="option.id === selectedShipping ? 'border-primary' : ''"
72
- >
73
- <span class="flex items-center gap-2">
74
- <input
75
- type="radio"
76
- name="shipping"
77
- :value="option.id"
78
- :checked="option.id === selectedShipping"
79
- @change="chooseShipping(option.id)"
80
- />
81
- {{ option.label }}
82
- </span>
83
- <span class="text-muted">{{ option.cost }}</span>
84
- </label>
85
- </div>
86
- </div>
87
-
88
- <CartSummary
89
- :subtotal="summary.subtotal"
90
- :shipping="summary.shipping ?? undefined"
91
- :tax="summary.tax ?? undefined"
92
- :total="summary.total"
93
- >
94
- <template #actions>
95
- <Link href="/checkout"><Button class="w-full">Checkout</Button></Link>
96
- </template>
97
- </CartSummary>
98
- </div>
99
-
100
- <div v-else class="rounded-lg border border-border bg-background px-4 py-16 text-center">
101
- <p class="text-muted">Your cart is empty.</p>
102
- <Link href="/shop" class="mt-4 inline-block">
103
- <Button variant="secondary">Continue shopping</Button>
104
- </Link>
105
- </div>
106
- </div>
107
- </PublicLayout>
108
- </template>
@@ -1,110 +0,0 @@
1
- <script setup lang="ts">
2
- import Seo from '@/components/Seo.vue';
3
- import PublicLayout from '@/layouts/PublicLayout.vue';
4
- import { Link, router } from '@inertiajs/vue3';
5
- import { type Block, Button, OrderSummary } from '@tower_74/cms-ui';
6
- import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
7
-
8
- interface OrderItem {
9
- name: string;
10
- variant: string | null;
11
- quantity: number;
12
- lineTotal: string;
13
- }
14
-
15
- const props = defineProps<{
16
- site: { name: string };
17
- menu: Array<{ label: string; url: string }>;
18
- footerWidgets: Block[];
19
- cartCount?: number | null;
20
- order: {
21
- number: string;
22
- status: string;
23
- paymentStatus: string;
24
- email: string;
25
- items: OrderItem[];
26
- subtotal: string;
27
- discount?: string | null;
28
- shipping?: string | null;
29
- tax?: string | null;
30
- total: string;
31
- };
32
- seo: { title: string; description?: string | null; url?: string | null; image?: string | null; type?: string | null };
33
- }>();
34
-
35
- // The webhook — not this redirect — sets the true payment status, so poll until paid
36
- // (or failed) instead of assuming success (PLANNING §13.4 step 10).
37
- const paymentStatus = ref(props.order.paymentStatus);
38
- const isPaid = computed(() => paymentStatus.value === 'paid');
39
- const isFailed = computed(() => paymentStatus.value === 'failed');
40
- const pending = computed(() => !isPaid.value && !isFailed.value);
41
-
42
- let timer: ReturnType<typeof setInterval> | undefined;
43
-
44
- const poll = async () => {
45
- try {
46
- const response = await fetch(`/checkout/status/${props.order.number}`, {
47
- headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
48
- credentials: 'same-origin',
49
- });
50
- if (!response.ok) return;
51
- const data = await response.json();
52
- paymentStatus.value = data.paymentStatus;
53
- if (!pending.value && timer) clearInterval(timer);
54
- } catch {
55
- // transient; the next tick retries
56
- }
57
- };
58
-
59
- onMounted(() => {
60
- if (pending.value) {
61
- timer = setInterval(poll, 3000);
62
- }
63
- });
64
-
65
- onBeforeUnmount(() => {
66
- if (timer) clearInterval(timer);
67
- });
68
-
69
- const refresh = () => router.reload();
70
- </script>
71
-
72
- <template>
73
- <Seo :seo="seo" />
74
-
75
- <PublicLayout :site="site" :menu="menu" :footer-widgets="footerWidgets" :cart-count="cartCount">
76
- <div class="mx-auto max-w-2xl px-4 py-16 sm:px-6">
77
- <div class="mb-8 text-center">
78
- <h1 class="text-3xl font-bold tracking-tight">
79
- <span v-if="isPaid">Thank you for your order!</span>
80
- <span v-else-if="isFailed">Payment failed</span>
81
- <span v-else>We're confirming your payment…</span>
82
- </h1>
83
- <p class="mt-2 text-muted">
84
- Order <span class="font-medium text-text">{{ order.number }}</span> — a receipt is on its way to {{ order.email }}.
85
- </p>
86
-
87
- <p v-if="pending" class="mt-4 inline-flex items-center gap-2 rounded-full bg-surface px-4 py-1.5 text-sm text-muted">
88
- <span class="h-2 w-2 animate-pulse rounded-full bg-primary" />
89
- Awaiting payment confirmation…
90
- </p>
91
- <p v-else-if="isFailed" class="mt-4 text-sm text-danger">Your payment didn't go through. Please try again from your cart.</p>
92
- </div>
93
-
94
- <OrderSummary
95
- :items="order.items"
96
- :subtotal="order.subtotal"
97
- :discount="order.discount ?? undefined"
98
- :shipping="order.shipping ?? undefined"
99
- :tax="order.tax ?? undefined"
100
- :total="order.total"
101
- title="Order details"
102
- />
103
-
104
- <div class="mt-8 flex justify-center gap-3">
105
- <Link href="/shop"><Button variant="secondary">Continue shopping</Button></Link>
106
- <Button v-if="pending" variant="ghost" @click="refresh">Refresh status</Button>
107
- </div>
108
- </div>
109
- </PublicLayout>
110
- </template>
@@ -1,174 +0,0 @@
1
- <script setup lang="ts">
2
- import Seo from '@/components/Seo.vue';
3
- import PublicLayout from '@/layouts/PublicLayout.vue';
4
- import { router } from '@inertiajs/vue3';
5
- import { type Block, Button, CheckoutForm, OrderSummary } from '@tower_74/cms-ui';
6
- import { nextTick, ref } from 'vue';
7
-
8
- interface SummaryItem {
9
- name: string;
10
- variant: string | null;
11
- image: { src: string; alt?: string } | null;
12
- quantity: number;
13
- lineTotal: string;
14
- }
15
- interface ShippingOption {
16
- id: string;
17
- label: string;
18
- cost: string;
19
- }
20
-
21
- const props = defineProps<{
22
- site: { name: string };
23
- menu: Array<{ label: string; url: string }>;
24
- footerWidgets: Block[];
25
- cartCount?: number | null;
26
- summary: {
27
- items: SummaryItem[];
28
- subtotal: string;
29
- discount?: string | null;
30
- shipping?: string | null;
31
- tax?: string | null;
32
- total: string;
33
- };
34
- shippingOptions: ShippingOption[];
35
- selectedShipping: string | null;
36
- gateway: string;
37
- stripeKey: string | null;
38
- seo: { title: string; description?: string | null; url?: string | null; image?: string | null; type?: string | null };
39
- }>();
40
-
41
- const errors = ref<Record<string, string>>({});
42
- const generalError = ref<string | null>(null);
43
- const processing = ref(false);
44
-
45
- // Stripe (browser-only; loaded lazily so SSR stays clean).
46
- const showPayment = ref(false);
47
- const paymentEl = ref<HTMLDivElement | null>(null);
48
-
49
- let stripe: any = null;
50
-
51
- let elements: any = null;
52
- let returnUrl = '';
53
-
54
- const onShippingChange = (id: string) => router.post('/cart/shipping', { method: id }, { preserveScroll: true, preserveState: true });
55
-
56
- const placeOrder = async (payload: any) => {
57
- processing.value = true;
58
- errors.value = {};
59
- generalError.value = null;
60
-
61
- try {
62
- const response = await fetch('/checkout', {
63
- method: 'POST',
64
- headers: {
65
- 'Content-Type': 'application/json',
66
- Accept: 'application/json',
67
- 'X-Requested-With': 'XMLHttpRequest',
68
- 'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
69
- },
70
- credentials: 'same-origin',
71
- body: JSON.stringify(payload),
72
- });
73
-
74
- const data = await response.json();
75
-
76
- if (response.status === 422) {
77
- errors.value = data.errors ? flattenErrors(data.errors) : {};
78
- generalError.value = data.errors ? null : (data.message ?? 'Please check your details.');
79
- return;
80
- }
81
- if (!response.ok) {
82
- generalError.value = data.message ?? 'Something went wrong. Please try again.';
83
- return;
84
- }
85
-
86
- returnUrl = data.returnUrl;
87
-
88
- if (data.gateway === 'stripe' && props.stripeKey) {
89
- await mountStripe(data.clientSecret);
90
- } else {
91
- router.visit(returnUrl);
92
- }
93
- } catch {
94
- generalError.value = 'We could not reach the payment service. Please try again.';
95
- } finally {
96
- processing.value = false;
97
- }
98
- };
99
-
100
- const flattenErrors = (raw: Record<string, string[]>): Record<string, string> =>
101
- Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, value[0]]));
102
-
103
- const mountStripe = async (clientSecret: string) => {
104
- const { loadStripe } = await import('@stripe/stripe-js');
105
- stripe = await loadStripe(props.stripeKey as string);
106
- if (!stripe) {
107
- generalError.value = 'Stripe failed to load.';
108
- return;
109
- }
110
- elements = stripe.elements({ clientSecret });
111
- showPayment.value = true;
112
- await nextTick();
113
- elements.create('payment').mount(paymentEl.value);
114
- };
115
-
116
- const confirmPayment = async () => {
117
- if (!stripe || !elements) return;
118
- processing.value = true;
119
- const { error } = await stripe.confirmPayment({
120
- elements,
121
- confirmParams: { return_url: returnUrl },
122
- });
123
- if (error) {
124
- generalError.value = error.message ?? 'Payment could not be completed.';
125
- processing.value = false;
126
- }
127
- };
128
- </script>
129
-
130
- <template>
131
- <Seo :seo="seo" />
132
-
133
- <PublicLayout :site="site" :menu="menu" :footer-widgets="footerWidgets" :cart-count="cartCount">
134
- <div class="mx-auto max-w-6xl px-4 py-12 sm:px-6">
135
- <h1 class="mb-8 text-3xl font-bold tracking-tight">Checkout</h1>
136
-
137
- <div class="grid gap-12 lg:grid-cols-[1fr_360px]">
138
- <div>
139
- <p v-if="generalError" class="border-danger/40 bg-danger/10 mb-6 rounded border px-4 py-3 text-sm text-danger">
140
- {{ generalError }}
141
- </p>
142
-
143
- <CheckoutForm
144
- v-if="!showPayment"
145
- :shipping-options="shippingOptions"
146
- :selected-shipping="selectedShipping"
147
- :errors="errors"
148
- :processing="processing"
149
- submit-label="Continue to payment"
150
- @update:shipping-method="onShippingChange"
151
- @submit="placeOrder"
152
- />
153
-
154
- <div v-else>
155
- <h2 class="mb-4 text-lg font-semibold">Payment</h2>
156
- <div ref="paymentEl" class="mb-6" />
157
- <Button size="lg" class="w-full" :disabled="processing" @click="confirmPayment">
158
- {{ processing ? 'Processing…' : 'Pay now' }}
159
- </Button>
160
- </div>
161
- </div>
162
-
163
- <OrderSummary
164
- :items="summary.items"
165
- :subtotal="summary.subtotal"
166
- :discount="summary.discount ?? undefined"
167
- :shipping="summary.shipping ?? undefined"
168
- :tax="summary.tax ?? undefined"
169
- :total="summary.total"
170
- />
171
- </div>
172
- </div>
173
- </PublicLayout>
174
- </template>
@@ -1,39 +0,0 @@
1
- <script setup lang="ts">
2
- import Pagination from '@/components/Pagination.vue';
3
- import Seo from '@/components/Seo.vue';
4
- import PublicLayout from '@/layouts/PublicLayout.vue';
5
- import { type Block, ProductGrid } from '@tower_74/cms-ui';
6
-
7
- interface ProductItem {
8
- name: string;
9
- url: string;
10
- price: string | null;
11
- image: { src: string; alt?: string } | null;
12
- }
13
- interface PageLink {
14
- url: string | null;
15
- label: string;
16
- active: boolean;
17
- }
18
-
19
- defineProps<{
20
- site: { name: string };
21
- menu: Array<{ label: string; url: string }>;
22
- footerWidgets: Block[];
23
- cartCount?: number | null;
24
- products: { data: ProductItem[]; links: PageLink[] };
25
- seo: { title: string; description?: string | null; url?: string | null; image?: string | null; type?: string | null };
26
- }>();
27
- </script>
28
-
29
- <template>
30
- <Seo :seo="seo" />
31
-
32
- <PublicLayout :site="site" :menu="menu" :footer-widgets="footerWidgets" :cart-count="cartCount">
33
- <div class="mx-auto max-w-6xl px-4 py-12 sm:px-6">
34
- <h1 class="mb-8 text-3xl font-bold tracking-tight">Shop</h1>
35
- <ProductGrid :products="products.data" :columns="3" empty="No products available yet." />
36
- <Pagination :links="products.links" />
37
- </div>
38
- </PublicLayout>
39
- </template>
@@ -1,47 +0,0 @@
1
- <script setup lang="ts">
2
- import Seo from '@/components/Seo.vue';
3
- import PublicLayout from '@/layouts/PublicLayout.vue';
4
- import { pluginBlocks } from '@/plugins';
5
- import { router } from '@inertiajs/vue3';
6
- import { type Block, BlockRenderer, ProductOverview } from '@tower_74/cms-ui';
7
-
8
- interface ProductData {
9
- name: string;
10
- description: string | null;
11
- image: { src: string; alt?: string } | null;
12
- blocks: Block[];
13
- options: Array<{ name: string; values: string[] }>;
14
- variants: Array<{ id: number; options: Record<string, string>; price: string; available: boolean }>;
15
- }
16
-
17
- defineProps<{
18
- site: { name: string };
19
- menu: Array<{ label: string; url: string }>;
20
- footerWidgets: Block[];
21
- cartCount?: number | null;
22
- product: ProductData;
23
- seo: { title: string; description?: string | null; url?: string | null; image?: string | null; type?: string | null };
24
- }>();
25
-
26
- const addToCart = (payload: { variantId: number | string; quantity: number }) => {
27
- // Wired to the cart in Phase 8.3. For now this posts to the (future) cart endpoint.
28
- router.post('/cart', { variant_id: payload.variantId, quantity: payload.quantity }, { preserveScroll: true });
29
- };
30
- </script>
31
-
32
- <template>
33
- <Seo :seo="seo" />
34
-
35
- <PublicLayout :site="site" :menu="menu" :footer-widgets="footerWidgets" :cart-count="cartCount">
36
- <ProductOverview
37
- :name="product.name"
38
- :description="product.description ?? undefined"
39
- :image="product.image ?? undefined"
40
- :options="product.options"
41
- :variants="product.variants"
42
- @add-to-cart="addToCart"
43
- />
44
-
45
- <BlockRenderer v-if="product.blocks.length" :blocks="product.blocks" :components="pluginBlocks()" />
46
- </PublicLayout>
47
- </template>