diff --git a/apps/client/edb/module-federation.config.ts b/apps/client/edb/module-federation.config.ts index 295ebf1c5..333faf732 100644 --- a/apps/client/edb/module-federation.config.ts +++ b/apps/client/edb/module-federation.config.ts @@ -21,9 +21,40 @@ const loose: SharedLibraryConfig = { requiredVersion: false, }; +const looseSingletonPackages = new Set([ + '@carbon/styles', + '@eDB/client-admin', + '@eDB/shared-env', + '@edb/shared-types', + '@edb/shared-ui', + '@edb/util-common', + '@edb/util-user-params', + '@fortawesome/angular-fontawesome', + '@microsoft/signalr', + '@tanstack/angular-query-experimental', + '@tanstack/query-core', + 'carbon-components', + 'carbon-components-angular', + 'chart.js', + 'ng2-charts', +]); + +const targetConfiguration = + process.env.NX_TASK_TARGET_CONFIGURATION ?? + process.env.NODE_ENV ?? + 'development'; + +const adminRemoteEntry = + process.env.MFE_EDB_ADMIN_REMOTE_ENTRY ?? + (targetConfiguration === 'production' + ? 'https://app.eliasdebock.com/admin/remoteEntry.mjs' + : targetConfiguration === 'staging' + ? 'https://app.staging.eliasdebock.com/admin/remoteEntry.mjs' + : 'http://localhost:4300/remoteEntry.mjs'); + export default { name: 'edb', - remotes: ['mfe-edb-admin'], + remotes: [['mfe-edb-admin', adminRemoteEntry]], exposes: {}, shared: (pkg?: string) => { @@ -46,20 +77,13 @@ export default { // 4. Your shared libs / UI kits – loose singleton if ( - pkg === '@edb/shared-ui' || - pkg === '@edb/shared-types' || - pkg === 'carbon-components-angular' || - pkg === 'carbon-components' || - pkg === '@carbon/styles' || - pkg === '@tanstack/angular-query-experimental' || - pkg === '@tanstack/query-core' || - pkg === 'chart.js' || - pkg === 'ng2-charts' + looseSingletonPackages.has(pkg) || + pkg.startsWith('carbon-components-angular/') ) { return loose; } - // 5. Everything else – do not share + // 5. Everything else – do not share. return false; }, } satisfies ModuleFederationConfig; diff --git a/apps/client/edb/project.json b/apps/client/edb/project.json index 35480ce07..b8c532b7f 100644 --- a/apps/client/edb/project.json +++ b/apps/client/edb/project.json @@ -254,7 +254,19 @@ "port": 4200, "staticFilePath": "dist/apps/client/edb/browser", "spa": true - } + }, + "configurations": { + "development": { + "buildTarget": "edb:build:development" + }, + "staging": { + "buildTarget": "edb:build:staging" + }, + "production": { + "buildTarget": "edb:build:production" + } + }, + "defaultConfiguration": "development" } } } diff --git a/apps/client/mfe-edb-admin/module-federation.config.ts b/apps/client/mfe-edb-admin/module-federation.config.ts index 2558967da..2730efe6a 100644 --- a/apps/client/mfe-edb-admin/module-federation.config.ts +++ b/apps/client/mfe-edb-admin/module-federation.config.ts @@ -20,6 +20,24 @@ const loose: SharedLibraryConfig = { requiredVersion: false, }; +const looseSingletonPackages = new Set([ + '@carbon/styles', + '@eDB/client-admin', + '@eDB/shared-env', + '@edb/shared-types', + '@edb/shared-ui', + '@edb/util-common', + '@edb/util-user-params', + '@fortawesome/angular-fontawesome', + '@microsoft/signalr', + '@tanstack/angular-query-experimental', + '@tanstack/query-core', + 'carbon-components', + 'carbon-components-angular', + 'chart.js', + 'ng2-charts', +]); + export default { name: 'mfe-edb-admin', @@ -48,15 +66,8 @@ export default { // 4. Your shared libs / UI kits – loose singleton if ( - pkg === '@edb/shared-ui' || - pkg === '@edb/shared-types' || - pkg === 'carbon-components-angular' || - pkg === 'carbon-components' || - pkg === '@carbon/styles' || - pkg === '@tanstack/angular-query-experimental' || - pkg === '@tanstack/query-core' || - pkg === 'chart.js' || - pkg === 'ng2-charts' + looseSingletonPackages.has(pkg) || + pkg.startsWith('carbon-components-angular/') ) { return loose; } diff --git a/apps/client/mfe-edb-admin/project.json b/apps/client/mfe-edb-admin/project.json index 4cbf81b5f..edb787aaa 100644 --- a/apps/client/mfe-edb-admin/project.json +++ b/apps/client/mfe-edb-admin/project.json @@ -57,7 +57,16 @@ } }, "staging": { - "optimization": true, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false + }, + "fonts": { + "inline": false + } + }, "sourceMap": false, "outputHashing": "all", "namedChunks": true, @@ -158,6 +167,9 @@ "development": { "buildTarget": "mfe-edb-admin:build:development" }, + "staging": { + "buildTarget": "mfe-edb-admin:build:staging" + }, "production": { "buildTarget": "mfe-edb-admin:build:production" } diff --git a/apps/client/mfe-edb-admin/src/app/app.component.ts b/apps/client/mfe-edb-admin/src/app/app.component.ts index 4bbd42939..18d120953 100644 --- a/apps/client/mfe-edb-admin/src/app/app.component.ts +++ b/apps/client/mfe-edb-admin/src/app/app.component.ts @@ -1,10 +1,20 @@ +// import { Component } from '@angular/core'; +// import { RouterOutlet } from '@angular/router'; + +// @Component({ +// // Dev-only host shell... In prod the remote is loaded via module federation routes. +// selector: 'app-admin-root', +// template: ``, +// imports: [RouterOutlet], +// }) +// export class AppComponent {} + import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; @Component({ // Dev-only host shell... In prod the remote is loaded via module federation routes. selector: 'app-admin-root', - template: ``, - imports: [RouterOutlet], + template: ``, + imports: [], }) export class AppComponent {} diff --git a/apps/client/mfe-edb-admin/src/main.ts b/apps/client/mfe-edb-admin/src/main.ts index 27e5b801d..58fa89b46 100644 --- a/apps/client/mfe-edb-admin/src/main.ts +++ b/apps/client/mfe-edb-admin/src/main.ts @@ -1,3 +1,3 @@ // import('./bootstrap').catch((err) => console.error(err)); -// apps/client/mfe-edb-admin/src/main.ts +// apps/client/mfe-edb-admin/src/main.ts. import('./bootstrap').catch((err) => console.error(err)); diff --git a/apps/server/admin-api/Mapping/MappingProfile.cs b/apps/server/admin-api/Mapping/MappingProfile.cs index 48204f88c..f07529675 100644 --- a/apps/server/admin-api/Mapping/MappingProfile.cs +++ b/apps/server/admin-api/Mapping/MappingProfile.cs @@ -25,7 +25,7 @@ public MappingProfile() ) .ForMember( dest => dest.UserEmail, - opt => opt.Ignore() // Or use a placeholder since email isn't available locally + opt => opt.MapFrom(_ => string.Empty) ) .ForMember( dest => dest.SubscriptionDate, diff --git a/apps/server/platform-api/appsettings.Development.json b/apps/server/platform-api/appsettings.Development.json index 66b13076e..a424ccd95 100644 --- a/apps/server/platform-api/appsettings.Development.json +++ b/apps/server/platform-api/appsettings.Development.json @@ -34,6 +34,8 @@ "CatalogApplications": { "ClaraUrl": "https://clara.eliasdebock.com", "NemesisUrl": "https://nemesis.eliasdebock.com", - "PropertyManagerUrl": "https://property.eliasdebock.com" + "PropertyManagerUrl": "https://property.eliasdebock.com", + "SmostrAdminUrl": "https://admin.smostr.com", + "SmostrShopUrl": "https://shop.smostr.com" } } diff --git a/apps/server/platform-api/appsettings.Staging.json b/apps/server/platform-api/appsettings.Staging.json index 6c682825b..28b21f48b 100644 --- a/apps/server/platform-api/appsettings.Staging.json +++ b/apps/server/platform-api/appsettings.Staging.json @@ -27,6 +27,8 @@ "CatalogApplications": { "ClaraUrl": "https://clara.eliasdebock.com", "NemesisUrl": "https://nemesis.eliasdebock.com", - "PropertyManagerUrl": "https://property.eliasdebock.com" + "PropertyManagerUrl": "https://property.eliasdebock.com", + "SmostrAdminUrl": "https://admin.smostr.com", + "SmostrShopUrl": "https://shop.smostr.com" } } diff --git a/apps/server/platform-api/appsettings.json b/apps/server/platform-api/appsettings.json index 2bf085b02..ca7813feb 100644 --- a/apps/server/platform-api/appsettings.json +++ b/apps/server/platform-api/appsettings.json @@ -27,6 +27,8 @@ "CatalogApplications": { "ClaraUrl": "https://clara.eliasdebock.com", "NemesisUrl": "https://nemesis.eliasdebock.com", - "PropertyManagerUrl": "https://property.eliasdebock.com" + "PropertyManagerUrl": "https://property.eliasdebock.com", + "SmostrAdminUrl": "https://admin.smostr.com", + "SmostrShopUrl": "https://shop.smostr.com" } } diff --git a/apps/server/webshop-api/.env.production b/apps/server/webshop-api/.env.production index 58bdc0653..7a1db1eb0 100644 --- a/apps/server/webshop-api/.env.production +++ b/apps/server/webshop-api/.env.production @@ -8,4 +8,4 @@ SCOUT_DRIVER=meilisearch MEILISEARCH_HOST=http://meilisearch-prod.search.svc.cluster.local:7700 CHECKOUT_SUCCESS_URL=https://app.eliasdebock.com/webshop/checkout/success?session_id={CHECKOUT_SESSION_ID} -CHECKOUT_CANCEL_URL=https://app.eliasdebock.com/webshop/checkout/cancel +CHECKOUT_CANCEL_URL=https://app.eliasdebock.com/webshop/checkout diff --git a/apps/server/webshop-api/.env.staging b/apps/server/webshop-api/.env.staging index 864ed40d2..9dc6ab688 100644 --- a/apps/server/webshop-api/.env.staging +++ b/apps/server/webshop-api/.env.staging @@ -8,4 +8,4 @@ SCOUT_DRIVER=meilisearch MEILISEARCH_HOST=http://meilisearch-staging.search.svc.cluster.local:7700 CHECKOUT_SUCCESS_URL=https://app.staging.eliasdebock.com/webshop/checkout/success?session_id={CHECKOUT_SESSION_ID} -CHECKOUT_CANCEL_URL=https://app.staging.eliasdebock.com/webshop/checkout/cancel +CHECKOUT_CANCEL_URL=https://app.staging.eliasdebock.com/webshop/checkout diff --git a/apps/server/webshop-api/app/Http/Controllers/Api/V1/CheckoutController.php b/apps/server/webshop-api/app/Http/Controllers/Api/V1/CheckoutController.php index 04235be6b..137853832 100644 --- a/apps/server/webshop-api/app/Http/Controllers/Api/V1/CheckoutController.php +++ b/apps/server/webshop-api/app/Http/Controllers/Api/V1/CheckoutController.php @@ -15,6 +15,7 @@ use App\Services\Messaging\AmqpPublisher; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Stripe\Exception\ApiErrorException; class CheckoutController extends Controller { @@ -64,30 +65,48 @@ public function createCheckoutSession(Request $request): JsonResponse // Save shipping data in cache for webhook Cache::put("checkout_user_{$userId}", $shipping, now()->addMinutes(30)); - // Create Stripe session - Stripe::setApiKey(config('services.stripe.secret')); + $stripeSecret = config('services.stripe.secret'); + if (!$stripeSecret) { + return response()->json(['error' => 'Stripe secret is not configured'], 500); + } + + $successUrl = config('services.checkout.success_url'); + $cancelUrl = config('services.checkout.cancel_url'); - $session = Session::create([ - 'payment_method_types' => ['card'], - 'line_items' => [[ - 'price_data' => [ - 'currency' => 'eur', - 'product_data' => [ - 'name' => 'Book Order', + Stripe::setApiKey($stripeSecret); + + try { + $session = Session::create([ + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price_data' => [ + 'currency' => 'eur', + 'product_data' => [ + 'name' => 'Book Order', + ], + 'unit_amount' => (int) ($amount * 100), ], - 'unit_amount' => (int) ($amount * 100), + 'quantity' => 1, + ]], + 'mode' => 'payment', + 'customer_email' => $shipping['email'], + 'metadata' => [ + 'user_id' => $userId, ], - 'quantity' => 1, - ]], - 'mode' => 'payment', - 'customer_email' => $shipping['email'], - 'metadata' => [ + 'success_url' => $successUrl, + 'cancel_url' => $cancelUrl, + ]); + } catch (ApiErrorException $e) { + Log::error('Stripe checkout session creation failed', [ 'user_id' => $userId, - ], - 'success_url' => env('CHECKOUT_SUCCESS_URL'), -'cancel_url' => env('CHECKOUT_CANCEL_URL'), + 'error' => $e->getMessage(), + ]); - ]); + return response()->json([ + 'error' => 'Could not create checkout session', + 'details' => $e->getMessage(), + ], 502); + } return response()->json(['url' => $session->url]); } diff --git a/apps/server/webshop-api/config/services.php b/apps/server/webshop-api/config/services.php index cec197e6b..29d395e77 100644 --- a/apps/server/webshop-api/config/services.php +++ b/apps/server/webshop-api/config/services.php @@ -36,4 +36,16 @@ 'secret' => env('STRIPE_SECRET'), ], + 'checkout' => [ + 'frontend_url' => rtrim(env('FRONTEND_URL', 'http://localhost:4200'), '/'), + 'success_url' => env( + 'CHECKOUT_SUCCESS_URL', + rtrim(env('FRONTEND_URL', 'http://localhost:4200'), '/') . '/webshop/checkout/success?session_id={CHECKOUT_SESSION_ID}' + ), + 'cancel_url' => env( + 'CHECKOUT_CANCEL_URL', + rtrim(env('FRONTEND_URL', 'http://localhost:4200'), '/') . '/webshop/checkout' + ), + ], + ]; diff --git a/libs/client/edb/data-access/client-books/src/index.ts b/libs/client/edb/data-access/client-books/src/index.ts index 9fca1cade..d48135327 100644 --- a/libs/client/edb/data-access/client-books/src/index.ts +++ b/libs/client/edb/data-access/client-books/src/index.ts @@ -1 +1,2 @@ export * from './lib/client'; +export * from './lib/wishlist.service'; diff --git a/libs/client/edb/data-access/client-books/src/lib/client.ts b/libs/client/edb/data-access/client-books/src/lib/client.ts index f75dc72fa..c566372d6 100644 --- a/libs/client/edb/data-access/client-books/src/lib/client.ts +++ b/libs/client/edb/data-access/client-books/src/lib/client.ts @@ -24,10 +24,13 @@ export class BooksService { const status = this.bookParamService.statusSignal(); const sort = this.bookParamService.sortSignal(); - // Build common query params - let params = new HttpParams().set('status', status || 'available'); + // Build common query params. "all" means no filter for that field. + let params = new HttpParams(); - if (genre) { + if (status && status !== 'all') { + params = params.set('status', status); + } + if (genre && genre !== 'all') { params = params.set('genre', genre); } if (search) { diff --git a/libs/client/edb/data-access/client-books/src/lib/wishlist.service.ts b/libs/client/edb/data-access/client-books/src/lib/wishlist.service.ts new file mode 100644 index 000000000..63b253b33 --- /dev/null +++ b/libs/client/edb/data-access/client-books/src/lib/wishlist.service.ts @@ -0,0 +1,51 @@ +import { Injectable, computed, signal } from '@angular/core'; +import { Book } from '@edb/shared-types'; + +const STORAGE_KEY = 'webshop:wishlist'; + +@Injectable({ providedIn: 'root' }) +export class WishlistService { + private readonly wishlist = signal(this.readWishlist()); + + readonly items = this.wishlist.asReadonly(); + readonly count = computed(() => this.items().length); + + isWishlisted(bookId: number | undefined): boolean { + return ( + bookId !== undefined && this.items().some((book) => book.id === bookId) + ); + } + + toggle(book: Book | undefined): void { + if (!book) return; + + const exists = this.isWishlisted(book.id); + const next = exists + ? this.items().filter((item) => item.id !== book.id) + : [book, ...this.items()]; + + this.setWishlist(next); + } + + remove(bookId: number): void { + this.setWishlist(this.items().filter((book) => book.id !== bookId)); + } + + private setWishlist(items: Book[]): void { + this.wishlist.set(items); + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } + } + + private readWishlist(): Book[] { + if (typeof localStorage === 'undefined') return []; + + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as Book[]) : []; + } catch { + return []; + } + } +} diff --git a/libs/client/edb/features/feature-catalog/src/lib/catalog.page.ts b/libs/client/edb/features/feature-catalog/src/lib/catalog.page.ts index fae5f9e59..99568d99e 100644 --- a/libs/client/edb/features/feature-catalog/src/lib/catalog.page.ts +++ b/libs/client/edb/features/feature-catalog/src/lib/catalog.page.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { CatalogService } from '@eDB/client-catalog'; import { UiComboboxComponent, UiTileComponent } from '@edb/shared-ui'; import { @@ -26,7 +26,12 @@ import { >

Catalog

- +
@@ -37,9 +42,9 @@ import { } - } @else if (catalog() && catalog().length > 0) { + } @else if (filteredCatalog().length > 0) {
- @for (item of catalog(); track item.id) { + @for (item of filteredCatalog(); track item.id) { ([]); + + protected tagItems = computed(() => + Array.from(new Set(this.catalog().flatMap((item) => item.tags))) + .sort((a, b) => a.localeCompare(b)) + .map((tag) => ({ + content: tag, + selected: this.selectedTags().includes(tag), + })), + ); + + protected filteredCatalog = computed(() => { + const selectedTags = this.selectedTags(); + if (selectedTags.length === 0) return this.catalog(); + + return this.catalog().filter((item) => + selectedTags.every((tag) => item.tags.includes(tag)), + ); + }); private toggleSubscribeMutation = this.catalogService.subscribeToApplicationMutation(); @@ -98,6 +115,28 @@ export class CatalogPageComponent { }); } + onTagSelection(selection: ListItem | ListItem[]): void { + if (Array.isArray(selection)) { + this.selectedTags.set( + selection + .filter((item) => item.selected) + .map((item) => String(item.content)), + ); + return; + } + + const tag = String(selection.content); + this.selectedTags.update((tags) => + tags.includes(tag) + ? tags.filter((selectedTag) => selectedTag !== tag) + : [...tags, tag], + ); + } + + clearTagSelection(): void { + this.selectedTags.set([]); + } + private handleSubscriptionToggle(): void { this.notificationService.showNotification({ type: 'success', diff --git a/libs/client/edb/features/feature-webshop-catalog/src/lib/book-catalog.page.ts b/libs/client/edb/features/feature-webshop-catalog/src/lib/book-catalog.page.ts index f134d2e00..a44986abd 100644 --- a/libs/client/edb/features/feature-webshop-catalog/src/lib/book-catalog.page.ts +++ b/libs/client/edb/features/feature-webshop-catalog/src/lib/book-catalog.page.ts @@ -52,7 +52,7 @@ import { data-testid="books-filters-form" [value]="query()" [bookStatus]="status()" - [activeGenre]="genre()" + [activeGenre]="genre() || 'all'" (searchChange)="onSearch($event)" (filterGenre)="filterGenre($event)" (filterStatus)="filterStatus($event)" @@ -137,7 +137,9 @@ export class BooksCollectionContainer implements AfterViewInit { this.bookParamService.navigate({ [SORT_QUERY_PARAM]: s }); } filterGenre(g: string) { - this.bookParamService.navigate({ [GENRE_QUERY_PARAM]: g }); + this.bookParamService.navigate({ + [GENRE_QUERY_PARAM]: g === 'all' ? '' : g, + }); } filterStatus(st: string) { this.bookParamService.navigate({ [STATUS_QUERY_PARAM]: st }); diff --git a/libs/client/edb/features/feature-webshop-catalog/src/lib/components/books-collection-grid-overview/books-collection-grid-item/books-grid-item.component.ts b/libs/client/edb/features/feature-webshop-catalog/src/lib/components/books-collection-grid-overview/books-collection-grid-item/books-grid-item.component.ts index 34938ecc8..91b0a70b6 100644 --- a/libs/client/edb/features/feature-webshop-catalog/src/lib/components/books-collection-grid-overview/books-collection-grid-item/books-grid-item.component.ts +++ b/libs/client/edb/features/feature-webshop-catalog/src/lib/components/books-collection-grid-overview/books-collection-grid-item/books-grid-item.component.ts @@ -1,6 +1,7 @@ import { DecimalPipe } from '@angular/common'; -import { Component, input, signal } from '@angular/core'; +import { Component, computed, inject, input, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { WishlistService } from '@edb/client-books'; import { Book } from '@edb/shared-types'; @Component({ @@ -14,8 +15,15 @@ import { Book } from '@edb/shared-types'; > +
+ + } + + } @else { +
+

+ No saved books yet +

+

+ Use the heart icon on catalog cards to add books here. +

+ + Back to catalog + +
+ } + + + `, +}) +export class WishlistPageComponent { + readonly wishlist = inject(WishlistService); +} diff --git a/libs/client/mfe-edb-admin/data-access/client-admin/src/lib/client.ts b/libs/client/mfe-edb-admin/data-access/client-admin/src/lib/client.ts index 1c246ad93..d2f65f07f 100644 --- a/libs/client/mfe-edb-admin/data-access/client-admin/src/lib/client.ts +++ b/libs/client/mfe-edb-admin/data-access/client-admin/src/lib/client.ts @@ -127,6 +127,7 @@ export class AdminService { }, onSuccess: () => { this.queryClient.invalidateQueries({ queryKey: ['applications'] }); + this.queryClient.refetchQueries({ queryKey: ['applications'] }); }, })); } @@ -148,7 +149,9 @@ export class AdminService { return subscriptions; }, refetchOnWindowFocus: false, - refetchOnMount: false, + refetchOnMount: true, + staleTime: 0, + retry: 1, })); } @@ -164,6 +167,7 @@ export class AdminService { }, onSuccess: () => { this.queryClient.invalidateQueries({ queryKey: ['applications'] }); + this.queryClient.refetchQueries({ queryKey: ['applications'] }); }, })); } @@ -180,6 +184,7 @@ export class AdminService { }, onSuccess: () => { this.queryClient.invalidateQueries({ queryKey: ['applications'] }); + this.queryClient.refetchQueries({ queryKey: ['applications'] }); }, })); } @@ -345,8 +350,9 @@ export class AdminService { ); return res.data.map(mapOrderDtoToOrder); }, - refetchOnMount: false, + refetchOnMount: true, refetchOnWindowFocus: false, + staleTime: 0, })); } } diff --git a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.config.ts b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.config.ts index 141d7f7f7..9e2e1b520 100644 --- a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.config.ts +++ b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.config.ts @@ -6,37 +6,32 @@ export const APPLICATION_TABLE_CONFIG: ExpandedDataConfig = { headers: [ new TableHeaderItem({ data: 'Application Name', sortable: false }), new TableHeaderItem({ data: 'Description', sortable: false }), + new TableHeaderItem({ data: 'Route', sortable: false }), + new TableHeaderItem({ data: 'Tags', sortable: false }), new TableHeaderItem({ data: 'Subscribers', sortable: false }), new TableHeaderItem({ data: 'Actions', sortable: false }), ], - rowMapper: ( - application: Application, - context?: Record, - ) => [ + rowMapper: (application: Application, context?: Record) => [ new TableItem({ data: application.name }), new TableItem({ data: application.description }), + new TableItem({ data: application.routePath }), + new TableItem({ data: application.tags?.join(', ') || '—' }), new TableItem({ data: application.subscriberCount }), new TableItem({ data: { application }, template: context?.['nonExpandedActionTemplate'], // Use non-expanded action template }), ], - expandedDataMapper: ( - app: Application, - context?: Record, - ) => { - const actionTemplate = context?.['expandedActionTemplate']; - + expandedDataMapper: (app: Application) => { return [ [ - new TableItem({ data: 'ID' }), - new TableItem({ data: 'User Name' }), + new TableItem({ data: 'Identity ID' }), + new TableItem({ data: 'Email' }), new TableItem({ data: 'Subscription Date' }), - new TableItem({ data: 'Actions' }), ], ...app.subscribedUsers.map((user) => [ - new TableItem({ data: user.userId }), new TableItem({ data: user.userName }), + new TableItem({ data: user.userEmail || '—' }), new TableItem({ data: new Date(user.subscriptionDate).toLocaleDateString('en-GB', { day: 'numeric', @@ -44,10 +39,6 @@ export const APPLICATION_TABLE_CONFIG: ExpandedDataConfig = { year: 'numeric', }), }), - new TableItem({ - data: { userId: user.userId, applicationId: app.id }, - template: actionTemplate, // Use expanded action template - }), ]), ]; }, @@ -58,10 +49,6 @@ export const MODAL_CONFIG = { header: 'Add Application', hasForm: true, }, - revokeAccess: (userId: number, applicationId: number) => ({ - header: 'Confirm Revocation', - content: `Are you sure you want to revoke access for User ID: ${userId} from Application ID: ${applicationId}? This action cannot be undone.`, - }), deleteApplication: (applicationName: string) => ({ header: 'Confirm Deletion', content: `Are you sure you want to delete the application "${applicationName}"? This action cannot be undone.`, diff --git a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.ts b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.ts index d84d57d14..1e5eef7b2 100644 --- a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.ts +++ b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/platform/applications-collection/applications-collection.container.ts @@ -13,6 +13,7 @@ import { effect, inject, OnInit, + signal, TemplateRef, ViewChild, } from '@angular/core'; @@ -20,7 +21,6 @@ import { Router } from '@angular/router'; import { AdminService } from '@eDB/client-admin'; import { TableUtilsService } from '@edb/util-common'; -import { PlaceholderModule, ModalModule } from 'carbon-components-angular'; import { TableModel } from 'carbon-components-angular/table'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -41,10 +41,6 @@ import { imports: [ UiTableComponent, UiButtonComponent, - // Carbon modal needs both the module (providers) and a placeholder in the - // view tree; keep it close to where the modal is opened. - ModalModule, - PlaceholderModule, UiPlatformOverflowMenuComponent, MatCardModule, ApplicationsCollectionAccordionComponent, @@ -68,30 +64,42 @@ import { (editApplication)="onMobileEdit($event)" [items]="applications()" /> + @if (applicationsQuery.isLoading()) { +

Loading applications…

+ } @else if (applicationsQuery.isError()) { +

+ Could not load applications. Please try again. +

+ } @else if (applications().length === 0) { +

No applications found.

+ } } @else { - + @if (applicationsQuery.isLoading()) { +
+

Applications

+

Loading applications…

+
+ } @else if (applicationsQuery.isError()) { +
+

Applications

+

+ Could not load applications. Please try again. +

+
+ } @else { + + } } - - - Revoke access - - - - -
- - - - - - -
+ @if (isApplicationModalOpen()) { + + } `, }) export class ApplicationsCollectionContainer implements OnInit { private breakpointObserver = inject(BreakpointObserver); isSmallScreen = false; - @ViewChild('actionTemplate', { static: true }) - actionTemplate!: TemplateRef; @ViewChild('deleteTemplate', { static: true }) deleteTemplate!: TemplateRef; - @ViewChild('applicationFormTemplate', { static: true }) - applicationFormTemplate!: TemplateRef; menuOptions = OVERFLOW_MENU_CONFIG; - tableModel = new TableModel(); + tableModel = signal(new TableModel()); adminService: AdminService = inject(AdminService); tableUtils: TableUtilsService = inject(TableUtilsService); @@ -159,18 +223,25 @@ export class ApplicationsCollectionContainer implements OnInit { applicationForm = this.fb.group({ name: ['', Validators.required], - description: [''], + description: ['', Validators.required], iconUrl: [''], - routePath: [''], + routePath: ['', Validators.required], tags: [''], }); - private applicationsQuery = this.adminService.queryApplications(); + protected applicationsQuery = this.adminService.queryApplications(); addApplicationMutation = this.adminService.addApplicationMutation(); editApplicationMutation = this.adminService.editApplicationMutation(); deleteApplicationMutation = this.adminService.deleteApplicationMutation(); - revokeSubscriptionMutation = this.adminService.revokeSubscriptionMutation(); applications = computed(() => this.applicationsQuery.data() || []); + isApplicationModalOpen = signal(false); + applicationModalMode = signal<'add' | 'edit'>('add'); + editingApplication = signal(null); + applicationModalTitle = computed(() => + this.applicationModalMode() === 'add' + ? 'Add Application' + : 'Edit Application', + ); constructor() { effect(() => { @@ -192,19 +263,23 @@ export class ApplicationsCollectionContainer implements OnInit { } initializeTable(applications: Application[]) { - this.tableModel.header = APPLICATION_TABLE_CONFIG.headers; - this.tableModel.data = this.tableUtils.createExpandedData( + const tableModel = new TableModel(); + tableModel.header = APPLICATION_TABLE_CONFIG.headers; + tableModel.data = this.tableUtils.createExpandedData( applications, APPLICATION_TABLE_CONFIG, { nonExpandedActionTemplate: this.deleteTemplate, - expandedActionTemplate: this.actionTemplate, }, ); + this.tableModel.set(tableModel); } clearTable() { - this.tableModel.data = []; + const tableModel = new TableModel(); + tableModel.header = APPLICATION_TABLE_CONFIG.headers; + tableModel.data = []; + this.tableModel.set(tableModel); } onMobileEdit(application: Application): void { @@ -230,33 +305,13 @@ export class ApplicationsCollectionContainer implements OnInit { } } - onRevokeAccess(userId: number, applicationId: number): void { - this.openRevokeAccessConfirmationModal(userId, applicationId); - } - openAddApplicationModal() { this.applicationForm.reset(); this.applicationForm.markAsPristine(); this.applicationForm.markAsUntouched(); - - this.modalUtils.openModal({ - header: MODAL_CONFIG.addApplication.header, - template: this.applicationFormTemplate, - context: { form: this.applicationForm }, - onSave: () => { - if (this.applicationForm.invalid) return; - const formValue = this.applicationForm.value; - this.handleAddApplication({ - name: formValue.name ?? '', - description: formValue.description ?? '', - iconUrl: formValue.iconUrl ?? '', - routePath: formValue.routePath ?? '', - tags: formValue.tags - ? formValue.tags.split(',').map((tag) => tag.trim()) - : [], - }); - }, - }); + this.applicationModalMode.set('add'); + this.editingApplication.set(null); + this.isApplicationModalOpen.set(true); } openEditApplicationModal(application: Application) { @@ -267,26 +322,56 @@ export class ApplicationsCollectionContainer implements OnInit { routePath: application.routePath, tags: application.tags?.join(', ') || '', }); + this.applicationForm.markAsPristine(); + this.applicationForm.markAsUntouched(); + this.applicationModalMode.set('edit'); + this.editingApplication.set(application); + this.isApplicationModalOpen.set(true); + } - this.modalUtils.openModal({ - header: MODAL_CONFIG.editApplication(application).header, - template: this.applicationFormTemplate, - context: { form: this.applicationForm }, - onSave: () => { - if (this.applicationForm.invalid) return; - const formValue = this.applicationForm.value; - this.handleEditApplication({ - ...application, - name: formValue.name ?? application.name, - description: formValue.description ?? application.description, - iconUrl: formValue.iconUrl ?? application.iconUrl, - routePath: formValue.routePath ?? application.routePath, - tags: formValue.tags - ? formValue.tags.split(',').map((tag) => tag.trim()) - : application.tags ?? [], - }); + closeApplicationModal(): void { + this.isApplicationModalOpen.set(false); + this.editingApplication.set(null); + this.applicationForm.reset(); + } + + submitApplicationModal(): void { + if (this.applicationForm.invalid) { + this.applicationForm.markAllAsTouched(); + return; + } + + const formValue = this.applicationForm.getRawValue(); + const tags = this.parseTags(formValue.tags); + + if (this.applicationModalMode() === 'add') { + this.handleAddApplication( + { + name: formValue.name ?? '', + description: formValue.description ?? '', + iconUrl: formValue.iconUrl ?? '', + routePath: formValue.routePath ?? '', + tags, + }, + () => this.closeApplicationModal(), + ); + return; + } + + const application = this.editingApplication(); + if (!application) return; + + this.handleEditApplication( + { + ...application, + name: formValue.name ?? application.name, + description: formValue.description ?? application.description, + iconUrl: formValue.iconUrl ?? application.iconUrl, + routePath: formValue.routePath ?? application.routePath, + tags, }, - }); + () => this.closeApplicationModal(), + ); } openDeleteConfirmationModal(application: Application) { @@ -296,41 +381,42 @@ export class ApplicationsCollectionContainer implements OnInit { }); } - openRevokeAccessConfirmationModal(userId: number, applicationId: number) { - this.modalUtils.openModal({ - ...MODAL_CONFIG.revokeAccess(userId, applicationId), - onSave: () => this.handleRevokeAccess(userId, applicationId), - }); - } - - handleAddApplication(formData: CreateApplicationDto) { + handleAddApplication(formData: CreateApplicationDto, onSuccess?: () => void) { this.addApplicationMutation.mutate(formData, { - onSuccess: () => console.log('Application added successfully'), + onSuccess: async () => { + console.log('Application added successfully'); + await this.applicationsQuery.refetch(); + onSuccess?.(); + }, onError: (err) => console.error('Failed to add application', err), }); } handleDeleteApplication(applicationId: number) { this.deleteApplicationMutation.mutate(applicationId, { - onSuccess: () => console.log('Application deleted successfully'), + onSuccess: async () => { + console.log('Application deleted successfully'); + await this.applicationsQuery.refetch(); + }, onError: (err) => console.error('Failed to delete application', err), }); } - handleEditApplication(newApplication: Application) { + handleEditApplication(newApplication: Application, onSuccess?: () => void) { this.editApplicationMutation.mutate(newApplication, { - onSuccess: () => console.log('Application edited successfully'), + onSuccess: async () => { + console.log('Application edited successfully'); + await this.applicationsQuery.refetch(); + onSuccess?.(); + }, onError: (err) => console.error('Failed to edit application', err), }); } - handleRevokeAccess(userId: number, applicationId: number) { - this.revokeSubscriptionMutation.mutate( - { userId, applicationId }, - { - onSuccess: () => console.log('Access successfully revoked'), - onError: (err) => console.error('Failed to revoke access', err), - }, - ); + private parseTags(tags: string | null | undefined): string[] { + return (tags ?? '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); } } diff --git a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/signalr/notifications-panel.component.ts b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/signalr/notifications-panel.component.ts index aaaa7d92b..888bae3ae 100644 --- a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/signalr/notifications-panel.component.ts +++ b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/signalr/notifications-panel.component.ts @@ -1,6 +1,6 @@ // apps/admin/src/app/notifications/notifications-panel.component.ts import { CommonModule, DatePipe } from '@angular/common'; -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, inject, output, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { injectQueryClient } from '@tanstack/angular-query-experimental'; import { NotificationsService } from './notifications.service'; // adjust import path @@ -10,21 +10,39 @@ import { NotificationsService } from './notifications.service'; // adjust import standalone: true, imports: [CommonModule, DatePipe, MatButtonModule], template: ` -
-

Notifications

-
@if (listQuery.isLoading()) {
Loading…
} @else { -
    +
      @for (n of items(); track n.id) { -
    • +
    • -
      -
      - {{ n.title }} +
      +
      +
      + {{ n.title }} +
      +
      @if (n.message) { -
      +
      {{ n.message }}
      } -
      - {{ n.createdAt | date: 'short' }} -
      -
      +
      @if (n.href) { - Open + Open order + } @if (!n.read) { - } @@ -77,6 +104,7 @@ import { NotificationsService } from './notifications.service'; // adjust import export class NotificationsPanelComponent { private api = inject(NotificationsService); private qc = injectQueryClient(); + readonly openOrder = output(); // initial page listQuery = this.api.queryList(50, null); @@ -105,11 +133,14 @@ export class NotificationsPanelComponent { const nextData = next.data; if (prev && nextData) { const merged = [...prev.items, ...nextData.items]; - this.qc.setQueryData(['admin-notifications', { limit: 50, cursor: null }], () => ({ - ...prev, - items: merged, - nextCursor: nextData.nextCursor, - })); + this.qc.setQueryData( + ['admin-notifications', { limit: 50, cursor: null }], + () => ({ + ...prev, + items: merged, + nextCursor: nextData.nextCursor, + }), + ); this.nextCursor.set(nextData.nextCursor); } } @@ -122,4 +153,20 @@ export class NotificationsPanelComponent { await this.api.markAllRead(); this.unreadQuery.refetch(); } + + async openNotification(notification: { id: string; href?: string }) { + const orderId = this.extractOrderId(notification.href); + if (!orderId) return; + + if (!this.items().find((n) => n.id === notification.id)?.read) { + await this.markRead(notification.id); + } + this.openOrder.emit(orderId); + } + + private extractOrderId(href?: string): string | null { + if (!href) return null; + const match = href.match(/\/orders\/([^/?#]+)/); + return match?.[1] ?? null; + } } diff --git a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/test/admin-dashboard.component.ts b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/test/admin-dashboard.component.ts index 788645855..39fbb8f9a 100644 --- a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/test/admin-dashboard.component.ts +++ b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/test/admin-dashboard.component.ts @@ -12,8 +12,6 @@ import { MatSidenavModule, } from '@angular/material/sidenav'; import { TilesModule } from 'carbon-components-angular'; -import { ChartConfiguration, ChartData } from 'chart.js'; -import { BaseChartDirective } from 'ng2-charts'; import { ApplicationsCollectionContainer } from '../platform/applications-collection/applications-collection.container'; import { UsersCollectionContainer } from '../platform/users-collection/users-collection.container'; @@ -35,7 +33,6 @@ import { AdminSidebarComponent } from './admin-sidebar.component'; AdminSidebarComponent, MatIconModule, MatButtonModule, - BaseChartDirective, UsersCollectionContainer, ApplicationsCollectionContainer, WebshopBooksTableComponent, @@ -110,100 +107,20 @@ import { AdminSidebarComponent } from './admin-sidebar.component'; @if (currentView() === 'platform') {
      -

      Dashboard

      +

      Platform Administration

      - + -
      - -
      Total Revenue
      -

      {{ totalRevenue }}

      -
      - -
      Customers
      -

      {{ customers }}

      -
      - -
      Avg Order Value
      -

      {{ avgOrderValue }}

      -
      - -
      Sessions
      -

      {{ sessions }}

      -
      -
      - -
      - -

      Revenue

      -
      - -
      -
      - -

      Sales by Category

      -
      - -
      -
      -
      -
      - - - - -

      Top Products

      -
        -
      • - Smartphone - 1,320 -
      • -
      • - Laptop - 883 -
      • -
      • - Headphones - 520 -
      • -
      • - Speakers - 315 -
      • -
      • - Watch - 290 -
      • -
      -
      -
      + + + -
      + -
      +
      } @if (currentView() === 'webshop') { @@ -223,7 +140,9 @@ import { AdminSidebarComponent } from './admin-sidebar.component';

      Orders

      - +
      @@ -242,6 +161,7 @@ export class AdminDashboardComponent implements OnInit { /* View toggle state */ currentView = signal<'platform' | 'webshop'>('platform'); + selectedOrderId = signal(null); private notifStream = inject(NotificationsStreamService); ngOnInit() { @@ -255,60 +175,8 @@ export class AdminDashboardComponent implements OnInit { if (this.drawer?.opened) this.drawer.close(); } - /* KPI data + chart configs */ - totalRevenue = '$56,945'; - customers = 1092; - avgOrderValue = '$202'; - sessions = 9285; - - revenueChartData: ChartConfiguration<'line'>['data'] = { - labels: [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ], - datasets: [ - { - data: [ - 2100, 3200, 4500, 5000, 6500, 7700, 8200, 9000, 10000, 12000, 14000, - 15000, - ], - label: 'Revenue', - fill: true, - tension: 0.4, - borderColor: '#3b82f6', - backgroundColor: 'rgba(59, 130, 246, 0.2)', - }, - ], - }; - - revenueChartOptions: ChartConfiguration<'line'>['options'] = { - responsive: true, - maintainAspectRatio: false, - scales: { y: { beginAtZero: true } }, - }; - - salesCategoryChartData: ChartData<'pie', number[], string> = { - labels: ['Apparel', 'Electronics', 'Other'], - datasets: [ - { - data: [45, 30, 25], - backgroundColor: ['#0f62fe', '#6929c4', '#1192e8'], - }, - ], - }; - - salesCategoryChartOptions: ChartConfiguration<'pie'>['options'] = { - responsive: true, - maintainAspectRatio: false, - }; + openOrder(orderId: string) { + this.selectedOrderId.set(orderId); + this.currentView.set('webshop'); + } } diff --git a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/webshop/order-collection/order.collection.ts b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/webshop/order-collection/order.collection.ts index fee19d0f7..a2250cabe 100644 --- a/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/webshop/order-collection/order.collection.ts +++ b/libs/client/mfe-edb-admin/features/feature-admin-dashboard/src/lib/components/webshop/order-collection/order.collection.ts @@ -1,7 +1,14 @@ // admin-orders-list.component.ts import { BreakpointObserver } from '@angular/cdk/layout'; import { CommonModule } from '@angular/common'; -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + input, + signal, +} from '@angular/core'; import { AdminService } from '@eDB/client-admin'; import { Order } from '@edb/shared-types'; @@ -30,9 +37,12 @@ import { Order } from '@edb/shared-types'; @if (isMobile()) {
      @@ -99,7 +109,11 @@ import { Order } from '@edb/shared-types'; } @else {
      (null); readonly ordersQuery = this.admin.queryAllOrders(); readonly orders = computed(() => this.ordersQuery.data() ?? []); @@ -186,6 +201,22 @@ export class AdminOrdersListComponent { .observe('(max-width: 767px)') .subscribe((r) => this.isMobile.set(r.matches)); }); + + effect(() => { + const selectedOrderId = this.selectedOrderId(); + const orders = this.orders(); + if (!selectedOrderId || orders.length === 0) return; + + const order = orders.find((item) => item.id === selectedOrderId); + if (!order) return; + + this.opened.add(order.id); + queueMicrotask(() => { + document + .getElementById(this.orderElementId(order.id)) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }); } /* ───────── accordion state (mobile) ───────── */ @@ -200,6 +231,13 @@ export class AdminOrdersListComponent { open(id: string | number) { return this.opened.has(id); } + isSelected(id: string | number) { + return this.selectedOrderId() === id; + } + + orderElementId(id: string | number) { + return `admin-order-${id}`; + } /* ───────── visual helpers ───────── */ statusAccent(status: Order['status']) { diff --git a/libs/shared/client/ui/src/lib/components/modal/modal.component.ts b/libs/shared/client/ui/src/lib/components/modal/modal.component.ts index b2e6ec927..0e923cfc7 100644 --- a/libs/shared/client/ui/src/lib/components/modal/modal.component.ts +++ b/libs/shared/client/ui/src/lib/components/modal/modal.component.ts @@ -76,6 +76,5 @@ export class UiModalComponent { onSave(): void { this.save.emit(); - this.onCancel(); } } diff --git a/libs/shared/client/ui/src/lib/components/tile/tile.component.ts b/libs/shared/client/ui/src/lib/components/tile/tile.component.ts index 627465958..b4ac823fb 100644 --- a/libs/shared/client/ui/src/lib/components/tile/tile.component.ts +++ b/libs/shared/client/ui/src/lib/components/tile/tile.component.ts @@ -89,7 +89,7 @@ import { UiTagComponent } from '../tag/tag.component'; icon="faDownload" iconSize="16px" iconColor="var(--accent)" - description="Subscribe" + [description]="isSubscribed() ? 'Unsubscribe' : 'Subscribe'" (iconButtonClick)="emitSubscribe()" >
      diff --git a/libs/shared/client/ui/src/lib/services/custom-modal.service.ts b/libs/shared/client/ui/src/lib/services/custom-modal.service.ts index 6e20faa8e..8d5fcea7f 100644 --- a/libs/shared/client/ui/src/lib/services/custom-modal.service.ts +++ b/libs/shared/client/ui/src/lib/services/custom-modal.service.ts @@ -13,20 +13,22 @@ export class CustomModalService { content?: string; template?: TemplateRef; context?: unknown; - onSave?: () => void; + onSave?: () => boolean | void; onClose?: () => void; }) { const modalRef = this.modalService.create({ component: UiModalComponent, }); - if (options.header) modalRef.instance.header.set(options.header); - if (options.content) modalRef.instance.content.set(options.content); - if (options.template) modalRef.instance.template.set(options.template); - if (options.context) modalRef.instance.context.set(options.context); + modalRef.setInput('header', options.header); + modalRef.setInput('content', options.content); + modalRef.setInput('template', options.template ?? null); + modalRef.setInput('context', options.context ?? null); + modalRef.changeDetectorRef.detectChanges(); modalRef.instance.save.subscribe(() => { - options.onSave?.(); + const shouldClose = options.onSave?.(); + if (shouldClose === false) return; modalRef.destroy(); }); diff --git a/libs/shared/server/data-access/Data/DbInitializer.cs b/libs/shared/server/data-access/Data/DbInitializer.cs index b9a29bdd1..de4589eaf 100644 --- a/libs/shared/server/data-access/Data/DbInitializer.cs +++ b/libs/shared/server/data-access/Data/DbInitializer.cs @@ -19,6 +19,12 @@ public static void Initialize(MyDbContext context, IConfiguration configuration) var propertyManagerUrl = configuration["CatalogApplications:PropertyManagerUrl"]?.Trim() ?? "https://property.eliasdebock.com"; + var smostrAdminUrl = + configuration["CatalogApplications:SmostrAdminUrl"]?.Trim() + ?? "https://admin.smostr.com"; + var smostrShopUrl = + configuration["CatalogApplications:SmostrShopUrl"]?.Trim() + ?? "https://shop.smostr.com"; var seededApplications = new[] { @@ -70,6 +76,22 @@ public static void Initialize(MyDbContext context, IConfiguration configuration) RoutePath = propertyManagerUrl, Tags = ["Property", "Management", "Operations"], }, + new Application + { + Name = "Smostr Admin", + Description = "Smostr administration portal", + IconUrl = "https://unpkg.com/lucide-static/icons/shield-check.svg", + RoutePath = smostrAdminUrl, + Tags = ["Angular", ".NET"], + }, + new Application + { + Name = "Smostr Shop", + Description = "Smostr mobile shop", + IconUrl = "https://unpkg.com/lucide-static/icons/store.svg", + RoutePath = smostrShopUrl, + Tags = ["React Native", ".NET"], + }, }; foreach (var seededApplication in seededApplications)