@tower_74/cms-app 0.4.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 +49 -49
- package/src/layouts/AdminLayout.vue +1 -19
- package/src/pages/Admin/Plugins/Index.vue +61 -25
- package/src/components/commerce/OptionsEditor.vue +0 -55
- package/src/components/commerce/VariantsEditor.vue +0 -71
- package/src/pages/Admin/Commerce/Orders/Index.vue +0 -80
- package/src/pages/Admin/Commerce/Orders/Show.vue +0 -200
- package/src/pages/Admin/Commerce/Products/Edit.vue +0 -167
- package/src/pages/Admin/Commerce/Products/Index.vue +0 -65
- package/src/pages/Public/Cart/Index.vue +0 -108
- package/src/pages/Public/Checkout/Confirmation.vue +0 -110
- package/src/pages/Public/Checkout/Index.vue +0 -174
- package/src/pages/Public/Shop/Index.vue +0 -39
- package/src/pages/Public/Shop/Show.vue +0 -47
package/package.json
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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' },
|
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
3
3
|
import { router } from '@inertiajs/vue3';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
interface PluginItem {
|
|
6
|
+
key: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string | null;
|
|
9
|
+
composer: string | null;
|
|
10
|
+
npm: string | null;
|
|
11
|
+
installed: boolean;
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
version: string | null;
|
|
14
|
+
}
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
defineProps<{ plugins: PluginItem[] }>();
|
|
17
|
+
|
|
18
|
+
// Enable/disable persists via the PluginRegistry; the redirect back reflects the new state
|
|
19
|
+
// (a plugin's capabilities are gated on enable at boot — ADR-0026).
|
|
20
|
+
const toggle = (plugin: PluginItem) => {
|
|
12
21
|
router.post(`/admin/plugins/${plugin.key}/${plugin.enabled ? 'disable' : 'enable'}`, {}, { preserveScroll: true });
|
|
13
22
|
};
|
|
14
23
|
</script>
|
|
@@ -18,31 +27,58 @@ const toggle = (plugin: { key: string; enabled: boolean }) => {
|
|
|
18
27
|
<div class="p-6">
|
|
19
28
|
<h1 class="text-2xl font-semibold">Plugins</h1>
|
|
20
29
|
<p class="mt-1 text-sm text-muted">
|
|
21
|
-
|
|
30
|
+
Plugins available for this site. Installed ones can be enabled or disabled; others show how to install them.
|
|
22
31
|
</p>
|
|
23
32
|
|
|
24
|
-
<div v-if="plugins.length" class="mt-6
|
|
25
|
-
<div v-for="plugin in plugins" :key="plugin.key" class="
|
|
26
|
-
<div>
|
|
27
|
-
<div class="
|
|
28
|
-
|
|
33
|
+
<div v-if="plugins.length" class="mt-6 space-y-3">
|
|
34
|
+
<div v-for="plugin in plugins" :key="plugin.key" class="rounded-lg border border-border p-4">
|
|
35
|
+
<div class="flex items-start justify-between gap-4">
|
|
36
|
+
<div class="min-w-0">
|
|
37
|
+
<div class="flex items-center gap-2">
|
|
38
|
+
<span class="font-medium">{{ plugin.name }}</span>
|
|
39
|
+
<span
|
|
40
|
+
class="rounded-full border px-2 py-0.5 text-xs font-medium"
|
|
41
|
+
:class="
|
|
42
|
+
plugin.enabled
|
|
43
|
+
? 'border-success text-success'
|
|
44
|
+
: plugin.installed
|
|
45
|
+
? 'border-border text-muted'
|
|
46
|
+
: 'border-info text-info'
|
|
47
|
+
"
|
|
48
|
+
>
|
|
49
|
+
{{ plugin.enabled ? 'Enabled' : plugin.installed ? 'Disabled' : 'Available' }}
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
52
|
+
<p v-if="plugin.description" class="mt-1 text-sm text-muted">{{ plugin.description }}</p>
|
|
53
|
+
<p class="mt-0.5 text-xs text-muted">
|
|
54
|
+
{{ plugin.key }}<span v-if="plugin.version"> · v{{ plugin.version }}</span>
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<button
|
|
59
|
+
v-if="plugin.installed"
|
|
60
|
+
type="button"
|
|
61
|
+
class="shrink-0 rounded-md px-3 py-1.5 text-sm font-medium transition"
|
|
62
|
+
:class="
|
|
63
|
+
plugin.enabled
|
|
64
|
+
? 'bg-accent text-accent-foreground hover:bg-accent/80'
|
|
65
|
+
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
66
|
+
"
|
|
67
|
+
@click="toggle(plugin)"
|
|
68
|
+
>
|
|
69
|
+
{{ plugin.enabled ? 'Disable' : 'Enable' }}
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div v-if="!plugin.installed && plugin.composer" class="mt-3 rounded-md border border-border bg-card p-3 text-xs">
|
|
74
|
+
<p class="mb-1 font-medium text-muted">Install, then it'll appear here to enable:</p>
|
|
75
|
+
<pre class="overflow-x-auto text-muted"><code>composer require {{ plugin.composer }}<template v-if="plugin.npm">
|
|
76
|
+
npm install {{ plugin.npm }}</template></code></pre>
|
|
29
77
|
</div>
|
|
30
|
-
<button
|
|
31
|
-
type="button"
|
|
32
|
-
class="rounded-md px-3 py-1.5 text-sm font-medium transition"
|
|
33
|
-
:class="
|
|
34
|
-
plugin.enabled
|
|
35
|
-
? 'bg-accent text-accent-foreground hover:bg-accent/80'
|
|
36
|
-
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
37
|
-
"
|
|
38
|
-
@click="toggle(plugin)"
|
|
39
|
-
>
|
|
40
|
-
{{ plugin.enabled ? 'Disable' : 'Enable' }}
|
|
41
|
-
</button>
|
|
42
78
|
</div>
|
|
43
79
|
</div>
|
|
44
80
|
|
|
45
|
-
<p v-else class="mt-6 text-sm text-muted">No plugins
|
|
81
|
+
<p v-else class="mt-6 text-sm text-muted">No plugins in the catalogue.</p>
|
|
46
82
|
</div>
|
|
47
83
|
</AdminLayout>
|
|
48
84
|
</template>
|
|
@@ -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>
|