@tower_74/cms-app 0.1.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/README.md +102 -0
- package/package.json +49 -0
- package/src/components/AppContent.vue +21 -0
- package/src/components/AppLogoIcon.vue +24 -0
- package/src/components/AppShell.vue +37 -0
- package/src/components/AppearanceTabs.vue +37 -0
- package/src/components/AuthBar.vue +58 -0
- package/src/components/BlockEditor.vue +95 -0
- package/src/components/DeleteUser.vue +87 -0
- package/src/components/FieldBuilder.vue +105 -0
- package/src/components/Heading.vue +20 -0
- package/src/components/HeadingSmall.vue +17 -0
- package/src/components/Icon.vue +30 -0
- package/src/components/InputError.vue +13 -0
- package/src/components/MenuItemsEditor.vue +59 -0
- package/src/components/NavUser.vue +30 -0
- package/src/components/Pagination.vue +28 -0
- package/src/components/PlaceholderPattern.vue +16 -0
- package/src/components/Seo.vue +28 -0
- package/src/components/TextLink.vue +24 -0
- package/src/components/UserInfo.vue +34 -0
- package/src/components/UserMenuContent.vue +37 -0
- package/src/components/commerce/OptionsEditor.vue +55 -0
- package/src/components/commerce/VariantsEditor.vue +71 -0
- package/src/components/ui/avatar/Avatar.vue +24 -0
- package/src/components/ui/avatar/AvatarFallback.vue +11 -0
- package/src/components/ui/avatar/AvatarImage.vue +9 -0
- package/src/components/ui/avatar/index.ts +24 -0
- package/src/components/ui/breadcrumb/Breadcrumb.vue +13 -0
- package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +18 -0
- package/src/components/ui/breadcrumb/BreadcrumbItem.vue +14 -0
- package/src/components/ui/breadcrumb/BreadcrumbLink.vue +15 -0
- package/src/components/ui/breadcrumb/BreadcrumbList.vue +14 -0
- package/src/components/ui/breadcrumb/BreadcrumbPage.vue +14 -0
- package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +17 -0
- package/src/components/ui/breadcrumb/index.ts +7 -0
- package/src/components/ui/button/Button.vue +22 -0
- package/src/components/ui/button/index.ts +31 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/checkbox/Checkbox.vue +36 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/collapsible/Collapsible.vue +15 -0
- package/src/components/ui/collapsible/CollapsibleContent.vue +14 -0
- package/src/components/ui/collapsible/CollapsibleTrigger.vue +11 -0
- package/src/components/ui/collapsible/index.ts +3 -0
- package/src/components/ui/dialog/Dialog.vue +14 -0
- package/src/components/ui/dialog/DialogClose.vue +11 -0
- package/src/components/ui/dialog/DialogContent.vue +51 -0
- package/src/components/ui/dialog/DialogDescription.vue +21 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/src/components/ui/dialog/DialogTitle.vue +21 -0
- package/src/components/ui/dialog/DialogTrigger.vue +11 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +42 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +40 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +11 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +21 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +43 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +21 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +31 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +13 -0
- package/src/components/ui/dropdown-menu/index.ts +16 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/label/Label.vue +22 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/navigation-menu/NavigationMenu.vue +25 -0
- package/src/components/ui/navigation-menu/NavigationMenuContent.vue +31 -0
- package/src/components/ui/navigation-menu/NavigationMenuIndicator.vue +29 -0
- package/src/components/ui/navigation-menu/NavigationMenuItem.vue +11 -0
- package/src/components/ui/navigation-menu/NavigationMenuLink.vue +14 -0
- package/src/components/ui/navigation-menu/NavigationMenuList.vue +21 -0
- package/src/components/ui/navigation-menu/NavigationMenuTrigger.vue +24 -0
- package/src/components/ui/navigation-menu/NavigationMenuViewport.vue +29 -0
- package/src/components/ui/navigation-menu/index.ts +14 -0
- package/src/components/ui/separator/Separator.vue +31 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/sheet/Sheet.vue +14 -0
- package/src/components/ui/sheet/SheetClose.vue +11 -0
- package/src/components/ui/sheet/SheetContent.vue +53 -0
- package/src/components/ui/sheet/SheetDescription.vue +19 -0
- package/src/components/ui/sheet/SheetFooter.vue +12 -0
- package/src/components/ui/sheet/SheetHeader.vue +12 -0
- package/src/components/ui/sheet/SheetTitle.vue +19 -0
- package/src/components/ui/sheet/SheetTrigger.vue +11 -0
- package/src/components/ui/sheet/index.ts +29 -0
- package/src/components/ui/sidebar/Sidebar.vue +99 -0
- package/src/components/ui/sidebar/SidebarContent.vue +17 -0
- package/src/components/ui/sidebar/SidebarFooter.vue +14 -0
- package/src/components/ui/sidebar/SidebarGroup.vue +14 -0
- package/src/components/ui/sidebar/SidebarGroupAction.vue +31 -0
- package/src/components/ui/sidebar/SidebarGroupContent.vue +14 -0
- package/src/components/ui/sidebar/SidebarGroupLabel.vue +29 -0
- package/src/components/ui/sidebar/SidebarHeader.vue +14 -0
- package/src/components/ui/sidebar/SidebarInput.vue +15 -0
- package/src/components/ui/sidebar/SidebarInset.vue +22 -0
- package/src/components/ui/sidebar/SidebarMenu.vue +14 -0
- package/src/components/ui/sidebar/SidebarMenuAction.vue +41 -0
- package/src/components/ui/sidebar/SidebarMenuBadge.vue +27 -0
- package/src/components/ui/sidebar/SidebarMenuButton.vue +52 -0
- package/src/components/ui/sidebar/SidebarMenuButtonChild.vue +33 -0
- package/src/components/ui/sidebar/SidebarMenuItem.vue +14 -0
- package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +22 -0
- package/src/components/ui/sidebar/SidebarMenuSub.vue +23 -0
- package/src/components/ui/sidebar/SidebarMenuSubButton.vue +42 -0
- package/src/components/ui/sidebar/SidebarMenuSubItem.vue +7 -0
- package/src/components/ui/sidebar/SidebarProvider.vue +89 -0
- package/src/components/ui/sidebar/SidebarRail.vue +34 -0
- package/src/components/ui/sidebar/SidebarSeparator.vue +15 -0
- package/src/components/ui/sidebar/SidebarTrigger.vue +20 -0
- package/src/components/ui/sidebar/index.ts +51 -0
- package/src/components/ui/sidebar/utils.ts +19 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tooltip/Tooltip.vue +14 -0
- package/src/components/ui/tooltip/TooltipContent.vue +39 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +11 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +11 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useAppearance.ts +53 -0
- package/src/composables/useInitials.ts +14 -0
- package/src/index.ts +22 -0
- package/src/layouts/AdminLayout.vue +170 -0
- package/src/layouts/AuthLayout.vue +14 -0
- package/src/layouts/PublicLayout.vue +53 -0
- package/src/layouts/auth/AuthCardLayout.vue +36 -0
- package/src/layouts/auth/AuthSimpleLayout.vue +31 -0
- package/src/layouts/auth/AuthSplitLayout.vue +40 -0
- package/src/layouts/settings/Layout.vue +56 -0
- package/src/lib/utils.ts +6 -0
- package/src/pages/Admin/Appearance/Theme.vue +58 -0
- package/src/pages/Admin/Appearance/Widgets.vue +48 -0
- package/src/pages/Admin/Commerce/Orders/Index.vue +80 -0
- package/src/pages/Admin/Commerce/Orders/Show.vue +200 -0
- package/src/pages/Admin/Commerce/Products/Edit.vue +167 -0
- package/src/pages/Admin/Commerce/Products/Index.vue +65 -0
- package/src/pages/Admin/Content/Edit.vue +170 -0
- package/src/pages/Admin/Content/Index.vue +88 -0
- package/src/pages/Admin/Content/Preview.vue +25 -0
- package/src/pages/Admin/Dashboard.vue +26 -0
- package/src/pages/Admin/Forms/Edit.vue +98 -0
- package/src/pages/Admin/Forms/Index.vue +68 -0
- package/src/pages/Admin/Forms/Submissions/Index.vue +68 -0
- package/src/pages/Admin/Forms/Submissions/Show.vue +47 -0
- package/src/pages/Admin/Media/Index.vue +75 -0
- package/src/pages/Admin/Menus/Create.vue +37 -0
- package/src/pages/Admin/Menus/Edit.vue +54 -0
- package/src/pages/Admin/Menus/Index.vue +52 -0
- package/src/pages/Admin/Settings/Index.vue +184 -0
- package/src/pages/Admin/Taxonomy/Edit.vue +83 -0
- package/src/pages/Admin/Taxonomy/Index.vue +68 -0
- package/src/pages/Admin/Users/Edit.vue +82 -0
- package/src/pages/Admin/Users/Index.vue +74 -0
- package/src/pages/Public/Cart/Index.vue +108 -0
- package/src/pages/Public/Checkout/Confirmation.vue +110 -0
- package/src/pages/Public/Checkout/Index.vue +174 -0
- package/src/pages/Public/Index.vue +54 -0
- package/src/pages/Public/Shop/Index.vue +39 -0
- package/src/pages/Public/Shop/Show.vue +46 -0
- package/src/pages/Public/Show.vue +41 -0
- package/src/pages/Setup/Complete.vue +53 -0
- package/src/pages/Setup/Index.vue +85 -0
- package/src/pages/Welcome.vue +787 -0
- package/src/pages/auth/ConfirmPassword.vue +53 -0
- package/src/pages/auth/ForgotPassword.vue +54 -0
- package/src/pages/auth/Login.vue +91 -0
- package/src/pages/auth/Register.vue +83 -0
- package/src/pages/auth/ResetPassword.vue +81 -0
- package/src/pages/auth/VerifyEmail.vue +36 -0
- package/src/pages/settings/Appearance.vue +23 -0
- package/src/pages/settings/Password.vue +120 -0
- package/src/pages/settings/Profile.vue +105 -0
- package/src/pages.ts +9 -0
- package/src/types/index.ts +42 -0
- package/src/types/ziggy.ts +12 -0
|
@@ -0,0 +1,68 @@
|
|
|
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 { Button, DataTable } from '@tower_74/cms-ui';
|
|
6
|
+
|
|
7
|
+
interface PageLink {
|
|
8
|
+
url: string | null;
|
|
9
|
+
label: string;
|
|
10
|
+
active: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TermRow {
|
|
14
|
+
id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
parent: string | null;
|
|
18
|
+
posts_count: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
taxonomy: { slug: string; label: string; name: string };
|
|
23
|
+
terms: { data: TermRow[]; total: number; links: PageLink[] };
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const columns = [
|
|
27
|
+
{ key: 'name', label: 'Name' },
|
|
28
|
+
{ key: 'slug', label: 'Slug' },
|
|
29
|
+
{ key: 'parent', label: 'Parent' },
|
|
30
|
+
{ key: 'posts_count', label: 'Used', align: 'right' as const },
|
|
31
|
+
{ key: 'actions', label: '', align: 'right' as const },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const destroy = (row: TermRow) => {
|
|
35
|
+
if (confirm(`Delete “${row.name}”?`)) {
|
|
36
|
+
router.delete(`/admin/taxonomy/${props.taxonomy.slug}/${row.id}`, { preserveScroll: true });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<AdminLayout>
|
|
43
|
+
<template #title>{{ taxonomy.label }}</template>
|
|
44
|
+
|
|
45
|
+
<div class="mb-4 flex items-center justify-between">
|
|
46
|
+
<h2 class="text-lg font-semibold">{{ taxonomy.label }}</h2>
|
|
47
|
+
<Link :href="`/admin/taxonomy/${taxonomy.slug}/create`">
|
|
48
|
+
<Button>New {{ taxonomy.name.toLowerCase() }}</Button>
|
|
49
|
+
</Link>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<DataTable :columns="columns" :rows="terms.data" :empty="`No ${taxonomy.label.toLowerCase()} yet.`">
|
|
53
|
+
<template #cell-parent="{ value }">
|
|
54
|
+
<span class="text-muted">{{ value ?? '—' }}</span>
|
|
55
|
+
</template>
|
|
56
|
+
<template #cell-actions="{ row }">
|
|
57
|
+
<div class="flex justify-end gap-2">
|
|
58
|
+
<Link :href="`/admin/taxonomy/${taxonomy.slug}/${(row as TermRow).id}/edit`">
|
|
59
|
+
<Button variant="ghost" size="sm">Edit</Button>
|
|
60
|
+
</Link>
|
|
61
|
+
<Button variant="danger" size="sm" @click="destroy(row as TermRow)">Delete</Button>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
</DataTable>
|
|
65
|
+
|
|
66
|
+
<Pagination :links="terms.links" />
|
|
67
|
+
</AdminLayout>
|
|
68
|
+
</template>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
3
|
+
import { Link, useForm } from '@inertiajs/vue3';
|
|
4
|
+
import { Button, FormBuilder } from '@tower_74/cms-ui';
|
|
5
|
+
import { computed } from 'vue';
|
|
6
|
+
|
|
7
|
+
interface UserData {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
email: string;
|
|
11
|
+
role: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
user: UserData | null;
|
|
16
|
+
roles: Array<{ label: string; value: string }>;
|
|
17
|
+
}>();
|
|
18
|
+
|
|
19
|
+
const isEdit = computed(() => props.user !== null);
|
|
20
|
+
|
|
21
|
+
const fields = computed(() => [
|
|
22
|
+
{ name: 'name', label: 'Name', required: true },
|
|
23
|
+
{ name: 'email', label: 'Email', type: 'email' as const, required: true },
|
|
24
|
+
{
|
|
25
|
+
name: 'password',
|
|
26
|
+
label: 'Password',
|
|
27
|
+
type: 'password' as const,
|
|
28
|
+
required: !isEdit.value,
|
|
29
|
+
help: isEdit.value ? 'Leave blank to keep the current password.' : undefined,
|
|
30
|
+
},
|
|
31
|
+
{ name: 'role', label: 'Role', type: 'select' as const, required: true, options: props.roles },
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const form = useForm({
|
|
35
|
+
name: props.user?.name ?? '',
|
|
36
|
+
email: props.user?.email ?? '',
|
|
37
|
+
password: '',
|
|
38
|
+
role: props.user?.role ?? props.roles[0]?.value ?? '',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const model = computed({
|
|
42
|
+
get: () => ({
|
|
43
|
+
name: form.name,
|
|
44
|
+
email: form.email,
|
|
45
|
+
password: form.password,
|
|
46
|
+
role: form.role,
|
|
47
|
+
}),
|
|
48
|
+
set: (value: Record<string, unknown>) => {
|
|
49
|
+
form.name = (value.name as string) ?? '';
|
|
50
|
+
form.email = (value.email as string) ?? '';
|
|
51
|
+
form.password = (value.password as string) ?? '';
|
|
52
|
+
form.role = (value.role as string) ?? '';
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const submit = () => {
|
|
57
|
+
if (isEdit.value && props.user) {
|
|
58
|
+
form.put(`/admin/users/${props.user.id}`);
|
|
59
|
+
} else {
|
|
60
|
+
form.post('/admin/users');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<AdminLayout>
|
|
67
|
+
<template #title>{{ isEdit ? 'Edit user' : 'New user' }}</template>
|
|
68
|
+
|
|
69
|
+
<form class="max-w-xl" @submit.prevent="submit">
|
|
70
|
+
<FormBuilder v-model="model" :fields="fields" :errors="form.errors" />
|
|
71
|
+
|
|
72
|
+
<div class="mt-6 flex items-center gap-2">
|
|
73
|
+
<Button type="submit" :disabled="form.processing">
|
|
74
|
+
{{ isEdit ? 'Save changes' : 'Create user' }}
|
|
75
|
+
</Button>
|
|
76
|
+
<Link href="/admin/users">
|
|
77
|
+
<Button variant="ghost" type="button">Cancel</Button>
|
|
78
|
+
</Link>
|
|
79
|
+
</div>
|
|
80
|
+
</form>
|
|
81
|
+
</AdminLayout>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Pagination from '@/components/Pagination.vue';
|
|
3
|
+
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
4
|
+
import { Link, router, usePage } from '@inertiajs/vue3';
|
|
5
|
+
import { Badge, Button, DataTable } from '@tower_74/cms-ui';
|
|
6
|
+
import { computed } from 'vue';
|
|
7
|
+
|
|
8
|
+
interface PageLink {
|
|
9
|
+
url: string | null;
|
|
10
|
+
label: string;
|
|
11
|
+
active: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UserRow {
|
|
15
|
+
id: number;
|
|
16
|
+
name: string;
|
|
17
|
+
email: string;
|
|
18
|
+
role: string | null;
|
|
19
|
+
verified: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
defineProps<{
|
|
23
|
+
users: { data: UserRow[]; total: number; links: PageLink[] };
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const page = usePage();
|
|
27
|
+
const currentUserId = computed(() => (page.props.auth?.user as { id: number } | undefined)?.id);
|
|
28
|
+
|
|
29
|
+
const columns = [
|
|
30
|
+
{ key: 'name', label: 'Name' },
|
|
31
|
+
{ key: 'email', label: 'Email' },
|
|
32
|
+
{ key: 'role', label: 'Role' },
|
|
33
|
+
{ key: 'verified', label: 'Verified' },
|
|
34
|
+
{ key: 'actions', label: '', align: 'right' as const },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const destroy = (row: UserRow) => {
|
|
38
|
+
if (confirm(`Delete ${row.name}?`)) {
|
|
39
|
+
router.delete(`/admin/users/${row.id}`, { preserveScroll: true });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<AdminLayout>
|
|
46
|
+
<template #title>Users</template>
|
|
47
|
+
|
|
48
|
+
<div class="mb-4 flex items-center justify-between">
|
|
49
|
+
<h2 class="text-lg font-semibold">Users</h2>
|
|
50
|
+
<Link href="/admin/users/create">
|
|
51
|
+
<Button>New user</Button>
|
|
52
|
+
</Link>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<DataTable :columns="columns" :rows="users.data" empty="No users yet.">
|
|
56
|
+
<template #cell-role="{ value }">
|
|
57
|
+
<Badge>{{ value ?? '—' }}</Badge>
|
|
58
|
+
</template>
|
|
59
|
+
<template #cell-verified="{ value }">
|
|
60
|
+
<Badge :variant="value ? 'success' : 'neutral'">{{ value ? 'Yes' : 'No' }}</Badge>
|
|
61
|
+
</template>
|
|
62
|
+
<template #cell-actions="{ row }">
|
|
63
|
+
<div class="flex justify-end gap-2">
|
|
64
|
+
<Link :href="`/admin/users/${(row as UserRow).id}/edit`">
|
|
65
|
+
<Button variant="ghost" size="sm">Edit</Button>
|
|
66
|
+
</Link>
|
|
67
|
+
<Button v-if="(row as UserRow).id !== currentUserId" variant="danger" size="sm" @click="destroy(row as UserRow)"> Delete </Button>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
</DataTable>
|
|
71
|
+
|
|
72
|
+
<Pagination :links="users.links" />
|
|
73
|
+
</AdminLayout>
|
|
74
|
+
</template>
|
|
@@ -0,0 +1,108 @@
|
|
|
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>
|
|
@@ -0,0 +1,110 @@
|
|
|
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>
|
|
@@ -0,0 +1,174 @@
|
|
|
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>
|
|
@@ -0,0 +1,54 @@
|
|
|
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 } from '@tower_74/cms-ui';
|
|
6
|
+
|
|
7
|
+
interface PostRow {
|
|
8
|
+
title: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
excerpt: string | null;
|
|
11
|
+
url: string;
|
|
12
|
+
date: string | null;
|
|
13
|
+
}
|
|
14
|
+
interface PageLink {
|
|
15
|
+
url: string | null;
|
|
16
|
+
label: string;
|
|
17
|
+
active: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
defineProps<{
|
|
21
|
+
site: { name: string };
|
|
22
|
+
menu: Array<{ label: string; url: string }>;
|
|
23
|
+
footerWidgets: Block[];
|
|
24
|
+
cartCount?: number | null;
|
|
25
|
+
type: { slug: string; label: string };
|
|
26
|
+
posts: { data: PostRow[]; links: PageLink[] };
|
|
27
|
+
seo: { title: string; description?: string | null; url?: string | null; image?: string | null; type?: string | null };
|
|
28
|
+
}>();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<Seo :seo="seo" />
|
|
33
|
+
|
|
34
|
+
<PublicLayout :site="site" :menu="menu" :footer-widgets="footerWidgets" :cart-count="cartCount">
|
|
35
|
+
<div class="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
|
36
|
+
<h1 class="text-3xl font-bold tracking-tight">{{ type.label }}</h1>
|
|
37
|
+
|
|
38
|
+
<div class="mt-8 space-y-8">
|
|
39
|
+
<article v-for="post in posts.data" :key="post.slug" class="border-b border-border pb-8 last:border-0">
|
|
40
|
+
<h2 class="text-xl font-semibold">
|
|
41
|
+
<a :href="post.url" class="transition-colors hover:text-primary">{{ post.title }}</a>
|
|
42
|
+
</h2>
|
|
43
|
+
<p v-if="post.date" class="mt-1 text-sm text-muted">{{ post.date }}</p>
|
|
44
|
+
<p v-if="post.excerpt" class="mt-3 text-muted">{{ post.excerpt }}</p>
|
|
45
|
+
<a :href="post.url" class="mt-3 inline-block text-sm text-primary">Read more →</a>
|
|
46
|
+
</article>
|
|
47
|
+
|
|
48
|
+
<p v-if="!posts.data.length" class="text-muted">Nothing published yet.</p>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<Pagination :links="posts.links" />
|
|
52
|
+
</div>
|
|
53
|
+
</PublicLayout>
|
|
54
|
+
</template>
|