@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
|
@@ -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>
|