@cavulsqa/create 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.
Files changed (75) hide show
  1. package/README.md +63 -0
  2. package/bin/create.mjs +142 -0
  3. package/lib/scaffold.mjs +124 -0
  4. package/package.json +60 -0
  5. package/templates/f7-app/.claude/rules/data-fetching.md +67 -0
  6. package/templates/f7-app/.claude/rules/database.md +65 -0
  7. package/templates/f7-app/.claude/rules/framework7-ui.md +79 -0
  8. package/templates/f7-app/.claude/rules/modules.md +43 -0
  9. package/templates/f7-app/.claude/rules/native.md +47 -0
  10. package/templates/f7-app/.claude/skills/f7-design/SKILL.md +67 -0
  11. package/templates/f7-app/.claude/skills/f7-design/components.md +49 -0
  12. package/templates/f7-app/.claude/skills/f7-design/icons.md +52 -0
  13. package/templates/f7-app/.claude/skills/module-architecture/SKILL.md +65 -0
  14. package/templates/f7-app/.claude/skills/module-architecture/file-templates.md +183 -0
  15. package/templates/f7-app/.claude/skills/reactive-data/SKILL.md +89 -0
  16. package/templates/f7-app/.claude/skills/reactive-data/testing.md +55 -0
  17. package/templates/f7-app/CLAUDE.md +105 -0
  18. package/templates/f7-app/auto-imports.d.ts +667 -0
  19. package/templates/f7-app/capacitor.config.ts +14 -0
  20. package/templates/f7-app/components.d.ts +59 -0
  21. package/templates/f7-app/index.html +15 -0
  22. package/templates/f7-app/package.json +51 -0
  23. package/templates/f7-app/src/App.vue +72 -0
  24. package/templates/f7-app/src/app/tabs.ts +25 -0
  25. package/templates/f7-app/src/assets/css/app.css +1 -0
  26. package/templates/f7-app/src/assets/css/icons.css +50 -0
  27. package/templates/f7-app/src/assets/fonts/material-icons-outlined.woff2 +0 -0
  28. package/templates/f7-app/src/assets/fonts/material-icons-round.woff2 +0 -0
  29. package/templates/f7-app/src/domains/sales/sales.repository.ts +458 -0
  30. package/templates/f7-app/src/env.d.ts +23 -0
  31. package/templates/f7-app/src/locales/en.json +179 -0
  32. package/templates/f7-app/src/locales/fr.json +179 -0
  33. package/templates/f7-app/src/main.ts +50 -0
  34. package/templates/f7-app/src/modules/demo/components/DemoBusLog.vue +26 -0
  35. package/templates/f7-app/src/modules/demo/components/DemoCreateOrderSheet.vue +160 -0
  36. package/templates/f7-app/src/modules/demo/components/DemoOrderList.vue +73 -0
  37. package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +52 -0
  38. package/templates/f7-app/src/modules/demo/components/DemoStatCards.vue +63 -0
  39. package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +168 -0
  40. package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +34 -0
  41. package/templates/f7-app/src/modules/demo/views/DemoView.vue +126 -0
  42. package/templates/f7-app/src/modules/demo/views/OrderDetailView.vue +131 -0
  43. package/templates/f7-app/src/modules/demo/views/OrderSearchView.vue +106 -0
  44. package/templates/f7-app/src/modules/home/composables/useHomeFeatures.ts +133 -0
  45. package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +29 -0
  46. package/templates/f7-app/src/modules/home/views/FeatureDetailView.vue +108 -0
  47. package/templates/f7-app/src/modules/home/views/HomeView.vue +53 -0
  48. package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +16 -0
  49. package/templates/f7-app/src/modules/settings/views/SettingsView.vue +126 -0
  50. package/templates/f7-app/src/plugins/capacitor/index.ts +14 -0
  51. package/templates/f7-app/src/plugins/capacitor/useAndroidBackButton.ts +68 -0
  52. package/templates/f7-app/src/plugins/capacitor/useKeyboard.ts +61 -0
  53. package/templates/f7-app/src/plugins/capacitor/useSplashScreen.ts +11 -0
  54. package/templates/f7-app/src/plugins/capacitor/useStatusBar.ts +15 -0
  55. package/templates/f7-app/src/plugins/framework7.plugin.ts +41 -0
  56. package/templates/f7-app/src/plugins/i18n.plugin.ts +10 -0
  57. package/templates/f7-app/src/plugins/seed.plugin.ts +17 -0
  58. package/templates/f7-app/src/plugins/sqlite.plugin.ts +21 -0
  59. package/templates/f7-app/src/router/global/global.routes.ts +16 -0
  60. package/templates/f7-app/src/router/index.ts +18 -0
  61. package/templates/f7-app/src/shared/components/error/404.vue +12 -0
  62. package/templates/f7-app/src/shared/components/metrics/MetricsPanel.vue +79 -0
  63. package/templates/f7-app/src/shared/composables/theme/useAppTheme.ts +68 -0
  64. package/templates/f7-app/src/shared/composables/useTabbarVisibility.ts +50 -0
  65. package/templates/f7-app/src/shared/database/database.ts +86 -0
  66. package/templates/f7-app/src/shared/database/index.ts +3 -0
  67. package/templates/f7-app/src/shared/database/migrations.ts +81 -0
  68. package/templates/f7-app/src/shared/database/queries.ts +22 -0
  69. package/templates/f7-app/src/shared/database/schema.ts +59 -0
  70. package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +152 -0
  71. package/templates/f7-app/tests/icons.test.ts +81 -0
  72. package/templates/f7-app/tests/sales.repository.test.ts +329 -0
  73. package/templates/f7-app/tsconfig.json +21 -0
  74. package/templates/f7-app/tsconfig.node.json +14 -0
  75. package/templates/f7-app/vite.config.ts +95 -0
