@sneat/extension-splitus-ui 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 +4 -0
- package/fesm2022/sneat-extension-splitus-ui-new-split-page.component-DkP-7W5H.mjs +39 -0
- package/fesm2022/sneat-extension-splitus-ui-new-split-page.component-DkP-7W5H.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-split-details-page.component-kzwMFwkF.mjs +87 -0
- package/fesm2022/sneat-extension-splitus-ui-split-details-page.component-kzwMFwkF.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs +44 -0
- package/fesm2022/sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-splitus-home-page.component-Dp5oLR86.mjs +99 -0
- package/fesm2022/sneat-extension-splitus-ui-splitus-home-page.component-Dp5oLR86.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui-start-split-page.component-BHsYZbKT.mjs +67 -0
- package/fesm2022/sneat-extension-splitus-ui-start-split-page.component-BHsYZbKT.mjs.map +1 -0
- package/fesm2022/sneat-extension-splitus-ui.mjs +469 -0
- package/fesm2022/sneat-extension-splitus-ui.mjs.map +1 -0
- package/package.json +35 -0
- package/types/sneat-extension-splitus-ui.d.ts +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Component } from '@angular/core';
|
|
3
|
+
import { IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent } from '@ionic/angular';
|
|
4
|
+
import { SpacePageBaseComponent, SpaceComponentBaseParams } from '@sneat/space-components';
|
|
5
|
+
import { ClassName } from '@sneat/ui';
|
|
6
|
+
import { NewSplitFormComponent } from './sneat-extension-splitus-ui.mjs';
|
|
7
|
+
|
|
8
|
+
// Create-split page. Entry: the "New split" button on the splits home page.
|
|
9
|
+
// Exit: the hosted form redirects to `split/:splitID` (details) on success
|
|
10
|
+
// with replaceUrl; Back/cancel returns to `splits`.
|
|
11
|
+
class NewSplitPageComponent extends SpacePageBaseComponent {
|
|
12
|
+
constructor() {
|
|
13
|
+
super();
|
|
14
|
+
this.$defaultBackUrlSpacePath.set('splits');
|
|
15
|
+
}
|
|
16
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
17
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.3", type: NewSplitPageComponent, isStandalone: true, selector: "sneat-splitus-new-split-page", providers: [
|
|
18
|
+
{ provide: ClassName, useValue: 'NewSplitPageComponent' },
|
|
19
|
+
SpaceComponentBaseParams,
|
|
20
|
+
], usesInheritance: true, ngImport: i0, template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New split</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <sneat-splitus-new-split-form\n [space]=\"space\"\n [currentUserID]=\"currentUserId\"\n />\n</ion-content>\n", dependencies: [{ kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonBackButton, selector: "ion-back-button" }, { kind: "component", type: IonTitle, selector: "ion-title", inputs: ["color", "size"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"], outputs: ["ionScrollStart", "ionScroll", "ionScrollEnd"] }, { kind: "component", type: NewSplitFormComponent, selector: "sneat-splitus-new-split-form", inputs: ["space", "currentUserID"] }] });
|
|
21
|
+
}
|
|
22
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitPageComponent, decorators: [{
|
|
23
|
+
type: Component,
|
|
24
|
+
args: [{ selector: 'sneat-splitus-new-split-page', imports: [
|
|
25
|
+
IonHeader,
|
|
26
|
+
IonToolbar,
|
|
27
|
+
IonButtons,
|
|
28
|
+
IonBackButton,
|
|
29
|
+
IonTitle,
|
|
30
|
+
IonContent,
|
|
31
|
+
NewSplitFormComponent,
|
|
32
|
+
], providers: [
|
|
33
|
+
{ provide: ClassName, useValue: 'NewSplitPageComponent' },
|
|
34
|
+
SpaceComponentBaseParams,
|
|
35
|
+
], template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New split</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <sneat-splitus-new-split-form\n [space]=\"space\"\n [currentUserID]=\"currentUserId\"\n />\n</ion-content>\n" }]
|
|
36
|
+
}], ctorParameters: () => [] });
|
|
37
|
+
|
|
38
|
+
export { NewSplitPageComponent };
|
|
39
|
+
//# sourceMappingURL=sneat-extension-splitus-ui-new-split-page.component-DkP-7W5H.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sneat-extension-splitus-ui-new-split-page.component-DkP-7W5H.mjs","sources":["../../../../../../libs/extensions/splitus/ui/src/lib/pages/new-split/new-split-page.component.ts","../../../../../../libs/extensions/splitus/ui/src/lib/pages/new-split/new-split-page.component.html"],"sourcesContent":["import { Component } from '@angular/core';\nimport {\n IonBackButton,\n IonButtons,\n IonContent,\n IonHeader,\n IonTitle,\n IonToolbar,\n} from '@ionic/angular';\nimport {\n SpaceComponentBaseParams,\n SpacePageBaseComponent,\n} from '@sneat/space-components';\nimport { ClassName } from '@sneat/ui';\nimport { NewSplitFormComponent } from '../../components';\n\n// Create-split page. Entry: the \"New split\" button on the splits home page.\n// Exit: the hosted form redirects to `split/:splitID` (details) on success\n// with replaceUrl; Back/cancel returns to `splits`.\n@Component({\n selector: 'sneat-splitus-new-split-page',\n templateUrl: './new-split-page.component.html',\n imports: [\n IonHeader,\n IonToolbar,\n IonButtons,\n IonBackButton,\n IonTitle,\n IonContent,\n NewSplitFormComponent,\n ],\n providers: [\n { provide: ClassName, useValue: 'NewSplitPageComponent' },\n SpaceComponentBaseParams,\n ],\n})\nexport class NewSplitPageComponent extends SpacePageBaseComponent {\n constructor() {\n super();\n this.$defaultBackUrlSpacePath.set('splits');\n }\n}\n","<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>New split</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <sneat-splitus-new-split-form\n [space]=\"space\"\n [currentUserID]=\"currentUserId\"\n />\n</ion-content>\n"],"names":[],"mappings":";;;;;;;AAgBA;AACA;AACA;AAkBM,MAAO,qBAAsB,SAAQ,sBAAsB,CAAA;AAC/D,IAAA,WAAA,GAAA;AACE,QAAA,KAAK,EAAE;AACP,QAAA,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,QAAQ,CAAC;IAC7C;uGAJW,qBAAqB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;AAArB,IAAA,OAAA,IAAA,GAAA,EAAA,CAAA,oBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,qBAAqB,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,8BAAA,EAAA,SAAA,EALrB;AACT,YAAA,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,uBAAuB,EAAE;YACzD,wBAAwB;AACzB,SAAA,EAAA,eAAA,EAAA,IAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,EClCH,mYAeA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EDQI,SAAS,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,aAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACT,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACV,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,UAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACV,aAAa,EAAA,QAAA,EAAA,iBAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACb,QAAQ,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACR,UAAU,kOACV,qBAAqB,EAAA,QAAA,EAAA,8BAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,eAAA,CAAA,EAAA,CAAA,EAAA,CAAA;;2FAOZ,qBAAqB,EAAA,UAAA,EAAA,CAAA;kBAjBjC,SAAS;AACE,YAAA,IAAA,EAAA,CAAA,EAAA,QAAA,EAAA,8BAA8B,EAAA,OAAA,EAE/B;wBACP,SAAS;wBACT,UAAU;wBACV,UAAU;wBACV,aAAa;wBACb,QAAQ;wBACR,UAAU;wBACV,qBAAqB;qBACtB,EAAA,SAAA,EACU;AACT,wBAAA,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,uBAAuB,EAAE;wBACzD,wBAAwB;AACzB,qBAAA,EAAA,QAAA,EAAA,mYAAA,EAAA;;;;;"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, signal, Component } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
4
|
+
import { IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonList, IonItem, IonLabel, IonNote, IonBadge, IonButton, IonSpinner } from '@ionic/angular';
|
|
5
|
+
import { SPLITUS_SERVICE } from '@sneat/extension-splitus-contract';
|
|
6
|
+
import { SpacePageBaseComponent, SpaceComponentBaseParams } from '@sneat/space-components';
|
|
7
|
+
import { ClassName } from '@sneat/ui';
|
|
8
|
+
import { combineLatest, switchMap } from 'rxjs';
|
|
9
|
+
import { s as statusColor, a as settleInDebtusUrl } from './sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs';
|
|
10
|
+
|
|
11
|
+
// Split details — per-participant shares with settled/outstanding read
|
|
12
|
+
// straight from getSplit (splitus#ac:settle-up-single-source-of-truth: no
|
|
13
|
+
// client-side settled logic or storage), plus a "Settle in Debtus" link on
|
|
14
|
+
// outstanding rows. Loads by the :splitID in the URL, so deep links and
|
|
15
|
+
// refreshes work without router state. Entry: the splits list
|
|
16
|
+
// (splitus-home-page) and new-split's post-create redirect. Exit: back to
|
|
17
|
+
// `splits`, or out to debtus.app to actually settle.
|
|
18
|
+
class SplitDetailsPageComponent extends SpacePageBaseComponent {
|
|
19
|
+
splitusService = inject(SPLITUS_SERVICE);
|
|
20
|
+
$loading = signal(true, /* @ts-ignore */
|
|
21
|
+
...(ngDevMode ? [{ debugName: "$loading" }] : /* istanbul ignore next */ []));
|
|
22
|
+
$error = signal(undefined, /* @ts-ignore */
|
|
23
|
+
...(ngDevMode ? [{ debugName: "$error" }] : /* istanbul ignore next */ []));
|
|
24
|
+
$split = signal(undefined, /* @ts-ignore */
|
|
25
|
+
...(ngDevMode ? [{ debugName: "$split" }] : /* istanbul ignore next */ []));
|
|
26
|
+
statusColor = statusColor;
|
|
27
|
+
constructor() {
|
|
28
|
+
super();
|
|
29
|
+
this.$defaultBackUrlSpacePath.set('splits');
|
|
30
|
+
combineLatest([this.spaceIDChanged$, this.route.paramMap])
|
|
31
|
+
.pipe(switchMap(([spaceID, params]) => {
|
|
32
|
+
const splitID = params.get('splitID') ?? '';
|
|
33
|
+
this.$loading.set(true);
|
|
34
|
+
this.$error.set(undefined);
|
|
35
|
+
return this.splitusService.getSplit(spaceID ?? '', splitID);
|
|
36
|
+
}), takeUntilDestroyed())
|
|
37
|
+
.subscribe({
|
|
38
|
+
next: (split) => {
|
|
39
|
+
this.$split.set(split);
|
|
40
|
+
this.$loading.set(false);
|
|
41
|
+
},
|
|
42
|
+
error: (err) => {
|
|
43
|
+
this.$error.set('Failed to load split');
|
|
44
|
+
this.$loading.set(false);
|
|
45
|
+
this.errorLogger.logError(err, 'Failed to load split', {
|
|
46
|
+
show: false,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
settleUrl(participant) {
|
|
52
|
+
return settleInDebtusUrl(participant, this.$spaceType(), this.$spaceID());
|
|
53
|
+
}
|
|
54
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SplitDetailsPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
55
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: SplitDetailsPageComponent, isStandalone: true, selector: "sneat-splitus-split-details-page", providers: [
|
|
56
|
+
{ provide: ClassName, useValue: 'SplitDetailsPageComponent' },
|
|
57
|
+
SpaceComponentBaseParams,
|
|
58
|
+
], usesInheritance: true, ngImport: i0, template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>{{ $split()?.title || 'Split' }}</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n @if ($loading()) {\n <ion-card>\n <ion-card-content>\n <ion-spinner name=\"lines\" class=\"ion-margin-end\" />\n Loading\u2026\n </ion-card-content>\n </ion-card>\n } @else if ($error()) {\n <ion-card>\n <ion-card-content>\n <ion-note color=\"danger\">{{ $error() }}</ion-note>\n </ion-card-content>\n </ion-card>\n } @else if ($split(); as split) {\n <ion-card>\n <ion-card-header>\n <ion-card-title>\n {{ split.title || 'Split' }}\n </ion-card-title>\n </ion-card-header>\n <ion-card-content>\n <p>Total: {{ split.amount.toFixed(2) }} {{ split.currency }}</p>\n <ion-badge [color]=\"statusColor(split.status)\">{{\n split.status\n }}</ion-badge>\n </ion-card-content>\n </ion-card>\n\n <ion-card>\n <ion-card-header>\n <ion-card-title>Participants</ion-card-title>\n </ion-card-header>\n <ion-list>\n @for (p of split.participants; track $index) {\n <ion-item>\n <ion-label>\n <h2>{{ p.name }}{{ p.isPayer ? ' (payer)' : '' }}</h2>\n <p>{{ p.share.toFixed(2) }} {{ split.currency }}</p>\n </ion-label>\n <div slot=\"end\" class=\"ion-text-end\">\n <ion-badge [color]=\"statusColor(p.status)\">{{\n p.status\n }}</ion-badge>\n @if (settleUrl(p); as url) {\n <div class=\"ion-margin-top\">\n <ion-button\n [href]=\"url\"\n target=\"_blank\"\n rel=\"noopener\"\n size=\"small\"\n fill=\"outline\"\n >\n Settle in Debtus\n </ion-button>\n </div>\n }\n </div>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n <ion-label color=\"medium\">No participants yet.</ion-label>\n </ion-item>\n }\n </ion-list>\n </ion-card>\n }\n</ion-content>\n", dependencies: [{ kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonBackButton, selector: "ion-back-button" }, { kind: "component", type: IonTitle, selector: "ion-title", inputs: ["color", "size"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"], outputs: ["ionScrollStart", "ionScroll", "ionScrollEnd"] }, { kind: "component", type: IonCard, selector: "ion-card", inputs: ["button", "color", "disabled", "download", "href", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonCardHeader, selector: "ion-card-header", inputs: ["color", "mode", "translucent"] }, { kind: "component", type: IonCardTitle, selector: "ion-card-title", inputs: ["color", "mode"] }, { kind: "component", type: IonCardContent, selector: "ion-card-content", inputs: ["mode"] }, { kind: "component", type: IonList, selector: "ion-list", inputs: ["inset", "lines", "mode"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonLabel, selector: "ion-label", inputs: ["color", "mode", "position"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }, { kind: "component", type: IonBadge, selector: "ion-badge", inputs: ["color", "mode"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"], outputs: ["ionFocus", "ionBlur"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }] });
|
|
59
|
+
}
|
|
60
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SplitDetailsPageComponent, decorators: [{
|
|
61
|
+
type: Component,
|
|
62
|
+
args: [{ selector: 'sneat-splitus-split-details-page', imports: [
|
|
63
|
+
IonHeader,
|
|
64
|
+
IonToolbar,
|
|
65
|
+
IonButtons,
|
|
66
|
+
IonBackButton,
|
|
67
|
+
IonTitle,
|
|
68
|
+
IonContent,
|
|
69
|
+
IonCard,
|
|
70
|
+
IonCardHeader,
|
|
71
|
+
IonCardTitle,
|
|
72
|
+
IonCardContent,
|
|
73
|
+
IonList,
|
|
74
|
+
IonItem,
|
|
75
|
+
IonLabel,
|
|
76
|
+
IonNote,
|
|
77
|
+
IonBadge,
|
|
78
|
+
IonButton,
|
|
79
|
+
IonSpinner,
|
|
80
|
+
], providers: [
|
|
81
|
+
{ provide: ClassName, useValue: 'SplitDetailsPageComponent' },
|
|
82
|
+
SpaceComponentBaseParams,
|
|
83
|
+
], template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>{{ $split()?.title || 'Split' }}</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n @if ($loading()) {\n <ion-card>\n <ion-card-content>\n <ion-spinner name=\"lines\" class=\"ion-margin-end\" />\n Loading\u2026\n </ion-card-content>\n </ion-card>\n } @else if ($error()) {\n <ion-card>\n <ion-card-content>\n <ion-note color=\"danger\">{{ $error() }}</ion-note>\n </ion-card-content>\n </ion-card>\n } @else if ($split(); as split) {\n <ion-card>\n <ion-card-header>\n <ion-card-title>\n {{ split.title || 'Split' }}\n </ion-card-title>\n </ion-card-header>\n <ion-card-content>\n <p>Total: {{ split.amount.toFixed(2) }} {{ split.currency }}</p>\n <ion-badge [color]=\"statusColor(split.status)\">{{\n split.status\n }}</ion-badge>\n </ion-card-content>\n </ion-card>\n\n <ion-card>\n <ion-card-header>\n <ion-card-title>Participants</ion-card-title>\n </ion-card-header>\n <ion-list>\n @for (p of split.participants; track $index) {\n <ion-item>\n <ion-label>\n <h2>{{ p.name }}{{ p.isPayer ? ' (payer)' : '' }}</h2>\n <p>{{ p.share.toFixed(2) }} {{ split.currency }}</p>\n </ion-label>\n <div slot=\"end\" class=\"ion-text-end\">\n <ion-badge [color]=\"statusColor(p.status)\">{{\n p.status\n }}</ion-badge>\n @if (settleUrl(p); as url) {\n <div class=\"ion-margin-top\">\n <ion-button\n [href]=\"url\"\n target=\"_blank\"\n rel=\"noopener\"\n size=\"small\"\n fill=\"outline\"\n >\n Settle in Debtus\n </ion-button>\n </div>\n }\n </div>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n <ion-label color=\"medium\">No participants yet.</ion-label>\n </ion-item>\n }\n </ion-list>\n </ion-card>\n }\n</ion-content>\n" }]
|
|
84
|
+
}], ctorParameters: () => [] });
|
|
85
|
+
|
|
86
|
+
export { SplitDetailsPageComponent };
|
|
87
|
+
//# sourceMappingURL=sneat-extension-splitus-ui-split-details-page.component-kzwMFwkF.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sneat-extension-splitus-ui-split-details-page.component-kzwMFwkF.mjs","sources":["../../../../../../libs/extensions/splitus/ui/src/lib/pages/split-details/split-details-page.component.ts","../../../../../../libs/extensions/splitus/ui/src/lib/pages/split-details/split-details-page.component.html"],"sourcesContent":["import { Component, inject, signal } from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport {\n IonBackButton,\n IonBadge,\n IonButton,\n IonButtons,\n IonCard,\n IonCardContent,\n IonCardHeader,\n IonCardTitle,\n IonContent,\n IonHeader,\n IonItem,\n IonLabel,\n IonList,\n IonNote,\n IonSpinner,\n IonTitle,\n IonToolbar,\n} from '@ionic/angular';\nimport { ISplit, ISplitParticipant, SPLITUS_SERVICE } from '@sneat/extension-splitus-contract';\nimport {\n SpaceComponentBaseParams,\n SpacePageBaseComponent,\n} from '@sneat/space-components';\nimport { ClassName } from '@sneat/ui';\nimport { combineLatest, switchMap } from 'rxjs';\nimport { settleInDebtusUrl, statusColor } from '../split-status-view';\n\n// Split details — per-participant shares with settled/outstanding read\n// straight from getSplit (splitus#ac:settle-up-single-source-of-truth: no\n// client-side settled logic or storage), plus a \"Settle in Debtus\" link on\n// outstanding rows. Loads by the :splitID in the URL, so deep links and\n// refreshes work without router state. Entry: the splits list\n// (splitus-home-page) and new-split's post-create redirect. Exit: back to\n// `splits`, or out to debtus.app to actually settle.\n@Component({\n selector: 'sneat-splitus-split-details-page',\n templateUrl: './split-details-page.component.html',\n imports: [\n IonHeader,\n IonToolbar,\n IonButtons,\n IonBackButton,\n IonTitle,\n IonContent,\n IonCard,\n IonCardHeader,\n IonCardTitle,\n IonCardContent,\n IonList,\n IonItem,\n IonLabel,\n IonNote,\n IonBadge,\n IonButton,\n IonSpinner,\n ],\n providers: [\n { provide: ClassName, useValue: 'SplitDetailsPageComponent' },\n SpaceComponentBaseParams,\n ],\n})\nexport class SplitDetailsPageComponent extends SpacePageBaseComponent {\n private readonly splitusService = inject(SPLITUS_SERVICE);\n\n protected readonly $loading = signal(true);\n protected readonly $error = signal<string | undefined>(undefined);\n protected readonly $split = signal<ISplit | undefined>(undefined);\n\n protected readonly statusColor = statusColor;\n\n constructor() {\n super();\n this.$defaultBackUrlSpacePath.set('splits');\n combineLatest([this.spaceIDChanged$, this.route.paramMap])\n .pipe(\n switchMap(([spaceID, params]) => {\n const splitID = params.get('splitID') ?? '';\n this.$loading.set(true);\n this.$error.set(undefined);\n return this.splitusService.getSplit(spaceID ?? '', splitID);\n }),\n takeUntilDestroyed(),\n )\n .subscribe({\n next: (split) => {\n this.$split.set(split);\n this.$loading.set(false);\n },\n error: (err) => {\n this.$error.set('Failed to load split');\n this.$loading.set(false);\n this.errorLogger.logError(err, 'Failed to load split', {\n show: false,\n });\n },\n });\n }\n\n protected settleUrl(participant: ISplitParticipant): string | undefined {\n return settleInDebtusUrl(\n participant,\n this.$spaceType(),\n this.$spaceID(),\n );\n }\n}\n","<ion-header>\n <ion-toolbar color=\"light\">\n <ion-buttons slot=\"start\">\n <ion-back-button [defaultHref]=\"$defaultBackUrl()\" />\n </ion-buttons>\n <ion-title>{{ $split()?.title || 'Split' }}</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n @if ($loading()) {\n <ion-card>\n <ion-card-content>\n <ion-spinner name=\"lines\" class=\"ion-margin-end\" />\n Loading…\n </ion-card-content>\n </ion-card>\n } @else if ($error()) {\n <ion-card>\n <ion-card-content>\n <ion-note color=\"danger\">{{ $error() }}</ion-note>\n </ion-card-content>\n </ion-card>\n } @else if ($split(); as split) {\n <ion-card>\n <ion-card-header>\n <ion-card-title>\n {{ split.title || 'Split' }}\n </ion-card-title>\n </ion-card-header>\n <ion-card-content>\n <p>Total: {{ split.amount.toFixed(2) }} {{ split.currency }}</p>\n <ion-badge [color]=\"statusColor(split.status)\">{{\n split.status\n }}</ion-badge>\n </ion-card-content>\n </ion-card>\n\n <ion-card>\n <ion-card-header>\n <ion-card-title>Participants</ion-card-title>\n </ion-card-header>\n <ion-list>\n @for (p of split.participants; track $index) {\n <ion-item>\n <ion-label>\n <h2>{{ p.name }}{{ p.isPayer ? ' (payer)' : '' }}</h2>\n <p>{{ p.share.toFixed(2) }} {{ split.currency }}</p>\n </ion-label>\n <div slot=\"end\" class=\"ion-text-end\">\n <ion-badge [color]=\"statusColor(p.status)\">{{\n p.status\n }}</ion-badge>\n @if (settleUrl(p); as url) {\n <div class=\"ion-margin-top\">\n <ion-button\n [href]=\"url\"\n target=\"_blank\"\n rel=\"noopener\"\n size=\"small\"\n fill=\"outline\"\n >\n Settle in Debtus\n </ion-button>\n </div>\n }\n </div>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n <ion-label color=\"medium\">No participants yet.</ion-label>\n </ion-item>\n }\n </ion-list>\n </ion-card>\n }\n</ion-content>\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Pure view-mapping helpers shared by the splits home list and the split
|
|
2
|
+
// details page. Kept side-effect-free and unit-tested directly (see
|
|
3
|
+
// split-status-view.spec.ts), the same way new-split-form's split-shares.ts
|
|
4
|
+
// isolates its reconciliation logic from the component.
|
|
5
|
+
/**
|
|
6
|
+
* Maps a split/participant status string to a badge color. This is the ONLY
|
|
7
|
+
* thing derived from `status` client-side — the status text itself is always
|
|
8
|
+
* rendered verbatim, exactly as returned by getSplit/getSplits, never
|
|
9
|
+
* recomputed or renamed here (splitus#ac:settle-up-single-source-of-truth:
|
|
10
|
+
* Splitus reads settled/unsettled state from Debtus and never keeps its own).
|
|
11
|
+
*/
|
|
12
|
+
function statusColor(status) {
|
|
13
|
+
return status === 'settled' ? 'success' : 'warning';
|
|
14
|
+
}
|
|
15
|
+
// Debtus is a separate app — splitus-app's space shell mounts only the
|
|
16
|
+
// splitus routes (see splitus-space.routes.ts), not debtus's, to keep the two
|
|
17
|
+
// apps decoupled. So an outstanding share's "Settle in Debtus" affordance
|
|
18
|
+
// links out to the debtus.app settle-up surface by an absolute URL rather
|
|
19
|
+
// than an in-app route (mounting debtus's routes/providers into splitus-app
|
|
20
|
+
// just for this one link would undo that intentional decoupling).
|
|
21
|
+
const DEBTUS_APP_BASE_URL = 'https://debtus.app';
|
|
22
|
+
/**
|
|
23
|
+
* The settle-up link for one participant row, or undefined when settling
|
|
24
|
+
* doesn't apply: the payer never settles with themselves, a settled share has
|
|
25
|
+
* nothing left to settle, and a participant with no contactID (e.g. a
|
|
26
|
+
* userID-only participant) has no debtus counterparty to link to.
|
|
27
|
+
*/
|
|
28
|
+
function settleInDebtusUrl(participant, spaceType, spaceID) {
|
|
29
|
+
if (participant.status !== 'outstanding' || participant.isPayer) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const contactID = participant.contactID;
|
|
33
|
+
if (!contactID || !spaceType || !spaceID) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return `${DEBTUS_APP_BASE_URL}/space/${spaceType}/${spaceID}/settle-up?contactID=${encodeURIComponent(contactID)}`;
|
|
37
|
+
}
|
|
38
|
+
/** "1 member" / "2 members" — used on the splits list rows. */
|
|
39
|
+
function formatMemberCount(count) {
|
|
40
|
+
return `${count} ${count === 1 ? 'member' : 'members'}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { settleInDebtusUrl as a, formatMemberCount as f, statusColor as s };
|
|
44
|
+
//# sourceMappingURL=sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs","sources":["../../../../../../libs/extensions/splitus/ui/src/lib/pages/split-status-view.ts"],"sourcesContent":["import { ISplitParticipant } from '@sneat/extension-splitus-contract';\n\n// Pure view-mapping helpers shared by the splits home list and the split\n// details page. Kept side-effect-free and unit-tested directly (see\n// split-status-view.spec.ts), the same way new-split-form's split-shares.ts\n// isolates its reconciliation logic from the component.\n\n/**\n * Maps a split/participant status string to a badge color. This is the ONLY\n * thing derived from `status` client-side — the status text itself is always\n * rendered verbatim, exactly as returned by getSplit/getSplits, never\n * recomputed or renamed here (splitus#ac:settle-up-single-source-of-truth:\n * Splitus reads settled/unsettled state from Debtus and never keeps its own).\n */\nexport function statusColor(status: string): 'success' | 'warning' {\n return status === 'settled' ? 'success' : 'warning';\n}\n\n// Debtus is a separate app — splitus-app's space shell mounts only the\n// splitus routes (see splitus-space.routes.ts), not debtus's, to keep the two\n// apps decoupled. So an outstanding share's \"Settle in Debtus\" affordance\n// links out to the debtus.app settle-up surface by an absolute URL rather\n// than an in-app route (mounting debtus's routes/providers into splitus-app\n// just for this one link would undo that intentional decoupling).\nconst DEBTUS_APP_BASE_URL = 'https://debtus.app';\n\n/**\n * The settle-up link for one participant row, or undefined when settling\n * doesn't apply: the payer never settles with themselves, a settled share has\n * nothing left to settle, and a participant with no contactID (e.g. a\n * userID-only participant) has no debtus counterparty to link to.\n */\nexport function settleInDebtusUrl(\n participant: Pick<ISplitParticipant, 'status' | 'isPayer' | 'contactID'>,\n spaceType: string | undefined,\n spaceID: string | undefined,\n): string | undefined {\n if (participant.status !== 'outstanding' || participant.isPayer) {\n return undefined;\n }\n const contactID = participant.contactID;\n if (!contactID || !spaceType || !spaceID) {\n return undefined;\n }\n return `${DEBTUS_APP_BASE_URL}/space/${spaceType}/${spaceID}/settle-up?contactID=${encodeURIComponent(contactID)}`;\n}\n\n/** \"1 member\" / \"2 members\" — used on the splits list rows. */\nexport function formatMemberCount(count: number): string {\n return `${count} ${count === 1 ? 'member' : 'members'}`;\n}\n"],"names":[],"mappings":"AAEA;AACA;AACA;AACA;AAEA;;;;;;AAMG;AACG,SAAU,WAAW,CAAC,MAAc,EAAA;IACxC,OAAO,MAAM,KAAK,SAAS,GAAG,SAAS,GAAG,SAAS;AACrD;AAEA;AACA;AACA;AACA;AACA;AACA;AACA,MAAM,mBAAmB,GAAG,oBAAoB;AAEhD;;;;;AAKG;SACa,iBAAiB,CAC/B,WAAwE,EACxE,SAA6B,EAC7B,OAA2B,EAAA;IAE3B,IAAI,WAAW,CAAC,MAAM,KAAK,aAAa,IAAI,WAAW,CAAC,OAAO,EAAE;AAC/D,QAAA,OAAO,SAAS;IAClB;AACA,IAAA,MAAM,SAAS,GAAG,WAAW,CAAC,SAAS;IACvC,IAAI,CAAC,SAAS,IAAI,CAAC,SAAS,IAAI,CAAC,OAAO,EAAE;AACxC,QAAA,OAAO,SAAS;IAClB;AACA,IAAA,OAAO,CAAA,EAAG,mBAAmB,CAAA,OAAA,EAAU,SAAS,CAAA,CAAA,EAAI,OAAO,CAAA,qBAAA,EAAwB,kBAAkB,CAAC,SAAS,CAAC,CAAA,CAAE;AACpH;AAEA;AACM,SAAU,iBAAiB,CAAC,KAAa,EAAA;AAC7C,IAAA,OAAO,CAAA,EAAG,KAAK,CAAA,CAAA,EAAI,KAAK,KAAK,CAAC,GAAG,QAAQ,GAAG,SAAS,EAAE;AACzD;;;;"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, signal, Component } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
4
|
+
import { IonHeader, IonToolbar, IonButtons, IonBackButton, IonContent, IonCard, IonCardContent, IonButton, IonIcon, IonList, IonItem, IonLabel, IonNote, IonBadge, IonSpinner } from '@ionic/angular';
|
|
5
|
+
import { SPLITUS_SERVICE } from '@sneat/extension-splitus-contract';
|
|
6
|
+
import { SpacePageBaseComponent, SpacePageTitleComponent, SpaceComponentBaseParams } from '@sneat/space-components';
|
|
7
|
+
import { SpaceServiceModule } from '@sneat/space-services';
|
|
8
|
+
import { ClassName } from '@sneat/ui';
|
|
9
|
+
import { switchMap, EMPTY } from 'rxjs';
|
|
10
|
+
import { DraftSplitStore } from './sneat-extension-splitus-ui.mjs';
|
|
11
|
+
import { s as statusColor, f as formatMemberCount } from './sneat-extension-splitus-ui-split-status-view-Dk3SLPhh.mjs';
|
|
12
|
+
|
|
13
|
+
// Splits home — the splitus entry point for a space: the space's splits
|
|
14
|
+
// (read via getSplits) plus the "New split" action. Entry: space navigation /
|
|
15
|
+
// the splitus-space default redirect. Exit: "New split" -> new-split; each
|
|
16
|
+
// row -> split/:splitID.
|
|
17
|
+
class SplitusHomePageComponent extends SpacePageBaseComponent {
|
|
18
|
+
splitusService = inject(SPLITUS_SERVICE);
|
|
19
|
+
draftSplitStore = inject(DraftSplitStore);
|
|
20
|
+
$loading = signal(true, /* @ts-ignore */
|
|
21
|
+
...(ngDevMode ? [{ debugName: "$loading" }] : /* istanbul ignore next */ []));
|
|
22
|
+
$error = signal(undefined, /* @ts-ignore */
|
|
23
|
+
...(ngDevMode ? [{ debugName: "$error" }] : /* istanbul ignore next */ []));
|
|
24
|
+
$splits = signal([], /* @ts-ignore */
|
|
25
|
+
...(ngDevMode ? [{ debugName: "$splits" }] : /* istanbul ignore next */ []));
|
|
26
|
+
statusColor = statusColor;
|
|
27
|
+
formatMemberCount = formatMemberCount;
|
|
28
|
+
constructor() {
|
|
29
|
+
super();
|
|
30
|
+
this.spaceIDChanged$
|
|
31
|
+
.pipe(switchMap((spaceID) => {
|
|
32
|
+
if (spaceID && this.draftSplitStore.hasPending()) {
|
|
33
|
+
// A bill was started before sign-in (see StartSplitPageComponent)
|
|
34
|
+
// — skip the splits list and go straight to finishing it.
|
|
35
|
+
this.spaceNav.navigateForwardToSpacePage(this.space, 'new-split');
|
|
36
|
+
return EMPTY;
|
|
37
|
+
}
|
|
38
|
+
this.$loading.set(true);
|
|
39
|
+
this.$error.set(undefined);
|
|
40
|
+
return this.splitusService.getSplits(spaceID ?? '');
|
|
41
|
+
}), takeUntilDestroyed())
|
|
42
|
+
.subscribe({
|
|
43
|
+
next: (splits) => {
|
|
44
|
+
this.$splits.set(splits);
|
|
45
|
+
this.$loading.set(false);
|
|
46
|
+
},
|
|
47
|
+
error: (err) => {
|
|
48
|
+
this.$error.set('Failed to load splits');
|
|
49
|
+
this.$loading.set(false);
|
|
50
|
+
this.errorLogger.logError(err, 'Failed to load splits');
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
goNewSplit() {
|
|
55
|
+
this.spaceNav.navigateForwardToSpacePage(this.space, 'new-split');
|
|
56
|
+
}
|
|
57
|
+
openSplit(splitID) {
|
|
58
|
+
this.spaceNav.navigateForwardToSpacePage(this.space, `split/${splitID}`);
|
|
59
|
+
}
|
|
60
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SplitusHomePageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
61
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: SplitusHomePageComponent, isStandalone: true, selector: "sneat-splitus-home-page", providers: [
|
|
62
|
+
{
|
|
63
|
+
provide: ClassName,
|
|
64
|
+
useValue: 'SplitusHomePageComponent',
|
|
65
|
+
},
|
|
66
|
+
SpaceComponentBaseParams,
|
|
67
|
+
], usesInheritance: true, ngImport: i0, template: "<ion-header>\n <ion-toolbar color=\"light\" class=\"with-back-button with-end-buttons\">\n <ion-buttons slot=\"start\">\n <!-- This is the top page of a space, so \"back\" should leave the space and\n return to the app home (the spaces list), not to the space's own root\n (which redirects straight back here). -->\n <ion-back-button defaultHref=\"/\" />\n </ion-buttons>\n <sneat-space-page-title\n [space]=\"space\"\n generalTitle=\"Splits\"\n icon=\"\uD83D\uDCB8\"\n [titlesBySpaceType]=\"{\n personal: 'Personal splits',\n family: 'Family splits',\n }\"\n />\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <ion-card>\n <ion-card-content>\n <ion-button expand=\"block\" (click)=\"goNewSplit()\">\n <ion-icon name=\"add\" slot=\"start\" />\n New split\n </ion-button>\n </ion-card-content>\n </ion-card>\n\n <ion-card>\n @if ($loading()) {\n <ion-card-content>\n <ion-spinner name=\"dots\" />\n </ion-card-content>\n } @else if ($error()) {\n <ion-card-content>\n <ion-note color=\"danger\">{{ $error() }}</ion-note>\n </ion-card-content>\n } @else if ($splits().length) {\n <ion-list>\n @for (s of $splits(); track s.id) {\n <ion-item button (click)=\"openSplit(s.id)\">\n <ion-label>\n <h2>{{ s.title || 'Split' }}</h2>\n <p>\n {{ s.amount.toFixed(2) }} {{ s.currency }} \u00B7\n {{ formatMemberCount(s.membersCount) }}\n </p>\n </ion-label>\n <ion-badge slot=\"end\" [color]=\"statusColor(s.status)\">{{\n s.status\n }}</ion-badge>\n </ion-item>\n }\n </ion-list>\n } @else {\n <ion-card-content class=\"ion-text-center\">\n <ion-note color=\"medium\">No splits yet.</ion-note>\n <div class=\"ion-margin-top\">\n <ion-button (click)=\"goNewSplit()\">\n <ion-icon name=\"add\" slot=\"start\" />\n Create your first split\n </ion-button>\n </div>\n </ion-card-content>\n }\n </ion-card>\n</ion-content>\n", dependencies: [{ kind: "component", type: SpacePageTitleComponent, selector: "sneat-space-page-title", inputs: ["icon", "generalTitle", "space", "titlesBySpaceType"] }, { kind: "ngmodule", type: SpaceServiceModule }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonBackButton, selector: "ion-back-button" }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"], outputs: ["ionScrollStart", "ionScroll", "ionScrollEnd"] }, { kind: "component", type: IonCard, selector: "ion-card", inputs: ["button", "color", "disabled", "download", "href", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonCardContent, selector: "ion-card-content", inputs: ["mode"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"], outputs: ["ionFocus", "ionBlur"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonList, selector: "ion-list", inputs: ["inset", "lines", "mode"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonLabel, selector: "ion-label", inputs: ["color", "mode", "position"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }, { kind: "component", type: IonBadge, selector: "ion-badge", inputs: ["color", "mode"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }] });
|
|
68
|
+
}
|
|
69
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SplitusHomePageComponent, decorators: [{
|
|
70
|
+
type: Component,
|
|
71
|
+
args: [{ selector: 'sneat-splitus-home-page', imports: [
|
|
72
|
+
SpacePageTitleComponent,
|
|
73
|
+
SpaceServiceModule,
|
|
74
|
+
IonHeader,
|
|
75
|
+
IonToolbar,
|
|
76
|
+
IonButtons,
|
|
77
|
+
IonBackButton,
|
|
78
|
+
IonContent,
|
|
79
|
+
IonCard,
|
|
80
|
+
IonCardContent,
|
|
81
|
+
IonButton,
|
|
82
|
+
IonIcon,
|
|
83
|
+
IonList,
|
|
84
|
+
IonItem,
|
|
85
|
+
IonLabel,
|
|
86
|
+
IonNote,
|
|
87
|
+
IonBadge,
|
|
88
|
+
IonSpinner,
|
|
89
|
+
], providers: [
|
|
90
|
+
{
|
|
91
|
+
provide: ClassName,
|
|
92
|
+
useValue: 'SplitusHomePageComponent',
|
|
93
|
+
},
|
|
94
|
+
SpaceComponentBaseParams,
|
|
95
|
+
], template: "<ion-header>\n <ion-toolbar color=\"light\" class=\"with-back-button with-end-buttons\">\n <ion-buttons slot=\"start\">\n <!-- This is the top page of a space, so \"back\" should leave the space and\n return to the app home (the spaces list), not to the space's own root\n (which redirects straight back here). -->\n <ion-back-button defaultHref=\"/\" />\n </ion-buttons>\n <sneat-space-page-title\n [space]=\"space\"\n generalTitle=\"Splits\"\n icon=\"\uD83D\uDCB8\"\n [titlesBySpaceType]=\"{\n personal: 'Personal splits',\n family: 'Family splits',\n }\"\n />\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <ion-card>\n <ion-card-content>\n <ion-button expand=\"block\" (click)=\"goNewSplit()\">\n <ion-icon name=\"add\" slot=\"start\" />\n New split\n </ion-button>\n </ion-card-content>\n </ion-card>\n\n <ion-card>\n @if ($loading()) {\n <ion-card-content>\n <ion-spinner name=\"dots\" />\n </ion-card-content>\n } @else if ($error()) {\n <ion-card-content>\n <ion-note color=\"danger\">{{ $error() }}</ion-note>\n </ion-card-content>\n } @else if ($splits().length) {\n <ion-list>\n @for (s of $splits(); track s.id) {\n <ion-item button (click)=\"openSplit(s.id)\">\n <ion-label>\n <h2>{{ s.title || 'Split' }}</h2>\n <p>\n {{ s.amount.toFixed(2) }} {{ s.currency }} \u00B7\n {{ formatMemberCount(s.membersCount) }}\n </p>\n </ion-label>\n <ion-badge slot=\"end\" [color]=\"statusColor(s.status)\">{{\n s.status\n }}</ion-badge>\n </ion-item>\n }\n </ion-list>\n } @else {\n <ion-card-content class=\"ion-text-center\">\n <ion-note color=\"medium\">No splits yet.</ion-note>\n <div class=\"ion-margin-top\">\n <ion-button (click)=\"goNewSplit()\">\n <ion-icon name=\"add\" slot=\"start\" />\n Create your first split\n </ion-button>\n </div>\n </ion-card-content>\n }\n </ion-card>\n</ion-content>\n" }]
|
|
96
|
+
}], ctorParameters: () => [] });
|
|
97
|
+
|
|
98
|
+
export { SplitusHomePageComponent };
|
|
99
|
+
//# sourceMappingURL=sneat-extension-splitus-ui-splitus-home-page.component-Dp5oLR86.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sneat-extension-splitus-ui-splitus-home-page.component-Dp5oLR86.mjs","sources":["../../../../../../libs/extensions/splitus/ui/src/lib/pages/splitus-home/splitus-home-page.component.ts","../../../../../../libs/extensions/splitus/ui/src/lib/pages/splitus-home/splitus-home-page.component.html"],"sourcesContent":["import { Component, inject, signal } from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport {\n IonBackButton,\n IonBadge,\n IonButton,\n IonButtons,\n IonCard,\n IonCardContent,\n IonContent,\n IonHeader,\n IonIcon,\n IonItem,\n IonLabel,\n IonList,\n IonNote,\n IonSpinner,\n IonToolbar,\n} from '@ionic/angular';\nimport { ISplitListItem, SPLITUS_SERVICE } from '@sneat/extension-splitus-contract';\nimport {\n SpaceComponentBaseParams,\n SpacePageTitleComponent,\n SpacePageBaseComponent,\n} from '@sneat/space-components';\nimport { SpaceServiceModule } from '@sneat/space-services';\nimport { ClassName } from '@sneat/ui';\nimport { EMPTY, switchMap } from 'rxjs';\nimport { DraftSplitStore } from '../../services/draft-split-store.service';\nimport { formatMemberCount, statusColor } from '../split-status-view';\n\n// Splits home — the splitus entry point for a space: the space's splits\n// (read via getSplits) plus the \"New split\" action. Entry: space navigation /\n// the splitus-space default redirect. Exit: \"New split\" -> new-split; each\n// row -> split/:splitID.\n@Component({\n selector: 'sneat-splitus-home-page',\n templateUrl: './splitus-home-page.component.html',\n imports: [\n SpacePageTitleComponent,\n SpaceServiceModule,\n IonHeader,\n IonToolbar,\n IonButtons,\n IonBackButton,\n IonContent,\n IonCard,\n IonCardContent,\n IonButton,\n IonIcon,\n IonList,\n IonItem,\n IonLabel,\n IonNote,\n IonBadge,\n IonSpinner,\n ],\n providers: [\n {\n provide: ClassName,\n useValue: 'SplitusHomePageComponent',\n },\n SpaceComponentBaseParams,\n ],\n})\nexport class SplitusHomePageComponent extends SpacePageBaseComponent {\n private readonly splitusService = inject(SPLITUS_SERVICE);\n private readonly draftSplitStore = inject(DraftSplitStore);\n\n protected readonly $loading = signal(true);\n protected readonly $error = signal<string | undefined>(undefined);\n protected readonly $splits = signal<ISplitListItem[]>([]);\n\n protected readonly statusColor = statusColor;\n protected readonly formatMemberCount = formatMemberCount;\n\n constructor() {\n super();\n this.spaceIDChanged$\n .pipe(\n switchMap((spaceID) => {\n if (spaceID && this.draftSplitStore.hasPending()) {\n // A bill was started before sign-in (see StartSplitPageComponent)\n // — skip the splits list and go straight to finishing it.\n this.spaceNav.navigateForwardToSpacePage(this.space, 'new-split');\n return EMPTY;\n }\n this.$loading.set(true);\n this.$error.set(undefined);\n return this.splitusService.getSplits(spaceID ?? '');\n }),\n takeUntilDestroyed(),\n )\n .subscribe({\n next: (splits) => {\n this.$splits.set(splits);\n this.$loading.set(false);\n },\n error: (err) => {\n this.$error.set('Failed to load splits');\n this.$loading.set(false);\n this.errorLogger.logError(err, 'Failed to load splits');\n },\n });\n }\n\n protected goNewSplit(): void {\n this.spaceNav.navigateForwardToSpacePage(this.space, 'new-split');\n }\n\n protected openSplit(splitID: string): void {\n this.spaceNav.navigateForwardToSpacePage(this.space, `split/${splitID}`);\n }\n}\n","<ion-header>\n <ion-toolbar color=\"light\" class=\"with-back-button with-end-buttons\">\n <ion-buttons slot=\"start\">\n <!-- This is the top page of a space, so \"back\" should leave the space and\n return to the app home (the spaces list), not to the space's own root\n (which redirects straight back here). -->\n <ion-back-button defaultHref=\"/\" />\n </ion-buttons>\n <sneat-space-page-title\n [space]=\"space\"\n generalTitle=\"Splits\"\n icon=\"💸\"\n [titlesBySpaceType]=\"{\n personal: 'Personal splits',\n family: 'Family splits',\n }\"\n />\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <ion-card>\n <ion-card-content>\n <ion-button expand=\"block\" (click)=\"goNewSplit()\">\n <ion-icon name=\"add\" slot=\"start\" />\n New split\n </ion-button>\n </ion-card-content>\n </ion-card>\n\n <ion-card>\n @if ($loading()) {\n <ion-card-content>\n <ion-spinner name=\"dots\" />\n </ion-card-content>\n } @else if ($error()) {\n <ion-card-content>\n <ion-note color=\"danger\">{{ $error() }}</ion-note>\n </ion-card-content>\n } @else if ($splits().length) {\n <ion-list>\n @for (s of $splits(); track s.id) {\n <ion-item button (click)=\"openSplit(s.id)\">\n <ion-label>\n <h2>{{ s.title || 'Split' }}</h2>\n <p>\n {{ s.amount.toFixed(2) }} {{ s.currency }} ·\n {{ formatMemberCount(s.membersCount) }}\n </p>\n </ion-label>\n <ion-badge slot=\"end\" [color]=\"statusColor(s.status)\">{{\n s.status\n }}</ion-badge>\n </ion-item>\n }\n </ion-list>\n } @else {\n <ion-card-content class=\"ion-text-center\">\n <ion-note color=\"medium\">No splits yet.</ion-note>\n <div class=\"ion-margin-top\">\n <ion-button (click)=\"goNewSplit()\">\n <ion-icon name=\"add\" slot=\"start\" />\n Create your first split\n </ion-button>\n </div>\n </ion-card-content>\n }\n </ion-card>\n</ion-content>\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, Component } from '@angular/core';
|
|
3
|
+
import * as i1 from '@angular/forms';
|
|
4
|
+
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
5
|
+
import { Router } from '@angular/router';
|
|
6
|
+
import { IonHeader, IonToolbar, IonTitle, IonContent, IonCard, IonCardHeader, IonCardTitle, IonItem, IonInput, IonSelect, IonSelectOption, IonNote, IonButton } from '@ionic/angular';
|
|
7
|
+
import { DraftSplitStore } from './sneat-extension-splitus-ui.mjs';
|
|
8
|
+
|
|
9
|
+
// Unguarded entry point: capture the bill basics (title/amount/currency)
|
|
10
|
+
// without requiring sign-in or a space. Saves a local draft and sends the
|
|
11
|
+
// user to '' (home) — the existing AuthGuard/space-list flow takes it from
|
|
12
|
+
// there: signed out -> /login; signed in with zero spaces -> the home page's
|
|
13
|
+
// own "create a team" step; signed in with a space -> that space's splits
|
|
14
|
+
// page, which redirects into new-split when a pending draft exists (see
|
|
15
|
+
// SplitusHomePageComponent). See
|
|
16
|
+
// docs/superpowers/specs/2026-07-03-anonymous-first-split-design.md.
|
|
17
|
+
class StartSplitPageComponent {
|
|
18
|
+
draftSplitStore = inject(DraftSplitStore);
|
|
19
|
+
router = inject(Router);
|
|
20
|
+
title = new FormControl('', {
|
|
21
|
+
nonNullable: true,
|
|
22
|
+
});
|
|
23
|
+
currency = new FormControl('EUR', {
|
|
24
|
+
nonNullable: true,
|
|
25
|
+
});
|
|
26
|
+
amount = new FormControl(null, [
|
|
27
|
+
Validators.required,
|
|
28
|
+
Validators.min(0.01),
|
|
29
|
+
]);
|
|
30
|
+
currencies = ['EUR', 'USD'];
|
|
31
|
+
onContinue() {
|
|
32
|
+
this.amount.markAsTouched();
|
|
33
|
+
if (this.amount.invalid) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.draftSplitStore.save({
|
|
37
|
+
title: this.title.value.trim(),
|
|
38
|
+
amount: this.amount.value ?? 0,
|
|
39
|
+
currencyCode: this.currency.value,
|
|
40
|
+
});
|
|
41
|
+
this.router.navigateByUrl('/');
|
|
42
|
+
}
|
|
43
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: StartSplitPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
44
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: StartSplitPageComponent, isStandalone: true, selector: "sneat-splitus-start-split-page", ngImport: i0, template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-title>Split a bill</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <ion-card>\n <ion-card-header>\n <ion-card-title>What's the bill?</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. Dinner at Luigi's\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select\n label=\"Currency\"\n interface=\"popover\"\n [formControl]=\"currency\"\n >\n @for (c of currencies; track c) {\n <ion-select-option [value]=\"c\">{{ c }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n Enter an amount greater than zero.\n </ion-note>\n }\n </ion-card>\n\n <div class=\"ion-padding\">\n <ion-button expand=\"block\" color=\"primary\" (click)=\"onContinue()\">\n Continue\n </ion-button>\n <ion-note\n color=\"medium\"\n class=\"ion-text-center\"\n style=\"display: block; margin-top: 8px;\"\n >\n You'll sign in next to add who's splitting this.\n </ion-note>\n </div>\n</ion-content>\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonTitle, selector: "ion-title", inputs: ["color", "size"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"], outputs: ["ionScrollStart", "ionScroll", "ionScrollEnd"] }, { kind: "component", type: IonCard, selector: "ion-card", inputs: ["button", "color", "disabled", "download", "href", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonCardHeader, selector: "ion-card-header", inputs: ["color", "mode", "translucent"] }, { kind: "component", type: IonCardTitle, selector: "ion-card-title", inputs: ["color", "mode"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonInput, selector: "ion-input", inputs: ["accept", "autocapitalize", "autocomplete", "autocorrect", "autofocus", "clearInput", "clearOnEdit", "color", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "max", "maxlength", "min", "minlength", "mode", "multiple", "name", "pattern", "placeholder", "readonly", "required", "shape", "size", "spellcheck", "step", "type", "value"] }, { kind: "component", type: IonSelect, selector: "ion-select", inputs: ["cancelText", "color", "compareWith", "disabled", "errorText", "expandedIcon", "fill", "helperText", "interface", "interfaceOptions", "justify", "label", "labelPlacement", "mode", "multiple", "name", "okText", "placeholder", "selectedText", "shape", "toggleIcon", "value"] }, { kind: "component", type: IonSelectOption, selector: "ion-select-option", inputs: ["description", "disabled", "justify", "labelPlacement", "mode", "value"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"], outputs: ["ionFocus", "ionBlur"] }] });
|
|
45
|
+
}
|
|
46
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: StartSplitPageComponent, decorators: [{
|
|
47
|
+
type: Component,
|
|
48
|
+
args: [{ selector: 'sneat-splitus-start-split-page', imports: [
|
|
49
|
+
ReactiveFormsModule,
|
|
50
|
+
IonHeader,
|
|
51
|
+
IonToolbar,
|
|
52
|
+
IonTitle,
|
|
53
|
+
IonContent,
|
|
54
|
+
IonCard,
|
|
55
|
+
IonCardHeader,
|
|
56
|
+
IonCardTitle,
|
|
57
|
+
IonItem,
|
|
58
|
+
IonInput,
|
|
59
|
+
IonSelect,
|
|
60
|
+
IonSelectOption,
|
|
61
|
+
IonNote,
|
|
62
|
+
IonButton,
|
|
63
|
+
], template: "<ion-header>\n <ion-toolbar color=\"light\">\n <ion-title>Split a bill</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <ion-card>\n <ion-card-header>\n <ion-card-title>What's the bill?</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. Dinner at Luigi's\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select\n label=\"Currency\"\n interface=\"popover\"\n [formControl]=\"currency\"\n >\n @for (c of currencies; track c) {\n <ion-select-option [value]=\"c\">{{ c }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n Enter an amount greater than zero.\n </ion-note>\n }\n </ion-card>\n\n <div class=\"ion-padding\">\n <ion-button expand=\"block\" color=\"primary\" (click)=\"onContinue()\">\n Continue\n </ion-button>\n <ion-note\n color=\"medium\"\n class=\"ion-text-center\"\n style=\"display: block; margin-top: 8px;\"\n >\n You'll sign in next to add who's splitting this.\n </ion-note>\n </div>\n</ion-content>\n" }]
|
|
64
|
+
}] });
|
|
65
|
+
|
|
66
|
+
export { StartSplitPageComponent };
|
|
67
|
+
//# sourceMappingURL=sneat-extension-splitus-ui-start-split-page.component-BHsYZbKT.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sneat-extension-splitus-ui-start-split-page.component-BHsYZbKT.mjs","sources":["../../../../../../libs/extensions/splitus/ui/src/lib/pages/start-split/start-split-page.component.ts","../../../../../../libs/extensions/splitus/ui/src/lib/pages/start-split/start-split-page.component.html"],"sourcesContent":["import { Component, inject } from '@angular/core';\nimport { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';\nimport { Router } from '@angular/router';\nimport {\n IonButton,\n IonCard,\n IonCardHeader,\n IonCardTitle,\n IonContent,\n IonHeader,\n IonInput,\n IonItem,\n IonNote,\n IonSelect,\n IonSelectOption,\n IonTitle,\n IonToolbar,\n} from '@ionic/angular';\nimport { CurrencyCode } from '@sneat/extension-splitus-contract';\nimport { DraftSplitStore } from '../../services/draft-split-store.service';\n\n// Unguarded entry point: capture the bill basics (title/amount/currency)\n// without requiring sign-in or a space. Saves a local draft and sends the\n// user to '' (home) — the existing AuthGuard/space-list flow takes it from\n// there: signed out -> /login; signed in with zero spaces -> the home page's\n// own \"create a team\" step; signed in with a space -> that space's splits\n// page, which redirects into new-split when a pending draft exists (see\n// SplitusHomePageComponent). See\n// docs/superpowers/specs/2026-07-03-anonymous-first-split-design.md.\n@Component({\n selector: 'sneat-splitus-start-split-page',\n templateUrl: './start-split-page.component.html',\n imports: [\n ReactiveFormsModule,\n IonHeader,\n IonToolbar,\n IonTitle,\n IonContent,\n IonCard,\n IonCardHeader,\n IonCardTitle,\n IonItem,\n IonInput,\n IonSelect,\n IonSelectOption,\n IonNote,\n IonButton,\n ],\n})\nexport class StartSplitPageComponent {\n private readonly draftSplitStore = inject(DraftSplitStore);\n private readonly router = inject(Router);\n\n protected readonly title = new FormControl<string>('', {\n nonNullable: true,\n });\n protected readonly currency = new FormControl<CurrencyCode>('EUR', {\n nonNullable: true,\n });\n protected readonly amount = new FormControl<number | null>(null, [\n Validators.required,\n Validators.min(0.01),\n ]);\n\n protected readonly currencies: CurrencyCode[] = ['EUR', 'USD'];\n\n protected onContinue(): void {\n this.amount.markAsTouched();\n if (this.amount.invalid) {\n return;\n }\n this.draftSplitStore.save({\n title: this.title.value.trim(),\n amount: this.amount.value ?? 0,\n currencyCode: this.currency.value,\n });\n this.router.navigateByUrl('/');\n }\n}\n","<ion-header>\n <ion-toolbar color=\"light\">\n <ion-title>Split a bill</ion-title>\n </ion-toolbar>\n</ion-header>\n\n<ion-content class=\"cardy\">\n <ion-card>\n <ion-card-header>\n <ion-card-title>What's the bill?</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. Dinner at Luigi's\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select\n label=\"Currency\"\n interface=\"popover\"\n [formControl]=\"currency\"\n >\n @for (c of currencies; track c) {\n <ion-select-option [value]=\"c\">{{ c }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n Enter an amount greater than zero.\n </ion-note>\n }\n </ion-card>\n\n <div class=\"ion-padding\">\n <ion-button expand=\"block\" color=\"primary\" (click)=\"onContinue()\">\n Continue\n </ion-button>\n <ion-note\n color=\"medium\"\n class=\"ion-text-center\"\n style=\"display: block; margin-top: 8px;\"\n >\n You'll sign in next to add who's splitting this.\n </ion-note>\n </div>\n</ion-content>\n"],"names":[],"mappings":";;;;;;;;AAqBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;MAqBa,uBAAuB,CAAA;AACjB,IAAA,eAAe,GAAG,MAAM,CAAC,eAAe,CAAC;AACzC,IAAA,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;AAErB,IAAA,KAAK,GAAG,IAAI,WAAW,CAAS,EAAE,EAAE;AACrD,QAAA,WAAW,EAAE,IAAI;AAClB,KAAA,CAAC;AACiB,IAAA,QAAQ,GAAG,IAAI,WAAW,CAAe,KAAK,EAAE;AACjE,QAAA,WAAW,EAAE,IAAI;AAClB,KAAA,CAAC;AACiB,IAAA,MAAM,GAAG,IAAI,WAAW,CAAgB,IAAI,EAAE;AAC/D,QAAA,UAAU,CAAC,QAAQ;AACnB,QAAA,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;AACrB,KAAA,CAAC;AAEiB,IAAA,UAAU,GAAmB,CAAC,KAAK,EAAE,KAAK,CAAC;IAEpD,UAAU,GAAA;AAClB,QAAA,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE;AAC3B,QAAA,IAAI,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE;YACvB;QACF;AACA,QAAA,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC;YACxB,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE;AAC9B,YAAA,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC;AAC9B,YAAA,YAAY,EAAE,IAAI,CAAC,QAAQ,CAAC,KAAK;AAClC,SAAA,CAAC;AACF,QAAA,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,GAAG,CAAC;IAChC;uGA5BW,uBAAuB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;AAAvB,IAAA,OAAA,IAAA,GAAA,EAAA,CAAA,oBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,uBAAuB,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,gCAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,ECjDpC,wmDA6DA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,UAAA,EAAA,IAAA,ED5BI,mBAAmB,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAA,EAAA,CAAA,eAAA,EAAA,QAAA,EAAA,2CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAA,EAAA,CAAA,oBAAA,EAAA,QAAA,EAAA,eAAA,EAAA,MAAA,EAAA,CAAA,aAAA,EAAA,UAAA,EAAA,SAAA,CAAA,EAAA,OAAA,EAAA,CAAA,eAAA,CAAA,EAAA,QAAA,EAAA,CAAA,QAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACnB,SAAS,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,UAAA,EAAA,MAAA,EAAA,aAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACT,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACV,QAAQ,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACR,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,oBAAA,EAAA,iBAAA,EAAA,YAAA,EAAA,cAAA,EAAA,SAAA,EAAA,SAAA,CAAA,EAAA,OAAA,EAAA,CAAA,gBAAA,EAAA,WAAA,EAAA,cAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACV,OAAO,EAAA,QAAA,EAAA,UAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,OAAA,EAAA,UAAA,EAAA,UAAA,EAAA,MAAA,EAAA,MAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACP,aAAa,EAAA,QAAA,EAAA,iBAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,EAAA,aAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACb,YAAY,EAAA,QAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACZ,OAAO,EAAA,QAAA,EAAA,UAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,YAAA,EAAA,UAAA,EAAA,UAAA,EAAA,MAAA,EAAA,OAAA,EAAA,MAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACP,QAAQ,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,gBAAA,EAAA,cAAA,EAAA,aAAA,EAAA,WAAA,EAAA,YAAA,EAAA,aAAA,EAAA,OAAA,EAAA,SAAA,EAAA,kBAAA,EAAA,UAAA,EAAA,UAAA,EAAA,cAAA,EAAA,WAAA,EAAA,MAAA,EAAA,YAAA,EAAA,WAAA,EAAA,OAAA,EAAA,gBAAA,EAAA,KAAA,EAAA,WAAA,EAAA,KAAA,EAAA,WAAA,EAAA,MAAA,EAAA,UAAA,EAAA,MAAA,EAAA,SAAA,EAAA,aAAA,EAAA,UAAA,EAAA,UAAA,EAAA,OAAA,EAAA,MAAA,EAAA,YAAA,EAAA,MAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACR,SAAS,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,YAAA,EAAA,OAAA,EAAA,aAAA,EAAA,UAAA,EAAA,WAAA,EAAA,cAAA,EAAA,MAAA,EAAA,YAAA,EAAA,WAAA,EAAA,kBAAA,EAAA,SAAA,EAAA,OAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,UAAA,EAAA,MAAA,EAAA,QAAA,EAAA,aAAA,EAAA,cAAA,EAAA,OAAA,EAAA,YAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACT,eAAe,EAAA,QAAA,EAAA,mBAAA,EAAA,MAAA,EAAA,CAAA,aAAA,EAAA,UAAA,EAAA,SAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACf,OAAO,gFACP,SAAS,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,YAAA,EAAA,OAAA,EAAA,UAAA,EAAA,UAAA,EAAA,QAAA,EAAA,MAAA,EAAA,MAAA,EAAA,MAAA,EAAA,MAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,iBAAA,EAAA,OAAA,EAAA,MAAA,EAAA,QAAA,EAAA,QAAA,EAAA,MAAA,CAAA,EAAA,OAAA,EAAA,CAAA,UAAA,EAAA,SAAA,CAAA,EAAA,CAAA,EAAA,CAAA;;2FAGA,uBAAuB,EAAA,UAAA,EAAA,CAAA;kBApBnC,SAAS;AACE,YAAA,IAAA,EAAA,CAAA,EAAA,QAAA,EAAA,gCAAgC,EAAA,OAAA,EAEjC;wBACP,mBAAmB;wBACnB,SAAS;wBACT,UAAU;wBACV,QAAQ;wBACR,UAAU;wBACV,OAAO;wBACP,aAAa;wBACb,YAAY;wBACZ,OAAO;wBACP,QAAQ;wBACR,SAAS;wBACT,eAAe;wBACf,OAAO;wBACP,SAAS;AACV,qBAAA,EAAA,QAAA,EAAA,wmDAAA,EAAA;;;;;"}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Injectable, inject, DestroyRef, signal, computed, Input, Component } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
4
|
+
import * as i1 from '@angular/forms';
|
|
5
|
+
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
6
|
+
import { IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonItem, IonLabel, IonNote, IonSelect, IonSelectOption, IonInput, IonCheckbox, IonSegment, IonSegmentButton, IonButton, IonSpinner } from '@ionic/angular';
|
|
7
|
+
import { ErrorLogger } from '@sneat/core';
|
|
8
|
+
import { CONTACTUS_SPACE_SERVICE } from '@sneat/extension-contactus-contract';
|
|
9
|
+
import { SPLITUS_SERVICE } from '@sneat/extension-splitus-contract';
|
|
10
|
+
import { SpaceNavService } from '@sneat/space-services';
|
|
11
|
+
|
|
12
|
+
const DRAFT_SPLIT_STORAGE_KEY = 'sneat.splitus.draftSplit';
|
|
13
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
14
|
+
function isDraftSplit(value) {
|
|
15
|
+
if (typeof value !== 'object' || value === null) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const v = value;
|
|
19
|
+
return (typeof v['title'] === 'string' &&
|
|
20
|
+
typeof v['amount'] === 'number' &&
|
|
21
|
+
typeof v['currencyCode'] === 'string' &&
|
|
22
|
+
typeof v['createdAt'] === 'number');
|
|
23
|
+
}
|
|
24
|
+
class DraftSplitStore {
|
|
25
|
+
save(draft) {
|
|
26
|
+
const full = { ...draft, createdAt: Date.now() };
|
|
27
|
+
try {
|
|
28
|
+
localStorage.setItem(DRAFT_SPLIT_STORAGE_KEY, JSON.stringify(full));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Storage unavailable (private mode, quota) — fail open, never block
|
|
32
|
+
// navigation over a lost draft.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
read() {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = localStorage.getItem(DRAFT_SPLIT_STORAGE_KEY);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (!raw) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
if (!isDraftSplit(parsed) || Date.now() - parsed.createdAt > MAX_AGE_MS) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
hasPending() {
|
|
59
|
+
return this.read() !== undefined;
|
|
60
|
+
}
|
|
61
|
+
clear() {
|
|
62
|
+
try {
|
|
63
|
+
localStorage.removeItem(DRAFT_SPLIT_STORAGE_KEY);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// ignore
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DraftSplitStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
70
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DraftSplitStore, providedIn: 'root' });
|
|
71
|
+
}
|
|
72
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DraftSplitStore, decorators: [{
|
|
73
|
+
type: Injectable,
|
|
74
|
+
args: [{ providedIn: 'root' }]
|
|
75
|
+
}] });
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parses a decimal user input ("33.34", 50) into integer hundredths (3334).
|
|
79
|
+
* Returns undefined for blank, non-numeric or negative values.
|
|
80
|
+
*/
|
|
81
|
+
function parseDecimal(raw) {
|
|
82
|
+
if (raw === null || raw === undefined) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const s = String(raw).trim();
|
|
86
|
+
if (!s) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const n = Number(s);
|
|
90
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return Math.round(n * 100);
|
|
94
|
+
}
|
|
95
|
+
/** Formats integer hundredths as a 2-decimal string: 3334 → "33.34". */
|
|
96
|
+
function formatHundredths(v) {
|
|
97
|
+
return (v / 100).toFixed(2);
|
|
98
|
+
}
|
|
99
|
+
/** Formats basis points as a trimmed percent number: 3334 → "33.34", 5000 → "50". */
|
|
100
|
+
function formatPercentValue(v) {
|
|
101
|
+
return String(v / 100);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Live reconciliation of custom shares against the expense total.
|
|
105
|
+
*
|
|
106
|
+
* - `equally`: always ok — there are no share inputs; the backend splits.
|
|
107
|
+
* - `exact-amount`: every share must be entered and sum exactly to the total.
|
|
108
|
+
* - `percentage`: every percentage must be entered and sum exactly to 100%.
|
|
109
|
+
*
|
|
110
|
+
* @param totalHundredths the expense total in hundredths (cents), if entered.
|
|
111
|
+
* @param shares raw share inputs, one per participant (payer included).
|
|
112
|
+
*/
|
|
113
|
+
function reconcileShares(mode, totalHundredths, shares) {
|
|
114
|
+
if (mode === 'equally') {
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
|
117
|
+
const parsed = shares.map(parseDecimal);
|
|
118
|
+
if (parsed.some((v) => v === undefined)) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: mode === 'percentage'
|
|
122
|
+
? 'Enter a percentage for every participant.'
|
|
123
|
+
: 'Enter a share for every participant.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const entered = parsed.reduce((sum, v) => sum + v, 0);
|
|
127
|
+
if (mode === 'percentage') {
|
|
128
|
+
if (entered === 100_00) {
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `Percentages sum to ${formatPercentValue(entered)}% — they must sum to exactly 100%.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// exact-amount
|
|
137
|
+
if (totalHundredths === undefined) {
|
|
138
|
+
return { ok: false, error: 'Enter the expense total first.' };
|
|
139
|
+
}
|
|
140
|
+
if (entered === totalHundredths) {
|
|
141
|
+
return { ok: true };
|
|
142
|
+
}
|
|
143
|
+
const delta = entered - totalHundredths;
|
|
144
|
+
const direction = delta < 0
|
|
145
|
+
? `${formatHundredths(-delta)} short of`
|
|
146
|
+
: `${formatHundredths(delta)} over`;
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: `Shares sum to ${formatHundredths(entered)} — ${direction} the ${formatHundredths(totalHundredths)} total.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Pulls a human message out of an HTTP error (Go handlers reply plain text). */
|
|
154
|
+
function extractServerMessage(err) {
|
|
155
|
+
if (typeof err !== 'object' || !err) {
|
|
156
|
+
return typeof err === 'string' && err.trim() ? err.trim() : undefined;
|
|
157
|
+
}
|
|
158
|
+
const e = err;
|
|
159
|
+
if (typeof e.error === 'string' && e.error.trim()) {
|
|
160
|
+
return e.error.trim();
|
|
161
|
+
}
|
|
162
|
+
if (typeof e.error === 'object' && e.error) {
|
|
163
|
+
const m = e.error.message;
|
|
164
|
+
if (typeof m === 'string' && m.trim()) {
|
|
165
|
+
return m.trim();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return typeof e.message === 'string' && e.message.trim()
|
|
169
|
+
? e.message.trim()
|
|
170
|
+
: undefined;
|
|
171
|
+
}
|
|
172
|
+
// Create-split form: amount + currency, participants picked from the space's
|
|
173
|
+
// existing contactus people (no free-text name entry — AC
|
|
174
|
+
// participants-from-contactus-membership-enforced), and an
|
|
175
|
+
// equally/exact-amount/percentage share editor with live reconciliation that
|
|
176
|
+
// blocks submit until custom shares sum to the total (AC
|
|
177
|
+
// custom-shares-must-sum). On success redirects to the new split's details
|
|
178
|
+
// page with replaceUrl (flows.md create→details default).
|
|
179
|
+
class NewSplitFormComponent {
|
|
180
|
+
errorLogger = inject(ErrorLogger);
|
|
181
|
+
splitusService = inject(SPLITUS_SERVICE);
|
|
182
|
+
contactusSpaceService = inject(CONTACTUS_SPACE_SERVICE);
|
|
183
|
+
spaceNavService = inject(SpaceNavService);
|
|
184
|
+
destroyRef = inject(DestroyRef);
|
|
185
|
+
draftSplitStore = inject(DraftSplitStore);
|
|
186
|
+
$resumedFromDraft = signal(false, /* @ts-ignore */
|
|
187
|
+
...(ngDevMode ? [{ debugName: "$resumedFromDraft" }] : /* istanbul ignore next */ []));
|
|
188
|
+
draftChecked = false;
|
|
189
|
+
$space = signal(undefined, /* @ts-ignore */
|
|
190
|
+
...(ngDevMode ? [{ debugName: "$space" }] : /* istanbul ignore next */ []));
|
|
191
|
+
// TODO: Skipped for migration because:
|
|
192
|
+
// Accessor inputs cannot be migrated as they are too complex.
|
|
193
|
+
set space(value) {
|
|
194
|
+
this.$space.set(value);
|
|
195
|
+
this.watchContacts(value?.id);
|
|
196
|
+
this.tryResumeDraft();
|
|
197
|
+
}
|
|
198
|
+
tryResumeDraft() {
|
|
199
|
+
if (this.draftChecked) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
this.draftChecked = true;
|
|
203
|
+
const draft = this.draftSplitStore.read();
|
|
204
|
+
if (!draft) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.title.setValue(draft.title);
|
|
208
|
+
this.currency.setValue(draft.currencyCode);
|
|
209
|
+
this.amount.setValue(draft.amount);
|
|
210
|
+
this.$resumedFromDraft.set(true);
|
|
211
|
+
}
|
|
212
|
+
dismissDraftBanner() {
|
|
213
|
+
this.$resumedFromDraft.set(false);
|
|
214
|
+
this.draftSplitStore.clear();
|
|
215
|
+
}
|
|
216
|
+
$currentUserID = signal(undefined, /* @ts-ignore */
|
|
217
|
+
...(ngDevMode ? [{ debugName: "$currentUserID" }] : /* istanbul ignore next */ []));
|
|
218
|
+
/** The authenticated user's ID — used to exclude the payer's own contact. */
|
|
219
|
+
// TODO: Skipped for migration because:
|
|
220
|
+
// Accessor inputs cannot be migrated as they are too complex.
|
|
221
|
+
set currentUserID(value) {
|
|
222
|
+
this.$currentUserID.set(value);
|
|
223
|
+
}
|
|
224
|
+
// ---- participants (sourced from contactus — never typed in) ----
|
|
225
|
+
contactsSubscription;
|
|
226
|
+
watchedSpaceID;
|
|
227
|
+
$contacts = signal(undefined, /* @ts-ignore */
|
|
228
|
+
...(ngDevMode ? [{ debugName: "$contacts" }] : /* istanbul ignore next */ []));
|
|
229
|
+
$contactsError = signal(undefined, /* @ts-ignore */
|
|
230
|
+
...(ngDevMode ? [{ debugName: "$contactsError" }] : /* istanbul ignore next */ []));
|
|
231
|
+
watchContacts(spaceID) {
|
|
232
|
+
if (spaceID === this.watchedSpaceID) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.watchedSpaceID = spaceID;
|
|
236
|
+
this.contactsSubscription?.unsubscribe();
|
|
237
|
+
this.$contacts.set(undefined);
|
|
238
|
+
this.$contactsError.set(undefined);
|
|
239
|
+
if (!spaceID) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.contactsSubscription = this.contactusSpaceService
|
|
243
|
+
.watchContactBriefs(spaceID)
|
|
244
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
245
|
+
.subscribe({
|
|
246
|
+
next: (contacts) => this.$contacts.set(contacts),
|
|
247
|
+
error: (err) => {
|
|
248
|
+
this.$contactsError.set('Failed to load contacts.');
|
|
249
|
+
this.errorLogger.logError(err, 'Failed to load space contacts', {
|
|
250
|
+
show: false,
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The space's contactus people offered as participants. The payer (the
|
|
257
|
+
* authenticated user) is always a participant and must not be listed in
|
|
258
|
+
* `participantContactIDs`, so their own contact is excluded from the picker.
|
|
259
|
+
*/
|
|
260
|
+
$participantChoices = computed(() => {
|
|
261
|
+
const userID = this.$currentUserID();
|
|
262
|
+
return this.$contacts()
|
|
263
|
+
?.filter((c) => !userID || c.brief?.userID !== userID)
|
|
264
|
+
.map((c) => ({
|
|
265
|
+
id: c.id,
|
|
266
|
+
title: c.brief?.title || c.brief?.shortTitle || c.id,
|
|
267
|
+
}));
|
|
268
|
+
}, /* @ts-ignore */
|
|
269
|
+
...(ngDevMode ? [{ debugName: "$participantChoices" }] : /* istanbul ignore next */ []));
|
|
270
|
+
$selectedContactIDs = signal([], /* @ts-ignore */
|
|
271
|
+
...(ngDevMode ? [{ debugName: "$selectedContactIDs" }] : /* istanbul ignore next */ []));
|
|
272
|
+
onParticipantToggle(contactID, checked) {
|
|
273
|
+
this.$selectedContactIDs.update((ids) => {
|
|
274
|
+
const without = ids.filter((id) => id !== contactID);
|
|
275
|
+
return checked ? [...without, contactID] : without;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
onParticipantChecked(contactID, event) {
|
|
279
|
+
const checked = !!event.detail
|
|
280
|
+
?.checked;
|
|
281
|
+
this.onParticipantToggle(contactID, checked);
|
|
282
|
+
}
|
|
283
|
+
// ---- expense fields ----
|
|
284
|
+
title = new FormControl('', { nonNullable: true });
|
|
285
|
+
// TODO: pre-fill from a per-space default currency once spaces carry one;
|
|
286
|
+
// EUR is the app-wide default (same as debtus's new-transfer form).
|
|
287
|
+
currency = new FormControl('EUR', {
|
|
288
|
+
nonNullable: true,
|
|
289
|
+
});
|
|
290
|
+
amount = new FormControl(null, [
|
|
291
|
+
Validators.required,
|
|
292
|
+
Validators.min(0.01),
|
|
293
|
+
]);
|
|
294
|
+
$amount = toSignal(this.amount.valueChanges, {
|
|
295
|
+
initialValue: this.amount.value,
|
|
296
|
+
});
|
|
297
|
+
currencies = ['EUR', 'USD'];
|
|
298
|
+
// ---- split mode + custom shares ----
|
|
299
|
+
$splitMode = signal('equally', /* @ts-ignore */
|
|
300
|
+
...(ngDevMode ? [{ debugName: "$splitMode" }] : /* istanbul ignore next */ []));
|
|
301
|
+
setSplitMode(mode) {
|
|
302
|
+
this.$splitMode.set(mode);
|
|
303
|
+
}
|
|
304
|
+
onSplitModeChange(event) {
|
|
305
|
+
const value = event.detail?.value;
|
|
306
|
+
if (value === 'equally' ||
|
|
307
|
+
value === 'exact-amount' ||
|
|
308
|
+
value === 'percentage') {
|
|
309
|
+
this.setSplitMode(value);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Raw share inputs keyed by contact ID ('' = the payer). */
|
|
313
|
+
$shareValues = signal({}, /* @ts-ignore */
|
|
314
|
+
...(ngDevMode ? [{ debugName: "$shareValues" }] : /* istanbul ignore next */ []));
|
|
315
|
+
setShareValue(contactID, value) {
|
|
316
|
+
this.$shareValues.update((values) => ({ ...values, [contactID]: value }));
|
|
317
|
+
}
|
|
318
|
+
shareValue(contactID) {
|
|
319
|
+
const value = this.$shareValues()[contactID];
|
|
320
|
+
return value ?? '';
|
|
321
|
+
}
|
|
322
|
+
onShareInput(contactID, event) {
|
|
323
|
+
const value = event.detail
|
|
324
|
+
?.value;
|
|
325
|
+
this.setShareValue(contactID, value ?? '');
|
|
326
|
+
}
|
|
327
|
+
/** Share-editor rows: the payer first, then each selected participant. */
|
|
328
|
+
$shareRows = computed(() => {
|
|
329
|
+
const choices = this.$participantChoices() ?? [];
|
|
330
|
+
const selected = this.$selectedContactIDs();
|
|
331
|
+
return [
|
|
332
|
+
{ id: '', title: 'You (payer)' },
|
|
333
|
+
...choices.filter((c) => selected.includes(c.id)),
|
|
334
|
+
];
|
|
335
|
+
}, /* @ts-ignore */
|
|
336
|
+
...(ngDevMode ? [{ debugName: "$shareRows" }] : /* istanbul ignore next */ []));
|
|
337
|
+
/** Live reconciliation of custom shares against the expense total. */
|
|
338
|
+
$reconciliation = computed(() => reconcileShares(this.$splitMode(), parseDecimal(this.$amount()), this.$shareRows().map((row) => this.$shareValues()[row.id])), /* @ts-ignore */
|
|
339
|
+
...(ngDevMode ? [{ debugName: "$reconciliation" }] : /* istanbul ignore next */ []));
|
|
340
|
+
$canSubmit = computed(() => {
|
|
341
|
+
const total = parseDecimal(this.$amount());
|
|
342
|
+
return (total !== undefined &&
|
|
343
|
+
total > 0 &&
|
|
344
|
+
this.$selectedContactIDs().length > 0 &&
|
|
345
|
+
this.$reconciliation().ok);
|
|
346
|
+
}, /* @ts-ignore */
|
|
347
|
+
...(ngDevMode ? [{ debugName: "$canSubmit" }] : /* istanbul ignore next */ []));
|
|
348
|
+
// ---- submit + exit ----
|
|
349
|
+
$submitting = signal(false, /* @ts-ignore */
|
|
350
|
+
...(ngDevMode ? [{ debugName: "$submitting" }] : /* istanbul ignore next */ []));
|
|
351
|
+
$submitError = signal(undefined, /* @ts-ignore */
|
|
352
|
+
...(ngDevMode ? [{ debugName: "$submitError" }] : /* istanbul ignore next */ []));
|
|
353
|
+
submit() {
|
|
354
|
+
this.amount.markAsTouched();
|
|
355
|
+
if (this.$submitting() || !this.$canSubmit()) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const space = this.$space();
|
|
359
|
+
if (!space?.id) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const totalCents = parseDecimal(this.$amount());
|
|
363
|
+
if (totalCents === undefined) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const mode = this.$splitMode();
|
|
367
|
+
const shares = mode === 'equally'
|
|
368
|
+
? undefined
|
|
369
|
+
: this.$shareRows().map((row) => {
|
|
370
|
+
const value = parseDecimal(this.$shareValues()[row.id]) ?? 0;
|
|
371
|
+
return mode === 'percentage'
|
|
372
|
+
? { contactID: row.id, percent: formatPercentValue(value) }
|
|
373
|
+
: { contactID: row.id, amount: formatHundredths(value) };
|
|
374
|
+
});
|
|
375
|
+
const request = {
|
|
376
|
+
spaceID: space.id,
|
|
377
|
+
title: this.title.value.trim() || undefined,
|
|
378
|
+
currency: this.currency.value,
|
|
379
|
+
amount: formatHundredths(totalCents),
|
|
380
|
+
splitMode: mode,
|
|
381
|
+
participantContactIDs: [...this.$selectedContactIDs()],
|
|
382
|
+
shares,
|
|
383
|
+
};
|
|
384
|
+
this.$submitting.set(true);
|
|
385
|
+
this.$submitError.set(undefined);
|
|
386
|
+
this.splitusService.createSplit(request).subscribe({
|
|
387
|
+
next: (response) => {
|
|
388
|
+
this.draftSplitStore.clear();
|
|
389
|
+
// flows.md: after a successful create, go to the new entity's details,
|
|
390
|
+
// replacing the create page in history so Back skips the stale form.
|
|
391
|
+
this.spaceNavService.navigateForwardToSpacePage(space, `split/${response.id}`, { replaceUrl: true });
|
|
392
|
+
},
|
|
393
|
+
error: (err) => {
|
|
394
|
+
this.$submitting.set(false);
|
|
395
|
+
const serverMessage = extractServerMessage(err);
|
|
396
|
+
this.$submitError.set(serverMessage
|
|
397
|
+
? `Failed to create split: ${serverMessage}`
|
|
398
|
+
: 'Failed to create split. Please try again.');
|
|
399
|
+
this.errorLogger.logError(err, 'Failed to create split', {
|
|
400
|
+
show: false,
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
406
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: NewSplitFormComponent, isStandalone: true, selector: "sneat-splitus-new-split-form", inputs: { space: "space", currentUserID: "currentUserID" }, ngImport: i0, template: "@if ($resumedFromDraft()) {\n <ion-card color=\"light\">\n <ion-card-content\n style=\"display: flex; align-items: center; justify-content: space-between; gap: 8px;\"\n >\n <ion-note color=\"medium\"\n >Picking up your bill from earlier \u2014 edit anything below.</ion-note\n >\n <ion-button fill=\"clear\" size=\"small\" (click)=\"dismissDraftBanner()\"\n >Dismiss</ion-button\n >\n </ion-card-content>\n </ion-card>\n}\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Expense</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. Dinner at Luigi's\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select\n label=\"Currency\"\n interface=\"popover\"\n [formControl]=\"currency\"\n >\n @for (c of currencies; track c) {\n <ion-select-option [value]=\"c\">{{ c }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n Enter an amount greater than zero.\n </ion-note>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Participants</ion-card-title>\n </ion-card-header>\n <!-- The payer (you) is always a participant. -->\n <ion-item>\n <ion-label>You (payer)</ion-label>\n <ion-note slot=\"end\" color=\"medium\">always included</ion-note>\n </ion-item>\n <!-- Participants come from the space's existing contactus people \u2014\n there is deliberately no free-text name entry here. -->\n @for (contact of $participantChoices() ?? []; track contact.id) {\n <ion-item>\n <ion-checkbox\n labelPlacement=\"end\"\n justify=\"start\"\n [checked]=\"$selectedContactIDs().includes(contact.id)\"\n (ionChange)=\"onParticipantChecked(contact.id, $event)\"\n >\n {{ contact.title }}\n </ion-checkbox>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n @if ($contactsError()) {\n <ion-label color=\"danger\">{{ $contactsError() }}</ion-label>\n } @else if ($contacts()) {\n <ion-label color=\"medium\" class=\"ion-text-wrap\">\n No contacts yet. Add people to this space first \u2014 splits are shared\n with the space's existing contacts.\n </ion-label>\n } @else {\n <ion-label color=\"medium\">Loading\u2026</ion-label>\n }\n </ion-item>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>How to split</ion-card-title>\n </ion-card-header>\n <ion-segment\n [value]=\"$splitMode()\"\n (ionChange)=\"onSplitModeChange($event)\"\n >\n <ion-segment-button value=\"equally\">\n <ion-label>Equally</ion-label>\n </ion-segment-button>\n <ion-segment-button value=\"exact-amount\">\n <ion-label>Amounts</ion-label>\n </ion-segment-button>\n <ion-segment-button value=\"percentage\">\n <ion-label>Percentages</ion-label>\n </ion-segment-button>\n </ion-segment>\n\n @if ($splitMode() !== 'equally') {\n @for (row of $shareRows(); track row.id) {\n <ion-item>\n <ion-input\n [label]=\"row.title\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [placeholder]=\"$splitMode() === 'percentage' ? '0' : '0.00'\"\n [value]=\"shareValue(row.id)\"\n (ionInput)=\"onShareInput(row.id, $event)\"\n />\n @if ($splitMode() === 'percentage') {\n <ion-note slot=\"end\">%</ion-note>\n }\n </ion-item>\n }\n <!-- Live reconciliation: submit stays blocked until shares sum to the\n total (or percentages to 100%). -->\n @if ($reconciliation(); as reconciliation) {\n @if (!reconciliation.ok) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n {{ reconciliation.error }}\n </ion-note>\n } @else {\n <ion-note color=\"success\" class=\"ion-padding-start\">\n Shares reconcile to the total. \u2713\n </ion-note>\n }\n }\n } @else {\n <ion-item lines=\"none\">\n <ion-note color=\"medium\">\n The total is split equally between you and the selected participants.\n </ion-note>\n </ion-item>\n }\n</ion-card>\n\n<div class=\"ion-padding\">\n <ion-button\n expand=\"block\"\n color=\"primary\"\n [disabled]=\"$submitting() || !$canSubmit()\"\n (click)=\"submit()\"\n >\n @if ($submitting()) {\n <ion-spinner name=\"dots\" slot=\"start\" />\n Creating\u2026\n } @else {\n Create split\n }\n </ion-button>\n @if ($submitError()) {\n <ion-note color=\"danger\">{{ $submitError() }}</ion-note>\n }\n</div>\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: IonCard, selector: "ion-card", inputs: ["button", "color", "disabled", "download", "href", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonCardHeader, selector: "ion-card-header", inputs: ["color", "mode", "translucent"] }, { kind: "component", type: IonCardTitle, selector: "ion-card-title", inputs: ["color", "mode"] }, { kind: "component", type: IonCardContent, selector: "ion-card-content", inputs: ["mode"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonLabel, selector: "ion-label", inputs: ["color", "mode", "position"] }, { kind: "component", type: IonNote, selector: "ion-note", inputs: ["color", "mode"] }, { kind: "component", type: IonSelect, selector: "ion-select", inputs: ["cancelText", "color", "compareWith", "disabled", "errorText", "expandedIcon", "fill", "helperText", "interface", "interfaceOptions", "justify", "label", "labelPlacement", "mode", "multiple", "name", "okText", "placeholder", "selectedText", "shape", "toggleIcon", "value"] }, { kind: "component", type: IonSelectOption, selector: "ion-select-option", inputs: ["description", "disabled", "justify", "labelPlacement", "mode", "value"] }, { kind: "component", type: IonInput, selector: "ion-input", inputs: ["accept", "autocapitalize", "autocomplete", "autocorrect", "autofocus", "clearInput", "clearOnEdit", "color", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "max", "maxlength", "min", "minlength", "mode", "multiple", "name", "pattern", "placeholder", "readonly", "required", "shape", "size", "spellcheck", "step", "type", "value"] }, { kind: "component", type: IonCheckbox, selector: "ion-checkbox", inputs: ["checked", "color", "disabled", "errorText", "helperText", "indeterminate", "justify", "labelPlacement", "mode", "name", "value"] }, { kind: "component", type: IonSegment, selector: "ion-segment", inputs: ["color", "disabled", "mode", "scrollable", "selectOnFocus", "swipeGesture", "value"] }, { kind: "component", type: IonSegmentButton, selector: "ion-segment-button", inputs: ["contentId", "disabled", "layout", "mode", "type", "value"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"], outputs: ["ionFocus", "ionBlur"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }] });
|
|
407
|
+
}
|
|
408
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NewSplitFormComponent, decorators: [{
|
|
409
|
+
type: Component,
|
|
410
|
+
args: [{ selector: 'sneat-splitus-new-split-form', imports: [
|
|
411
|
+
ReactiveFormsModule,
|
|
412
|
+
IonCard,
|
|
413
|
+
IonCardHeader,
|
|
414
|
+
IonCardTitle,
|
|
415
|
+
IonCardContent,
|
|
416
|
+
IonItem,
|
|
417
|
+
IonLabel,
|
|
418
|
+
IonNote,
|
|
419
|
+
IonSelect,
|
|
420
|
+
IonSelectOption,
|
|
421
|
+
IonInput,
|
|
422
|
+
IonCheckbox,
|
|
423
|
+
IonSegment,
|
|
424
|
+
IonSegmentButton,
|
|
425
|
+
IonButton,
|
|
426
|
+
IonSpinner,
|
|
427
|
+
], template: "@if ($resumedFromDraft()) {\n <ion-card color=\"light\">\n <ion-card-content\n style=\"display: flex; align-items: center; justify-content: space-between; gap: 8px;\"\n >\n <ion-note color=\"medium\"\n >Picking up your bill from earlier \u2014 edit anything below.</ion-note\n >\n <ion-button fill=\"clear\" size=\"small\" (click)=\"dismissDraftBanner()\"\n >Dismiss</ion-button\n >\n </ion-card-content>\n </ion-card>\n}\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Expense</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. Dinner at Luigi's\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select\n label=\"Currency\"\n interface=\"popover\"\n [formControl]=\"currency\"\n >\n @for (c of currencies; track c) {\n <ion-select-option [value]=\"c\">{{ c }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n Enter an amount greater than zero.\n </ion-note>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Participants</ion-card-title>\n </ion-card-header>\n <!-- The payer (you) is always a participant. -->\n <ion-item>\n <ion-label>You (payer)</ion-label>\n <ion-note slot=\"end\" color=\"medium\">always included</ion-note>\n </ion-item>\n <!-- Participants come from the space's existing contactus people \u2014\n there is deliberately no free-text name entry here. -->\n @for (contact of $participantChoices() ?? []; track contact.id) {\n <ion-item>\n <ion-checkbox\n labelPlacement=\"end\"\n justify=\"start\"\n [checked]=\"$selectedContactIDs().includes(contact.id)\"\n (ionChange)=\"onParticipantChecked(contact.id, $event)\"\n >\n {{ contact.title }}\n </ion-checkbox>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n @if ($contactsError()) {\n <ion-label color=\"danger\">{{ $contactsError() }}</ion-label>\n } @else if ($contacts()) {\n <ion-label color=\"medium\" class=\"ion-text-wrap\">\n No contacts yet. Add people to this space first \u2014 splits are shared\n with the space's existing contacts.\n </ion-label>\n } @else {\n <ion-label color=\"medium\">Loading\u2026</ion-label>\n }\n </ion-item>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>How to split</ion-card-title>\n </ion-card-header>\n <ion-segment\n [value]=\"$splitMode()\"\n (ionChange)=\"onSplitModeChange($event)\"\n >\n <ion-segment-button value=\"equally\">\n <ion-label>Equally</ion-label>\n </ion-segment-button>\n <ion-segment-button value=\"exact-amount\">\n <ion-label>Amounts</ion-label>\n </ion-segment-button>\n <ion-segment-button value=\"percentage\">\n <ion-label>Percentages</ion-label>\n </ion-segment-button>\n </ion-segment>\n\n @if ($splitMode() !== 'equally') {\n @for (row of $shareRows(); track row.id) {\n <ion-item>\n <ion-input\n [label]=\"row.title\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [placeholder]=\"$splitMode() === 'percentage' ? '0' : '0.00'\"\n [value]=\"shareValue(row.id)\"\n (ionInput)=\"onShareInput(row.id, $event)\"\n />\n @if ($splitMode() === 'percentage') {\n <ion-note slot=\"end\">%</ion-note>\n }\n </ion-item>\n }\n <!-- Live reconciliation: submit stays blocked until shares sum to the\n total (or percentages to 100%). -->\n @if ($reconciliation(); as reconciliation) {\n @if (!reconciliation.ok) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n {{ reconciliation.error }}\n </ion-note>\n } @else {\n <ion-note color=\"success\" class=\"ion-padding-start\">\n Shares reconcile to the total. \u2713\n </ion-note>\n }\n }\n } @else {\n <ion-item lines=\"none\">\n <ion-note color=\"medium\">\n The total is split equally between you and the selected participants.\n </ion-note>\n </ion-item>\n }\n</ion-card>\n\n<div class=\"ion-padding\">\n <ion-button\n expand=\"block\"\n color=\"primary\"\n [disabled]=\"$submitting() || !$canSubmit()\"\n (click)=\"submit()\"\n >\n @if ($submitting()) {\n <ion-spinner name=\"dots\" slot=\"start\" />\n Creating\u2026\n } @else {\n Create split\n }\n </ion-button>\n @if ($submitError()) {\n <ion-note color=\"danger\">{{ $submitError() }}</ion-note>\n }\n</div>\n" }]
|
|
428
|
+
}], propDecorators: { space: [{
|
|
429
|
+
type: Input,
|
|
430
|
+
args: [{ required: true }]
|
|
431
|
+
}], currentUserID: [{
|
|
432
|
+
type: Input
|
|
433
|
+
}] } });
|
|
434
|
+
|
|
435
|
+
// Splitus space-scoped routes, mounted under space/:spaceType/:spaceID.
|
|
436
|
+
// Flow: splits (home) --"New split"--> new-split --create--> split/:splitID.
|
|
437
|
+
const spacePagesRoutes = [
|
|
438
|
+
{
|
|
439
|
+
path: 'splits',
|
|
440
|
+
data: { title: 'Splits' },
|
|
441
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-splitus-home-page.component-Dp5oLR86.mjs').then((m) => m.SplitusHomePageComponent),
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
path: 'new-split',
|
|
445
|
+
data: { title: 'New split' },
|
|
446
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-new-split-page.component-DkP-7W5H.mjs').then((m) => m.NewSplitPageComponent),
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
path: 'split/:splitID',
|
|
450
|
+
data: { title: 'Split' },
|
|
451
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-split-details-page.component-kzwMFwkF.mjs').then((m) => m.SplitDetailsPageComponent),
|
|
452
|
+
},
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
// Unguarded entry point for a first-time visitor to start a split before
|
|
456
|
+
// signing in. Deliberately NOT space-scoped — kept separate from
|
|
457
|
+
// spacePagesRoutes, which mounts under space/:spaceType/:spaceID.
|
|
458
|
+
const startSplitRoute = {
|
|
459
|
+
path: 'start-split',
|
|
460
|
+
data: { title: 'Split a bill' },
|
|
461
|
+
loadComponent: () => import('./sneat-extension-splitus-ui-start-split-page.component-BHsYZbKT.mjs').then((m) => m.StartSplitPageComponent),
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Generated bundle index. Do not edit.
|
|
466
|
+
*/
|
|
467
|
+
|
|
468
|
+
export { DRAFT_SPLIT_STORAGE_KEY, DraftSplitStore, NewSplitFormComponent, spacePagesRoutes, startSplitRoute };
|
|
469
|
+
//# sourceMappingURL=sneat-extension-splitus-ui.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sneat-extension-splitus-ui.mjs","sources":["../../../../../../libs/extensions/splitus/ui/src/lib/services/draft-split-store.service.ts","../../../../../../libs/extensions/splitus/ui/src/lib/components/new-split-form/split-shares.ts","../../../../../../libs/extensions/splitus/ui/src/lib/components/new-split-form/new-split-form.component.ts","../../../../../../libs/extensions/splitus/ui/src/lib/components/new-split-form/new-split-form.component.html","../../../../../../libs/extensions/splitus/ui/src/lib/pages/space-pages-routing.ts","../../../../../../libs/extensions/splitus/ui/src/lib/pages/start-split/start-split.routes.ts","../../../../../../libs/extensions/splitus/ui/src/sneat-extension-splitus-ui.ts"],"sourcesContent":["import { Injectable } from '@angular/core';\nimport { CurrencyCode } from '@sneat/extension-splitus-contract';\n\n/**\n * A single in-progress \"start a split\" draft — captured before the user has\n * a space or is even signed in (see StartSplitPageComponent), resumed into\n * NewSplitFormComponent once they are. See\n * docs/superpowers/specs/2026-07-03-anonymous-first-split-design.md.\n */\nexport interface IDraftSplit {\n readonly title: string;\n readonly amount: number;\n readonly currencyCode: CurrencyCode;\n readonly createdAt: number; // epoch ms\n}\n\nexport const DRAFT_SPLIT_STORAGE_KEY = 'sneat.splitus.draftSplit';\n\nconst MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h\n\nfunction isDraftSplit(value: unknown): value is IDraftSplit {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as Record<string, unknown>;\n return (\n typeof v['title'] === 'string' &&\n typeof v['amount'] === 'number' &&\n typeof v['currencyCode'] === 'string' &&\n typeof v['createdAt'] === 'number'\n );\n}\n\n@Injectable({ providedIn: 'root' })\nexport class DraftSplitStore {\n save(draft: Omit<IDraftSplit, 'createdAt'>): void {\n const full: IDraftSplit = { ...draft, createdAt: Date.now() };\n try {\n localStorage.setItem(DRAFT_SPLIT_STORAGE_KEY, JSON.stringify(full));\n } catch {\n // Storage unavailable (private mode, quota) — fail open, never block\n // navigation over a lost draft.\n }\n }\n\n read(): IDraftSplit | undefined {\n let raw: string | null;\n try {\n raw = localStorage.getItem(DRAFT_SPLIT_STORAGE_KEY);\n } catch {\n return undefined;\n }\n if (!raw) {\n return undefined;\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return undefined;\n }\n if (!isDraftSplit(parsed) || Date.now() - parsed.createdAt > MAX_AGE_MS) {\n return undefined;\n }\n return parsed;\n }\n\n hasPending(): boolean {\n return this.read() !== undefined;\n }\n\n clear(): void {\n try {\n localStorage.removeItem(DRAFT_SPLIT_STORAGE_KEY);\n } catch {\n // ignore\n }\n }\n}\n","import { SplitMode } from '@sneat/extension-splitus-contract';\n\n// Pure client-side share reconciliation for the create-split form.\n// All arithmetic is done in integer hundredths (cents for amounts, basis\n// points for percentages) so \"0.10 + 0.20 == 0.30\" holds exactly — the same\n// fixed-point discipline the backend applies (decimal.Decimal64p2).\n\nexport interface IShareReconciliation {\n readonly ok: boolean;\n /** Explanatory, user-facing message when `ok` is false. */\n readonly error?: string;\n}\n\n/**\n * Parses a decimal user input (\"33.34\", 50) into integer hundredths (3334).\n * Returns undefined for blank, non-numeric or negative values.\n */\nexport function parseDecimal(\n raw: string | number | null | undefined,\n): number | undefined {\n if (raw === null || raw === undefined) {\n return undefined;\n }\n const s = String(raw).trim();\n if (!s) {\n return undefined;\n }\n const n = Number(s);\n if (!Number.isFinite(n) || n < 0) {\n return undefined;\n }\n return Math.round(n * 100);\n}\n\n/** Formats integer hundredths as a 2-decimal string: 3334 → \"33.34\". */\nexport function formatHundredths(v: number): string {\n return (v / 100).toFixed(2);\n}\n\n/** Formats basis points as a trimmed percent number: 3334 → \"33.34\", 5000 → \"50\". */\nexport function formatPercentValue(v: number): string {\n return String(v / 100);\n}\n\n/**\n * Live reconciliation of custom shares against the expense total.\n *\n * - `equally`: always ok — there are no share inputs; the backend splits.\n * - `exact-amount`: every share must be entered and sum exactly to the total.\n * - `percentage`: every percentage must be entered and sum exactly to 100%.\n *\n * @param totalHundredths the expense total in hundredths (cents), if entered.\n * @param shares raw share inputs, one per participant (payer included).\n */\nexport function reconcileShares(\n mode: SplitMode,\n totalHundredths: number | undefined,\n shares: readonly (string | number | null | undefined)[],\n): IShareReconciliation {\n if (mode === 'equally') {\n return { ok: true };\n }\n\n const parsed = shares.map(parseDecimal);\n if (parsed.some((v) => v === undefined)) {\n return {\n ok: false,\n error:\n mode === 'percentage'\n ? 'Enter a percentage for every participant.'\n : 'Enter a share for every participant.',\n };\n }\n const entered = (parsed as number[]).reduce((sum, v) => sum + v, 0);\n\n if (mode === 'percentage') {\n if (entered === 100_00) {\n return { ok: true };\n }\n return {\n ok: false,\n error: `Percentages sum to ${formatPercentValue(entered)}% — they must sum to exactly 100%.`,\n };\n }\n\n // exact-amount\n if (totalHundredths === undefined) {\n return { ok: false, error: 'Enter the expense total first.' };\n }\n if (entered === totalHundredths) {\n return { ok: true };\n }\n const delta = entered - totalHundredths;\n const direction =\n delta < 0\n ? `${formatHundredths(-delta)} short of`\n : `${formatHundredths(delta)} over`;\n return {\n ok: false,\n error: `Shares sum to ${formatHundredths(entered)} — ${direction} the ${formatHundredths(totalHundredths)} total.`,\n };\n}\n","import {\n Component,\n DestroyRef,\n Input,\n computed,\n inject,\n signal,\n} from '@angular/core';\nimport { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';\nimport {\n FormControl,\n ReactiveFormsModule,\n Validators,\n} from '@angular/forms';\nimport {\n IonButton,\n IonCard,\n IonCardContent,\n IonCardHeader,\n IonCardTitle,\n IonCheckbox,\n IonInput,\n IonItem,\n IonLabel,\n IonNote,\n IonSegment,\n IonSegmentButton,\n IonSelect,\n IonSelectOption,\n IonSpinner,\n} from '@ionic/angular';\nimport { ErrorLogger, IErrorLogger, IIdAndBrief } from '@sneat/core';\nimport {\n CONTACTUS_SPACE_SERVICE,\n IContactBrief,\n} from '@sneat/extension-contactus-contract';\nimport {\n CurrencyCode,\n ICreateSplitRequest,\n ISplitShare,\n SPLITUS_SERVICE,\n SplitMode,\n} from '@sneat/extension-splitus-contract';\nimport { ISpaceContext } from '@sneat/space-models';\nimport { SpaceNavService } from '@sneat/space-services';\nimport { Subscription } from 'rxjs';\nimport { DraftSplitStore } from '../../services/draft-split-store.service';\nimport {\n formatHundredths,\n formatPercentValue,\n parseDecimal,\n reconcileShares,\n} from './split-shares';\n\ninterface IParticipantChoice {\n readonly id: string;\n readonly title: string;\n}\n\n/** One row of the custom-share editor. `id === ''` is the payer. */\ninterface IShareRow {\n readonly id: string;\n readonly title: string;\n}\n\n/** Pulls a human message out of an HTTP error (Go handlers reply plain text). */\nfunction extractServerMessage(err: unknown): string | undefined {\n if (typeof err !== 'object' || !err) {\n return typeof err === 'string' && err.trim() ? err.trim() : undefined;\n }\n const e = err as { error?: unknown; message?: unknown };\n if (typeof e.error === 'string' && e.error.trim()) {\n return e.error.trim();\n }\n if (typeof e.error === 'object' && e.error) {\n const m = (e.error as { message?: unknown }).message;\n if (typeof m === 'string' && m.trim()) {\n return m.trim();\n }\n }\n return typeof e.message === 'string' && e.message.trim()\n ? e.message.trim()\n : undefined;\n}\n\n// Create-split form: amount + currency, participants picked from the space's\n// existing contactus people (no free-text name entry — AC\n// participants-from-contactus-membership-enforced), and an\n// equally/exact-amount/percentage share editor with live reconciliation that\n// blocks submit until custom shares sum to the total (AC\n// custom-shares-must-sum). On success redirects to the new split's details\n// page with replaceUrl (flows.md create→details default).\n@Component({\n selector: 'sneat-splitus-new-split-form',\n templateUrl: './new-split-form.component.html',\n imports: [\n ReactiveFormsModule,\n IonCard,\n IonCardHeader,\n IonCardTitle,\n IonCardContent,\n IonItem,\n IonLabel,\n IonNote,\n IonSelect,\n IonSelectOption,\n IonInput,\n IonCheckbox,\n IonSegment,\n IonSegmentButton,\n IonButton,\n IonSpinner,\n ],\n})\nexport class NewSplitFormComponent {\n private readonly errorLogger = inject<IErrorLogger>(ErrorLogger);\n private readonly splitusService = inject(SPLITUS_SERVICE);\n private readonly contactusSpaceService = inject(CONTACTUS_SPACE_SERVICE);\n private readonly spaceNavService = inject(SpaceNavService);\n private readonly destroyRef = inject(DestroyRef);\n private readonly draftSplitStore = inject(DraftSplitStore);\n\n protected readonly $resumedFromDraft = signal(false);\n private draftChecked = false;\n\n protected readonly $space = signal<ISpaceContext | undefined>(undefined);\n // TODO: Skipped for migration because:\n // Accessor inputs cannot be migrated as they are too complex.\n @Input({ required: true }) public set space(value: ISpaceContext | undefined) {\n this.$space.set(value);\n this.watchContacts(value?.id);\n this.tryResumeDraft();\n }\n\n private tryResumeDraft(): void {\n if (this.draftChecked) {\n return;\n }\n this.draftChecked = true;\n const draft = this.draftSplitStore.read();\n if (!draft) {\n return;\n }\n this.title.setValue(draft.title);\n this.currency.setValue(draft.currencyCode);\n this.amount.setValue(draft.amount);\n this.$resumedFromDraft.set(true);\n }\n\n protected dismissDraftBanner(): void {\n this.$resumedFromDraft.set(false);\n this.draftSplitStore.clear();\n }\n\n protected readonly $currentUserID = signal<string | undefined>(undefined);\n /** The authenticated user's ID — used to exclude the payer's own contact. */\n // TODO: Skipped for migration because:\n // Accessor inputs cannot be migrated as they are too complex.\n @Input() public set currentUserID(value: string | undefined) {\n this.$currentUserID.set(value);\n }\n\n // ---- participants (sourced from contactus — never typed in) ----\n\n private contactsSubscription?: Subscription;\n private watchedSpaceID?: string;\n protected readonly $contacts = signal<\n IIdAndBrief<IContactBrief>[] | undefined\n >(undefined);\n protected readonly $contactsError = signal<string | undefined>(undefined);\n\n private watchContacts(spaceID: string | undefined): void {\n if (spaceID === this.watchedSpaceID) {\n return;\n }\n this.watchedSpaceID = spaceID;\n this.contactsSubscription?.unsubscribe();\n this.$contacts.set(undefined);\n this.$contactsError.set(undefined);\n if (!spaceID) {\n return;\n }\n this.contactsSubscription = this.contactusSpaceService\n .watchContactBriefs(spaceID)\n .pipe(takeUntilDestroyed(this.destroyRef))\n .subscribe({\n next: (contacts) => this.$contacts.set(contacts),\n error: (err) => {\n this.$contactsError.set('Failed to load contacts.');\n this.errorLogger.logError(err, 'Failed to load space contacts', {\n show: false,\n });\n },\n });\n }\n\n /**\n * The space's contactus people offered as participants. The payer (the\n * authenticated user) is always a participant and must not be listed in\n * `participantContactIDs`, so their own contact is excluded from the picker.\n */\n protected readonly $participantChoices = computed<\n readonly IParticipantChoice[] | undefined\n >(() => {\n const userID = this.$currentUserID();\n return this.$contacts()\n ?.filter((c) => !userID || c.brief?.userID !== userID)\n .map((c) => ({\n id: c.id,\n title: c.brief?.title || c.brief?.shortTitle || c.id,\n }));\n });\n\n protected readonly $selectedContactIDs = signal<readonly string[]>([]);\n\n protected onParticipantToggle(contactID: string, checked: boolean): void {\n this.$selectedContactIDs.update((ids) => {\n const without = ids.filter((id) => id !== contactID);\n return checked ? [...without, contactID] : without;\n });\n }\n\n protected onParticipantChecked(contactID: string, event: Event): void {\n const checked = !!(event as CustomEvent<{ checked?: boolean }>).detail\n ?.checked;\n this.onParticipantToggle(contactID, checked);\n }\n\n // ---- expense fields ----\n\n protected readonly title = new FormControl<string>('', { nonNullable: true });\n // TODO: pre-fill from a per-space default currency once spaces carry one;\n // EUR is the app-wide default (same as debtus's new-transfer form).\n protected readonly currency = new FormControl<CurrencyCode>('EUR', {\n nonNullable: true,\n });\n protected readonly amount = new FormControl<number | null>(null, [\n Validators.required,\n Validators.min(0.01),\n ]);\n private readonly $amount = toSignal(this.amount.valueChanges, {\n initialValue: this.amount.value,\n });\n\n protected readonly currencies: CurrencyCode[] = ['EUR', 'USD'];\n\n // ---- split mode + custom shares ----\n\n protected readonly $splitMode = signal<SplitMode>('equally');\n\n protected setSplitMode(mode: SplitMode): void {\n this.$splitMode.set(mode);\n }\n\n protected onSplitModeChange(event: Event): void {\n const value = (event as CustomEvent<{ value?: string }>).detail?.value;\n if (\n value === 'equally' ||\n value === 'exact-amount' ||\n value === 'percentage'\n ) {\n this.setSplitMode(value);\n }\n }\n\n /** Raw share inputs keyed by contact ID ('' = the payer). */\n protected readonly $shareValues = signal<Readonly<Record<string, string>>>(\n {},\n );\n\n protected setShareValue(contactID: string, value: string): void {\n this.$shareValues.update((values) => ({ ...values, [contactID]: value }));\n }\n\n protected shareValue(contactID: string): string {\n const value: string | undefined = this.$shareValues()[contactID];\n return value ?? '';\n }\n\n protected onShareInput(contactID: string, event: Event): void {\n const value = (event as CustomEvent<{ value?: string | null }>).detail\n ?.value;\n this.setShareValue(contactID, value ?? '');\n }\n\n /** Share-editor rows: the payer first, then each selected participant. */\n protected readonly $shareRows = computed<readonly IShareRow[]>(() => {\n const choices = this.$participantChoices() ?? [];\n const selected = this.$selectedContactIDs();\n return [\n { id: '', title: 'You (payer)' },\n ...choices.filter((c) => selected.includes(c.id)),\n ];\n });\n\n /** Live reconciliation of custom shares against the expense total. */\n protected readonly $reconciliation = computed(() =>\n reconcileShares(\n this.$splitMode(),\n parseDecimal(this.$amount()),\n this.$shareRows().map((row) => this.$shareValues()[row.id]),\n ),\n );\n\n protected readonly $canSubmit = computed(() => {\n const total = parseDecimal(this.$amount());\n return (\n total !== undefined &&\n total > 0 &&\n this.$selectedContactIDs().length > 0 &&\n this.$reconciliation().ok\n );\n });\n\n // ---- submit + exit ----\n\n protected readonly $submitting = signal(false);\n protected readonly $submitError = signal<string | undefined>(undefined);\n\n protected submit(): void {\n this.amount.markAsTouched();\n if (this.$submitting() || !this.$canSubmit()) {\n return;\n }\n const space = this.$space();\n if (!space?.id) {\n return;\n }\n const totalCents = parseDecimal(this.$amount());\n if (totalCents === undefined) {\n return;\n }\n const mode = this.$splitMode();\n const shares: ISplitShare[] | undefined =\n mode === 'equally'\n ? undefined\n : this.$shareRows().map((row) => {\n const value = parseDecimal(this.$shareValues()[row.id]) ?? 0;\n return mode === 'percentage'\n ? { contactID: row.id, percent: formatPercentValue(value) }\n : { contactID: row.id, amount: formatHundredths(value) };\n });\n const request: ICreateSplitRequest = {\n spaceID: space.id,\n title: this.title.value.trim() || undefined,\n currency: this.currency.value,\n amount: formatHundredths(totalCents),\n splitMode: mode,\n participantContactIDs: [...this.$selectedContactIDs()],\n shares,\n };\n this.$submitting.set(true);\n this.$submitError.set(undefined);\n this.splitusService.createSplit(request).subscribe({\n next: (response) => {\n this.draftSplitStore.clear();\n // flows.md: after a successful create, go to the new entity's details,\n // replacing the create page in history so Back skips the stale form.\n this.spaceNavService.navigateForwardToSpacePage(\n space,\n `split/${response.id}`,\n { replaceUrl: true },\n );\n },\n error: (err) => {\n this.$submitting.set(false);\n const serverMessage = extractServerMessage(err);\n this.$submitError.set(\n serverMessage\n ? `Failed to create split: ${serverMessage}`\n : 'Failed to create split. Please try again.',\n );\n this.errorLogger.logError(err, 'Failed to create split', {\n show: false,\n });\n },\n });\n }\n}\n","@if ($resumedFromDraft()) {\n <ion-card color=\"light\">\n <ion-card-content\n style=\"display: flex; align-items: center; justify-content: space-between; gap: 8px;\"\n >\n <ion-note color=\"medium\"\n >Picking up your bill from earlier — edit anything below.</ion-note\n >\n <ion-button fill=\"clear\" size=\"small\" (click)=\"dismissDraftBanner()\"\n >Dismiss</ion-button\n >\n </ion-card-content>\n </ion-card>\n}\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Expense</ion-card-title>\n </ion-card-header>\n <ion-item>\n <ion-input\n label=\"Title (optional)\"\n labelPlacement=\"stacked\"\n [formControl]=\"title\"\n placeholder=\"e.g. Dinner at Luigi's\"\n autocapitalize=\"sentences\"\n />\n </ion-item>\n <ion-item>\n <ion-select\n label=\"Currency\"\n interface=\"popover\"\n [formControl]=\"currency\"\n >\n @for (c of currencies; track c) {\n <ion-select-option [value]=\"c\">{{ c }}</ion-select-option>\n }\n </ion-select>\n </ion-item>\n <ion-item>\n <ion-input\n label=\"Amount\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [formControl]=\"amount\"\n placeholder=\"0.00\"\n />\n </ion-item>\n @if (amount.touched && amount.invalid) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n Enter an amount greater than zero.\n </ion-note>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>Participants</ion-card-title>\n </ion-card-header>\n <!-- The payer (you) is always a participant. -->\n <ion-item>\n <ion-label>You (payer)</ion-label>\n <ion-note slot=\"end\" color=\"medium\">always included</ion-note>\n </ion-item>\n <!-- Participants come from the space's existing contactus people —\n there is deliberately no free-text name entry here. -->\n @for (contact of $participantChoices() ?? []; track contact.id) {\n <ion-item>\n <ion-checkbox\n labelPlacement=\"end\"\n justify=\"start\"\n [checked]=\"$selectedContactIDs().includes(contact.id)\"\n (ionChange)=\"onParticipantChecked(contact.id, $event)\"\n >\n {{ contact.title }}\n </ion-checkbox>\n </ion-item>\n } @empty {\n <ion-item lines=\"none\">\n @if ($contactsError()) {\n <ion-label color=\"danger\">{{ $contactsError() }}</ion-label>\n } @else if ($contacts()) {\n <ion-label color=\"medium\" class=\"ion-text-wrap\">\n No contacts yet. Add people to this space first — splits are shared\n with the space's existing contacts.\n </ion-label>\n } @else {\n <ion-label color=\"medium\">Loading…</ion-label>\n }\n </ion-item>\n }\n</ion-card>\n\n<ion-card>\n <ion-card-header>\n <ion-card-title>How to split</ion-card-title>\n </ion-card-header>\n <ion-segment\n [value]=\"$splitMode()\"\n (ionChange)=\"onSplitModeChange($event)\"\n >\n <ion-segment-button value=\"equally\">\n <ion-label>Equally</ion-label>\n </ion-segment-button>\n <ion-segment-button value=\"exact-amount\">\n <ion-label>Amounts</ion-label>\n </ion-segment-button>\n <ion-segment-button value=\"percentage\">\n <ion-label>Percentages</ion-label>\n </ion-segment-button>\n </ion-segment>\n\n @if ($splitMode() !== 'equally') {\n @for (row of $shareRows(); track row.id) {\n <ion-item>\n <ion-input\n [label]=\"row.title\"\n labelPlacement=\"stacked\"\n type=\"number\"\n inputmode=\"decimal\"\n [placeholder]=\"$splitMode() === 'percentage' ? '0' : '0.00'\"\n [value]=\"shareValue(row.id)\"\n (ionInput)=\"onShareInput(row.id, $event)\"\n />\n @if ($splitMode() === 'percentage') {\n <ion-note slot=\"end\">%</ion-note>\n }\n </ion-item>\n }\n <!-- Live reconciliation: submit stays blocked until shares sum to the\n total (or percentages to 100%). -->\n @if ($reconciliation(); as reconciliation) {\n @if (!reconciliation.ok) {\n <ion-note color=\"danger\" class=\"ion-padding-start\">\n {{ reconciliation.error }}\n </ion-note>\n } @else {\n <ion-note color=\"success\" class=\"ion-padding-start\">\n Shares reconcile to the total. ✓\n </ion-note>\n }\n }\n } @else {\n <ion-item lines=\"none\">\n <ion-note color=\"medium\">\n The total is split equally between you and the selected participants.\n </ion-note>\n </ion-item>\n }\n</ion-card>\n\n<div class=\"ion-padding\">\n <ion-button\n expand=\"block\"\n color=\"primary\"\n [disabled]=\"$submitting() || !$canSubmit()\"\n (click)=\"submit()\"\n >\n @if ($submitting()) {\n <ion-spinner name=\"dots\" slot=\"start\" />\n Creating…\n } @else {\n Create split\n }\n </ion-button>\n @if ($submitError()) {\n <ion-note color=\"danger\">{{ $submitError() }}</ion-note>\n }\n</div>\n","import { Route } from '@angular/router';\n\n// Splitus space-scoped routes, mounted under space/:spaceType/:spaceID.\n// Flow: splits (home) --\"New split\"--> new-split --create--> split/:splitID.\nexport const spacePagesRoutes: Route[] = [\n {\n path: 'splits',\n data: { title: 'Splits' },\n loadComponent: () =>\n import('./splitus-home/splitus-home-page.component').then(\n (m) => m.SplitusHomePageComponent,\n ),\n },\n {\n path: 'new-split',\n data: { title: 'New split' },\n loadComponent: () =>\n import('./new-split/new-split-page.component').then(\n (m) => m.NewSplitPageComponent,\n ),\n },\n {\n path: 'split/:splitID',\n data: { title: 'Split' },\n loadComponent: () =>\n import('./split-details/split-details-page.component').then(\n (m) => m.SplitDetailsPageComponent,\n ),\n },\n];\n","import { Route } from '@angular/router';\n\n// Unguarded entry point for a first-time visitor to start a split before\n// signing in. Deliberately NOT space-scoped — kept separate from\n// spacePagesRoutes, which mounts under space/:spaceType/:spaceID.\nexport const startSplitRoute: Route = {\n path: 'start-split',\n data: { title: 'Split a bill' },\n loadComponent: () =>\n import('./start-split-page.component').then(\n (m) => m.StartSplitPageComponent,\n ),\n};\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;;;;;;;;;;AAgBO,MAAM,uBAAuB,GAAG;AAEvC,MAAM,UAAU,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEvC,SAAS,YAAY,CAAC,KAAc,EAAA;IAClC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,EAAE;AAC/C,QAAA,OAAO,KAAK;IACd;IACA,MAAM,CAAC,GAAG,KAAgC;AAC1C,IAAA,QACE,OAAO,CAAC,CAAC,OAAO,CAAC,KAAK,QAAQ;AAC9B,QAAA,OAAO,CAAC,CAAC,QAAQ,CAAC,KAAK,QAAQ;AAC/B,QAAA,OAAO,CAAC,CAAC,cAAc,CAAC,KAAK,QAAQ;AACrC,QAAA,OAAO,CAAC,CAAC,WAAW,CAAC,KAAK,QAAQ;AAEtC;MAGa,eAAe,CAAA;AAC1B,IAAA,IAAI,CAAC,KAAqC,EAAA;AACxC,QAAA,MAAM,IAAI,GAAgB,EAAE,GAAG,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE;AAC7D,QAAA,IAAI;AACF,YAAA,YAAY,CAAC,OAAO,CAAC,uBAAuB,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACrE;AAAE,QAAA,MAAM;;;QAGR;IACF;IAEA,IAAI,GAAA;AACF,QAAA,IAAI,GAAkB;AACtB,QAAA,IAAI;AACF,YAAA,GAAG,GAAG,YAAY,CAAC,OAAO,CAAC,uBAAuB,CAAC;QACrD;AAAE,QAAA,MAAM;AACN,YAAA,OAAO,SAAS;QAClB;QACA,IAAI,CAAC,GAAG,EAAE;AACR,YAAA,OAAO,SAAS;QAClB;AACA,QAAA,IAAI,MAAe;AACnB,QAAA,IAAI;AACF,YAAA,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC;QAC1B;AAAE,QAAA,MAAM;AACN,YAAA,OAAO,SAAS;QAClB;AACA,QAAA,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,SAAS,GAAG,UAAU,EAAE;AACvE,YAAA,OAAO,SAAS;QAClB;AACA,QAAA,OAAO,MAAM;IACf;IAEA,UAAU,GAAA;AACR,QAAA,OAAO,IAAI,CAAC,IAAI,EAAE,KAAK,SAAS;IAClC;IAEA,KAAK,GAAA;AACH,QAAA,IAAI;AACF,YAAA,YAAY,CAAC,UAAU,CAAC,uBAAuB,CAAC;QAClD;AAAE,QAAA,MAAM;;QAER;IACF;uGA3CW,eAAe,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,UAAA,EAAA,CAAA;AAAf,IAAA,OAAA,KAAA,GAAA,EAAA,CAAA,qBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,QAAA,EAAA,EAAA,EAAA,IAAA,EAAA,eAAe,cADF,MAAM,EAAA,CAAA;;2FACnB,eAAe,EAAA,UAAA,EAAA,CAAA;kBAD3B,UAAU;mBAAC,EAAE,UAAU,EAAE,MAAM,EAAE;;;ACpBlC;;;AAGG;AACG,SAAU,YAAY,CAC1B,GAAuC,EAAA;IAEvC,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,EAAE;AACrC,QAAA,OAAO,SAAS;IAClB;IACA,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE;IAC5B,IAAI,CAAC,CAAC,EAAE;AACN,QAAA,OAAO,SAAS;IAClB;AACA,IAAA,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC;AACnB,IAAA,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;AAChC,QAAA,OAAO,SAAS;IAClB;IACA,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC;AAC5B;AAEA;AACM,SAAU,gBAAgB,CAAC,CAAS,EAAA;IACxC,OAAO,CAAC,CAAC,GAAG,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC;AAC7B;AAEA;AACM,SAAU,kBAAkB,CAAC,CAAS,EAAA;AAC1C,IAAA,OAAO,MAAM,CAAC,CAAC,GAAG,GAAG,CAAC;AACxB;AAEA;;;;;;;;;AASG;SACa,eAAe,CAC7B,IAAe,EACf,eAAmC,EACnC,MAAuD,EAAA;AAEvD,IAAA,IAAI,IAAI,KAAK,SAAS,EAAE;AACtB,QAAA,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE;IACrB;IAEA,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,YAAY,CAAC;AACvC,IAAA,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,SAAS,CAAC,EAAE;QACvC,OAAO;AACL,YAAA,EAAE,EAAE,KAAK;YACT,KAAK,EACH,IAAI,KAAK;AACP,kBAAE;AACF,kBAAE,sCAAsC;SAC7C;IACH;AACA,IAAA,MAAM,OAAO,GAAI,MAAmB,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,KAAK,GAAG,GAAG,CAAC,EAAE,CAAC,CAAC;AAEnE,IAAA,IAAI,IAAI,KAAK,YAAY,EAAE;AACzB,QAAA,IAAI,OAAO,KAAK,MAAM,EAAE;AACtB,YAAA,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE;QACrB;QACA,OAAO;AACL,YAAA,EAAE,EAAE,KAAK;AACT,YAAA,KAAK,EAAE,CAAA,mBAAA,EAAsB,kBAAkB,CAAC,OAAO,CAAC,CAAA,kCAAA,CAAoC;SAC7F;IACH;;AAGA,IAAA,IAAI,eAAe,KAAK,SAAS,EAAE;QACjC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,gCAAgC,EAAE;IAC/D;AACA,IAAA,IAAI,OAAO,KAAK,eAAe,EAAE;AAC/B,QAAA,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE;IACrB;AACA,IAAA,MAAM,KAAK,GAAG,OAAO,GAAG,eAAe;AACvC,IAAA,MAAM,SAAS,GACb,KAAK,GAAG;AACN,UAAE,CAAA,EAAG,gBAAgB,CAAC,CAAC,KAAK,CAAC,CAAA,SAAA;AAC7B,UAAE,CAAA,EAAG,gBAAgB,CAAC,KAAK,CAAC,OAAO;IACvC,OAAO;AACL,QAAA,EAAE,EAAE,KAAK;AACT,QAAA,KAAK,EAAE,CAAA,cAAA,EAAiB,gBAAgB,CAAC,OAAO,CAAC,CAAA,GAAA,EAAM,SAAS,CAAA,KAAA,EAAQ,gBAAgB,CAAC,eAAe,CAAC,CAAA,OAAA,CAAS;KACnH;AACH;;ACpCA;AACA,SAAS,oBAAoB,CAAC,GAAY,EAAA;IACxC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,CAAC,GAAG,EAAE;QACnC,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,EAAE,GAAG,GAAG,CAAC,IAAI,EAAE,GAAG,SAAS;IACvE;IACA,MAAM,CAAC,GAAG,GAA6C;AACvD,IAAA,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE;AACjD,QAAA,OAAO,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE;IACvB;IACA,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ,IAAI,CAAC,CAAC,KAAK,EAAE;AAC1C,QAAA,MAAM,CAAC,GAAI,CAAC,CAAC,KAA+B,CAAC,OAAO;QACpD,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE;AACrC,YAAA,OAAO,CAAC,CAAC,IAAI,EAAE;QACjB;IACF;AACA,IAAA,OAAO,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ,IAAI,CAAC,CAAC,OAAO,CAAC,IAAI;AACpD,UAAE,CAAC,CAAC,OAAO,CAAC,IAAI;UACd,SAAS;AACf;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;MAuBa,qBAAqB,CAAA;AACf,IAAA,WAAW,GAAG,MAAM,CAAe,WAAW,CAAC;AAC/C,IAAA,cAAc,GAAG,MAAM,CAAC,eAAe,CAAC;AACxC,IAAA,qBAAqB,GAAG,MAAM,CAAC,uBAAuB,CAAC;AACvD,IAAA,eAAe,GAAG,MAAM,CAAC,eAAe,CAAC;AACzC,IAAA,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;AAC/B,IAAA,eAAe,GAAG,MAAM,CAAC,eAAe,CAAC;IAEvC,iBAAiB,GAAG,MAAM,CAAC,KAAK;0FAAC;IAC5C,YAAY,GAAG,KAAK;IAET,MAAM,GAAG,MAAM,CAA4B,SAAS;+EAAC;;;IAGxE,IAAsC,KAAK,CAAC,KAAgC,EAAA;AAC1E,QAAA,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC;AACtB,QAAA,IAAI,CAAC,aAAa,CAAC,KAAK,EAAE,EAAE,CAAC;QAC7B,IAAI,CAAC,cAAc,EAAE;IACvB;IAEQ,cAAc,GAAA;AACpB,QAAA,IAAI,IAAI,CAAC,YAAY,EAAE;YACrB;QACF;AACA,QAAA,IAAI,CAAC,YAAY,GAAG,IAAI;QACxB,MAAM,KAAK,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE;QACzC,IAAI,CAAC,KAAK,EAAE;YACV;QACF;QACA,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC;QAChC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,YAAY,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC;AAClC,QAAA,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,IAAI,CAAC;IAClC;IAEU,kBAAkB,GAAA;AAC1B,QAAA,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC;AACjC,QAAA,IAAI,CAAC,eAAe,CAAC,KAAK,EAAE;IAC9B;IAEmB,cAAc,GAAG,MAAM,CAAqB,SAAS;uFAAC;;;;IAIzE,IAAoB,aAAa,CAAC,KAAyB,EAAA;AACzD,QAAA,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,KAAK,CAAC;IAChC;;AAIQ,IAAA,oBAAoB;AACpB,IAAA,cAAc;IACH,SAAS,GAAG,MAAM,CAEnC,SAAS;kFAAC;IACO,cAAc,GAAG,MAAM,CAAqB,SAAS;uFAAC;AAEjE,IAAA,aAAa,CAAC,OAA2B,EAAA;AAC/C,QAAA,IAAI,OAAO,KAAK,IAAI,CAAC,cAAc,EAAE;YACnC;QACF;AACA,QAAA,IAAI,CAAC,cAAc,GAAG,OAAO;AAC7B,QAAA,IAAI,CAAC,oBAAoB,EAAE,WAAW,EAAE;AACxC,QAAA,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC;AAC7B,QAAA,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,SAAS,CAAC;QAClC,IAAI,CAAC,OAAO,EAAE;YACZ;QACF;AACA,QAAA,IAAI,CAAC,oBAAoB,GAAG,IAAI,CAAC;aAC9B,kBAAkB,CAAC,OAAO;AAC1B,aAAA,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,UAAU,CAAC;AACxC,aAAA,SAAS,CAAC;AACT,YAAA,IAAI,EAAE,CAAC,QAAQ,KAAK,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC;AAChD,YAAA,KAAK,EAAE,CAAC,GAAG,KAAI;AACb,gBAAA,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,0BAA0B,CAAC;gBACnD,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,GAAG,EAAE,+BAA+B,EAAE;AAC9D,oBAAA,IAAI,EAAE,KAAK;AACZ,iBAAA,CAAC;YACJ,CAAC;AACF,SAAA,CAAC;IACN;AAEA;;;;AAIG;AACgB,IAAA,mBAAmB,GAAG,QAAQ,CAE/C,MAAK;AACL,QAAA,MAAM,MAAM,GAAG,IAAI,CAAC,cAAc,EAAE;QACpC,OAAO,IAAI,CAAC,SAAS;AACnB,cAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM;AACpD,aAAA,GAAG,CAAC,CAAC,CAAC,MAAM;YACX,EAAE,EAAE,CAAC,CAAC,EAAE;AACR,YAAA,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,IAAI,CAAC,CAAC,KAAK,EAAE,UAAU,IAAI,CAAC,CAAC,EAAE;AACrD,SAAA,CAAC,CAAC;IACP,CAAC;4FAAC;IAEiB,mBAAmB,GAAG,MAAM,CAAoB,EAAE;4FAAC;IAE5D,mBAAmB,CAAC,SAAiB,EAAE,OAAgB,EAAA;QAC/D,IAAI,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,GAAG,KAAI;AACtC,YAAA,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,SAAS,CAAC;AACpD,YAAA,OAAO,OAAO,GAAG,CAAC,GAAG,OAAO,EAAE,SAAS,CAAC,GAAG,OAAO;AACpD,QAAA,CAAC,CAAC;IACJ;IAEU,oBAAoB,CAAC,SAAiB,EAAE,KAAY,EAAA;AAC5D,QAAA,MAAM,OAAO,GAAG,CAAC,CAAE,KAA4C,CAAC;AAC9D,cAAE,OAAO;AACX,QAAA,IAAI,CAAC,mBAAmB,CAAC,SAAS,EAAE,OAAO,CAAC;IAC9C;;AAImB,IAAA,KAAK,GAAG,IAAI,WAAW,CAAS,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;;;AAG1D,IAAA,QAAQ,GAAG,IAAI,WAAW,CAAe,KAAK,EAAE;AACjE,QAAA,WAAW,EAAE,IAAI;AAClB,KAAA,CAAC;AACiB,IAAA,MAAM,GAAG,IAAI,WAAW,CAAgB,IAAI,EAAE;AAC/D,QAAA,UAAU,CAAC,QAAQ;AACnB,QAAA,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;AACrB,KAAA,CAAC;IACe,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,EAAE;AAC5D,QAAA,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK;AAChC,KAAA,CAAC;AAEiB,IAAA,UAAU,GAAmB,CAAC,KAAK,EAAE,KAAK,CAAC;;IAI3C,UAAU,GAAG,MAAM,CAAY,SAAS;mFAAC;AAElD,IAAA,YAAY,CAAC,IAAe,EAAA;AACpC,QAAA,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;IAC3B;AAEU,IAAA,iBAAiB,CAAC,KAAY,EAAA;AACtC,QAAA,MAAM,KAAK,GAAI,KAAyC,CAAC,MAAM,EAAE,KAAK;QACtE,IACE,KAAK,KAAK,SAAS;AACnB,YAAA,KAAK,KAAK,cAAc;YACxB,KAAK,KAAK,YAAY,EACtB;AACA,YAAA,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC;QAC1B;IACF;;IAGmB,YAAY,GAAG,MAAM,CACtC,EAAE;qFACH;IAES,aAAa,CAAC,SAAiB,EAAE,KAAa,EAAA;QACtD,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,MAAM,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,SAAS,GAAG,KAAK,EAAE,CAAC,CAAC;IAC3E;AAEU,IAAA,UAAU,CAAC,SAAiB,EAAA;QACpC,MAAM,KAAK,GAAuB,IAAI,CAAC,YAAY,EAAE,CAAC,SAAS,CAAC;QAChE,OAAO,KAAK,IAAI,EAAE;IACpB;IAEU,YAAY,CAAC,SAAiB,EAAE,KAAY,EAAA;AACpD,QAAA,MAAM,KAAK,GAAI,KAAgD,CAAC;AAC9D,cAAE,KAAK;QACT,IAAI,CAAC,aAAa,CAAC,SAAS,EAAE,KAAK,IAAI,EAAE,CAAC;IAC5C;;AAGmB,IAAA,UAAU,GAAG,QAAQ,CAAuB,MAAK;QAClE,MAAM,OAAO,GAAG,IAAI,CAAC,mBAAmB,EAAE,IAAI,EAAE;AAChD,QAAA,MAAM,QAAQ,GAAG,IAAI,CAAC,mBAAmB,EAAE;QAC3C,OAAO;AACL,YAAA,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,aAAa,EAAE;AAChC,YAAA,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;SAClD;IACH,CAAC;mFAAC;;AAGiB,IAAA,eAAe,GAAG,QAAQ,CAAC,MAC5C,eAAe,CACb,IAAI,CAAC,UAAU,EAAE,EACjB,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,EAC5B,IAAI,CAAC,UAAU,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAC5D;wFACF;AAEkB,IAAA,UAAU,GAAG,QAAQ,CAAC,MAAK;QAC5C,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;QAC1C,QACE,KAAK,KAAK,SAAS;AACnB,YAAA,KAAK,GAAG,CAAC;AACT,YAAA,IAAI,CAAC,mBAAmB,EAAE,CAAC,MAAM,GAAG,CAAC;AACrC,YAAA,IAAI,CAAC,eAAe,EAAE,CAAC,EAAE;IAE7B,CAAC;mFAAC;;IAIiB,WAAW,GAAG,MAAM,CAAC,KAAK;oFAAC;IAC3B,YAAY,GAAG,MAAM,CAAqB,SAAS;qFAAC;IAE7D,MAAM,GAAA;AACd,QAAA,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE;QAC3B,IAAI,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE;YAC5C;QACF;AACA,QAAA,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE;AAC3B,QAAA,IAAI,CAAC,KAAK,EAAE,EAAE,EAAE;YACd;QACF;QACA,MAAM,UAAU,GAAG,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;AAC/C,QAAA,IAAI,UAAU,KAAK,SAAS,EAAE;YAC5B;QACF;AACA,QAAA,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,EAAE;AAC9B,QAAA,MAAM,MAAM,GACV,IAAI,KAAK;AACP,cAAE;cACA,IAAI,CAAC,UAAU,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,KAAI;AAC5B,gBAAA,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC;gBAC5D,OAAO,IAAI,KAAK;AACd,sBAAE,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,EAAE,OAAO,EAAE,kBAAkB,CAAC,KAAK,CAAC;AACzD,sBAAE,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,gBAAgB,CAAC,KAAK,CAAC,EAAE;AAC5D,YAAA,CAAC,CAAC;AACR,QAAA,MAAM,OAAO,GAAwB;YACnC,OAAO,EAAE,KAAK,CAAC,EAAE;YACjB,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,SAAS;AAC3C,YAAA,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,KAAK;AAC7B,YAAA,MAAM,EAAE,gBAAgB,CAAC,UAAU,CAAC;AACpC,YAAA,SAAS,EAAE,IAAI;AACf,YAAA,qBAAqB,EAAE,CAAC,GAAG,IAAI,CAAC,mBAAmB,EAAE,CAAC;YACtD,MAAM;SACP;AACD,QAAA,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC;AAC1B,QAAA,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC;QAChC,IAAI,CAAC,cAAc,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,SAAS,CAAC;AACjD,YAAA,IAAI,EAAE,CAAC,QAAQ,KAAI;AACjB,gBAAA,IAAI,CAAC,eAAe,CAAC,KAAK,EAAE;;;AAG5B,gBAAA,IAAI,CAAC,eAAe,CAAC,0BAA0B,CAC7C,KAAK,EACL,CAAA,MAAA,EAAS,QAAQ,CAAC,EAAE,CAAA,CAAE,EACtB,EAAE,UAAU,EAAE,IAAI,EAAE,CACrB;YACH,CAAC;AACD,YAAA,KAAK,EAAE,CAAC,GAAG,KAAI;AACb,gBAAA,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,CAAC;AAC3B,gBAAA,MAAM,aAAa,GAAG,oBAAoB,CAAC,GAAG,CAAC;AAC/C,gBAAA,IAAI,CAAC,YAAY,CAAC,GAAG,CACnB;sBACI,CAAA,wBAAA,EAA2B,aAAa,CAAA;sBACxC,2CAA2C,CAChD;gBACD,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,GAAG,EAAE,wBAAwB,EAAE;AACvD,oBAAA,IAAI,EAAE,KAAK;AACZ,iBAAA,CAAC;YACJ,CAAC;AACF,SAAA,CAAC;IACJ;uGAvQW,qBAAqB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;AAArB,IAAA,OAAA,IAAA,GAAA,EAAA,CAAA,oBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,qBAAqB,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,8BAAA,EAAA,MAAA,EAAA,EAAA,KAAA,EAAA,OAAA,EAAA,aAAA,EAAA,eAAA,EAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,EClHlC,8/JA0KA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,UAAA,EAAA,IAAA,ED1EI,mBAAmB,0TACnB,OAAO,EAAA,QAAA,EAAA,UAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,OAAA,EAAA,UAAA,EAAA,UAAA,EAAA,MAAA,EAAA,MAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACP,aAAa,EAAA,QAAA,EAAA,iBAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,EAAA,aAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACb,YAAY,EAAA,QAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACZ,cAAc,EAAA,QAAA,EAAA,kBAAA,EAAA,MAAA,EAAA,CAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACd,OAAO,EAAA,QAAA,EAAA,UAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,YAAA,EAAA,UAAA,EAAA,UAAA,EAAA,MAAA,EAAA,OAAA,EAAA,MAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACP,QAAQ,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,EAAA,UAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACR,OAAO,EAAA,QAAA,EAAA,UAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACP,SAAS,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,YAAA,EAAA,OAAA,EAAA,aAAA,EAAA,UAAA,EAAA,WAAA,EAAA,cAAA,EAAA,MAAA,EAAA,YAAA,EAAA,WAAA,EAAA,kBAAA,EAAA,SAAA,EAAA,OAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,UAAA,EAAA,MAAA,EAAA,QAAA,EAAA,aAAA,EAAA,cAAA,EAAA,OAAA,EAAA,YAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACT,eAAe,iJACf,QAAQ,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,gBAAA,EAAA,cAAA,EAAA,aAAA,EAAA,WAAA,EAAA,YAAA,EAAA,aAAA,EAAA,OAAA,EAAA,SAAA,EAAA,kBAAA,EAAA,UAAA,EAAA,UAAA,EAAA,cAAA,EAAA,WAAA,EAAA,MAAA,EAAA,YAAA,EAAA,WAAA,EAAA,OAAA,EAAA,gBAAA,EAAA,KAAA,EAAA,WAAA,EAAA,KAAA,EAAA,WAAA,EAAA,MAAA,EAAA,UAAA,EAAA,MAAA,EAAA,SAAA,EAAA,aAAA,EAAA,UAAA,EAAA,UAAA,EAAA,OAAA,EAAA,MAAA,EAAA,YAAA,EAAA,MAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACR,WAAW,EAAA,QAAA,EAAA,cAAA,EAAA,MAAA,EAAA,CAAA,SAAA,EAAA,OAAA,EAAA,UAAA,EAAA,WAAA,EAAA,YAAA,EAAA,eAAA,EAAA,SAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACX,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,UAAA,EAAA,MAAA,EAAA,YAAA,EAAA,eAAA,EAAA,cAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACV,gBAAgB,EAAA,QAAA,EAAA,oBAAA,EAAA,MAAA,EAAA,CAAA,WAAA,EAAA,UAAA,EAAA,QAAA,EAAA,MAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAChB,SAAS,sRACT,UAAU,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,UAAA,EAAA,MAAA,EAAA,QAAA,CAAA,EAAA,CAAA,EAAA,CAAA;;2FAGD,qBAAqB,EAAA,UAAA,EAAA,CAAA;kBAtBjC,SAAS;AACE,YAAA,IAAA,EAAA,CAAA,EAAA,QAAA,EAAA,8BAA8B,EAAA,OAAA,EAE/B;wBACP,mBAAmB;wBACnB,OAAO;wBACP,aAAa;wBACb,YAAY;wBACZ,cAAc;wBACd,OAAO;wBACP,QAAQ;wBACR,OAAO;wBACP,SAAS;wBACT,eAAe;wBACf,QAAQ;wBACR,WAAW;wBACX,UAAU;wBACV,gBAAgB;wBAChB,SAAS;wBACT,UAAU;AACX,qBAAA,EAAA,QAAA,EAAA,8/JAAA,EAAA;;sBAgBA,KAAK;uBAAC,EAAE,QAAQ,EAAE,IAAI,EAAE;;sBA8BxB;;;AE5JH;AACA;AACO,MAAM,gBAAgB,GAAY;AACvC,IAAA;AACE,QAAA,IAAI,EAAE,QAAQ;AACd,QAAA,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE;AACzB,QAAA,aAAa,EAAE,MACb,OAAO,uEAA4C,CAAC,CAAC,IAAI,CACvD,CAAC,CAAC,KAAK,CAAC,CAAC,wBAAwB,CAClC;AACJ,KAAA;AACD,IAAA;AACE,QAAA,IAAI,EAAE,WAAW;AACjB,QAAA,IAAI,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE;AAC5B,QAAA,aAAa,EAAE,MACb,OAAO,oEAAsC,CAAC,CAAC,IAAI,CACjD,CAAC,CAAC,KAAK,CAAC,CAAC,qBAAqB,CAC/B;AACJ,KAAA;AACD,IAAA;AACE,QAAA,IAAI,EAAE,gBAAgB;AACtB,QAAA,IAAI,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE;AACxB,QAAA,aAAa,EAAE,MACb,OAAO,wEAA8C,CAAC,CAAC,IAAI,CACzD,CAAC,CAAC,KAAK,CAAC,CAAC,yBAAyB,CACnC;AACJ,KAAA;;;AC1BH;AACA;AACA;AACO,MAAM,eAAe,GAAU;AACpC,IAAA,IAAI,EAAE,aAAa;AACnB,IAAA,IAAI,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE;AAC/B,IAAA,aAAa,EAAE,MACb,OAAO,sEAA8B,CAAC,CAAC,IAAI,CACzC,CAAC,CAAC,KAAK,CAAC,CAAC,uBAAuB,CACjC;;;ACXL;;AAEG;;;;"}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sneat/extension-splitus-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"peerDependencies": {
|
|
5
|
+
"@angular/common": ">=22.0.0 <23.0.0",
|
|
6
|
+
"@angular/core": ">=22.0.0 <23.0.0",
|
|
7
|
+
"@angular/forms": ">=22.0.0 <23.0.0",
|
|
8
|
+
"@angular/router": ">=22.0.0 <23.0.0",
|
|
9
|
+
"@ionic/angular": ">=9.0.0 <10.0.0",
|
|
10
|
+
"rxjs": "^7.0.0",
|
|
11
|
+
"@sneat/extension-contactus-contract": "0.12.2",
|
|
12
|
+
"@sneat/core": "^0.26.4",
|
|
13
|
+
"@sneat/space-components": "^0.26.4",
|
|
14
|
+
"@sneat/space-models": "^0.26.4",
|
|
15
|
+
"@sneat/space-services": "^0.26.4",
|
|
16
|
+
"@sneat/ui": "^0.26.4",
|
|
17
|
+
"@sneat/extension-splitus-contract": "^0.2.0"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"tslib": "^2.3.0"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"module": "fesm2022/sneat-extension-splitus-ui.mjs",
|
|
24
|
+
"typings": "types/sneat-extension-splitus-ui.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
"./package.json": {
|
|
27
|
+
"default": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./types/sneat-extension-splitus-ui.d.ts",
|
|
31
|
+
"default": "./fesm2022/sneat-extension-splitus-ui.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"type": "module"
|
|
35
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as _angular_core from '@angular/core';
|
|
2
|
+
import { FormControl } from '@angular/forms';
|
|
3
|
+
import { IIdAndBrief } from '@sneat/core';
|
|
4
|
+
import { IContactBrief } from '@sneat/extension-contactus-contract';
|
|
5
|
+
import { CurrencyCode, SplitMode } from '@sneat/extension-splitus-contract';
|
|
6
|
+
import { ISpaceContext } from '@sneat/space-models';
|
|
7
|
+
import { Route } from '@angular/router';
|
|
8
|
+
|
|
9
|
+
interface IShareReconciliation {
|
|
10
|
+
readonly ok: boolean;
|
|
11
|
+
/** Explanatory, user-facing message when `ok` is false. */
|
|
12
|
+
readonly error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface IParticipantChoice {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly title: string;
|
|
18
|
+
}
|
|
19
|
+
/** One row of the custom-share editor. `id === ''` is the payer. */
|
|
20
|
+
interface IShareRow {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly title: string;
|
|
23
|
+
}
|
|
24
|
+
declare class NewSplitFormComponent {
|
|
25
|
+
private readonly errorLogger;
|
|
26
|
+
private readonly splitusService;
|
|
27
|
+
private readonly contactusSpaceService;
|
|
28
|
+
private readonly spaceNavService;
|
|
29
|
+
private readonly destroyRef;
|
|
30
|
+
private readonly draftSplitStore;
|
|
31
|
+
protected readonly $resumedFromDraft: _angular_core.WritableSignal<boolean>;
|
|
32
|
+
private draftChecked;
|
|
33
|
+
protected readonly $space: _angular_core.WritableSignal<ISpaceContext | undefined>;
|
|
34
|
+
set space(value: ISpaceContext | undefined);
|
|
35
|
+
private tryResumeDraft;
|
|
36
|
+
protected dismissDraftBanner(): void;
|
|
37
|
+
protected readonly $currentUserID: _angular_core.WritableSignal<string | undefined>;
|
|
38
|
+
/** The authenticated user's ID — used to exclude the payer's own contact. */
|
|
39
|
+
set currentUserID(value: string | undefined);
|
|
40
|
+
private contactsSubscription?;
|
|
41
|
+
private watchedSpaceID?;
|
|
42
|
+
protected readonly $contacts: _angular_core.WritableSignal<IIdAndBrief<IContactBrief>[] | undefined>;
|
|
43
|
+
protected readonly $contactsError: _angular_core.WritableSignal<string | undefined>;
|
|
44
|
+
private watchContacts;
|
|
45
|
+
/**
|
|
46
|
+
* The space's contactus people offered as participants. The payer (the
|
|
47
|
+
* authenticated user) is always a participant and must not be listed in
|
|
48
|
+
* `participantContactIDs`, so their own contact is excluded from the picker.
|
|
49
|
+
*/
|
|
50
|
+
protected readonly $participantChoices: _angular_core.Signal<readonly IParticipantChoice[] | undefined>;
|
|
51
|
+
protected readonly $selectedContactIDs: _angular_core.WritableSignal<readonly string[]>;
|
|
52
|
+
protected onParticipantToggle(contactID: string, checked: boolean): void;
|
|
53
|
+
protected onParticipantChecked(contactID: string, event: Event): void;
|
|
54
|
+
protected readonly title: FormControl<string>;
|
|
55
|
+
protected readonly currency: FormControl<CurrencyCode>;
|
|
56
|
+
protected readonly amount: FormControl<number | null>;
|
|
57
|
+
private readonly $amount;
|
|
58
|
+
protected readonly currencies: CurrencyCode[];
|
|
59
|
+
protected readonly $splitMode: _angular_core.WritableSignal<SplitMode>;
|
|
60
|
+
protected setSplitMode(mode: SplitMode): void;
|
|
61
|
+
protected onSplitModeChange(event: Event): void;
|
|
62
|
+
/** Raw share inputs keyed by contact ID ('' = the payer). */
|
|
63
|
+
protected readonly $shareValues: _angular_core.WritableSignal<Readonly<Record<string, string>>>;
|
|
64
|
+
protected setShareValue(contactID: string, value: string): void;
|
|
65
|
+
protected shareValue(contactID: string): string;
|
|
66
|
+
protected onShareInput(contactID: string, event: Event): void;
|
|
67
|
+
/** Share-editor rows: the payer first, then each selected participant. */
|
|
68
|
+
protected readonly $shareRows: _angular_core.Signal<readonly IShareRow[]>;
|
|
69
|
+
/** Live reconciliation of custom shares against the expense total. */
|
|
70
|
+
protected readonly $reconciliation: _angular_core.Signal<IShareReconciliation>;
|
|
71
|
+
protected readonly $canSubmit: _angular_core.Signal<boolean>;
|
|
72
|
+
protected readonly $submitting: _angular_core.WritableSignal<boolean>;
|
|
73
|
+
protected readonly $submitError: _angular_core.WritableSignal<string | undefined>;
|
|
74
|
+
protected submit(): void;
|
|
75
|
+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<NewSplitFormComponent, never>;
|
|
76
|
+
static ɵcmp: _angular_core.ɵɵComponentDeclaration<NewSplitFormComponent, "sneat-splitus-new-split-form", never, { "space": { "alias": "space"; "required": true; }; "currentUserID": { "alias": "currentUserID"; "required": false; }; }, {}, never, never, true, never>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
declare const spacePagesRoutes: Route[];
|
|
80
|
+
|
|
81
|
+
declare const startSplitRoute: Route;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A single in-progress "start a split" draft — captured before the user has
|
|
85
|
+
* a space or is even signed in (see StartSplitPageComponent), resumed into
|
|
86
|
+
* NewSplitFormComponent once they are. See
|
|
87
|
+
* docs/superpowers/specs/2026-07-03-anonymous-first-split-design.md.
|
|
88
|
+
*/
|
|
89
|
+
interface IDraftSplit {
|
|
90
|
+
readonly title: string;
|
|
91
|
+
readonly amount: number;
|
|
92
|
+
readonly currencyCode: CurrencyCode;
|
|
93
|
+
readonly createdAt: number;
|
|
94
|
+
}
|
|
95
|
+
declare const DRAFT_SPLIT_STORAGE_KEY = "sneat.splitus.draftSplit";
|
|
96
|
+
declare class DraftSplitStore {
|
|
97
|
+
save(draft: Omit<IDraftSplit, 'createdAt'>): void;
|
|
98
|
+
read(): IDraftSplit | undefined;
|
|
99
|
+
hasPending(): boolean;
|
|
100
|
+
clear(): void;
|
|
101
|
+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<DraftSplitStore, never>;
|
|
102
|
+
static ɵprov: _angular_core.ɵɵInjectableDeclaration<DraftSplitStore>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { DRAFT_SPLIT_STORAGE_KEY, DraftSplitStore, NewSplitFormComponent, spacePagesRoutes, startSplitRoute };
|
|
106
|
+
export type { IDraftSplit };
|