@@ -0,0 +1,168 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+ import type { TableChangeEvent } from "@cavulsqa/reactive-db";
3
+ import {
4
+ advanceOrderStatus,
5
+ clearAll,
6
+ deleteOrder,
7
+ ensureReferenceData,
8
+ loadDashboardStats,
9
+ saveOrder,
10
+ searchOrders,
11
+ seedSampleData,
12
+ setOrderStatus,
13
+ type DashboardStats,
14
+ type DraftLine,
15
+ type OrderRow,
16
+ } from "@/domains/sales/sales.repository";
17
+ import { changeBus, getDatabase, rdb } from "@/shared/database/database";
18
+ import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
19
+
20
+ export interface BusEntry {
21
+ /** Monotonic, because a millisecond timestamp is not unique - several events share one. */
22
+ id: number;
23
+ at: number;
24
+ table: string;
25
+ type: string;
26
+ }
27
+
28
+ export interface PipelineResult {
29
+ parallelMs: number;
30
+ sequentialMs: number;
31
+ ratio: number;
32
+ }
33
+
34
+ const EVENT_LOG_LIMIT = 20;
35
+ const READS_PER_RUN = 5;
36
+ const EMPTY_STATS: DashboardStats = {
37
+ customers: 0,
38
+ products: 0,
39
+ orders: 0,
40
+ draft: 0,
41
+ confirmed: 0,
42
+ delivered: 0,
43
+ revenueCents: 0,
44
+ committedCents: 0,
45
+ };
46
+
47
+ /**
48
+ * Everything the Reactive screen shows, so the view stays presentational.
49
+ *
50
+ * Nothing here tells a query to refresh. Writes go through `rdb`, which announces the tables they
51
+ * touched, and both queries below watch those tables - which is why creating one order moves the
52
+ * revenue tile, the order count and the list at the same time.
53
+ */
54
+ export function useReactiveDemo() {
55
+ const busy = ref(false);
56
+ const busLog = ref<BusEntry[]>([]);
57
+ const pipeline = ref<PipelineResult | null>(null);
58
+ const measuring = ref(false);
59
+
60
+ const statsQuery = useReactiveQuery(() => loadDashboardStats(getDatabase().db), {
61
+ // Four tables, because the tiles aggregate across all of them.
62
+ tables: ["customer", "product", "sales_order", "order_line"],
63
+ queryKey: uniqueQueryKey("demo:stats"),
64
+ debounce: 250,
65
+ });
66
+
67
+ const ordersQuery = useReactiveQuery(() => searchOrders(getDatabase().db, ""), {
68
+ tables: ["sales_order", "order_line", "customer"],
69
+ queryKey: uniqueQueryKey("demo:orders"),
70
+ debounce: 250,
71
+ });
72
+
73
+ let busSequence = 0;
74
+ const stopWatching = changeBus.on(["*"], (event: TableChangeEvent) => {
75
+ busSequence += 1;
76
+ busLog.value = [
77
+ { id: busSequence, at: Date.now(), table: event.table, type: event.type },
78
+ ...busLog.value,
79
+ ].slice(0, EVENT_LOG_LIMIT);
80
+ });
81
+ onUnmounted(stopWatching);
82
+
83
+ const stats = computed<DashboardStats>(() => statsQuery.data.value ?? EMPTY_STATS);
84
+ const orders = computed<OrderRow[]>(() => ordersQuery.data.value ?? []);
85
+
86
+ async function withBusy(work: () => Promise<void>): Promise<void> {
87
+ busy.value = true;
88
+ try {
89
+ await work();
90
+ } finally {
91
+ busy.value = false;
92
+ }
93
+ }
94
+
95
+ const seed = () => withBusy(() => seedSampleData(rdb));
96
+
97
+ const advance = (orderId: number) => withBusy(() => advanceOrderStatus(rdb, orderId));
98
+
99
+ const setStatus = (orderId: number, status: "draft" | "confirmed" | "delivered") =>
100
+ withBusy(() => setOrderStatus(rdb, orderId, status));
101
+
102
+ const remove = (orderId: number) => withBusy(() => deleteOrder(rdb, orderId));
103
+
104
+ const save = (input: { customerId: number; reference: string; lines: DraftLine[] }) =>
105
+ withBusy(() => saveOrder(rdb, input));
106
+ /**
107
+ * Wipe the demo data, then put the catalogue back. Deleting the products and customers too would
108
+ * leave the app in a state where no order can be written and the only way out is a restart.
109
+ */
110
+ const clear = () =>
111
+ withBusy(async () => {
112
+ await clearAll(rdb);
113
+ await ensureReferenceData(rdb);
114
+ });
115
+
116
+ /**
117
+ * Reads issued together against reads awaited one by one.
118
+ *
119
+ * On a device the ratio is the point: the native bridge pipelines concurrent calls and the dialect
120
+ * keeps reads out of the write lock, so a screen loading with `Promise.all` pays once rather than
121
+ * N times. In a browser it sits near 1x because sql.js in memory has no bridge to pipeline, and
122
+ * the screen says so rather than reporting a number that reads as a regression.
123
+ */
124
+ async function measurePipelining(): Promise<void> {
125
+ measuring.value = true;
126
+ try {
127
+ const db = getDatabase().db;
128
+ const read = () => db.selectFrom("sales_order").select("id").limit(5).execute();
129
+ await read();
130
+
131
+ const parallelStart = performance.now();
132
+ await Promise.all(Array.from({ length: READS_PER_RUN }, read));
133
+ const parallelMs = performance.now() - parallelStart;
134
+
135
+ const sequentialStart = performance.now();
136
+ for (let index = 0; index < READS_PER_RUN; index++) await read();
137
+ const sequentialMs = performance.now() - sequentialStart;
138
+
139
+ pipeline.value = {
140
+ parallelMs: Number(parallelMs.toFixed(1)),
141
+ sequentialMs: Number(sequentialMs.toFixed(1)),
142
+ ratio: Number((sequentialMs / Math.max(parallelMs, 0.01)).toFixed(2)),
143
+ };
144
+ } finally {
145
+ measuring.value = false;
146
+ }
147
+ }
148
+
149
+ return {
150
+ stats,
151
+ orders,
152
+ loading: ordersQuery.loading,
153
+ busy,
154
+ busLog,
155
+ pipeline,
156
+ measuring,
157
+ readsPerRun: READS_PER_RUN,
158
+ isNative: Capacitor.isNativePlatform(),
159
+ platform: Capacitor.getPlatform(),
160
+ seed,
161
+ advance,
162
+ setStatus,
163
+ remove,
164
+ save,
165
+ clear,
166
+ measurePipelining,
167
+ };
168
+ }
@@ -0,0 +1,34 @@
1
+ import type { Router } from "framework7/types";
2
+
3
+ const demoRoutes: Router.RouteParameters[] = [
4
+ {
5
+ name: "demo",
6
+ path: "/demo/",
7
+ // `async` is Framework7's route hook, not an async function - resolve from the promise.
8
+ async({ resolve }) {
9
+ void import("@/modules/demo/views/DemoView.vue").then((view) => {
10
+ resolve({ component: view.default });
11
+ });
12
+ },
13
+ },
14
+ {
15
+ name: "demo-search",
16
+ path: "/demo/search/",
17
+ async({ resolve }) {
18
+ void import("@/modules/demo/views/OrderSearchView.vue").then((view) => {
19
+ resolve({ component: view.default });
20
+ });
21
+ },
22
+ },
23
+ {
24
+ name: "demo-order",
25
+ path: "/demo/order/:id/",
26
+ async({ resolve }) {
27
+ void import("@/modules/demo/views/OrderDetailView.vue").then((view) => {
28
+ resolve({ component: view.default });
29
+ });
30
+ },
31
+ },
32
+ ];
33
+
34
+ export default demoRoutes;
@@ -0,0 +1,126 @@
1
+ <template>
2
+ <F7Page :page-content="false">
3
+ <F7Navbar :title="t('demo.title')" :sliding="true">
4
+ <F7NavRight>
5
+ <F7Link icon-f7="search" href="/demo/search/" />
6
+ <F7Link icon-f7="chart_bar_alt_fill" panel-open="right" />
7
+ </F7NavRight>
8
+ </F7Navbar>
9
+
10
+ <!-- Real Framework7 tabs, so the panes swipe rather than switching on a v-if. -->
11
+ <F7Toolbar tabbar top>
12
+ <F7Link tab-link="#demo-tab-data" tab-link-active>{{ t("demo.data") }}</F7Link>
13
+ <F7Link tab-link="#demo-tab-diagnostics">{{ t("demo.diagnostics") }}</F7Link>
14
+ </F7Toolbar>
15
+
16
+ <F7Tabs swipeable>
17
+ <F7Tab id="demo-tab-data" class="page-content" tab-active>
18
+ <DemoStatCards :stats="stats" class="mt-3" />
19
+
20
+ <F7BlockTitle>
21
+ {{ t("demo.orders") }}
22
+ <span v-if="loading" class="ml-2 text-xs opacity-60">{{ t("demo.refetching") }}</span>
23
+ </F7BlockTitle>
24
+
25
+ <DemoOrderList
26
+ v-if="orders.length"
27
+ :orders="orders"
28
+ @open="openOrder"
29
+ @advance="advance"
30
+ @remove="remove"
31
+ />
32
+ <F7Block v-else strong inset class="rounded-2xl!">
33
+ <p class="m-0 text-sm opacity-70">{{ t("demo.empty") }}</p>
34
+ </F7Block>
35
+
36
+ <F7Block class="text-center">
37
+ <p class="m-0 text-xs opacity-45">{{ t("demo.swipeHint") }} {{ t("demo.tapHint") }}</p>
38
+ </F7Block>
39
+ </F7Tab>
40
+
41
+ <F7Tab id="demo-tab-diagnostics" class="page-content">
42
+ <DemoBusLog :entries="busLog" />
43
+ <DemoPipelineBenchmark
44
+ :result="pipeline"
45
+ :measuring="measuring"
46
+ :reads-per-run="readsPerRun"
47
+ :is-native="isNative"
48
+ :platform="platform"
49
+ @measure="measurePipelining"
50
+ />
51
+ </F7Tab>
52
+ </F7Tabs>
53
+
54
+ <!--
55
+ Buttons open upward: the app's tab bar sits at the bottom of the shell, so a downward or
56
+ morphed menu disappears behind it.
57
+ -->
58
+ <F7Fab slot="fixed" position="right-bottom">
59
+ <F7Icon f7="plus" />
60
+ <F7Icon f7="xmark" />
61
+ <F7FabButtons position="top">
62
+ <F7FabButton fab-close :label="t('demo.newOrder')" @click="createOpen = true">
63
+ <F7Icon f7="square_pencil_fill" size="20" />
64
+ </F7FabButton>
65
+ <F7FabButton fab-close :label="t('demo.seed')" @click="run(seed)">
66
+ <F7Icon f7="wand_stars" size="20" />
67
+ </F7FabButton>
68
+ <F7FabButton fab-close :label="t('demo.clear')" @click="run(clear)">
69
+ <F7Icon f7="trash" size="20" />
70
+ </F7FabButton>
71
+ </F7FabButtons>
72
+ </F7Fab>
73
+
74
+ <DemoCreateOrderSheet :opened="createOpen" @close="createOpen = false" @save="onSaveOrder" />
75
+ </F7Page>
76
+ </template>
77
+
78
+ <script setup lang="ts">
79
+ import type { OrderRow } from "@/domains/sales/sales.repository";
80
+ import DemoBusLog from "@/modules/demo/components/DemoBusLog.vue";
81
+ import DemoCreateOrderSheet from "@/modules/demo/components/DemoCreateOrderSheet.vue";
82
+ import DemoOrderList from "@/modules/demo/components/DemoOrderList.vue";
83
+ import DemoPipelineBenchmark from "@/modules/demo/components/DemoPipelineBenchmark.vue";
84
+ import DemoStatCards from "@/modules/demo/components/DemoStatCards.vue";
85
+ import { useReactiveDemo } from "@/modules/demo/composables/useReactiveDemo";
86
+
87
+ import type { Router } from "framework7/types";
88
+
89
+ const { t } = useI18n();
90
+ const props = defineProps<{ f7router: Router.Router }>();
91
+ const createOpen = ref(false);
92
+
93
+ const {
94
+ stats,
95
+ orders,
96
+ loading,
97
+ busy,
98
+ busLog,
99
+ pipeline,
100
+ measuring,
101
+ readsPerRun,
102
+ isNative,
103
+ platform,
104
+ seed,
105
+ advance,
106
+ remove,
107
+ save,
108
+ clear,
109
+ measurePipelining,
110
+ } = useReactiveDemo();
111
+
112
+ async function run(action: () => Promise<void>): Promise<void> {
113
+ if (busy.value) return;
114
+ await action();
115
+ }
116
+
117
+ /** Tapping a row opens the order; the actions live there, in a bar fixed to the bottom. */
118
+ function openOrder(order: OrderRow) {
119
+ props.f7router.navigate(`/demo/order/${String(order.id)}/`);
120
+ }
121
+
122
+ async function onSaveOrder(payload: Parameters<typeof save>[0]): Promise<void> {
123
+ await save(payload);
124
+ createOpen.value = false;
125
+ }
126
+ </script>
@@ -0,0 +1,131 @@
1
+ <template>
2
+ <F7Page>
3
+ <F7Navbar
4
+ :title="order?.reference ?? t('demo.order')"
5
+ :subtitle="order?.customerName"
6
+ back-link
7
+ >
8
+ <F7NavRight>
9
+ <F7Link v-if="order" icon-f7="trash" @click="confirmDelete" />
10
+ </F7NavRight>
11
+ </F7Navbar>
12
+
13
+ <template v-if="order">
14
+ <F7Block strong inset class="rounded-2xl! mt-3!">
15
+ <div class="flex items-start justify-between">
16
+ <div>
17
+ <F7Chip :text="order.status" :color="statusColor" />
18
+ <div class="mt-1.5 text-[13px] opacity-60">{{ order.city }}</div>
19
+ <div v-if="order.tags.length" class="mt-2 flex flex-wrap gap-1.5">
20
+ <F7Chip v-for="tag in order.tags" :key="tag" :text="tag" outline />
21
+ </div>
22
+ </div>
23
+ <div class="text-right">
24
+ <div class="text-[11px] uppercase tracking-wide opacity-55">{{ t("demo.total") }}</div>
25
+ <div class="text-2xl font-semibold tabular-nums leading-tight">
26
+ {{ money(order.totalCents) }}
27
+ </div>
28
+ </div>
29
+ </div>
30
+ </F7Block>
31
+
32
+ <F7BlockTitle>{{ t("demo.lineItems") }}</F7BlockTitle>
33
+ <!--
34
+ media-list, not a plain list: Framework7 only renders `subtitle` in a media list, so the
35
+ quantity and unit price were being dropped silently.
36
+ -->
37
+ <F7List media-list strong inset dividers class="rounded-2xl!">
38
+ <F7ListItem
39
+ v-for="line in order.lines"
40
+ :key="line.id"
41
+ :title="line.productName"
42
+ :after="money(line.lineTotalCents)"
43
+ >
44
+ <template #subtitle>
45
+ <span class="text-[13px] tabular-nums opacity-60">
46
+ {{ line.quantity }} × {{ money(line.unitPriceCents) }}
47
+ </span>
48
+ </template>
49
+ </F7ListItem>
50
+ </F7List>
51
+ </template>
52
+
53
+ <F7Block v-else strong inset class="rounded-2xl! mt-3!">
54
+ <p class="m-0 text-sm opacity-70">{{ t("demo.orderGone") }}</p>
55
+ </F7Block>
56
+
57
+ <!--
58
+ A bottom toolbar rather than a hand-built bar: Framework7 spaces its links evenly and keeps
59
+ them clear of the safe area, which the previous stacked segmented control did neither of.
60
+ Delete moved to the navbar - it does not belong next to three statuses you pick between.
61
+ -->
62
+ <F7Toolbar v-if="order" bottom>
63
+ <F7Link
64
+ v-for="option in statuses"
65
+ :key="option"
66
+ :class="{ 'text-color-primary font-semibold': order.status === option }"
67
+ @click="apply(option)"
68
+ >
69
+ {{ t(`demo.status${option.charAt(0).toUpperCase()}${option.slice(1)}`) }}
70
+ </F7Link>
71
+ </F7Toolbar>
72
+ </F7Page>
73
+ </template>
74
+
75
+ <script setup lang="ts">
76
+ import type { Router } from "framework7/types";
77
+ import {
78
+ deleteOrder,
79
+ loadOrderDetail,
80
+ setOrderStatus,
81
+ type OrderDetail,
82
+ } from "@/domains/sales/sales.repository";
83
+ import { useHiddenTabbar } from "@/shared/composables/useTabbarVisibility";
84
+ import { getDatabase, rdb } from "@/shared/database/database";
85
+ import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
86
+
87
+ const { t } = useI18n();
88
+
89
+ // A pushed page owns the whole screen; the tab bar belongs to the tab roots.
90
+ useHiddenTabbar();
91
+
92
+ const props = defineProps<{ f7route: Router.Route; f7router: Router.Router }>();
93
+
94
+ const orderId = Number(props.f7route.params.id ?? 0);
95
+ const statuses = ["draft", "confirmed", "delivered"] as const;
96
+
97
+ /**
98
+ * Reads five tables, so any write in that set refreshes this page - including the status changes
99
+ * made from the toolbar below, which is why nothing here calls refetch by hand.
100
+ */
101
+ const query = useReactiveQuery(() => loadOrderDetail(getDatabase().db, orderId), {
102
+ tables: ["sales_order", "order_line", "customer", "product", "customer_tag"],
103
+ queryKey: uniqueQueryKey(`demo:order-${String(orderId)}`),
104
+ });
105
+
106
+ const order = computed<OrderDetail | null>(() => query.data.value ?? null);
107
+
108
+ const formatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
109
+ const money = (cents: number) => formatter.format(cents / 100);
110
+
111
+ const statusColor = computed(() =>
112
+ order.value?.status === "delivered"
113
+ ? "green"
114
+ : order.value?.status === "confirmed"
115
+ ? "orange"
116
+ : "gray",
117
+ );
118
+
119
+ async function apply(status: (typeof statuses)[number]) {
120
+ if (!order.value || order.value.status === status) return;
121
+ await setOrderStatus(rdb, orderId, status);
122
+ }
123
+
124
+ function confirmDelete() {
125
+ f7.dialog.confirm(t("demo.deleteConfirm"), t("demo.delete"), () => {
126
+ void deleteOrder(rdb, orderId).then(() => {
127
+ props.f7router.back();
128
+ });
129
+ });
130
+ }
131
+ </script>
@@ -0,0 +1,106 @@
1
+ <template>
2
+ <F7Page>
3
+ <!--
4
+ The shape from the Framework7 searchbar docs: an expandable searchbar inside the navbar, opened
5
+ by a `searchbar-enable` link that points at it. Two earlier attempts got this wrong - a bare
6
+ `expandable` bar with nothing to enable it renders nothing, and moving it to a subnavbar puts
7
+ it below the title instead of taking the navbar over.
8
+ -->
9
+ <F7Navbar :title="t('demo.searchTitle')" back-link>
10
+ <F7NavRight>
11
+ <F7Link
12
+ class="searchbar-enable"
13
+ data-searchbar=".searchbar-orders"
14
+ icon-ios="f7:search"
15
+ icon-md="material:search"
16
+ />
17
+ </F7NavRight>
18
+ <F7Searchbar
19
+ class="searchbar-orders"
20
+ expandable
21
+ :placeholder="t('demo.searchPlaceholder')"
22
+ :custom-search="true"
23
+ :clear-button="true"
24
+ @input="onInput"
25
+ @searchbar:clear="onClear"
26
+ @searchbar:disable="onClear"
27
+ />
28
+ </F7Navbar>
29
+
30
+ <F7BlockTitle>
31
+ {{ term ? t("demo.matches", { count: results.length }) : t("demo.allOrders") }}
32
+ </F7BlockTitle>
33
+
34
+ <F7List v-if="results.length" media-list strong inset dividers class="rounded-2xl!">
35
+ <F7ListItem
36
+ v-for="order in results"
37
+ :key="order.id"
38
+ :title="order.reference"
39
+ :after="money(order.totalCents)"
40
+ :link="`/demo/order/${String(order.id)}/`"
41
+ >
42
+ <template #media>
43
+ <F7Icon :f7="statusIcon(order.status)" :color="statusColor(order.status)" size="22" />
44
+ </template>
45
+ <template #subtitle>
46
+ <span class="text-[13px] opacity-60">{{ order.customerName }} · {{ order.city }}</span>
47
+ </template>
48
+ </F7ListItem>
49
+ </F7List>
50
+
51
+ <F7Block v-else strong inset class="rounded-2xl!">
52
+ <p class="m-0 text-sm opacity-60">
53
+ {{ term ? t("demo.noMatches") : t("demo.empty") }}
54
+ </p>
55
+ </F7Block>
56
+ </F7Page>
57
+ </template>
58
+
59
+ <script setup lang="ts">
60
+ import { useHiddenTabbar } from "@/shared/composables/useTabbarVisibility";
61
+ import { searchOrders, type OrderRow } from "@/domains/sales/sales.repository";
62
+ import { getDatabase } from "@/shared/database/database";
63
+ import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
64
+
65
+ const { t } = useI18n();
66
+
67
+ // A pushed page owns the whole screen; the tab bar belongs to the tab roots.
68
+ useHiddenTabbar();
69
+
70
+ const term = ref("");
71
+
72
+ /**
73
+ * The list is populated before anything is typed - an empty search screen gives no sense of what is
74
+ * searchable. An empty term returns the most recent orders, which is what the query already does.
75
+ */
76
+ const query = useReactiveQuery(() => searchOrders(getDatabase().db, term.value), {
77
+ tables: ["sales_order", "order_line", "customer"],
78
+ queryKey: uniqueQueryKey("demo:search"),
79
+ debounce: 200,
80
+ });
81
+
82
+ const results = computed<OrderRow[]>(() => query.data.value ?? []);
83
+
84
+ function onInput(event: Event) {
85
+ term.value = (event.target as HTMLInputElement).value;
86
+ void query.refetch();
87
+ }
88
+
89
+ function onClear() {
90
+ term.value = "";
91
+ void query.refetch();
92
+ }
93
+
94
+ const formatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
95
+ const money = (cents: number) => formatter.format(cents / 100);
96
+
97
+ const statusColor = (status: string) =>
98
+ status === "delivered" ? "green" : status === "confirmed" ? "orange" : "gray";
99
+
100
+ const statusIcon = (status: string) =>
101
+ status === "delivered"
102
+ ? "checkmark_seal_fill"
103
+ : status === "confirmed"
104
+ ? "shippingbox_fill"
105
+ : "doc_plaintext";
106
+ </script>
@@ -0,0 +1,133 @@
1
+ /** Framework7 page transitions, one per detail route so each push looks different. */
2
+ export type PageTransition =
3
+ | "f7-circle"
4
+ | "f7-cover"
5
+ | "f7-cover-v"
6
+ | "f7-dive"
7
+ | "f7-fade"
8
+ | "f7-flip"
9
+ | "f7-parallax"
10
+ | "f7-push";
11
+
12
+ /** How the detail page's navbar is configured, to show the variants side by side. */
13
+ export interface NavbarVariant {
14
+ large: boolean;
15
+ transparent: boolean;
16
+ hideOnScroll: boolean;
17
+ /** Adds a second row under the title, which stays put while the large title collapses. */
18
+ subnavbar: boolean;
19
+ }
20
+
21
+ export interface HomeFeature {
22
+ id: string;
23
+ icon: string;
24
+ /** Framework7 colour name - the palette the theme already ships. */
25
+ color: string;
26
+ titleKey: string;
27
+ subtitleKey: string;
28
+ textKey: string;
29
+ transition: PageTransition;
30
+ navbar: NavbarVariant;
31
+ }
32
+
33
+ const navbar = (
34
+ large: boolean,
35
+ transparent: boolean,
36
+ hideOnScroll: boolean,
37
+ subnavbar = false,
38
+ ): NavbarVariant => ({ large, transparent, hideOnScroll, subnavbar });
39
+
40
+ /**
41
+ * Data rather than markup, so removing a layer means removing an entry and the screen cannot keep
42
+ * advertising something the generator left out.
43
+ *
44
+ * Each entry also drives a detail route inside the Home tab's own view. That is the point of the
45
+ * nesting: the push happens within the tab, so the tab bar stays put, the back gesture unwinds only
46
+ * this tab's history, and every other tab keeps the screen it was on.
47
+ */
48
+ export const features: HomeFeature[] = [
49
+ {
50
+ id: "tabs",
51
+ icon: "square_grid_2x2_fill",
52
+ color: "blue",
53
+ titleKey: "features.tabs.title",
54
+ subtitleKey: "features.tabs.subtitle",
55
+ textKey: "features.tabs.text",
56
+ transition: "f7-parallax",
57
+ navbar: navbar(true, false, false),
58
+ },
59
+ {
60
+ id: "f7",
61
+ icon: "layers_alt_fill",
62
+ color: "pink",
63
+ titleKey: "features.f7.title",
64
+ subtitleKey: "features.f7.subtitle",
65
+ textKey: "features.f7.text",
66
+ transition: "f7-cover",
67
+ navbar: navbar(true, true, false),
68
+ },
69
+ {
70
+ id: "sqlite",
71
+ icon: "archivebox_fill",
72
+ color: "green",
73
+ titleKey: "features.sqlite.title",
74
+ subtitleKey: "features.sqlite.subtitle",
75
+ textKey: "features.sqlite.text",
76
+ transition: "f7-push",
77
+ navbar: navbar(false, false, true),
78
+ },
79
+ {
80
+ id: "reactive",
81
+ icon: "arrow_2_circlepath",
82
+ color: "teal",
83
+ titleKey: "features.reactive.title",
84
+ subtitleKey: "features.reactive.subtitle",
85
+ textKey: "features.reactive.text",
86
+ transition: "f7-dive",
87
+ navbar: navbar(true, false, false, true),
88
+ },
89
+ {
90
+ id: "back",
91
+ icon: "arrow_uturn_left_circle_fill",
92
+ color: "orange",
93
+ titleKey: "features.back.title",
94
+ subtitleKey: "features.back.subtitle",
95
+ textKey: "features.back.text",
96
+ transition: "f7-flip",
97
+ navbar: navbar(false, false, false),
98
+ },
99
+ {
100
+ id: "keyboard",
101
+ icon: "keyboard",
102
+ color: "purple",
103
+ titleKey: "features.keyboard.title",
104
+ subtitleKey: "features.keyboard.subtitle",
105
+ textKey: "features.keyboard.text",
106
+ transition: "f7-fade",
107
+ navbar: navbar(false, true, false),
108
+ },
109
+ {
110
+ id: "metrics",
111
+ icon: "chart_bar_alt_fill",
112
+ color: "red",
113
+ titleKey: "features.metrics.title",
114
+ subtitleKey: "features.metrics.subtitle",
115
+ textKey: "features.metrics.text",
116
+ transition: "f7-circle",
117
+ navbar: navbar(true, true, true),
118
+ },
119
+ {
120
+ id: "dx",
121
+ icon: "rocket_fill",
122
+ color: "yellow",
123
+ titleKey: "features.dx.title",
124
+ subtitleKey: "features.dx.subtitle",
125
+ textKey: "features.dx.text",
126
+ transition: "f7-cover-v",
127
+ navbar: navbar(true, false, false),
128
+ },
129
+ ];
130
+
131
+ export function findFeature(id: string): HomeFeature | undefined {
132
+ return features.find((feature) => feature.id === id);
133
+ }