+
-
-
-
- {{#if @resource.meta.is_pickup}}
-
-
- {{t "storefront.component.widget.orders.pickup-order"}}
-
- {{else}}
-
-
- Delivery Order
-
- {{/if}}
+
+
-
+
+
diff --git a/addon/components/storefront/product/form.hbs b/addon/components/storefront/product/form.hbs
index 35819aff..4b1dd79e 100644
--- a/addon/components/storefront/product/form.hbs
+++ b/addon/components/storefront/product/form.hbs
@@ -1,50 +1,93 @@
diff --git a/addon/components/storefront/product/form.js b/addon/components/storefront/product/form.js
new file mode 100644
index 00000000..047e4060
--- /dev/null
+++ b/addon/components/storefront/product/form.js
@@ -0,0 +1,154 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+const TABS = ['details', 'variants', 'availability', 'media'];
+
+function number(value) {
+ const parsed = parseFloat(value);
+
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+/**
+ * The product editor: ten stacked panels become four tabs. Validation is inline and
+ * the tab that holds an invalid field carries a badge; the save bar counts changes.
+ */
+export default class StorefrontProductFormComponent extends Component {
+ @service intl;
+ @tracked activeTab = this.args.activeTab ?? 'details';
+ @tracked moreOpen = false;
+
+ get product() {
+ return this.args.product;
+ }
+
+ get variantsCount() {
+ return this.product?.variants?.length ?? 0;
+ }
+
+ get addonCategoriesCount() {
+ return this.product?.addon_categories?.length ?? 0;
+ }
+
+ get mediaCount() {
+ return (this.product?.files?.length ?? 0) + (Array.isArray(this.product?.youtube_urls) ? this.product.youtube_urls.length : 0);
+ }
+
+ get translationsCount() {
+ const translations = this.product?.translations;
+
+ return translations && typeof translations === 'object' ? Object.keys(translations).length : 0;
+ }
+
+ get metadataCount() {
+ const meta = this.product?.meta;
+
+ return meta && typeof meta === 'object' ? Object.keys(meta).length : 0;
+ }
+
+ /**
+ * What blocks a clean publish, each tied to the tab that holds the field.
+ */
+ get issues() {
+ const product = this.product;
+ const issues = [];
+
+ if (!product) {
+ return issues;
+ }
+
+ if (!String(product.name ?? '').trim()) {
+ issues.push({ tab: 'details', field: 'name', message: this.intl.t('storefront.products.form.issues.name') });
+ }
+
+ const price = number(product.price);
+ const salePrice = number(product.sale_price);
+
+ if (price === null || price < 0) {
+ issues.push({ tab: 'details', field: 'price', message: this.intl.t('storefront.products.form.issues.price') });
+ }
+
+ if (product.is_on_sale && salePrice !== null && price !== null && salePrice >= price) {
+ issues.push({ tab: 'details', field: 'sale_price', message: this.intl.t('storefront.products.form.issues.sale-price') });
+ }
+
+ if (product.is_on_sale && salePrice === null) {
+ issues.push({ tab: 'details', field: 'sale_price', message: this.intl.t('storefront.products.form.issues.sale-price-missing') });
+ }
+
+ if (!product.category_uuid && !product.category?.id && product.status === 'published') {
+ issues.push({ tab: 'details', field: 'category', message: this.intl.t('storefront.products.form.issues.category') });
+ }
+
+ if (product.status === 'published' && this.mediaCount === 0 && !product.primary_image_url) {
+ issues.push({ tab: 'media', field: 'media', message: this.intl.t('storefront.products.form.issues.media') });
+ }
+
+ return issues;
+ }
+
+ get issuesByTab() {
+ return this.issues.reduce((map, issue) => {
+ map[issue.tab] = (map[issue.tab] ?? 0) + 1;
+ return map;
+ }, {});
+ }
+
+ get detailIssueMessages() {
+ return this.issues.filter((issue) => issue.tab === 'details').map((issue) => issue.message);
+ }
+
+ get tabs() {
+ const counts = {
+ details: null,
+ variants: this.variantsCount + this.addonCategoriesCount || null,
+ availability: this.product?.hours?.length || null,
+ media: this.mediaCount || null,
+ };
+
+ return TABS.map((id) => ({
+ id,
+ label: this.intl.t(`storefront.products.form.tabs.${id}`),
+ count: counts[id],
+ issues: this.issuesByTab[id] ?? 0,
+ isActive: id === this.activeTab,
+ }));
+ }
+
+ get dirtyCount() {
+ const product = this.product;
+
+ if (!product) {
+ return 0;
+ }
+
+ if (product.isNew) {
+ return 1;
+ }
+
+ const changed = Object.keys(product.changedAttributes?.() ?? {}).length;
+ const dirtyVariants = (product.variants?.toArray?.() ?? []).filter((variant) => variant.hasDirtyAttributes || variant.isNew).length;
+ const dirtyAddons = (product.addon_categories?.toArray?.() ?? []).filter((category) => category.hasDirtyAttributes || category.isNew).length;
+ const queued = this.args.uploadQueue?.length ?? 0;
+
+ return changed + dirtyVariants + dirtyAddons + queued;
+ }
+
+ get isDirty() {
+ return this.dirtyCount > 0;
+ }
+
+ @action selectTab(tab) {
+ this.activeTab = tab.id ?? tab;
+ }
+
+ @action toggleMore() {
+ this.moreOpen = !this.moreOpen;
+ }
+
+ @action jumpToIssue(issue) {
+ this.activeTab = issue.tab;
+ }
+}
diff --git a/addon/components/storefront/setting-rule.hbs b/addon/components/storefront/setting-rule.hbs
new file mode 100644
index 00000000..43381bee
--- /dev/null
+++ b/addon/components/storefront/setting-rule.hbs
@@ -0,0 +1,19 @@
+
+
+
+
+
+
{{@label}}
+ {{#if @description}}
+
{{@description}}
+ {{/if}}
+ {{#if (and @value (has-block))}}
+
+ {{yield}}
+
+ {{/if}}
+
+
+
+
+
diff --git a/addon/components/storefront/truck/card.hbs b/addon/components/storefront/truck/card.hbs
new file mode 100644
index 00000000..84bbab1d
--- /dev/null
+++ b/addon/components/storefront/truck/card.hbs
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
diff --git a/addon/components/storefront/truck/card.js b/addon/components/storefront/truck/card.js
new file mode 100644
index 00000000..3527d9ec
--- /dev/null
+++ b/addon/components/storefront/truck/card.js
@@ -0,0 +1,80 @@
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import { resolveResourceImage, getPlaceholderImage } from '@fleetbase/fleetops-data/utils/placeholder-images';
+
+/**
+ * A truck (the `food-truck` resource) on the shared card anatomy: the vehicle
+ * pill as its identity, the vehicle photo as media, and what matters to a
+ * store in the footer: whether it is online, where it serves, and which
+ * catalogs it carries.
+ */
+export default class StorefrontTruckCardComponent extends Component {
+ get truck() {
+ return this.args.truck ?? this.args.foodTruck ?? this.args.resource;
+ }
+
+ get vehicle() {
+ return get(this.truck, 'vehicle');
+ }
+
+ get isOnline() {
+ const online = get(this.truck, 'online');
+
+ if (typeof online === 'boolean') {
+ return online;
+ }
+
+ return Boolean(this.vehicle && get(this.vehicle, 'online'));
+ }
+
+ get status() {
+ return get(this.truck, 'status') ?? 'inactive';
+ }
+
+ get isActive() {
+ return this.status === 'active';
+ }
+
+ get photoUrl() {
+ return resolveResourceImage(this.vehicle ? get(this.vehicle, 'photo_url') : null, 'vehicle');
+ }
+
+ get placeholder() {
+ return getPlaceholderImage('vehicle');
+ }
+
+ get serviceAreaName() {
+ return get(this.truck, 'service_area.name');
+ }
+
+ get zoneName() {
+ return get(this.truck, 'zone.name');
+ }
+
+ get catalogs() {
+ const catalogs = get(this.truck, 'catalogs');
+
+ return catalogs ? catalogs.slice() : [];
+ }
+
+ get storeName() {
+ return get(this.truck, 'store_summary.name');
+ }
+
+ /** What the store needs to know at a glance: serving, online with nothing to sell, or offline. */
+ get presence() {
+ if (!this.isActive) {
+ return { status: 'inactive', labelKey: 'storefront.trucks.card.inactive' };
+ }
+
+ if (!this.isOnline) {
+ return { status: 'offline', labelKey: 'storefront.trucks.card.offline' };
+ }
+
+ if (this.catalogs.length === 0) {
+ return { status: 'warning', labelKey: 'storefront.trucks.card.no-catalog' };
+ }
+
+ return { status: 'online', labelKey: 'storefront.trucks.card.online' };
+ }
+}
diff --git a/addon/controllers/application.js b/addon/controllers/application.js
index 82fca604..d0135b3a 100644
--- a/addon/controllers/application.js
+++ b/addon/controllers/application.js
@@ -1,7 +1,6 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
-import { alias } from '@ember/object/computed';
import { tracked } from '@glimmer/tracking';
export default class ApplicationController extends Controller {
@@ -12,11 +11,96 @@ export default class ApplicationController extends Controller {
@service intl;
@service abilities;
@service store;
- @alias('storefront.activeStore') activeStore;
+ /**
+ * Native getters so the sidebar re-renders when the service's tracked ids change;
+ * a computed alias onto a native getter never invalidates.
+ */
+ get activeStore() {
+ return this.storefront.activeStore;
+ }
+
+ get activeNetwork() {
+ return this.storefront.activeNetwork;
+ }
@tracked productCategories = [];
categoryLoadStoreUuid;
+ get isNetworkContext() {
+ return Boolean(this.activeNetwork?.id);
+ }
+
+ /**
+ * The sidebar is rewritten for the active context: a network gets its own
+ * navigation, everything else is scoped to the active store.
+ */
get navigationItems() {
+ return this.isNetworkContext ? this.networkNavigationItems : this.storeNavigationItems;
+ }
+
+ get networkNavigationItems() {
+ const publicId = this.activeNetwork.public_id;
+ const networkItem = (key, item) => {
+ const route = `console.storefront.networks.index.network.${key}`;
+
+ return {
+ id: `network:${publicId}:${key}`,
+ ...item,
+ // These routes share one dynamic segment, so route-name matching alone cannot tell networks apart.
+ activeWhen: () => this.hostRouter.isActive(route, publicId),
+ onClick: () => this.hostRouter.transitionTo(route, publicId),
+ };
+ };
+
+ return [
+ networkItem('index', {
+ label: this.intl.t('storefront.networks.index.network.overview'),
+ description: 'Members, invitations, orders and revenue across the network.',
+ icon: 'gauge-high',
+ permission: 'storefront view network',
+ keywords: ['overview', 'dashboard', 'network'],
+ }),
+ networkItem('stores', {
+ label: this.intl.t('storefront.networks.index.network.stores.store'),
+ description: 'Stores selling through this network, their categories and invitations.',
+ icon: 'store',
+ permission: 'storefront view network',
+ keywords: ['stores', 'members', 'invite', 'categories'],
+ }),
+ networkItem('trucks', {
+ label: this.intl.t('storefront.common.food-trucks'),
+ description: 'Trucks run by the stores in this network.',
+ icon: 'truck',
+ permission: 'storefront list food-truck',
+ visible: this.can('storefront see food-truck'),
+ keywords: ['vehicles', 'mobile store', 'truck'],
+ }),
+ networkItem('orders', {
+ label: this.intl.t('storefront.networks.index.network.orders'),
+ description: 'Orders placed through this network.',
+ icon: 'file-invoice-dollar',
+ permission: 'storefront list order',
+ visible: this.can('storefront see order'),
+ keywords: ['fulfillment', 'deliveries', 'checkout orders'],
+ }),
+ networkItem('customers', {
+ label: this.intl.t('storefront.networks.index.network.customers'),
+ description: 'Customers who ordered through this network.',
+ icon: 'users',
+ permission: 'storefront list user',
+ visible: this.can('storefront see user'),
+ keywords: ['contacts', 'buyers', 'users'],
+ }),
+ networkItem('settings', {
+ label: this.intl.t('storefront.networks.index.network.settings'),
+ description: 'Network details, gateways and notification channels.',
+ icon: 'cog',
+ permission: 'storefront update network',
+ keywords: ['settings', 'general', 'gateways', 'notifications'],
+ }),
+ ];
+ }
+
+ get storeNavigationItems() {
const hasActiveStore = Boolean(this.activeStore?.id);
return [
@@ -27,6 +111,16 @@ export default class ApplicationController extends Controller {
route: 'console.storefront.home',
keywords: ['dashboard', 'overview', 'metrics', 'sales'],
},
+ {
+ label: this.intl.t('storefront.sidebar.orders'),
+ description: 'Manage incoming storefront orders.',
+ icon: 'file-invoice-dollar',
+ route: 'console.storefront.orders',
+ permission: 'storefront list order',
+ visible: this.can('storefront see order'),
+ disabled: !hasActiveStore,
+ keywords: ['fulfillment', 'deliveries', 'checkout orders'],
+ },
{
label: this.intl.t('storefront.sidebar.products'),
description: 'Manage products, categories, variants, and addons.',
@@ -65,29 +159,9 @@ export default class ApplicationController extends Controller {
disabled: !hasActiveStore,
keywords: ['contacts', 'buyers', 'users'],
},
- {
- label: this.intl.t('storefront.sidebar.orders'),
- description: 'Manage incoming storefront orders.',
- icon: 'file-invoice-dollar',
- route: 'console.storefront.orders',
- permission: 'storefront list order',
- visible: this.can('storefront see order'),
- disabled: !hasActiveStore,
- keywords: ['fulfillment', 'deliveries', 'checkout orders'],
- },
- {
- label: this.intl.t('storefront.sidebar.networks'),
- description: 'Manage marketplace networks and participating stores.',
- icon: 'network-wired',
- route: 'console.storefront.networks',
- permission: 'storefront list network',
- visible: this.can('storefront see network'),
- disabled: !hasActiveStore,
- keywords: ['marketplace', 'network stores', 'partners'],
- },
{
label: this.intl.t('storefront.sidebar.food-trucks'),
- description: 'Manage food truck locations and availability.',
+ description: 'Manage trucks, where they serve and what they sell.',
icon: 'truck',
route: 'console.storefront.food-trucks',
permission: 'storefront list food-truck',
@@ -259,7 +333,6 @@ export default class ApplicationController extends Controller {
const loader = this.loader.show({ loadingMessage: 'Switching to newly created store...' });
this.hostRouter.refresh().then(() => {
- this.notifyPropertyChange('activeStore');
this.loadProductCategories(this.activeStoreUuid);
this.loader.removeLoader(loader);
});
@@ -267,14 +340,23 @@ export default class ApplicationController extends Controller {
});
}
+ @action createNewNetwork() {
+ return this.storefront.createNewNetwork({
+ onSuccess: (network) => this.switchActiveNetwork(network),
+ });
+ }
+
@action switchActiveStore(store) {
const loader = this.loader.show({ loadingMessage: `Switching Storefront to ${store.name}...` });
+ const leavingNetwork = this.isNetworkContext;
this.storefront.setActiveStorefront(store);
this.productCategories = [];
- return this.hostRouter
- .refresh()
+ // A page inside a network has no meaning for a store, so the switch lands on the store dashboard;
+ // leaving the network route is what clears the network context.
+ const transition = leavingNetwork ? this.hostRouter.transitionTo('console.storefront.home') : this.hostRouter.refresh();
+
+ return Promise.resolve(transition)
.then(() => {
- this.notifyPropertyChange('activeStore');
return this.loadProductCategories(store.id);
})
.finally(() => {
@@ -282,6 +364,11 @@ export default class ApplicationController extends Controller {
});
}
+ @action switchActiveNetwork(network) {
+ // Entering the network route is what sets the network context.
+ return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', network.public_id);
+ }
+
@action
async searchNavigation({ query, limit = 12 }) {
const trimmedQuery = query?.trim();
diff --git a/addon/controllers/catalogs/index.js b/addon/controllers/catalogs/index.js
index 67de1bf4..b988deaa 100644
--- a/addon/controllers/catalogs/index.js
+++ b/addon/controllers/catalogs/index.js
@@ -2,6 +2,8 @@ import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { isBlank } from '@ember/utils';
+import { task, timeout } from 'ember-concurrency';
export default class CatalogsIndexController extends Controller {
@service store;
@@ -14,106 +16,64 @@ export default class CatalogsIndexController extends Controller {
queryParams = ['query'];
@tracked query;
+
+ /**
+ * Debounced search; updates the `query` param the route refreshes on.
+ */
+ @task({ restartable: true }) *search({ target: { value } }) {
+ if (isBlank(value)) {
+ this.query = null;
+ return;
+ }
+
+ yield timeout(250);
+ this.query = value;
+ }
@tracked statusOptions = ['draft', 'published'];
+ /**
+ * The editor route renders in this template's outlet; the grid hides while it is open.
+ */
+ get isEditing() {
+ return String(this.hostRouter.currentRouteName ?? '').endsWith('catalogs.index.edit');
+ }
+
@action createCatalog() {
const catalog = this.store.createRecord('catalog', {
store_uuid: this.storefront.activeStore.id,
status: 'draft',
});
- return this.editCatalog(catalog, {
- title: 'New Catalog',
- acceptButtonText: 'Confirm',
- acceptButtonIcon: 'check',
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- await catalog.save();
- this.hostRouter.refresh();
- this.notifications.success('New catalog created.');
- } catch (error) {
- this.notifications.serverError(error);
- } finally {
- modal.stopLoading();
- }
- },
- });
- }
-
- @action editCatalog(catalog, modalOptions = {}) {
- const allProducts = this.store.query('product', { limit: -1 });
-
this.modalsManager.show('modals/create-catalog', {
- title: 'Edit Food Truck',
- acceptButtonText: 'Save Changes',
- acceptButtonIcon: 'save',
+ title: this.intl.t('storefront.catalogs.index.create-catalog'),
+ acceptButtonText: this.intl.t('storefront.catalogs.editor.create-and-open'),
+ acceptButtonIcon: 'arrow-right',
statusOptions: this.statusOptions,
catalog,
- allProducts,
- selectedProducts: [],
- toggleCategory: (category) => {
- category.set('expanded', !category.get('expanded'));
- },
- finishAddingProducts: (category) => {
- category.setProperties({
- adding_products: false,
- });
- },
- confirmSelectedProducts: (category, products) => {
- category.setProperties({
- products,
- });
- },
- removeProduct: (category, product) => {
- const products = category.get('products').filter((p) => p.id !== product.id);
- category.set('products', products);
- },
- addProducts: (category) => {
- category.setProperties({
- expanded: true,
- adding_products: true,
- });
- },
- createCategory: () => {
- const name = prompt('Enter category name');
- const category = this.store.createRecord('catalog-category', { name });
- catalog.categories.pushObject(category);
- },
- editCategory: (category) => {
- const name = prompt('Change category name', category.name);
- category.set('name', name);
- },
- deleteCategory: async (category) => {
- const confirmed = confirm('Delete this category?');
- if (confirmed) {
- category.set('deleting', true);
-
- try {
- await category.destroyRecord();
- } catch (error) {
- this.notifications.serverError(error);
- }
- }
- },
confirm: async (modal) => {
modal.startLoading();
try {
await catalog.save();
- this.hostRouter.refresh();
- this.notifications.success('Changes to catalog saved.');
+ this.notifications.success(this.intl.t('storefront.catalogs.editor.created'));
+ modal.done();
+ return this.editCatalog(catalog);
} catch (error) {
- this.notifications.serverError(error);
- } finally {
modal.stopLoading();
+ this.notifications.serverError(error);
}
},
- ...modalOptions,
+ decline: (modal) => {
+ catalog.destroyRecord();
+ modal.done();
+ },
});
}
+ @action editCatalog(catalog) {
+ return this.hostRouter.transitionTo('console.storefront.catalogs.index.edit', catalog.public_id ?? catalog.id);
+ }
+
@action deleteCatalog(catalog) {
this.crud.delete(catalog, {
onSuccess: () => {
diff --git a/addon/controllers/catalogs/index/edit.js b/addon/controllers/catalogs/index/edit.js
new file mode 100644
index 00000000..b07a907c
--- /dev/null
+++ b/addon/controllers/catalogs/index/edit.js
@@ -0,0 +1,407 @@
+import Controller from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+
+const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+
+export default class CatalogsIndexEditController extends Controller {
+ @service store;
+ @service intl;
+ @service notifications;
+ @service modalsManager;
+ @service hostRouter;
+ @service crud;
+ @tracked catalog;
+ @tracked products = [];
+ @tracked stores = [];
+ @tracked trucks = [];
+ @tracked selectedCategory = null;
+ @tracked isPickerOpen = false;
+ @tracked pickerQuery = '';
+ @tracked pickerSelection = [];
+ @tracked newCategoryName = '';
+ @tracked renamingCategory = null;
+ @tracked renameValue = '';
+ @tracked revision = 0;
+ @tracked activeSection = 'categories';
+ structureSnapshot = '';
+
+ statusOptions = ['draft', 'published'];
+
+ get categories() {
+ return this.catalog?.categories?.toArray?.() ?? Array.from(this.catalog?.categories ?? []);
+ }
+
+ get categoryRows() {
+ this.revision;
+
+ return this.categories.map((category, index) => ({
+ category,
+ index,
+ count: category.products?.length ?? 0,
+ isSelected: category === this.selectedCategory,
+ isFirst: index === 0,
+ isLast: index === this.categories.length - 1,
+ }));
+ }
+
+ get selectedProducts() {
+ this.revision;
+
+ return this.selectedCategory?.products?.toArray?.() ?? Array.from(this.selectedCategory?.products ?? []);
+ }
+
+ get productIdsInCatalog() {
+ this.revision;
+
+ return new Set(this.categories.flatMap((category) => (category.products?.toArray?.() ?? Array.from(category.products ?? [])).map((product) => product.id)));
+ }
+
+ /**
+ * Products the picker offers: the store's products not yet in this catalog, filtered by the search box.
+ */
+ get pickerProducts() {
+ const inCatalog = this.productIdsInCatalog;
+ const query = this.pickerQuery.trim().toLowerCase();
+
+ return this.products.filter((product) => !inCatalog.has(product.id)).filter((product) => !query || String(product.name ?? '').toLowerCase().includes(query) || String(product.sku ?? '').toLowerCase().includes(query));
+ }
+
+ get productsCount() {
+ this.revision;
+
+ return this.catalog?.productsCount ?? 0;
+ }
+
+ get outOfStockCount() {
+ this.revision;
+
+ return this.categories.flatMap((category) => category.products?.toArray?.() ?? Array.from(category.products ?? [])).filter((product) => product.is_available === false).length;
+ }
+
+ get emptyCategoriesCount() {
+ this.revision;
+
+ return this.categories.filter((category) => !(category.products?.length > 0)).length;
+ }
+
+ get hours() {
+ return this.catalog?.hours?.toArray?.() ?? Array.from(this.catalog?.hours ?? []);
+ }
+
+ get hoursSummary() {
+ const hours = this.hours;
+
+ if (!hours.length) {
+ return this.intl.t('storefront.catalogs.editor.no-hours');
+ }
+
+ const days = [...new Set(hours.map((hour) => hour.day_of_week))];
+ const sample = hours[0];
+ const dayLabel = days.length === 7 ? this.intl.t('storefront.catalogs.editor.every-day') : days.map((day) => String(day).slice(0, 3)).join(', ');
+
+ return `${sample.start ?? ''}${sample.end ? ` – ${sample.end}` : ''} · ${dayLabel}`;
+ }
+
+ get isVisibleInApp() {
+ return this.catalog?.status === 'published';
+ }
+
+ get servedBy() {
+ return this.catalog?.subjects ?? [];
+ }
+
+ get structure() {
+ this.revision;
+
+ return JSON.stringify({
+ name: this.catalog?.name,
+ description: this.catalog?.description,
+ status: this.catalog?.status,
+ categories: this.categories.map((category) => ({ id: category.id, name: category.name, products: (category.products?.toArray?.() ?? Array.from(category.products ?? [])).map((product) => product.id) })),
+ });
+ }
+
+ get isDirty() {
+ return this.structure !== this.structureSnapshot;
+ }
+
+ get dirtySummary() {
+ if (!this.isDirty) {
+ return null;
+ }
+
+ try {
+ const before = JSON.parse(this.structureSnapshot || '{}');
+ const after = JSON.parse(this.structure);
+ const beforeIds = new Set((before.categories ?? []).flatMap((category) => category.products));
+ const afterIds = new Set((after.categories ?? []).flatMap((category) => category.products));
+ const added = [...afterIds].filter((id) => !beforeIds.has(id)).length;
+ const removed = [...beforeIds].filter((id) => !afterIds.has(id)).length;
+ const categoriesChanged = (before.categories ?? []).length !== (after.categories ?? []).length;
+
+ return this.intl.t('storefront.catalogs.editor.unsaved-summary', { added, removed, categories: categoriesChanged ? 1 : 0 });
+ } catch {
+ return this.intl.t('storefront.catalogs.editor.unsaved');
+ }
+ }
+
+ snapshot() {
+ this.structureSnapshot = this.structure;
+ this.revision++;
+ }
+
+ touch() {
+ this.revision++;
+ }
+
+ @action setSection(section) {
+ this.activeSection = section;
+ }
+
+ @action selectCategory(category) {
+ this.selectedCategory = category;
+ this.isPickerOpen = false;
+ }
+
+ @action setNewCategoryName(event) {
+ this.newCategoryName = event.target.value;
+ }
+
+ @action addCategory(event) {
+ event?.preventDefault?.();
+ const name = this.newCategoryName.trim();
+
+ if (!name) {
+ return;
+ }
+
+ const category = this.store.createRecord('catalog-category', { name, for: 'storefront_catalog', order: this.categories.length });
+ this.catalog.categories.pushObject(category);
+ this.newCategoryName = '';
+ this.selectedCategory = category;
+ this.touch();
+ }
+
+ @action startRename(category) {
+ this.renamingCategory = category;
+ this.renameValue = category.name ?? '';
+ }
+
+ @action setRenameValue(event) {
+ this.renameValue = event.target.value;
+ }
+
+ @action commitRename(event) {
+ event?.preventDefault?.();
+
+ if (this.renamingCategory && this.renameValue.trim()) {
+ this.renamingCategory.set('name', this.renameValue.trim());
+ this.touch();
+ }
+
+ this.renamingCategory = null;
+ }
+
+ @action cancelRename() {
+ this.renamingCategory = null;
+ }
+
+ @action moveCategory(category, direction) {
+ const list = this.catalog.categories;
+ const index = list.indexOf(category);
+ const target = index + direction;
+
+ if (index < 0 || target < 0 || target >= list.length) {
+ return;
+ }
+
+ list.removeObject(category);
+ list.insertAt(target, category);
+ list.forEach((item, position) => item.set('order', position));
+ this.touch();
+ }
+
+ @action removeCategory(category) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('storefront.catalogs.editor.remove-category-title', { name: category.name }),
+ body: this.intl.t('storefront.catalogs.editor.remove-category-body'),
+ acceptButtonText: this.intl.t('common.delete'),
+ acceptButtonScheme: 'danger',
+ confirm: (modal) => {
+ this.catalog.categories.removeObject(category);
+
+ if (this.selectedCategory === category) {
+ this.selectedCategory = this.categories[0] ?? null;
+ }
+
+ this.touch();
+ modal.done();
+ },
+ });
+ }
+
+ @action openPicker(category = this.selectedCategory) {
+ if (category) {
+ this.selectedCategory = category;
+ }
+
+ this.pickerSelection = [];
+ this.pickerQuery = '';
+ this.isPickerOpen = true;
+ }
+
+ @action closePicker() {
+ this.isPickerOpen = false;
+ this.pickerSelection = [];
+ }
+
+ @action setPickerQuery(event) {
+ this.pickerQuery = event.target.value;
+ }
+
+ @action togglePickerProduct(product) {
+ if (this.pickerSelection.includes(product)) {
+ this.pickerSelection = this.pickerSelection.filter((item) => item !== product);
+ } else {
+ this.pickerSelection = [...this.pickerSelection, product];
+ }
+ }
+
+ @action confirmPicker() {
+ const category = this.selectedCategory;
+
+ if (!category) {
+ return;
+ }
+
+ this.pickerSelection.forEach((product) => {
+ if (!category.products.includes(product)) {
+ category.products.pushObject(product);
+ }
+ });
+ this.closePicker();
+ this.touch();
+ }
+
+ @action removeProduct(category, product) {
+ category.products.removeObject(product);
+ this.touch();
+ }
+
+ @action setStatus(status) {
+ this.catalog.set('status', status);
+ this.touch();
+ }
+
+ @action touchField() {
+ this.touch();
+ }
+
+ @action manageServedBy() {
+ const catalog = this.catalog;
+ const memberStoreIds = new Set(catalog.storeSubjects.map((subject) => subject.id));
+ const memberTruckIds = new Set(catalog.truckSubjects.map((subject) => subject.id));
+
+ this.modalsManager.show('modals/assign-catalog-subjects', {
+ title: this.intl.t('storefront.catalogs.editor.served-by-title', { name: catalog.name }),
+ acceptButtonText: this.intl.t('common.save'),
+ acceptButtonIcon: 'save',
+ catalog,
+ stores: this.stores,
+ trucks: this.trucks,
+ selectedStores: this.stores.filter((store) => memberStoreIds.has(store.id)),
+ selectedTrucks: this.trucks.filter((truck) => memberTruckIds.has(truck.id)),
+ setStores: (stores) => this.modalsManager.setOption('selectedStores', Array.from(stores ?? [])),
+ setTrucks: (trucks) => this.modalsManager.setOption('selectedTrucks', Array.from(trucks ?? [])),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await catalog.assignSubjects({ stores: modal.getOption('selectedStores'), food_trucks: modal.getOption('selectedTrucks') });
+ this.notifications.success(this.intl.t('storefront.catalogs.editor.served-by-saved'));
+ this.touch();
+ modal.done();
+ } catch (error) {
+ modal.stopLoading();
+ this.notifications.serverError(error);
+ }
+ },
+ });
+ }
+
+ @task *save() {
+ try {
+ yield this.catalog.save();
+ this.snapshot();
+ this.notifications.success(this.intl.t('storefront.catalogs.editor.saved'));
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action discard() {
+ this.catalog.rollbackAttributes();
+ this.catalog.categories.forEach((category) => {
+ if (category.isNew) {
+ this.catalog.categories.removeObject(category);
+ } else {
+ category.rollbackAttributes();
+ }
+ });
+ this.selectedCategory = this.categories[0] ?? null;
+ this.hostRouter.refresh();
+ }
+
+ @action duplicate() {
+ const copy = this.store.createRecord('catalog', {
+ store_uuid: this.catalog.store_uuid,
+ name: `${this.catalog.name} (copy)`,
+ description: this.catalog.description,
+ status: 'draft',
+ });
+
+ this.categories.forEach((category) => {
+ const clone = this.store.createRecord('catalog-category', { name: category.name, for: 'storefront_catalog', order: category.order });
+ (category.products?.toArray?.() ?? []).forEach((product) => clone.products.pushObject(product));
+ copy.categories.pushObject(clone);
+ });
+
+ return copy
+ .save()
+ .then(() => {
+ this.notifications.success(this.intl.t('storefront.catalogs.editor.duplicated'));
+ return this.hostRouter.transitionTo('console.storefront.catalogs.index.edit', copy.public_id ?? copy.id);
+ })
+ .catch((error) => this.notifications.serverError(error));
+ }
+
+ @action deleteCatalog() {
+ return this.crud.delete(this.catalog, {
+ onSuccess: () => this.hostRouter.transitionTo('console.storefront.catalogs.index'),
+ });
+ }
+
+ @action back() {
+ if (this.isDirty) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('storefront.catalogs.editor.leave-title'),
+ body: this.intl.t('storefront.catalogs.editor.leave-body'),
+ acceptButtonText: this.intl.t('storefront.catalogs.editor.leave'),
+ confirm: (modal) => {
+ modal.done();
+ this.discard();
+ return this.hostRouter.transitionTo('console.storefront.catalogs.index');
+ },
+ });
+ }
+
+ return this.hostRouter.transitionTo('console.storefront.catalogs.index');
+ }
+
+ get dayNames() {
+ return DAYS;
+ }
+}
diff --git a/addon/controllers/food-trucks/index.js b/addon/controllers/food-trucks/index.js
index d0fe2c6b..32e37cdd 100644
--- a/addon/controllers/food-trucks/index.js
+++ b/addon/controllers/food-trucks/index.js
@@ -2,6 +2,8 @@ import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { isBlank } from '@ember/utils';
+import { task, timeout } from 'ember-concurrency';
export default class FoodTrucksIndexController extends Controller {
@service store;
@@ -14,6 +16,19 @@ export default class FoodTrucksIndexController extends Controller {
queryParams = ['query'];
@tracked query;
+
+ /**
+ * Debounced search; updates the `query` param the route refreshes on.
+ */
+ @task({ restartable: true }) *search({ target: { value } }) {
+ if (isBlank(value)) {
+ this.query = null;
+ return;
+ }
+
+ yield timeout(250);
+ this.query = value;
+ }
@tracked statusOptions = ['active', 'inactive'];
@action createFoodTruck() {
@@ -23,7 +38,7 @@ export default class FoodTrucksIndexController extends Controller {
});
this.modalsManager.show('modals/create-food-truck', {
- title: 'New Food Truck',
+ title: 'New Truck',
statusOptions: this.statusOptions,
foodTruck,
confirm: async (modal) => {
@@ -32,7 +47,7 @@ export default class FoodTrucksIndexController extends Controller {
try {
await foodTruck.save();
this.hostRouter.refresh();
- this.notifications.success('New food truck created.');
+ this.notifications.success('New truck created.');
} catch (error) {
this.notifications.serverError(error);
} finally {
@@ -44,7 +59,7 @@ export default class FoodTrucksIndexController extends Controller {
@action editFoodTruck(foodTruck) {
this.modalsManager.show('modals/create-food-truck', {
- title: 'Edit Food Truck',
+ title: 'Edit Truck',
acceptButtonText: 'Save Changes',
acceptButtonIcon: 'save',
statusOptions: this.statusOptions,
@@ -55,7 +70,7 @@ export default class FoodTrucksIndexController extends Controller {
try {
await foodTruck.save();
this.hostRouter.refresh();
- this.notifications.success('Changes to food truck saved.');
+ this.notifications.success('Changes to truck saved.');
} catch (error) {
this.notifications.serverError(error);
} finally {
@@ -69,7 +84,7 @@ export default class FoodTrucksIndexController extends Controller {
const allCatalogs = await this.store.query('catalog', { limit: -1 });
console.log('[allCatalogs]', allCatalogs);
this.modalsManager.show('modals/assign-food-truck-catalogs', {
- title: "Assign Catalog's to this Food Truck",
+ title: 'Assign catalogs to this truck',
acceptButtonText: 'Done',
acceptButtonIcon: 'save',
foodTruck,
@@ -83,7 +98,7 @@ export default class FoodTrucksIndexController extends Controller {
try {
await foodTruck.save();
this.hostRouter.refresh();
- this.notifications.success('Changes to food truck saved.');
+ this.notifications.success('Changes to truck saved.');
} catch (error) {
this.notifications.serverError(error);
} finally {
diff --git a/addon/controllers/networks/index.js b/addon/controllers/networks/index.js
index 50ff3600..88b0aead 100644
--- a/addon/controllers/networks/index.js
+++ b/addon/controllers/networks/index.js
@@ -77,6 +77,13 @@ export default class NetworksIndexController extends BaseController {
*/
@service hostRouter;
+ /**
+ * Inject the `storefront` service
+ *
+ * @var {Service}
+ */
+ @service storefront;
+
/**
* Queryable parameters for this controller's model
*
@@ -165,27 +172,8 @@ export default class NetworksIndexController extends BaseController {
* @public
*/
@action createNetwork() {
- const network = this.store.createRecord('network');
- const currency = this.currentUser.getWhoisProperty('currency.code');
-
- if (currency) {
- network.setProperties({ currency });
- }
-
- this.modalsManager.show('modals/create-network', {
- title: this.intl.t('storefront.networks.index.create-new-network'),
- network,
- confirm: (modal) => {
- modal.startLoading();
-
- return network.save().then(() => {
- this.notifications.success(this.intl.t('storefront.networks.index.success-message'));
- return this.hostRouter.refresh();
- });
- },
- decline: () => {
- return network.destroyRecord();
- },
+ return this.storefront.createNewNetwork({
+ onSuccess: () => this.hostRouter.refresh(),
});
}
diff --git a/addon/controllers/networks/index/network.js b/addon/controllers/networks/index/network.js
index 2f8b823a..b0c65454 100644
--- a/addon/controllers/networks/index/network.js
+++ b/addon/controllers/networks/index/network.js
@@ -9,8 +9,8 @@ export default class NetworksIndexNetworkController extends BaseController {
get tabs() {
return [
{
- label: this.intl.t('storefront.networks.index.network.settings'),
- icon: 'cog',
+ label: this.intl.t('storefront.networks.index.network.overview'),
+ icon: 'gauge-high',
route: 'networks.index.network.index',
},
{
@@ -28,10 +28,19 @@ export default class NetworksIndexNetworkController extends BaseController {
icon: 'users',
route: 'networks.index.network.customers',
},
+ {
+ label: this.intl.t('storefront.networks.index.network.settings'),
+ icon: 'cog',
+ route: 'networks.index.network.settings',
+ },
];
}
- @action transitionBack({ closeOverlay }) {
+ get storesCount() {
+ return this.model?.stores_count ?? this.model?.stores?.length ?? 0;
+ }
+
+ @action transitionBack({ closeOverlay } = {}) {
if (this.model.hasDirtyAttributes) {
// warn user about unsaved changes
return this.modalsManager.confirm({
@@ -48,8 +57,12 @@ export default class NetworksIndexNetworkController extends BaseController {
}
@action exit(closeOverlay) {
- return closeOverlay(() => {
- return this.transitionToRoute('networks.index');
- });
+ const leave = () => this.transitionToRoute('networks.index');
+
+ if (typeof closeOverlay === 'function') {
+ return closeOverlay(leave);
+ }
+
+ return leave();
}
}
diff --git a/addon/controllers/networks/index/network/index.js b/addon/controllers/networks/index/network/index.js
index e44bed45..2f16ad22 100644
--- a/addon/controllers/networks/index/network/index.js
+++ b/addon/controllers/networks/index/network/index.js
@@ -1,300 +1,117 @@
-import Controller, { inject as controller } from '@ember/controller';
+import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
-import { alias } from '@ember/object/computed';
import { action } from '@ember/object';
-import getPodMethods from '@fleetbase/ember-core/utils/get-pod-methods';
+import { formatDistanceToNow } from 'date-fns';
+import createShareableLink from '../../../../utils/create-shareable-link';
-/**
- * NetworksIndexNetworkIndexController
- *
- * This controller handles the logic for managing networks, gateways, and notification channels.
- *
- * @class NetworksIndexNetworkIndexController
- * @extends Controller
- */
export default class NetworksIndexNetworkIndexController extends Controller {
- /**
- * Controller for managing gateways.
- *
- * @property {Controller} gatewaysController
- */
- @controller('settings.gateways') gatewaysController;
-
- /**
- * Controller for managing notifications.
- *
- * @property {Controller} notificationsController
- */
- @controller('settings.notifications') notificationsController;
-
- /**
- * Notifications service to handle notification logic.
- *
- * @property {Service} notifications
- */
- @service notifications;
-
- /**
- * Fetch service to handle file uploads and other network requests.
- *
- * @property {Service} fetch
- */
- @service fetch;
-
- /**
- * intl service to handle file uploads and other network requests.
- *
- * @property {Service} intl
- */
@service intl;
-
- /**
- * Store service to handle file uploads and other network requests.
- *
- * @property {Service} store
- */
- @service store;
-
- /**
- * Proof of delivery methods.
- *
- * @property {Array} podMethods
- */
- @tracked podMethods = getPodMethods();
-
- /**
- * Loading state, indicating whether a network request is in progress.
- *
- * @property {Boolean} isLoading
- */
- @tracked isLoading = false;
-
- @tracked orderConfigs = [];
-
- /**
- * Alias for model.gateways, representing the gateways associated with the network.
- *
- * @property {Array} gateways
- */
- @alias('model.gateways') gateways;
-
- /**
- * Alias for model.notification_channels, representing the notification channels associated with the network.
- *
- * @property {Array} channels
- */
- @alias('model.notification_channels') channels;
-
- /**
- * Save network settings.
- *
- * @method saveSettings
- * @public
- */
- @action saveSettings() {
- this.isLoading = true;
-
- this.model
- .save()
- .then(() => {
- this.notifications.success(this.intl.t('storefront.networks.index.network.index.change-network-saved'));
- })
- .catch((error) => {
- this.notifications.serverError(error);
- })
- .finally(() => {
- this.isLoading = false;
- });
+ @service notifications;
+ @service hostRouter;
+ @service modalsManager;
+ @tracked network;
+ @tracked overview;
+ @tracked invitations = [];
+ @tracked isInvitePanelOpen = false;
+ @tracked invitePanelTab = 'email';
+
+ get currency() {
+ return this.overview?.currency ?? this.network?.currency;
}
- /**
- * Upload a file.
- *
- * @method uploadFile
- * @param {String} type - Type of the file.
- * @param {File} file - File to upload.
- * @public
- */
- @action uploadFile(type, file) {
- const prefix = type.replace('storefront_', '');
-
- this.fetch.uploadFile.perform(
- file,
- {
- path: `uploads/storefront/${this.model.id}/${type}`,
- key_uuid: this.model.id,
- key_type: `storefront:network`,
- type,
- },
- (uploadedFile) => {
- this.model.setProperties({
- [`${prefix}_uuid`]: uploadedFile.id,
- [`${prefix}_url`]: uploadedFile.url,
- [prefix]: uploadedFile,
- });
- }
- );
+ get members() {
+ return this.overview?.members ?? [];
}
- /**
- * Create a new payment gateway.
- *
- * @method createGateway
- * @public
- */
- @action createGateway() {
- const gateway = this.store.createRecord('gateway', {
- owner_uuid: this.model.id,
- owner_type: 'storefront:network',
- });
-
- this.editGateway(gateway, {
- title: this.intl.t('storefront.networks.index.network.index.create-new-payment-gateway'),
- acceptButtonText: this.intl.t('storefront.networks.index.network.index.save-gateway'),
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- await gateway.save();
- this.notifications.success(this.intl.t('storefront.networks.index.network.index.new-gateway-add-network'));
- this.gateways.pushObject(gateway);
- } catch (error) {
- modal.stopLoading();
- this.notifications.serverError(error);
- }
- },
- decline: (modal) => {
- gateway.destroyRecord();
- modal.done();
- },
- });
+ get recentOrders() {
+ return this.overview?.recent_orders ?? [];
}
- /**
- * Edit a payment gateway.
- *
- * @method editGateway
- * @param {Object} gateway - The gateway object to edit.
- * @param {Object} [options={}] - Optional parameters for editing the gateway.
- * @public
- */
- @action editGateway(gateway, options = {}) {
- if (options === null) {
- options = {};
- }
+ get openInvitations() {
+ return this.invitations.filter((invitation) => ['pending', 'declined', 'expired'].includes(invitation.status)).map((invitation) => ({ ...invitation, sentAgo: this.sentAgo(invitation) }));
+ }
- // The settings controller's modal saves the gateway and reports errors; only the message differs.
- if (!options.successNotification) {
- options.successNotification = this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success');
- }
+ get pendingCount() {
+ return this.invitations.filter((invitation) => invitation.status === 'pending').length;
+ }
- return this.gatewaysController.editGateway(gateway, options);
+ get declinedCount() {
+ return this.invitations.filter((invitation) => invitation.status === 'declined').length;
}
- /**
- * Delete a payment gateway.
- *
- * @method deleteGateway
- * @public
- */
- @action deleteGateway() {
- return this.gatewaysController.deleteGateway(...arguments);
+ get shareableLink() {
+ return createShareableLink(`join/network/${this.network?.public_id}`);
}
- /**
- * Create a new notification channel.
- *
- * @method createChannel
- * @public
- */
- @action createChannel() {
- const channel = this.store.createRecord('notification-channel', {
- owner_uuid: this.model.id,
- owner_type: 'storefront:network',
- });
+ get shareableLinkEnabled() {
+ return Boolean(this.network?.options?.shareable_link_enabled);
+ }
- this.editChannel(channel, {
- title: this.intl.t('storefront.networks.index.network.index.create-new-notification-channel'),
- acceptButtonText: this.intl.t('storefront.networks.index.network.index.create-notification-channel'),
- confirm: (modal) => {
- modal.startLoading();
+ sentAgo(invitation) {
+ const sentAt = invitation.resent_at ?? invitation.created_at;
- return channel.save().then((channel) => {
- this.notifications.success(this.intl.t('storefront.networks.index.network.index.notification-channel-add-network'));
- this.channels.pushObject(channel);
- });
- },
- decline: (modal) => {
- channel.destroyRecord();
- modal.done();
- },
- });
+ return sentAt ? formatDistanceToNow(new Date(sentAt), { addSuffix: true }) : null;
}
- /**
- * Edit a notification channel.
- *
- * @method editChannel
- * @param {Object} channel - The channel object to edit.
- * @param {Object} [options={}] - Optional parameters for editing the channel.
- * @public
- */
- @action editChannel(channel, options = {}) {
- if (options === null) {
- options = {};
- }
+ @action openInvitePanel(tab = 'email') {
+ this.invitePanelTab = tab;
+ this.isInvitePanelOpen = true;
+ }
- if (!options.confirm) {
- options.confirm = (modal) => {
- modal.startLoading();
+ @action closeInvitePanel() {
+ this.isInvitePanelOpen = false;
+ }
- return channel.save().then(() => {
- this.notifications.success(this.intl.t('storefront.controllers.networks.index.notification-channel-changes-save'));
- });
- };
+ @action async refresh() {
+ try {
+ this.invitations = await this.network.loadInvitations();
+ this.overview = await this.network.loadOverview();
+ } catch (error) {
+ this.notifications.serverError(error);
}
+ }
- return this.notificationsController.editChannel(channel, options);
+ @action manageStores() {
+ return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', this.network.public_id);
}
- /**
- * Delete a notification channel.
- *
- * @method deleteChannel
- * @public
- */
- @action deleteChannel() {
- return this.notificationsController.deleteChannel(...arguments);
+ @action openSettings() {
+ return this.hostRouter.transitionTo('console.storefront.networks.index.network.settings', this.network.public_id);
}
- /**
- * Send a test push notification through a notification channel.
- *
- * @method testChannel
- * @public
- */
- @action testChannel() {
- return this.notificationsController.testChannel(...arguments);
+ @action viewOrders() {
+ return this.hostRouter.transitionTo('console.storefront.networks.index.network.orders', this.network.public_id);
}
- /**
- * Make an alertable action.
- *
- * @method makeAlertable
- * @param {String} reason - Reason for the alert.
- * @param {Array} models - Models associated with the alert.
- * @public
- */
- @action makeAlertable(reason, models) {
- if (!this.model.alertable || !this.model.alertable?.length) {
- this.model.set('alertable', {});
+ @action async resendInvitation(invitation) {
+ try {
+ await this.network.resendInvitation(invitation);
+ this.notifications.success(this.intl.t('storefront.networks.invitations.resent', { email: invitation.email }));
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
}
+ }
- const serializedModels = models.map((model) => {
- return model.serialize();
- });
+ @action revokeInvitation(invitation) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('storefront.networks.invitations.revoke-title', { email: invitation.email }),
+ body: this.intl.t('storefront.networks.invitations.revoke-body'),
+ acceptButtonText: this.intl.t('storefront.networks.invitations.revoke'),
+ acceptButtonScheme: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
- this.model.set(`alertable.${reason}`, serializedModels);
+ try {
+ await this.network.revokeInvitation(invitation);
+ await this.refresh();
+ modal.done();
+ } catch (error) {
+ modal.stopLoading();
+ this.notifications.serverError(error);
+ }
+ },
+ });
}
}
diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js
index 2411a5d0..d6a011a9 100644
--- a/addon/controllers/networks/index/network/orders.js
+++ b/addon/controllers/networks/index/network/orders.js
@@ -6,329 +6,156 @@ import { tracked, cached } from '@glimmer/tracking';
import { isBlank } from '@ember/utils';
import { timeout, task } from 'ember-concurrency';
import { action } from '@ember/object';
+import { groupOrdersByCheckout, STATUS_TABS } from '../../../../utils/order-groups';
+/**
+ * Everything placed through the network, grouped by checkout: a multi-store purchase is one
+ * row that expands into the order each store fulfils.
+ */
export default class NetworksIndexNetworkOrdersController extends BaseController {
- /**
- * Inject the `notifications` service
- *
- * @var {Service}
- */
@service store;
@service notifications;
-
- /**
- * Inject the `intl` service
- *
- * @var {Service}
- */
@service intl;
-
- /**
- * Inject the `modals-manager` service
- *
- * @var {Service}
- */
@service modalsManager;
-
- /**
- * Inject the `crud` service
- *
- * @var {Service}
- */
@service crud;
-
- /**
- * Inject the `fetch` service
- *
- * @var {Service}
- */
@service fetch;
-
- /**
- * Inject the `filters` service
- *
- * @var {Service}
- */
@service filters;
-
+ @service hostRouter;
@service storefrontOrderActions;
- /**
- * Queryable parameters for this controller's model
- *
- * @var {Array}
- */
- queryParams = this.registeredQueryParams('network-order', []);
+ queryParams = this.registeredQueryParams('network-order', [{ page: 'o_page' }, { limit: 'o_limit' }, { sort: 'o_sort' }, { query: 'o_query' }, { status: 'o_status' }, { view: 'o_view' }]);
@tracked page = 1;
@tracked limit;
@tracked query;
@tracked sort = '-created_at';
- @tracked public_id;
- @tracked internal_id;
- @tracked tracking;
- @tracked facilitator;
- @tracked customer;
- @tracked driver;
- @tracked payload;
- @tracked pickup;
- @tracked dropoff;
- @tracked updated_by;
- @tracked created_by;
@tracked status;
+ @tracked view = 'grouped';
+ @tracked network;
+ @tracked stores = [];
+ @tracked table;
+
+ get isGrouped() {
+ return this.view !== 'flat';
+ }
+
+ get activeStatusTab() {
+ const current = String(this.status ?? '')
+ .split(',')
+ .filter(Boolean)
+ .sort()
+ .join(',');
- @tracked columns = [
- {
- id: 'public-id',
- label: this.intl.t('storefront.common.id'),
- valuePath: 'public_id',
- width: '150px',
- cellComponent: 'table/cell/anchor',
- action: this.viewOrder,
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/string',
- },
- {
- id: 'internal-id',
- label: this.intl.t('storefront.orders.index.internal-id'),
- valuePath: 'internal_id',
- width: '125px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/string',
- },
- {
- id: 'customer-name',
- label: this.intl.t('storefront.orders.index.customer'),
- valuePath: 'customer.name',
- cellComponent: 'table/cell/identity',
- resourceType: 'customer',
- resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }),
- action: this.viewCustomer,
- emptyText: 'No customer',
- width: '125px',
- resizable: true,
- sortable: true,
- hidden: true,
- filterable: true,
- filterComponent: 'filter/model',
- filterComponentPlaceholder: this.intl.t('storefront.orders.index.select-order-customer'),
- filterParam: 'customer',
- model: 'customer',
- },
- {
- id: 'pickup-name',
- label: this.intl.t('storefront.common.pickup'),
- valuePath: 'pickupName',
- cellComponent: 'table/cell/identity',
- resourceType: 'place',
- resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }),
- width: '160px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/model',
- filterComponentPlaceholder: this.intl.t('storefront.orders.index.select-order-pickup-location'),
- filterParam: 'pickup',
- model: 'place',
- },
- {
- id: 'dropoff-name',
- label: this.intl.t('storefront.common.dropoff'),
- valuePath: 'dropoffName',
- cellComponent: 'table/cell/identity',
- resourceType: 'place',
- resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }),
- width: '160px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/model',
- filterComponentPlaceholder: this.intl.t('storefront.orders.index.select-order-dropoff-location'),
- filterParam: 'dropoff',
- model: 'place',
- },
- {
- id: 'scheduled-at',
- label: this.intl.t('storefront.orders.index.scheduled-at'),
- valuePath: 'scheduledAt',
- sortParam: 'scheduled_at',
- filterParam: 'scheduled_at',
- width: '150px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/date',
- },
- {
- id: 'item-count',
- label: '# Items',
- cellComponent: 'table/cell/base',
- valuePath: 'item_count',
- resizable: true,
- hidden: true,
- width: '50px',
- },
- {
- id: 'transaction-amount',
- label: this.intl.t('storefront.orders.index.transaction-total'),
- cellComponent: 'table/cell/base',
- valuePath: 'transaction_amount',
- width: '50px',
- resizable: true,
- hidden: true,
- sortable: true,
- },
- {
- id: 'tracking-number-tracking-number',
- label: this.intl.t('storefront.orders.index.tracking-number'),
- cellComponent: 'table/cell/base',
- valuePath: 'tracking_number.tracking_number',
- width: '170px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/string',
- },
- {
- id: 'driver-assigned',
- label: this.intl.t('storefront.orders.index.driver-assigned'),
- cellComponent: 'table/cell/identity',
- resourceType: 'driver',
- valuePath: 'driver_assigned',
- resourcePath: (order) =>
- relationValue(order, 'driver_assigned') ??
- buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }),
- emptyText: 'No driver assigned',
- width: '170px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/model',
- filterComponentPlaceholder: this.intl.t('storefront.orders.index.select-driver-for-order'),
- filterParam: 'driver',
- model: 'driver',
- query: {
- // no model, serializer, adapter for relations
- without: ['fleets', 'vendor', 'vehicle', 'currentJob'],
+ return STATUS_TABS.find((tab) => tab.statuses.slice().sort().join(',') === current)?.id ?? 'custom';
+ }
+
+ get statusTabs() {
+ return STATUS_TABS.map((tab) => ({
+ ...tab,
+ label: this.intl.t(`storefront.networks.orders.tabs.${tab.id}`),
+ isActive: tab.id === this.activeStatusTab,
+ }));
+ }
+
+ get storesById() {
+ return this.stores.reduce((map, store) => {
+ map[store.public_id] = store;
+ map[store.id] = store;
+ return map;
+ }, {});
+ }
+
+ /**
+ * Rows for the table: checkout groups in grouped view, orders otherwise.
+ */
+ get rows() {
+ return this.isGrouped ? groupOrdersByCheckout(this.model) : (this.model?.toArray?.() ?? Array.from(this.model ?? []));
+ }
+
+ @cached get columns() {
+ return [
+ {
+ id: 'reference',
+ label: this.intl.t('storefront.networks.orders.columns.reference'),
+ valuePath: 'public_id',
+ cellComponent: 'storefront/network/orders/cell/reference',
+ onView: this.viewOrder,
+ width: '190px',
+ resizable: true,
+ sortable: false,
+ },
+ {
+ id: 'customer',
+ label: this.intl.t('storefront.orders.index.customer'),
+ valuePath: 'customer.name',
+ cellComponent: 'table/cell/identity',
+ resourceType: 'customer',
+ resourcePath: (row) => relationValue(row, 'customer') ?? buildIdentityStub(row, { type: row.customer_type ?? 'customer', nameKey: 'customer_name' }),
+ action: this.viewCustomer,
+ emptyText: this.intl.t('storefront.networks.orders.no-customer'),
+ width: '170px',
+ resizable: true,
+ },
+ {
+ id: 'stores',
+ label: this.intl.t('storefront.networks.index.network.stores.store'),
+ cellComponent: 'storefront/network/orders/cell/stores',
+ storesById: this.storesById,
+ width: '200px',
+ resizable: true,
+ },
+ {
+ id: 'fulfillment',
+ label: this.intl.t('storefront.networks.orders.columns.fulfillment'),
+ cellComponent: 'storefront/network/orders/cell/fulfillment',
+ width: '110px',
+ },
+ {
+ id: 'total',
+ label: this.intl.t('storefront.networks.orders.columns.total'),
+ cellComponent: 'storefront/network/orders/cell/total',
+ width: '140px',
+ resizable: true,
+ },
+ {
+ id: 'driver',
+ label: this.intl.t('storefront.orders.index.driver-assigned'),
+ cellComponent: 'storefront/network/orders/cell/driver',
+ onAssign: this.assignDriver,
+ width: '170px',
+ resizable: true,
+ },
+ {
+ id: 'status',
+ label: this.intl.t('storefront.common.status'),
+ cellComponent: 'storefront/network/orders/cell/status',
+ width: '150px',
+ resizable: true,
},
- },
- {
- id: 'type',
- label: this.intl.t('storefront.common.type'),
- cellComponent: 'cell/humanize',
- valuePath: 'type',
- width: '100px',
- resizable: true,
- hidden: true,
- sortable: true,
- },
- {
- id: 'status',
- label: this.intl.t('storefront.common.status'),
- valuePath: 'status',
- cellComponent: 'table/cell/status',
- width: '120px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/multi-option',
- // filterOptions: this.statusOptions,
- },
- {
- id: 'created-at',
- label: this.intl.t('storefront.orders.index.created-at'),
- valuePath: 'createdAt',
- sortParam: 'created_at',
- filterParam: 'created_at',
- width: '140px',
- resizable: true,
- sortable: true,
- filterable: true,
- filterComponent: 'filter/date',
- },
- {
- id: 'updated-at',
- label: this.intl.t('storefront.orders.index.updated-at'),
- valuePath: 'updatedAt',
- sortParam: 'updated_at',
- filterParam: 'updated_at',
- width: '125px',
- resizable: true,
- sortable: true,
- hidden: true,
- filterable: true,
- filterComponent: 'filter/date',
- },
- {
- id: 'created-by-name',
- label: this.intl.t('storefront.orders.index.created-by'),
- valuePath: 'created_by_name',
- width: '125px',
- resizable: true,
- hidden: true,
- filterable: true,
- filterComponent: 'filter/model',
- filterComponentPlaceholder: 'Select user',
- filterParam: 'created_by',
- model: 'user',
- },
- {
- id: 'updated-by-name',
- label: this.intl.t('storefront.orders.index.updated-by'),
- valuePath: 'updated_by_name',
- width: '125px',
- resizable: true,
- hidden: true,
- filterable: true,
- filterComponent: 'filter/model',
- filterComponentPlaceholder: this.intl.t('storefront.orders.index.select-user'),
- filterParam: 'updated_by',
- model: 'user',
- },
- {
- id: 'row-actions',
- label: '',
- cellComponent: 'table/cell/dropdown',
- ddButtonText: false,
- ddButtonIcon: 'ellipsis-h',
- ddButtonIconPrefix: 'fas',
- ddMenuLabel: 'Order Actions',
- cellClassNames: 'overflow-visible',
- wrapperClass: 'flex items-center justify-end mx-2',
- width: '12%',
- actions: [
- {
- id: 'view-order',
- label: this.intl.t('storefront.orders.index.view-order'),
- icon: 'eye',
- fn: this.viewOrder,
- },
- {
- id: 'cancel-order',
- label: this.intl.t('storefront.orders.index.cancel-order'),
- icon: 'ban',
- fn: this.cancelOrder,
- },
- {
- separator: true,
- },
- {
- id: 'delete-order',
- label: this.intl.t('storefront.orders.index.delete-order'),
- icon: 'trash',
- fn: this.deleteOrder,
- },
- ],
- sortable: false,
- filterable: false,
- resizable: false,
- searchable: false,
- },
- ];
+ {
+ id: 'placed',
+ label: this.intl.t('storefront.networks.orders.columns.placed'),
+ cellComponent: 'storefront/network/orders/cell/placed',
+ width: '110px',
+ },
+ {
+ id: 'row-actions',
+ label: '',
+ cellComponent: 'storefront/network/orders/cell/actions',
+ onView: this.viewOrder,
+ onCancel: this.cancelOrder,
+ onDelete: this.deleteOrder,
+ onChange: this.refresh,
+ cellClassNames: 'overflow-visible',
+ wrapperClass: 'flex items-center justify-end mx-2',
+ width: '170px',
+ sortable: false,
+ filterable: false,
+ resizable: false,
+ searchable: false,
+ },
+ ];
+ }
/**
* The search task.
@@ -336,24 +163,33 @@ export default class NetworksIndexNetworkOrdersController extends BaseController
* @void
*/
@task({ restartable: true }) *search({ target: { value } }) {
- // if no query don't search
if (isBlank(value)) {
this.query = null;
return;
}
- // timeout for typing
yield timeout(250);
- // reset page for results
if (this.page > 1) {
this.page = 1;
}
- // update the query param
this.query = value;
}
+ @action selectStatusTab(tab) {
+ this.status = tab.statuses?.length ? tab.statuses.join(',') : null;
+ this.page = 1;
+ }
+
+ @action setView(view) {
+ this.view = view;
+ }
+
+ @action refresh() {
+ return this.hostRouter.refresh();
+ }
+
/**
* Opens the storefront customer behind an order. A stub that only carries
* the customer's name has no page to open.
@@ -368,7 +204,21 @@ export default class NetworksIndexNetworkOrdersController extends BaseController
}
@action viewOrder(order) {
- return this.storefrontOrderActions.viewOrder(order);
+ return this.storefrontOrderActions.viewOrder(order, { onChange: this.refresh });
+ }
+
+ @action assignDriver(order) {
+ return this.storefrontOrderActions.assignDriver(order, this.refresh);
+ }
+
+ @action cancelOrder(order) {
+ return this.storefrontOrderActions.cancelOrder(order, this.refresh);
+ }
+
+ @action deleteOrder(order) {
+ return this.crud.delete(order, {
+ onSuccess: () => this.refresh(),
+ });
}
/**
diff --git a/addon/controllers/networks/index/network/settings.js b/addon/controllers/networks/index/network/settings.js
new file mode 100644
index 00000000..9a3fdc72
--- /dev/null
+++ b/addon/controllers/networks/index/network/settings.js
@@ -0,0 +1,300 @@
+import Controller, { inject as controller } from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { alias } from '@ember/object/computed';
+import { action } from '@ember/object';
+import getPodMethods from '@fleetbase/ember-core/utils/get-pod-methods';
+
+/**
+ * NetworksIndexNetworkSettingsController
+ *
+ * This controller handles the logic for managing networks, gateways, and notification channels.
+ *
+ * @class NetworksIndexNetworkSettingsController
+ * @extends Controller
+ */
+export default class NetworksIndexNetworkSettingsController extends Controller {
+ /**
+ * Controller for managing gateways.
+ *
+ * @property {Controller} gatewaysController
+ */
+ @controller('settings.gateways') gatewaysController;
+
+ /**
+ * Controller for managing notifications.
+ *
+ * @property {Controller} notificationsController
+ */
+ @controller('settings.notifications') notificationsController;
+
+ /**
+ * Notifications service to handle notification logic.
+ *
+ * @property {Service} notifications
+ */
+ @service notifications;
+
+ /**
+ * Fetch service to handle file uploads and other network requests.
+ *
+ * @property {Service} fetch
+ */
+ @service fetch;
+
+ /**
+ * intl service to handle file uploads and other network requests.
+ *
+ * @property {Service} intl
+ */
+ @service intl;
+
+ /**
+ * Store service to handle file uploads and other network requests.
+ *
+ * @property {Service} store
+ */
+ @service store;
+
+ /**
+ * Proof of delivery methods.
+ *
+ * @property {Array} podMethods
+ */
+ @tracked podMethods = getPodMethods();
+
+ /**
+ * Loading state, indicating whether a network request is in progress.
+ *
+ * @property {Boolean} isLoading
+ */
+ @tracked isLoading = false;
+
+ @tracked orderConfigs = [];
+
+ /**
+ * Alias for model.gateways, representing the gateways associated with the network.
+ *
+ * @property {Array} gateways
+ */
+ @alias('model.gateways') gateways;
+
+ /**
+ * Alias for model.notification_channels, representing the notification channels associated with the network.
+ *
+ * @property {Array} channels
+ */
+ @alias('model.notification_channels') channels;
+
+ /**
+ * Save network settings.
+ *
+ * @method saveSettings
+ * @public
+ */
+ @action saveSettings() {
+ this.isLoading = true;
+
+ this.model
+ .save()
+ .then(() => {
+ this.notifications.success(this.intl.t('storefront.networks.index.network.index.change-network-saved'));
+ })
+ .catch((error) => {
+ this.notifications.serverError(error);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ }
+
+ /**
+ * Upload a file.
+ *
+ * @method uploadFile
+ * @param {String} type - Type of the file.
+ * @param {File} file - File to upload.
+ * @public
+ */
+ @action uploadFile(type, file) {
+ const prefix = type.replace('storefront_', '');
+
+ this.fetch.uploadFile.perform(
+ file,
+ {
+ path: `uploads/storefront/${this.model.id}/${type}`,
+ key_uuid: this.model.id,
+ key_type: `storefront:network`,
+ type,
+ },
+ (uploadedFile) => {
+ this.model.setProperties({
+ [`${prefix}_uuid`]: uploadedFile.id,
+ [`${prefix}_url`]: uploadedFile.url,
+ [prefix]: uploadedFile,
+ });
+ }
+ );
+ }
+
+ /**
+ * Create a new payment gateway.
+ *
+ * @method createGateway
+ * @public
+ */
+ @action createGateway() {
+ const gateway = this.store.createRecord('gateway', {
+ owner_uuid: this.model.id,
+ owner_type: 'storefront:network',
+ });
+
+ this.editGateway(gateway, {
+ title: this.intl.t('storefront.networks.index.network.index.create-new-payment-gateway'),
+ acceptButtonText: this.intl.t('storefront.networks.index.network.index.save-gateway'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await gateway.save();
+ this.notifications.success(this.intl.t('storefront.networks.index.network.index.new-gateway-add-network'));
+ this.gateways.pushObject(gateway);
+ } catch (error) {
+ modal.stopLoading();
+ this.notifications.serverError(error);
+ }
+ },
+ decline: (modal) => {
+ gateway.destroyRecord();
+ modal.done();
+ },
+ });
+ }
+
+ /**
+ * Edit a payment gateway.
+ *
+ * @method editGateway
+ * @param {Object} gateway - The gateway object to edit.
+ * @param {Object} [options={}] - Optional parameters for editing the gateway.
+ * @public
+ */
+ @action editGateway(gateway, options = {}) {
+ if (options === null) {
+ options = {};
+ }
+
+ // The settings controller's modal saves the gateway and reports errors; only the message differs.
+ if (!options.successNotification) {
+ options.successNotification = this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success');
+ }
+
+ return this.gatewaysController.editGateway(gateway, options);
+ }
+
+ /**
+ * Delete a payment gateway.
+ *
+ * @method deleteGateway
+ * @public
+ */
+ @action deleteGateway() {
+ return this.gatewaysController.deleteGateway(...arguments);
+ }
+
+ /**
+ * Create a new notification channel.
+ *
+ * @method createChannel
+ * @public
+ */
+ @action createChannel() {
+ const channel = this.store.createRecord('notification-channel', {
+ owner_uuid: this.model.id,
+ owner_type: 'storefront:network',
+ });
+
+ this.editChannel(channel, {
+ title: this.intl.t('storefront.networks.index.network.index.create-new-notification-channel'),
+ acceptButtonText: this.intl.t('storefront.networks.index.network.index.create-notification-channel'),
+ confirm: (modal) => {
+ modal.startLoading();
+
+ return channel.save().then((channel) => {
+ this.notifications.success(this.intl.t('storefront.networks.index.network.index.notification-channel-add-network'));
+ this.channels.pushObject(channel);
+ });
+ },
+ decline: (modal) => {
+ channel.destroyRecord();
+ modal.done();
+ },
+ });
+ }
+
+ /**
+ * Edit a notification channel.
+ *
+ * @method editChannel
+ * @param {Object} channel - The channel object to edit.
+ * @param {Object} [options={}] - Optional parameters for editing the channel.
+ * @public
+ */
+ @action editChannel(channel, options = {}) {
+ if (options === null) {
+ options = {};
+ }
+
+ if (!options.confirm) {
+ options.confirm = (modal) => {
+ modal.startLoading();
+
+ return channel.save().then(() => {
+ this.notifications.success(this.intl.t('storefront.controllers.networks.index.notification-channel-changes-save'));
+ });
+ };
+ }
+
+ return this.notificationsController.editChannel(channel, options);
+ }
+
+ /**
+ * Delete a notification channel.
+ *
+ * @method deleteChannel
+ * @public
+ */
+ @action deleteChannel() {
+ return this.notificationsController.deleteChannel(...arguments);
+ }
+
+ /**
+ * Send a test push notification through a notification channel.
+ *
+ * @method testChannel
+ * @public
+ */
+ @action testChannel() {
+ return this.notificationsController.testChannel(...arguments);
+ }
+
+ /**
+ * Make an alertable action.
+ *
+ * @method makeAlertable
+ * @param {String} reason - Reason for the alert.
+ * @param {Array} models - Models associated with the alert.
+ * @public
+ */
+ @action makeAlertable(reason, models) {
+ if (!this.model.alertable || !this.model.alertable?.length) {
+ this.model.set('alertable', {});
+ }
+
+ const serializedModels = models.map((model) => {
+ return model.serialize();
+ });
+
+ this.model.set(`alertable.${reason}`, serializedModels);
+ }
+}
diff --git a/addon/controllers/networks/index/network/stores.js b/addon/controllers/networks/index/network/stores.js
index 9df68e66..1914294c 100644
--- a/addon/controllers/networks/index/network/stores.js
+++ b/addon/controllers/networks/index/network/stores.js
@@ -4,9 +4,8 @@ import { inject as service } from '@ember/service';
import { action, set } from '@ember/object';
import { isBlank } from '@ember/utils';
import { timeout, task } from 'ember-concurrency';
-import createShareableLink from '../../../../utils/create-shareable-link';
-import isEmail from '@fleetbase/ember-core/utils/is-email';
import isModel from '@fleetbase/ember-core/utils/is-model';
+import { formatDistanceToNow } from 'date-fns';
export default class NetworksIndexNetworkStoresController extends BaseController {
@service notifications;
@@ -93,6 +92,89 @@ export default class NetworksIndexNetworkStoresController extends BaseController
*/
@tracked categoryModel;
+ /**
+ * Invitations this network has sent, loaded by the route.
+ *
+ * @var {Array}
+ */
+ @tracked invitations = [];
+
+ /**
+ * Which membership state the table shows: all, online, offline or invited.
+ *
+ * @var {String}
+ */
+ @tracked statusTab = 'all';
+
+ /**
+ * The table instance, for bulk actions on the selected rows.
+ *
+ * @var {Object}
+ */
+ @tracked table;
+
+ /**
+ * Whether the invite panel is open and which tab it starts on.
+ */
+ @tracked isInvitePanelOpen = false;
+ @tracked invitePanelTab = 'email';
+
+ get openInvitations() {
+ return this.invitations
+ .filter((invitation) => ['pending', 'declined', 'expired'].includes(invitation.status))
+ .map((invitation) => ({ ...invitation, sentAgo: this.sentAgo(invitation) }));
+ }
+
+ get pendingInvitations() {
+ return this.openInvitations.filter((invitation) => invitation.status === 'pending');
+ }
+
+ get members() {
+ return this.model?.toArray?.() ?? Array.from(this.model ?? []);
+ }
+
+ get statusTabs() {
+ const members = this.members;
+
+ return [
+ { id: 'all', label: this.intl.t('storefront.common.all'), count: members.length + this.openInvitations.length },
+ { id: 'online', label: this.intl.t('storefront.common.online'), count: members.filter((store) => store.online).length },
+ { id: 'offline', label: this.intl.t('storefront.common.offline'), count: members.filter((store) => !store.online).length },
+ { id: 'invited', label: this.intl.t('storefront.networks.invitations.invited'), count: this.openInvitations.length },
+ ].map((tab) => ({ ...tab, isActive: tab.id === this.statusTab }));
+ }
+
+ get visibleStores() {
+ switch (this.statusTab) {
+ case 'online':
+ return this.members.filter((store) => store.online);
+ case 'offline':
+ return this.members.filter((store) => !store.online);
+ case 'invited':
+ return [];
+ default:
+ return this.members;
+ }
+ }
+
+ get showInvitations() {
+ return ['all', 'invited'].includes(this.statusTab) && this.openInvitations.length > 0;
+ }
+
+ get selectedStores() {
+ return this.table?.selectedRows ?? [];
+ }
+
+ get hasSelection() {
+ return this.selectedStores.length > 0;
+ }
+
+ sentAgo(invitation) {
+ const sentAt = invitation.resent_at ?? invitation.created_at;
+
+ return sentAt ? formatDistanceToNow(new Date(sentAt), { addSuffix: true }) : null;
+ }
+
/**
* All columns applicable for network stores
*
@@ -103,6 +185,9 @@ export default class NetworksIndexNetworkStoresController extends BaseController
id: 'name',
label: this.intl.t('storefront.networks.index.network.stores.store'),
valuePath: 'name',
+ cellComponent: 'table/cell/identity',
+ resourceType: 'store',
+ popover: true,
width: '130px',
resizable: true,
sortable: true,
@@ -528,8 +613,10 @@ export default class NetworksIndexNetworkStoresController extends BaseController
@action async removeStore(store) {
this.modalsManager.confirm({
title: this.intl.t('storefront.networks.index.network.stores.remove-this-store', { storeName: store.name, networkName: this.network.name }),
- body: this.intl.t('storefront.networks.index.network.stores.longer-findable-by-this-network'),
- acceptButtonIcon: 'check',
+ body: this.intl.t('storefront.networks.invitations.remove-body'),
+ acceptButtonText: this.intl.t('storefront.networks.index.network.stores.remove-store-from-network'),
+ acceptButtonScheme: 'danger',
+ acceptButtonIcon: 'trash',
acceptButtonIconPrefix: 'fas',
declineButtonIcon: 'times',
declineButtonIconPrefix: 'fas',
@@ -603,61 +690,143 @@ export default class NetworksIndexNetworkStoresController extends BaseController
*
* @action
*/
- @action invite() {
- const shareableLink = createShareableLink(`join/network/${this.network.public_id}`);
+ @action invite(network, tab = 'email') {
+ if (isModel(network) && network !== this.network) {
+ this.network = network;
+ }
- this.modalsManager.show('modals/share-network', {
- title: this.intl.t('storefront.networks.index.network.stores.add-stores-to-network'),
- acceptButtonText: this.intl.t('storefront.networks.index.network.stores.send-invitations'),
- acceptButtonIcon: 'paper-plane',
- acceptButtonDisabled: true,
- shareableLink,
- recipients: [],
- network: this.network,
- addRecipient: (email) => {
- const recipients = this.modalsManager.getOption('recipients');
- recipients.pushObject(email);
-
- if (recipients.length === 0) {
- this.modalsManager.setOption('acceptButtonDisabled', true);
- } else {
- this.modalsManager.setOption('acceptButtonDisabled', false);
+ this.invitePanelTab = typeof tab === 'string' ? tab : 'email';
+ this.isInvitePanelOpen = true;
+ }
+
+ @action closeInvitePanel() {
+ this.isInvitePanelOpen = false;
+ }
+
+ @action selectStatusTab(tab) {
+ this.statusTab = tab.id ?? tab;
+ }
+
+ @action async refreshInvitations() {
+ try {
+ this.invitations = await this.network.loadInvitations();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async afterInvitationsSent() {
+ await this.refreshInvitations();
+ return this.hostRouter.refresh();
+ }
+
+ @action async resendInvitation(invitation) {
+ try {
+ await this.network.resendInvitation(invitation);
+ this.notifications.success(this.intl.t('storefront.networks.invitations.resent', { email: invitation.email }));
+ await this.refreshInvitations();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action revokeInvitation(invitation) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('storefront.networks.invitations.revoke-title', { email: invitation.email }),
+ body: this.intl.t('storefront.networks.invitations.revoke-body'),
+ acceptButtonText: this.intl.t('storefront.networks.invitations.revoke'),
+ acceptButtonScheme: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.network.revokeInvitation(invitation);
+ await this.refreshInvitations();
+ modal.done();
+ } catch (error) {
+ modal.stopLoading();
+ this.notifications.serverError(error);
}
},
- removeRecipient: (index) => {
- const recipients = this.modalsManager.getOption('recipients');
- recipients.removeAt(index);
-
- if (recipients.length === 0) {
- this.modalsManager.setOption('acceptButtonDisabled', true);
- } else {
- this.modalsManager.setOption('acceptButtonDisabled', false);
+ });
+ }
+
+ /**
+ * Assign one category to every selected store.
+ */
+ @action assignCategoryToSelected() {
+ const stores = this.selectedStores;
+
+ if (!stores.length) {
+ return;
+ }
+
+ this.modalsManager.show('modals/add-store-to-category', {
+ title: this.intl.t('storefront.networks.invitations.assign-category-count', { count: stores.length }),
+ acceptButtonText: this.intl.t('storefront.networks.index.network.stores.assign-category'),
+ acceptButtonIcon: 'check',
+ network: this.network,
+ store: stores[0],
+ category: null,
+ setCategory: (category) => this.modalsManager.setOption('category', category),
+ confirm: async (modal) => {
+ const category = modal.getOption('category');
+
+ if (!category) {
+ return this.notifications.warning(this.intl.t('storefront.networks.invitations.pick-a-category'));
}
- },
- toggleShareableLink: (enabled) => {
- set(this.network, 'options.shareable_link_enabled', enabled);
- this.network.save();
- },
- confirm: (modal) => {
- modal.startLoading();
- const recipients = modal.getOption('recipients');
- const isValid = recipients.every((email) => isEmail(email));
+ modal.startLoading();
- if (!isValid) {
+ try {
+ for (const store of stores) {
+ await this.fetch.post(`networks/${this.network.id}/set-store-category`, { store: store.id, category: category.id }, { namespace: 'storefront/int/v1' });
+ }
+ await this.hostRouter.refresh();
+ modal.done();
+ } catch (error) {
modal.stopLoading();
-
- return this.notifications.error(this.intl.t('storefront.networks.index.network.stores.invalid-emails-provided-error'));
+ this.notifications.serverError(error);
}
+ },
+ });
+ }
+
+ /**
+ * Remove every selected store from the network.
+ */
+ @action removeSelected() {
+ const stores = this.selectedStores;
+
+ if (!stores.length) {
+ return;
+ }
- return this.network.sendInvites(recipients).then(() => {
+ this.modalsManager.confirm({
+ title: this.intl.t('storefront.networks.invitations.remove-count-title', { count: stores.length, networkName: this.network.name }),
+ body: this.intl.t('storefront.networks.invitations.remove-body'),
+ acceptButtonText: this.intl.t('storefront.networks.index.network.stores.remove-store-from-network'),
+ acceptButtonScheme: 'danger',
+ acceptButtonIcon: 'trash',
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.network.removeStores(stores);
+ await this.hostRouter.refresh();
+ modal.done();
+ } catch (error) {
modal.stopLoading();
- this.notifications.success(this.intl.t('storefront.networks.index.network.stores.invitation-sent-recipients'));
- });
+ this.notifications.serverError(error);
+ }
},
});
}
+ @action clearSelection() {
+ this.selectedStores.forEach((row) => set(row, 'checked', false));
+ }
+
/**
* The columns with what extensions registered under `storefront:network-store:table` merged in.
*
diff --git a/addon/controllers/networks/index/network/trucks.js b/addon/controllers/networks/index/network/trucks.js
new file mode 100644
index 00000000..d6626276
--- /dev/null
+++ b/addon/controllers/networks/index/network/trucks.js
@@ -0,0 +1,8 @@
+import FoodTrucksIndexController from '@fleetbase/storefront-engine/controllers/food-trucks/index';
+
+/**
+ * Trucks run by the stores of the active network. Creating a truck still happens from its store.
+ */
+export default class NetworksIndexNetworkTrucksController extends FoodTrucksIndexController {
+ queryParams = [{ query: 't_query' }];
+}
diff --git a/addon/controllers/products/index/category/new.js b/addon/controllers/products/index/category/new.js
index 9cda403d..01cdcdc2 100644
--- a/addon/controllers/products/index/category/new.js
+++ b/addon/controllers/products/index/category/new.js
@@ -98,6 +98,20 @@ export default class ProductsIndexCategoryNewController extends BaseController {
this.reset();
}
+ /**
+ * A new product starts over; an existing one rolls back to what was loaded.
+ */
+ @action discardChanges() {
+ if (this.product?.isNew) {
+ return this.reset();
+ }
+
+ this.product.rollbackAttributes();
+ (this.product.variants?.toArray?.() ?? []).forEach((variant) => (variant.isNew ? this.product.variants.removeObject(variant) : variant.rollbackAttributes()));
+ (this.product.addon_categories?.toArray?.() ?? []).forEach((category) => (category.isNew ? this.product.addon_categories.removeObject(category) : category.rollbackAttributes()));
+ this.uploadQueue = [];
+ }
+
@action addTag(tag) {
if (!isArray(this.product.tags)) {
this.product.tags = [];
diff --git a/addon/controllers/settings.js b/addon/controllers/settings.js
index cd2630df..ce666dd6 100644
--- a/addon/controllers/settings.js
+++ b/addon/controllers/settings.js
@@ -1,36 +1,90 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { alias } from '@ember/object/computed';
+/**
+ * Settings are one page with a section rail: the General page carries its sections as
+ * anchors, the other pages keep their routes so deep links still work.
+ */
export default class SettingsController extends Controller {
@service intl;
+ @service hostRouter;
+ @service storefront;
+ @service notifications;
+ @alias('storefront.activeStore') activeStore;
+
+ get currentRouteName() {
+ return this.hostRouter.currentRouteName ?? '';
+ }
+
+ get isGeneralRoute() {
+ return this.currentRouteName.endsWith('settings.index');
+ }
+
+ get railItems() {
+ const routeItem = (id, label, icon, route, extra = {}) => ({
+ id,
+ label,
+ icon,
+ route,
+ isActive: this.currentRouteName.endsWith(route),
+ ...extra,
+ });
+ const sectionItem = (id, label, icon) => ({
+ id,
+ label,
+ icon,
+ section: `settings-${id}`,
+ isActive: this.isGeneralRoute && this.activeSection === id,
+ });
- get tabs() {
return [
- {
- route: 'settings.index',
- label: this.intl.t('storefront.common.general'),
- icon: 'cog',
- },
- {
- route: 'settings.locations',
- label: this.intl.t('storefront.common.location'),
- icon: 'map-marker-alt',
- },
- {
- route: 'settings.gateways',
- label: this.intl.t('storefront.common.gateways'),
- icon: 'cash-register',
- },
- {
- route: 'settings.api',
- label: this.intl.t('storefront.common.api'),
- icon: 'code',
- },
- {
- route: 'settings.notifications',
- label: this.intl.t('storefront.common.notification'),
- icon: 'bell-concierge',
- },
+ sectionItem('general', this.intl.t('storefront.common.general'), 'cog'),
+ sectionItem('branding', this.intl.t('storefront.settings.sections.branding'), 'image'),
+ routeItem('locations', this.intl.t('storefront.common.location'), 'map-marker-alt', 'settings.locations'),
+ sectionItem('checkout', this.intl.t('storefront.settings.sections.checkout-rules'), 'cart-shopping'),
+ routeItem('gateways', this.intl.t('storefront.common.gateways'), 'cash-register', 'settings.gateways'),
+ routeItem('notifications', this.intl.t('storefront.common.notification'), 'bell-concierge', 'settings.notifications'),
+ sectionItem('alerts', this.intl.t('storefront.common.alerts'), 'bell'),
+ routeItem('api', this.intl.t('storefront.settings.sections.api-keys'), 'code', 'settings.api'),
];
}
+
+ activeSection = 'general';
+
+ @action openItem(item) {
+ if (item.route) {
+ return this.hostRouter.transitionTo(`console.storefront.${item.route}`);
+ }
+
+ this.activeSection = item.id;
+
+ const scrollToSection = () => {
+ const element = document.getElementById(item.section);
+ element?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
+ };
+
+ if (this.isGeneralRoute) {
+ scrollToSection();
+ return;
+ }
+
+ return Promise.resolve(this.hostRouter.transitionTo('console.storefront.settings.index')).then(() => setTimeout(scrollToSection, 50));
+ }
+
+ @action async copyStoreKey() {
+ const key = this.activeStore?.key;
+
+ if (!key) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(key);
+ this.notifications.success(this.intl.t('storefront.settings.shell.store-key-copied'));
+ } catch {
+ this.notifications.warning(key);
+ }
+ }
}
diff --git a/addon/controllers/settings/index.js b/addon/controllers/settings/index.js
index 871606b5..b1427f39 100644
--- a/addon/controllers/settings/index.js
+++ b/addon/controllers/settings/index.js
@@ -21,6 +21,104 @@ export default class SettingsIndexController extends Controller {
@tracked uploadQueue = [];
@tracked uploadedFiles = [];
@tracked orderConfigs = [];
+ @tracked optionsRevision = 0;
+ optionsSnapshot = '{}';
+ alertableSnapshot = '{}';
+
+ sectionLabels = {
+ name: 'general',
+ description: 'general',
+ tags: 'general',
+ currency: 'general',
+ order_config_uuid: 'general',
+ phone: 'general',
+ email: 'general',
+ website: 'general',
+ facebook: 'general',
+ instagram: 'general',
+ twitter: 'general',
+ logo_uuid: 'branding',
+ backdrop_uuid: 'branding',
+ online: 'checkout-rules',
+ pod_method: 'checkout-rules',
+ };
+
+ get memberNetworks() {
+ return this.model?.networks ?? [];
+ }
+
+ get optionsChanged() {
+ // read so the getter recomputes after setOption / touch
+ this.optionsRevision;
+
+ return JSON.stringify(this.model?.options ?? {}) !== this.optionsSnapshot;
+ }
+
+ get alertableChanged() {
+ this.optionsRevision;
+
+ return JSON.stringify(this.model?.alertable ?? {}) !== this.alertableSnapshot;
+ }
+
+ get changedAttributeNames() {
+ const changed = this.model?.changedAttributes?.() ?? {};
+
+ return Object.keys(changed).filter((key) => !['options', 'alertable'].includes(key));
+ }
+
+ get isDirty() {
+ return Boolean(this.model?.isNew) || this.changedAttributeNames.length > 0 || this.optionsChanged || this.alertableChanged;
+ }
+
+ get dirtyCount() {
+ return this.changedAttributeNames.length + (this.optionsChanged ? 1 : 0) + (this.alertableChanged ? 1 : 0);
+ }
+
+ get dirtySections() {
+ const sections = new Set(this.changedAttributeNames.map((key) => this.sectionLabels[key] ?? 'general'));
+
+ if (this.optionsChanged) {
+ sections.add('checkout-rules');
+ }
+
+ if (this.alertableChanged) {
+ sections.add('alerts');
+ }
+
+ return [...sections].map((section) => this.intl.t(`storefront.settings.sections.${section}`));
+ }
+
+ snapshotOptions() {
+ this.optionsSnapshot = JSON.stringify(this.model?.options ?? {});
+ this.alertableSnapshot = JSON.stringify(this.model?.alertable ?? {});
+ this.optionsRevision++;
+ }
+
+ @action touch() {
+ this.optionsRevision++;
+ }
+
+ @action setOption(key, value) {
+ if (!this.model.options || typeof this.model.options !== 'object') {
+ this.model.set('options', {});
+ }
+
+ this.model.set(`options.${key}`, value);
+ this.optionsRevision++;
+ }
+
+ @action discardChanges() {
+ this.model.rollbackAttributes();
+
+ try {
+ this.model.set('options', JSON.parse(this.optionsSnapshot));
+ this.model.set('alertable', JSON.parse(this.alertableSnapshot));
+ } catch {
+ // snapshots are JSON we wrote ourselves
+ }
+
+ this.optionsRevision++;
+ }
@action addTag(tag) {
if (!isArray(this.model.tags)) {
@@ -34,13 +132,15 @@ export default class SettingsIndexController extends Controller {
this.model.tags?.removeAt(index);
}
- @action saveSettings() {
+ @action saveSettings(event) {
+ event?.preventDefault?.();
this.isLoading = true;
this.model
.save()
.then(() => {
- this.notifications.success('Changes saved');
+ this.snapshotOptions();
+ this.notifications.success(this.intl.t('storefront.settings.save-bar.saved-toast'));
})
.catch((error) => {
this.notifications.serverError(error);
@@ -114,5 +214,6 @@ export default class SettingsIndexController extends Controller {
});
this.model.set(`alertable.${reason}`, serializedModels);
+ this.optionsRevision++;
}
}
diff --git a/addon/helpers/width-style-px.js b/addon/helpers/width-style-px.js
new file mode 100644
index 00000000..4b73b2fb
--- /dev/null
+++ b/addon/helpers/width-style-px.js
@@ -0,0 +1,15 @@
+import { helper } from '@ember/component/helper';
+import { htmlSafe } from '@ember/template';
+
+/**
+ * A safe inline width from a column width such as `190px` or `12%`; nothing when unset.
+ */
+export default helper(function widthStylePx([width]) {
+ if (!width) {
+ return htmlSafe('');
+ }
+
+ const value = String(width).trim();
+
+ return htmlSafe(/^\d+(\.\d+)?(px|%|rem|em)$/.test(value) ? `width: ${value}; flex: 0 0 ${value};` : '');
+});
diff --git a/addon/helpers/width-style.js b/addon/helpers/width-style.js
new file mode 100644
index 00000000..2ee30ed4
--- /dev/null
+++ b/addon/helpers/width-style.js
@@ -0,0 +1,11 @@
+import { helper } from '@ember/component/helper';
+import { htmlSafe } from '@ember/template';
+
+/**
+ * A safe inline width for share bars: `{{width-style 42}}` → `width: 42%`.
+ */
+export default helper(function widthStyle([percent]) {
+ const value = Math.max(0, Math.min(100, Number(percent) || 0));
+
+ return htmlSafe(`width: ${value}%`);
+});
diff --git a/addon/instance-initializers/register-storefront-resource-descriptors.js b/addon/instance-initializers/register-storefront-resource-descriptors.js
new file mode 100644
index 00000000..21f31575
--- /dev/null
+++ b/addon/instance-initializers/register-storefront-resource-descriptors.js
@@ -0,0 +1,125 @@
+import { get } from '@ember/object';
+
+/**
+ * Registers the Storefront resources (store, network) with the shared resource
+ * identity registry so they render as identity cells, pills and summaries with
+ * the same components every other resource uses. Runs for the host and again for
+ * the engine; only fills in keys nobody has registered.
+ */
+export function initialize(owner) {
+ let registry;
+
+ try {
+ registry = owner.lookup('service:resource-registry');
+ } catch {
+ registry = null;
+ }
+
+ if (!registry || typeof registry.registerDescriptors !== 'function') {
+ return;
+ }
+
+ const missing = buildStorefrontResourceDescriptors(owner).filter((descriptor) => !isRegistered(registry, descriptor.key));
+
+ if (missing.length) {
+ registry.registerDescriptors(missing);
+ }
+}
+
+export function buildStorefrontResourceDescriptors(owner) {
+ const first = (record, ...paths) => {
+ for (const path of paths) {
+ const value = record ? get(record, path) : undefined;
+ if (value !== undefined && value !== null && value !== '') {
+ return value;
+ }
+ }
+
+ return null;
+ };
+ const online = (record) => (first(record, 'online') === null ? null : Boolean(get(record, 'online')));
+ const presence = (record) => (online(record) === null ? null : online(record) ? 'online' : 'offline');
+ const fact = (label, value, extra = {}) => (value ? { label, value, ...extra } : null);
+ const transitionTo = (route, record) => {
+ try {
+ const hostRouter = owner.lookup('service:host-router');
+ const publicId = get(record, 'public_id');
+
+ if (hostRouter && publicId) {
+ hostRouter.transitionTo(route, publicId);
+ return true;
+ }
+ } catch {
+ // no host router outside the console
+ }
+
+ return false;
+ };
+
+ return [
+ {
+ key: 'store',
+ labelKey: 'storefront.common.store',
+ icon: 'store',
+ modelNames: ['store'],
+ aliases: ['storefront-store'],
+ polymorphicTypes: ['storefront:store', 'Fleetbase\\Storefront\\Models\\Store'],
+ permission: 'storefront view store',
+ statusTones: {
+ online: 'text-green-500',
+ offline: 'text-gray-400',
+ },
+ title: (store) => first(store, 'name', 'public_id'),
+ identifier: (store) => first(store, 'currency', 'public_id'),
+ image: (store) => first(store, 'logo_url'),
+ online,
+ status: presence,
+ badges: (store) => [first(store, 'currency') ? { key: 'currency', icon: 'coins', label: first(store, 'currency') } : null].filter(Boolean),
+ selectDetails: (store) => [first(store, 'currency'), first(store, 'email')],
+ facts: (store) => [
+ fact('email', first(store, 'email')),
+ fact('phone', first(store, 'phone')),
+ fact('website', first(store, 'website')),
+ fact('currency', first(store, 'currency')),
+ fact('timezone', first(store, 'timezone')),
+ ],
+ canOpen: () => false,
+ },
+ {
+ key: 'network',
+ labelKey: 'storefront.common.network',
+ icon: 'network-wired',
+ modelNames: ['network'],
+ aliases: ['storefront-network'],
+ polymorphicTypes: ['storefront:network', 'Fleetbase\\Storefront\\Models\\Network'],
+ permission: 'storefront view network',
+ statusTones: {
+ online: 'text-green-500',
+ offline: 'text-gray-400',
+ },
+ title: (network) => first(network, 'name', 'public_id'),
+ identifier: (network) => first(network, 'currency', 'public_id'),
+ image: (network) => first(network, 'logo_url'),
+ online,
+ status: presence,
+ badges: (network) => [first(network, 'stores_count') !== null ? { key: 'stores', icon: 'store', label: `${first(network, 'stores_count')} stores` } : null].filter(Boolean),
+ selectDetails: (network) => [first(network, 'currency'), first(network, 'email')],
+ facts: (network) => [fact('email', first(network, 'email')), fact('phone', first(network, 'phone')), fact('website', first(network, 'website')), fact('currency', first(network, 'currency'))],
+ canOpen: () => true,
+ open: (network) => transitionTo('console.storefront.networks.index.network.index', network),
+ },
+ ];
+}
+
+function isRegistered(registry, key) {
+ try {
+ return Boolean(typeof registry.getDescriptor === 'function' ? registry.getDescriptor(key) : registry.descriptors?.some?.((descriptor) => descriptor.key === key));
+ } catch {
+ return false;
+ }
+}
+
+export default {
+ name: 'register-storefront-resource-descriptors',
+ initialize,
+};
diff --git a/addon/models/catalog.js b/addon/models/catalog.js
index 3a10505b..fd378b1f 100644
--- a/addon/models/catalog.js
+++ b/addon/models/catalog.js
@@ -1,5 +1,6 @@
import Model, { attr, hasMany } from '@ember-data/model';
import { format, formatDistanceToNow } from 'date-fns';
+import { getOwner } from '@ember/application';
export default class CatalogModel extends Model {
/** @ids */
@@ -15,6 +16,7 @@ export default class CatalogModel extends Model {
@attr('string') name;
@attr('string') description;
@attr('raw') meta;
+ @attr('raw') subjects;
@attr('string') status;
/** @dates */
@@ -26,6 +28,39 @@ export default class CatalogModel extends Model {
return this.serialize();
}
+ /**
+ * Replace the stores and trucks that serve this catalog.
+ * @param {{ stores?: Array, food_trucks?: Array }} subjects uuids or records
+ */
+ assignSubjects({ stores = [], food_trucks = [] } = {}) {
+ const owner = getOwner(this);
+ const fetch = owner.lookup('service:fetch');
+ const ids = (list) => list.map((item) => (typeof item === 'string' ? item : item?.id)).filter(Boolean);
+
+ return fetch.put(`catalogs/${this.id}/subjects`, { stores: ids(stores), food_trucks: ids(food_trucks) }, { namespace: 'storefront/int/v1' }).then((response) => {
+ const subjects = response?.catalog?.subjects;
+
+ if (Array.isArray(subjects)) {
+ this.subjects = subjects;
+ }
+
+ return this.subjects ?? [];
+ });
+ }
+
+ /** @computed */
+ get storeSubjects() {
+ return (this.subjects ?? []).filter((subject) => subject.type === 'store');
+ }
+
+ get truckSubjects() {
+ return (this.subjects ?? []).filter((subject) => subject.type === 'food-truck');
+ }
+
+ get productsCount() {
+ return (this.categories ?? []).reduce((sum, category) => sum + (category.products?.length ?? 0), 0);
+ }
+
/** @computed */
get updatedAgo() {
return formatDistanceToNow(this.updated_at);
diff --git a/addon/models/food-truck.js b/addon/models/food-truck.js
index ba6ee514..4ab50ed0 100644
--- a/addon/models/food-truck.js
+++ b/addon/models/food-truck.js
@@ -17,7 +17,9 @@ export default class FoodTruckModel extends Model {
/** @attributes */
@attr('string', { defaultValue: 'storefront:store' }) subject_type;
@attr('raw') meta;
+ @attr('raw') store_summary;
@attr('string') status;
+ @attr('boolean') online;
/** @dates */
@attr('date') created_at;
diff --git a/addon/models/network.js b/addon/models/network.js
index e168842f..fac10611 100644
--- a/addon/models/network.js
+++ b/addon/models/network.js
@@ -37,6 +37,7 @@ export default class NetworkModel extends Model {
@attr('string') timezone;
@attr('boolean') online;
@attr('number') stores_count;
+ @attr('raw') invitations;
@attr('object') options;
@attr('string') logo_url;
@attr('string') backdrop_url;
@@ -154,13 +155,49 @@ export default class NetworkModel extends Model {
return fetch.post(`networks/${this.id}/remove-stores`, { stores }, { namespace: 'storefront/int/v1' });
}
- sendInvites(recipients = []) {
+ /**
+ * Send invitations. Accepts a list of emails, or `{ recipients, category_uuid, expires_in_days, message, require_approval }`.
+ */
+ sendInvites(payload = []) {
const owner = getOwner(this);
const fetch = owner.lookup(`service:fetch`);
+ const options = Array.isArray(payload) ? { recipients: payload } : { ...payload };
// only send to valid recipients
- recipients = recipients.filter((email) => isEmail(email));
+ options.recipients = (options.recipients ?? []).filter((email) => isEmail(email));
- return fetch.post(`networks/${this.id}/invite`, { recipients }, { namespace: 'storefront/int/v1' });
+ return fetch.post(`networks/${this.id}/invite`, options, { namespace: 'storefront/int/v1' });
+ }
+
+ loadInvitations() {
+ const owner = getOwner(this);
+ const fetch = owner.lookup(`service:fetch`);
+
+ return fetch.get(`networks/${this.id}/invitations`, {}, { namespace: 'storefront/int/v1' }).then((invitations) => {
+ this.invitations = Array.isArray(invitations) ? invitations : [];
+
+ return this.invitations;
+ });
+ }
+
+ resendInvitation(invitation, options = {}) {
+ const owner = getOwner(this);
+ const fetch = owner.lookup(`service:fetch`);
+
+ return fetch.post(`networks/${this.id}/invitations/${invitation.id ?? invitation}/resend`, options, { namespace: 'storefront/int/v1' });
+ }
+
+ revokeInvitation(invitation) {
+ const owner = getOwner(this);
+ const fetch = owner.lookup(`service:fetch`);
+
+ return fetch.delete(`networks/${this.id}/invitations/${invitation.id ?? invitation}`, {}, { namespace: 'storefront/int/v1' });
+ }
+
+ loadOverview() {
+ const owner = getOwner(this);
+ const fetch = owner.lookup(`service:fetch`);
+
+ return fetch.get(`networks/${this.id}/overview`, {}, { namespace: 'storefront/int/v1' });
}
}
diff --git a/addon/routes.js b/addon/routes.js
index d558ea6f..28b805a0 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -14,7 +14,9 @@ export default buildRoutes(function () {
});
});
this.route('catalogs', function () {
- this.route('index', { path: '/' }, function () {});
+ this.route('index', { path: '/' }, function () {
+ this.route('edit', { path: '/:public_id' });
+ });
});
this.route('customers', function () {
this.route('index', { path: '/' }, function () {
@@ -36,8 +38,10 @@ export default buildRoutes(function () {
this.route('index', { path: '/' }, function () {
this.route('network', { path: '/:public_id' }, function () {
this.route('stores');
+ this.route('trucks');
this.route('customers');
this.route('orders');
+ this.route('settings');
});
});
});
diff --git a/addon/routes/application.js b/addon/routes/application.js
index 13505b5e..aa96d8f6 100644
--- a/addon/routes/application.js
+++ b/addon/routes/application.js
@@ -1,6 +1,7 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { hash } from 'rsvp';
export default class ApplicationRoute extends Route {
@service fetch;
@@ -42,7 +43,10 @@ export default class ApplicationRoute extends Route {
}
model() {
- return this.store.query('store', { limit: 300, sort: '-updated_at' });
+ return hash({
+ stores: this.store.query('store', { limit: 300, sort: '-updated_at' }),
+ networks: this.abilities.cannot('storefront list network') ? [] : this.store.query('network', { limit: 300, sort: '-updated_at' }),
+ });
}
setupController(controller) {
@@ -50,7 +54,7 @@ export default class ApplicationRoute extends Route {
controller.loadProductCategories();
}
- afterModel(stores) {
+ afterModel({ stores }) {
this.storefront.synchronizeActiveStore(stores);
this.storefront.listenForIncomingOrders();
}
diff --git a/addon/routes/catalogs/index/edit.js b/addon/routes/catalogs/index/edit.js
new file mode 100644
index 00000000..fd22a6f0
--- /dev/null
+++ b/addon/routes/catalogs/index/edit.js
@@ -0,0 +1,52 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { hash } from 'rsvp';
+
+/**
+ * The catalog editor: a catalog edited as a container of categories, products, hours and
+ * the stores and trucks that serve it.
+ */
+export default class CatalogsIndexEditRoute extends Route {
+ @service store;
+ @service storefront;
+ @service intl;
+ @service abilities;
+ @service hostRouter;
+ @service notifications;
+
+ beforeModel() {
+ if (this.abilities.cannot('storefront view catalog')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.storefront.catalogs');
+ }
+ }
+
+ model({ public_id }) {
+ const storeId = this.storefront.getActiveStore('id');
+
+ return hash({
+ catalog: this.store.findRecord('catalog', public_id, { reload: true }),
+ products: this.store.query('product', { limit: -1, store_uuid: storeId }).catch(() => []),
+ stores: this.store.query('store', { limit: 300 }).catch(() => []),
+ trucks: this.store.query('food-truck', { store_uuid: storeId, limit: -1 }).catch(() => []),
+ });
+ }
+
+ setupController(controller, model) {
+ super.setupController(...arguments);
+ controller.catalog = model.catalog;
+ controller.products = model.products?.toArray?.() ?? Array.from(model.products ?? []);
+ controller.stores = model.stores?.toArray?.() ?? Array.from(model.stores ?? []);
+ controller.trucks = model.trucks?.toArray?.() ?? Array.from(model.trucks ?? []);
+ controller.selectedCategory = model.catalog.categories?.firstObject ?? null;
+ controller.snapshot();
+ }
+
+ resetController(controller, isExiting) {
+ if (isExiting) {
+ controller.selectedCategory = null;
+ controller.isPickerOpen = false;
+ controller.pickerSelection = [];
+ }
+ }
+}
diff --git a/addon/routes/networks/index/network.js b/addon/routes/networks/index/network.js
index f0b671f1..a05b3620 100644
--- a/addon/routes/networks/index/network.js
+++ b/addon/routes/networks/index/network.js
@@ -7,6 +7,7 @@ export default class NetworksIndexNetworkRoute extends Route {
@service abilities;
@service hostRouter;
@service notifications;
+ @service storefront;
beforeModel() {
if (this.abilities.cannot('storefront view network')) {
@@ -18,4 +19,17 @@ export default class NetworksIndexNetworkRoute extends Route {
model({ public_id }) {
return this.store.findRecord('network', public_id);
}
+
+ /**
+ * A URL under a network scopes the console to that network; moving between two
+ * networks re-runs this hook without deactivating the route.
+ */
+ afterModel(network) {
+ this.storefront.setActiveNetwork(network);
+ }
+
+ deactivate() {
+ super.deactivate(...arguments);
+ this.storefront.clearActiveNetwork();
+ }
}
diff --git a/addon/routes/networks/index/network/customers.js b/addon/routes/networks/index/network/customers.js
index 652e00f8..e46b7b9b 100644
--- a/addon/routes/networks/index/network/customers.js
+++ b/addon/routes/networks/index/network/customers.js
@@ -3,7 +3,6 @@ import { inject as service } from '@ember/service';
export default class NetworksIndexNetworkCustomersRoute extends Route {
@service store;
- @service storefront;
queryParams = {
page: { refreshModel: true, as: 'n_page' },
@@ -21,6 +20,6 @@ export default class NetworksIndexNetworkCustomersRoute extends Route {
};
model(params) {
- return this.store.query('customer', { ...params, storefront: this.storefront.getActiveStore('public_id') });
+ return this.store.query('customer', { ...params, network: this.modelFor('networks.index.network').public_id });
}
}
diff --git a/addon/routes/networks/index/network/index.js b/addon/routes/networks/index/network/index.js
index d7489cba..42ad56d8 100644
--- a/addon/routes/networks/index/network/index.js
+++ b/addon/routes/networks/index/network/index.js
@@ -1,15 +1,32 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
+import { hash } from 'rsvp';
+/**
+ * The network overview: members, invitations, the last seven days of orders and revenue.
+ */
export default class NetworksIndexNetworkIndexRoute extends Route {
- @service store;
+ @service fetch;
+ @service notifications;
- model() {
+ get network() {
return this.modelFor('networks.index.network');
}
- async setupController(controller) {
+ model() {
+ const { network } = this;
+
+ return hash({
+ network,
+ overview: this.fetch.get(`networks/${network.id}/overview`, {}, { namespace: 'storefront/int/v1' }).catch(() => null),
+ invitations: this.fetch.get(`networks/${network.id}/invitations`, {}, { namespace: 'storefront/int/v1' }).catch(() => []),
+ });
+ }
+
+ setupController(controller, model) {
super.setupController(...arguments);
- controller.orderConfigs = await this.store.findAll('order-config');
+ controller.network = model.network;
+ controller.overview = model.overview;
+ controller.invitations = model.invitations ?? [];
}
}
diff --git a/addon/routes/networks/index/network/orders.js b/addon/routes/networks/index/network/orders.js
index 36e2adbf..c573a839 100644
--- a/addon/routes/networks/index/network/orders.js
+++ b/addon/routes/networks/index/network/orders.js
@@ -2,8 +2,17 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class NetworksIndexNetworkOrdersRoute extends Route {
- @service storefront;
@service fetch;
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true, as: 'o_page' },
+ limit: { refreshModel: true, as: 'o_limit' },
+ sort: { refreshModel: true, as: 'o_sort' },
+ query: { refreshModel: true, as: 'o_query' },
+ status: { refreshModel: true, as: 'o_status' },
+ view: { refreshModel: false, as: 'o_view' },
+ };
async model(params) {
const response = await this.fetch.get('orders', this.buildQueryParams(params), { namespace: 'storefront/int/v1' });
@@ -14,8 +23,22 @@ export default class NetworksIndexNetworkOrdersRoute extends Route {
return orders;
}
+ async setupController(controller, model) {
+ super.setupController(controller, model);
+ controller.network = this.modelFor('networks.index.network');
+
+ try {
+ const stores = await this.store.query('store', { network: controller.network.id });
+ controller.stores = stores?.toArray?.() ?? Array.from(stores ?? []);
+ } catch {
+ controller.stores = [];
+ }
+ }
+
buildQueryParams(params = {}) {
- return Object.entries({ ...params, storefront: this.storefront.getActiveStore('public_id') }).reduce((queryParams, [key, value]) => {
+ const { view, ...rest } = params;
+
+ return Object.entries({ ...rest, network: this.modelFor('networks.index.network').public_id }).reduce((queryParams, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams[key] = value;
}
diff --git a/addon/routes/networks/index/network/settings.js b/addon/routes/networks/index/network/settings.js
new file mode 100644
index 00000000..e970a9fc
--- /dev/null
+++ b/addon/routes/networks/index/network/settings.js
@@ -0,0 +1,15 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class NetworksIndexNetworkSettingsRoute extends Route {
+ @service store;
+
+ model() {
+ return this.modelFor('networks.index.network');
+ }
+
+ async setupController(controller) {
+ super.setupController(...arguments);
+ controller.orderConfigs = await this.store.findAll('order-config');
+ }
+}
diff --git a/addon/routes/networks/index/network/stores.js b/addon/routes/networks/index/network/stores.js
index 6f8870a6..710c5de4 100644
--- a/addon/routes/networks/index/network/stores.js
+++ b/addon/routes/networks/index/network/stores.js
@@ -23,6 +23,7 @@ export default class NetworksIndexNetworkStoresRoute extends Route {
// set the network to controller
controller.network = this.network;
+ controller.invitations = (await this.network.loadInvitations().catch(() => [])) ?? [];
// set the cateogry if set
const { category: categoryId } = this.paramsFor(this.routeName);
diff --git a/addon/routes/networks/index/network/trucks.js b/addon/routes/networks/index/network/trucks.js
new file mode 100644
index 00000000..c676f004
--- /dev/null
+++ b/addon/routes/networks/index/network/trucks.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class NetworksIndexNetworkTrucksRoute extends Route {
+ @service store;
+
+ queryParams = {
+ query: { refreshModel: true, as: 't_query' },
+ };
+
+ get network() {
+ return this.modelFor('networks.index.network');
+ }
+
+ model(params = {}) {
+ return this.store.query('food-truck', { ...params, network: this.network.id });
+ }
+}
diff --git a/addon/routes/settings/index.js b/addon/routes/settings/index.js
index c4d15fdc..60eaa024 100644
--- a/addon/routes/settings/index.js
+++ b/addon/routes/settings/index.js
@@ -31,6 +31,7 @@ export default class SettingsIndexRoute extends Route {
async setupController(controller) {
super.setupController(...arguments);
+ controller.snapshotOptions();
controller.orderConfigs = await this.store.findAll('order-config');
}
}
diff --git a/addon/serializers/food-truck.js b/addon/serializers/food-truck.js
index 9b96e1f0..9111cfec 100644
--- a/addon/serializers/food-truck.js
+++ b/addon/serializers/food-truck.js
@@ -9,6 +9,7 @@ export default class FoodTruckSerializer extends ApplicationSerializer.extend(Em
*/
get attrs() {
return {
+ store_summary: { key: 'store' },
catalogs: { embedded: 'always' },
vehicle: { embedded: 'always' },
serviceArea: { embedded: 'always' },
diff --git a/addon/services/storefront-order-actions.js b/addon/services/storefront-order-actions.js
index ed5f4290..9ad3642b 100644
--- a/addon/services/storefront-order-actions.js
+++ b/addon/services/storefront-order-actions.js
@@ -2,6 +2,7 @@ import Service, { inject as service } from '@ember/service';
import { isArray } from '@ember/array';
export default class StorefrontOrderActionsService extends Service {
+ @service hostRouter;
@service intl;
@service notifications;
@service fetch;
@@ -32,7 +33,7 @@ export default class StorefrontOrderActionsService extends Service {
tabs: this.tabsFor(hydratedOrder),
actionButtons: this.actionButtonsFor(hydratedOrder, options.onChange),
registry: 'storefront:order:details',
- width: '560px',
+ width: '600px',
size: 'sm',
dismissible: false,
bodyClass: 'scrollable',
@@ -47,12 +48,12 @@ export default class StorefrontOrderActionsService extends Service {
const registeredTabs = this.menuService.getMenuItems('storefront:component:order:details');
return [
- {
- label: 'Overview',
- key: 'overview',
- icon: 'folder-open',
- component: 'storefront/order/details',
- },
+ { label: this.intl.t('storefront.order.tabs.items'), key: 'items', icon: 'basket-shopping', component: 'storefront/order/details/tabs/items' },
+ { label: this.intl.t('storefront.order.tabs.route'), key: 'route', icon: 'route', component: 'storefront/order/details/tabs/route' },
+ { label: this.intl.t('storefront.order.tabs.payment'), key: 'payment', icon: 'credit-card', component: 'storefront/order/details/tabs/payment' },
+ { label: this.intl.t('storefront.order.tabs.activity'), key: 'activity', icon: 'wave-square', component: 'storefront/order/details/tabs/activity' },
+ { label: this.intl.t('storefront.order.tabs.customer'), key: 'customer', icon: 'user', component: 'storefront/order/details/tabs/customer' },
+ { label: this.intl.t('storefront.order.tabs.data'), key: 'data', icon: 'database', component: 'storefront/order/details/tabs/data' },
...(isArray(registeredTabs)
? registeredTabs.map((tab) => ({
label: tab.label ?? tab.title,
@@ -65,12 +66,42 @@ export default class StorefrontOrderActionsService extends Service {
];
}
+ /**
+ * Header actions: the next workflow action promoted to a button, Edit, then everything else behind a menu.
+ */
actionButtonsFor(order, callback) {
- return [
- {
- items: this.actionItemsFor(order, callback),
- },
- ];
+ const items = this.actionItemsFor(order, callback);
+ const primary = items.find((item) => item.id === 'perform-workflow-action');
+ const rest = items.filter((item) => item !== primary);
+ const buttons = [];
+
+ if (primary) {
+ buttons.push({ text: primary.text, icon: primary.icon, type: primary.type ?? 'primary', size: 'sm', fn: primary.fn, permission: 'storefront update order' });
+ }
+
+ buttons.push({
+ text: this.intl.t('common.edit'),
+ icon: 'pencil',
+ size: 'sm',
+ permission: 'storefront update order',
+ fn: () => this.editOrder(order),
+ });
+
+ buttons.push({ items: rest });
+
+ return buttons;
+ }
+
+ editOrder(order) {
+ if (!order?.public_id) {
+ return;
+ }
+
+ try {
+ return this.hostRouter.transitionTo('console.storefront.orders.index.edit', order.public_id);
+ } catch {
+ return null;
+ }
}
actionItemsFor(order, callback) {
diff --git a/addon/services/storefront.js b/addon/services/storefront.js
index 3ce5d258..fadae993 100644
--- a/addon/services/storefront.js
+++ b/addon/services/storefront.js
@@ -18,6 +18,7 @@ export default class StorefrontService extends Service.extend(Evented) {
@service abilities;
@service socket;
@tracked activeStoreId;
+ @tracked activeNetworkId;
get hostRouter() {
const owner = getOwner(this);
@@ -35,6 +36,39 @@ export default class StorefrontService extends Service.extend(Evented) {
return activeStoreId ? this.store.peekRecord('store', activeStoreId) : null;
}
+ /**
+ * Gets the active network. A network is active only while the user is inside a
+ * network page: the context is derived from the route, not persisted.
+ * @returns {Object|null} The active network record.
+ */
+ get activeNetwork() {
+ return this.activeNetworkId ? (this.store.peekRecord('network', this.activeNetworkId) ?? null) : null;
+ }
+
+ /**
+ * Whether the console is scoped to a network instead of a store.
+ * @returns {boolean}
+ */
+ get isNetworkContext() {
+ return Boolean(this.activeNetwork);
+ }
+
+ /**
+ * The active context type: a store or a network, never both at once.
+ * @returns {'store'|'network'}
+ */
+ get activeContextType() {
+ return this.isNetworkContext ? 'network' : 'store';
+ }
+
+ /**
+ * The record the console is currently scoped to.
+ * @returns {Object|null} the active network, otherwise the active store
+ */
+ get activeContext() {
+ return this.activeNetwork ?? this.activeStore;
+ }
+
/**
* Sets the active storefront.
* @param {Object} store - The store to set as active.
@@ -45,6 +79,45 @@ export default class StorefrontService extends Service.extend(Evented) {
this.trigger('storefront.changed', store);
}
+ /**
+ * Enters the network context for a network, or leaves it when given nothing.
+ * @param {Object|null} network - The network to scope the console to.
+ */
+ setActiveNetwork(network = null) {
+ const nextNetworkId = network?.id ?? null;
+
+ if ((this.activeNetworkId ?? null) === nextNetworkId) {
+ return;
+ }
+
+ this.activeNetworkId = nextNetworkId;
+ this.trigger('storefront.context.changed', this.activeContextType, this.activeContext);
+ }
+
+ /**
+ * Leaves the network context and returns to the active store.
+ */
+ clearActiveNetwork() {
+ this.setActiveNetwork(null);
+ }
+
+ /**
+ * Gets the active network or a specific property of it.
+ * @param {string|null} property - The property to retrieve from the active network.
+ * @returns {Object|null} The active network or its specific property.
+ */
+ getActiveNetwork(property = null) {
+ if (this.activeNetwork) {
+ if (typeof property === 'string') {
+ return get(this.activeNetwork, property);
+ }
+
+ return this.activeNetwork;
+ }
+
+ return null;
+ }
+
/**
* Gets the active store or a specific property of it.
* @param {string|null} property - The property to retrieve from the active store.
@@ -254,6 +327,48 @@ export default class StorefrontService extends Service.extend(Evented) {
});
}
+ /**
+ * Creates a new network with given options.
+ * @param {Object} [options={}] - Options for creating the network; `onSuccess(network)` runs after it saves.
+ */
+ createNewNetwork(options = {}) {
+ const network = this.store.createRecord('network');
+ const currency = this.currentUser.getWhoisProperty('currency.code');
+
+ if (currency) {
+ network.setProperties({ currency });
+ }
+
+ this.modalsManager.show('modals/create-network', {
+ title: this.intl.t('storefront.networks.index.create-new-network'),
+ acceptButtonIcon: 'check',
+ acceptButtonIconPrefix: 'fas',
+ declineButtonIcon: 'times',
+ declineButtonIconPrefix: 'fas',
+ network,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await network.save();
+ this.notifications.success(this.intl.t('storefront.networks.index.success-message'));
+ if (typeof options?.onSuccess === 'function') {
+ options.onSuccess(network);
+ }
+ modal.done();
+ } catch (error) {
+ modal.stopLoading();
+ this.notifications.serverError(error);
+ }
+ },
+ decline: (modal) => {
+ network.destroyRecord();
+ modal.done();
+ },
+ ...options,
+ });
+ }
+
/**
* Plays an alert sound.
*/
diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css
index 9034af9d..7d0d4462 100644
--- a/addon/styles/storefront-engine.css
+++ b/addon/styles/storefront-engine.css
@@ -3092,3 +3092,2172 @@ body[data-theme='dark'] .storefront-order-store__row-value a:hover {
justify-content: center;
}
}
+
+/* ==========================================================================
+ Shared resource card (catalog, truck, network) on Layout::Resource::Card
+ ========================================================================== */
+
+.storefront-card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.storefront-card__title {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ width: 100%;
+ min-width: 0;
+}
+
+.storefront-card__title-text {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: #111827;
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+a.storefront-card__title-text:hover {
+ color: #2563eb;
+}
+
+body[data-theme='dark'] .storefront-card__title-text {
+ color: #f3f4f6;
+}
+
+.storefront-card__indicator {
+ flex: none;
+}
+
+.storefront-card__subtitle {
+ width: 100%;
+ overflow: hidden;
+ color: #4b5563;
+ font-size: 0.75rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .storefront-card__subtitle {
+ color: #9ca3af;
+}
+
+.storefront-card__identity {
+ width: 100%;
+ min-width: 0;
+}
+
+.storefront-card__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+}
+
+.storefront-card__media-body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 10rem;
+ overflow: hidden;
+}
+
+.storefront-card__media {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.storefront-card__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.storefront-card__chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+ background: #e5e7eb;
+ color: #374151;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ line-height: 1rem;
+}
+
+.storefront-card__chip-count {
+ color: #6b7280;
+ font-weight: 500;
+}
+
+body[data-theme='dark'] .storefront-card__chip {
+ background: #374151;
+ color: #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-card__chip-count {
+ color: #9ca3af;
+}
+
+.storefront-card__empty {
+ color: #6b7280;
+ font-size: 0.75rem;
+}
+
+.storefront-card__actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+.storefront-card__buttons {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.storefront-card__count {
+ color: #4b5563;
+ font-size: 0.75rem;
+}
+
+body[data-theme='dark'] .storefront-card__count {
+ color: #9ca3af;
+}
+
+.storefront-card__meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ margin-top: 0.375rem;
+ width: 100%;
+ color: #4b5563;
+ font-size: 0.75rem;
+}
+
+.storefront-card__meta > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .storefront-card__meta {
+ color: #9ca3af;
+}
+
+.storefront-catalog-card.is-draft {
+ border-style: dashed;
+}
+
+.storefront-network-card__backdrop {
+ background-color: #e5e7eb;
+ background-position: center;
+ background-size: cover;
+}
+
+body[data-theme='dark'] .storefront-network-card__backdrop {
+ background-color: #374151;
+}
+
+.storefront-network-card__logo {
+ max-width: 8rem;
+ max-height: 5rem;
+ object-fit: contain;
+}
+
+/* Hours strip: one cell per weekday, filled when the schedule covers it */
+
+.storefront-hours-strip {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 0.25rem;
+ width: 100%;
+}
+
+.storefront-hours-strip__day {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ min-width: 0;
+}
+
+.storefront-hours-strip__label {
+ color: #6b7280;
+ font-size: 0.625rem;
+ line-height: 1;
+ text-align: center;
+}
+
+.storefront-hours-strip__bar {
+ display: block;
+ height: 0.375rem;
+ border-radius: 0.125rem;
+ background: #e5e7eb;
+}
+
+.storefront-hours-strip__day.is-open .storefront-hours-strip__bar {
+ background: #86efac;
+}
+
+body[data-theme='dark'] .storefront-hours-strip__bar {
+ background: #374151;
+}
+
+body[data-theme='dark'] .storefront-hours-strip__day.is-open .storefront-hours-strip__bar {
+ background: #16a34a;
+}
+
+/* Context switcher: stores and networks as peers at the top of the sidebar */
+.storefront-switcher {
+ position: relative;
+ margin-bottom: 0.5rem;
+}
+
+.storefront-switcher__trigger {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #2563eb;
+ border-radius: 0.375rem;
+ background-color: #2563eb;
+ color: #ffffff;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color 120ms ease, border-color 120ms ease;
+}
+
+.storefront-switcher__trigger:hover,
+.storefront-switcher__trigger.is-open {
+ background-color: #1d4ed8;
+ border-color: #1d4ed8;
+}
+
+.storefront-switcher__trigger--network {
+ background-color: #4f46e5;
+ border-color: #4f46e5;
+}
+
+.storefront-switcher__trigger--network:hover,
+.storefront-switcher__trigger--network.is-open {
+ background-color: #4338ca;
+ border-color: #4338ca;
+}
+
+.storefront-switcher__icon {
+ display: inline-flex;
+ flex-shrink: 0;
+ opacity: 0.95;
+}
+
+.storefront-switcher__name {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storefront-switcher__kind {
+ flex-shrink: 0;
+ font-size: 0.625rem;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ opacity: 0.85;
+}
+
+.storefront-switcher__caret {
+ display: inline-flex;
+ flex-shrink: 0;
+ opacity: 0.85;
+}
+
+.storefront-switcher__badge {
+ position: absolute;
+ top: -0.375rem;
+ right: -0.25rem;
+ min-width: 1.125rem;
+ padding: 0 0.3rem;
+ border-radius: 9999px;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ line-height: 1.125rem;
+ text-align: center;
+ color: #ffffff;
+ background-color: #f59e0b;
+ pointer-events: none;
+}
+
+.storefront-switcher-menu {
+ position: fixed;
+ z-index: 900;
+ margin: 0;
+ padding: 0.375rem;
+ max-height: calc(100vh - 16px);
+ overflow-y: auto;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ background-color: #ffffff;
+ box-shadow: 0 10px 25px rgb(0 0 0 / 12%);
+ color: #111827;
+}
+
+.storefront-switcher-menu[data-theme='dark'] {
+ border-color: #374151;
+ background-color: #1f2937;
+ color: #f3f4f6;
+}
+
+.storefront-switcher-menu__group + .storefront-switcher-menu__group {
+ margin-top: 0.25rem;
+}
+
+.storefront-switcher-menu__footer {
+ margin-top: 0.375rem;
+ padding-top: 0.375rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__footer {
+ border-color: #374151;
+}
+
+.storefront-switcher-menu__head {
+ padding: 0.5rem 0.625rem 0.25rem;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #6b7280;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__head {
+ color: #9ca3af;
+}
+
+.storefront-switcher-menu__item {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ width: 100%;
+ padding: 0.5rem 0.625rem;
+ border: 0;
+ border-radius: 0.375rem;
+ background: transparent;
+ font: inherit;
+ font-size: 0.8125rem;
+ color: #111827;
+ text-align: left;
+ cursor: pointer;
+}
+
+.storefront-switcher-menu__item:hover,
+.storefront-switcher-menu__item:focus-visible {
+ background-color: #f3f4f6;
+ outline: none;
+}
+
+.storefront-switcher-menu__item.is-active {
+ background-color: #eff6ff;
+ font-weight: 600;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__item {
+ color: #f3f4f6;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__item:hover,
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__item:focus-visible {
+ background-color: #374151;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__item.is-active {
+ background-color: rgb(55 65 81 / 80%);
+}
+
+.storefront-switcher-menu__label {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storefront-switcher-menu__meta {
+ flex-shrink: 0;
+ font-size: 0.6875rem;
+ font-weight: 400;
+ color: #6b7280;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__meta {
+ color: #9ca3af;
+}
+
+.storefront-switcher-menu__check {
+ display: inline-flex;
+ flex-shrink: 0;
+ color: #2563eb;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-menu__check {
+ color: #60a5fa;
+}
+
+.storefront-switcher-menu__empty {
+ padding: 0.5rem 0.625rem;
+ font-size: 0.8125rem;
+ color: #9ca3af;
+}
+
+.storefront-switcher-tile {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25rem;
+ height: 1.25rem;
+ flex-shrink: 0;
+ border-radius: 0.25rem;
+}
+
+.storefront-switcher-tile--store {
+ background-color: #dbeafe;
+ color: #1d4ed8;
+}
+
+.storefront-switcher-tile--network {
+ background-color: #e0e7ff;
+ color: #4338ca;
+}
+
+.storefront-switcher-tile--muted {
+ background-color: #f3f4f6;
+ color: #1d4ed8;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-tile--store {
+ background-color: rgb(29 78 216 / 35%);
+ color: #93c5fd;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-tile--network {
+ background-color: rgb(67 56 202 / 35%);
+ color: #a5b4fc;
+}
+
+.storefront-switcher-menu[data-theme='dark'] .storefront-switcher-tile--muted {
+ background-color: #374151;
+ color: #93c5fd;
+}
+
+/* Network page: full-page container for the network sections */
+.storefront-network-page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.storefront-network-page__header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .storefront-network-page__header {
+ border-color: #374151;
+ background-color: rgb(55 65 81 / 50%);
+}
+
+.storefront-network-page__logo {
+ width: 2.5rem;
+ height: 2.5rem;
+ flex-shrink: 0;
+ border-radius: 0.375rem;
+ object-fit: cover;
+ border: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-network-page__logo {
+ border-color: #4b5563;
+}
+
+.storefront-network-page__identity {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.storefront-network-page__name {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.25rem;
+ color: #111827;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .storefront-network-page__name {
+ color: #f3f4f6;
+}
+
+.storefront-network-page__meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-network-page__meta {
+ color: #9ca3af;
+}
+
+.storefront-network-page__body {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+/* Network overview: stat tiles, member rows, share bars */
+.storefront-stat-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+@media (max-width: 1200px) {
+ .storefront-stat-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 768px) {
+ .storefront-stat-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.storefront-stat-tile {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #ffffff;
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%);
+}
+
+body[data-theme='dark'] .storefront-stat-tile {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+.storefront-stat-tile__label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-stat-tile__label {
+ color: #9ca3af;
+}
+
+.storefront-stat-tile__value {
+ font-size: 1.5rem;
+ line-height: 2rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+body[data-theme='dark'] .storefront-stat-tile__value {
+ color: #f3f4f6;
+}
+
+.storefront-stat-tile__hint {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-stat-tile__hint {
+ color: #9ca3af;
+}
+
+.storefront-member-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.storefront-member-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.625rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ font-size: 0.875rem;
+}
+
+body[data-theme='dark'] .storefront-member-row {
+ border-color: #374151;
+}
+
+.storefront-member-row:last-child {
+ border-bottom: 0;
+}
+
+.storefront-member-row--link {
+ border-top: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .storefront-member-row--link {
+ border-color: #374151;
+ background-color: rgb(55 65 81 / 50%);
+}
+
+.storefront-member-row__logo {
+ width: 2rem;
+ height: 2rem;
+ flex-shrink: 0;
+ border-radius: 0.375rem;
+ object-fit: cover;
+ border: 1px solid #e5e7eb;
+}
+
+.storefront-member-row__logo--icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #6b7280;
+ background-color: #f3f4f6;
+}
+
+body[data-theme='dark'] .storefront-member-row__logo {
+ border-color: #4b5563;
+}
+
+body[data-theme='dark'] .storefront-member-row__logo--icon {
+ color: #9ca3af;
+ background-color: #374151;
+}
+
+.storefront-member-row__identity {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.storefront-member-row__name {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-weight: 600;
+ color: #111827;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .storefront-member-row__name {
+ color: #f3f4f6;
+}
+
+.storefront-member-row__meta,
+.storefront-member-row__share-label {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-member-row__meta,
+body[data-theme='dark'] .storefront-member-row__share-label {
+ color: #9ca3af;
+}
+
+.storefront-member-row__share {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ width: 14rem;
+ flex-shrink: 0;
+}
+
+.storefront-member-row__amount {
+ font-weight: 600;
+ color: #111827;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .storefront-member-row__amount {
+ color: #f3f4f6;
+}
+
+.storefront-member-row__link {
+ max-width: 18rem;
+ font-size: 0.75rem;
+}
+
+.storefront-share-bar {
+ height: 0.375rem;
+ border-radius: 9999px;
+ background-color: #e5e7eb;
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .storefront-share-bar {
+ background-color: #374151;
+}
+
+.storefront-share-bar__fill {
+ height: 100%;
+ border-radius: 9999px;
+ background-color: #0ea5e9;
+}
+
+.storefront-empty-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-empty-row {
+ color: #9ca3af;
+}
+
+/* Status tabs above a table */
+.storefront-status-tabs {
+ display: flex;
+ gap: 0.25rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-status-tabs {
+ border-color: #374151;
+}
+
+.storefront-status-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.5rem 0.75rem;
+ margin-bottom: -1px;
+ border-bottom: 2px solid transparent;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #6b7280;
+ background: none;
+}
+
+.storefront-status-tab:hover {
+ color: #111827;
+}
+
+.storefront-status-tab.is-active {
+ color: #0284c7;
+ border-bottom-color: #0284c7;
+}
+
+body[data-theme='dark'] .storefront-status-tab {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .storefront-status-tab:hover {
+ color: #f3f4f6;
+}
+
+body[data-theme='dark'] .storefront-status-tab.is-active {
+ color: #38bdf8;
+ border-bottom-color: #38bdf8;
+}
+
+.storefront-status-tab__count {
+ padding: 0 0.375rem;
+ border-radius: 9999px;
+ font-size: 0.6875rem;
+ background-color: #f3f4f6;
+ color: #374151;
+}
+
+body[data-theme='dark'] .storefront-status-tab__count {
+ background-color: #374151;
+ color: #e5e7eb;
+}
+
+/* Bulk action bar shown while rows are selected */
+.storefront-bulk-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #bae6fd;
+ border-radius: 0.375rem;
+ background-color: #f0f9ff;
+ font-size: 0.875rem;
+}
+
+body[data-theme='dark'] .storefront-bulk-bar {
+ border-color: #0c4a6e;
+ background-color: rgb(12 74 110 / 35%);
+}
+
+.storefront-bulk-bar__count {
+ font-weight: 600;
+ color: #0369a1;
+ margin-right: 0.25rem;
+}
+
+body[data-theme='dark'] .storefront-bulk-bar__count {
+ color: #7dd3fc;
+}
+
+.storefront-invitation-list {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .storefront-invitation-list {
+ border-color: #374151;
+}
+
+.storefront-invitation-list__title {
+ padding: 0.5rem 1rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #6b7280;
+ background-color: #f9fafb;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-invitation-list__title {
+ color: #9ca3af;
+ background-color: rgb(55 65 81 / 50%);
+ border-color: #374151;
+}
+
+.storefront-invitation-list__note {
+ padding: 0.5rem 1rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+/* Invite panel */
+.storefront-invite-panel__lede {
+ padding: 0.75rem 1rem 0;
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-invite-panel__lede {
+ color: #9ca3af;
+}
+
+.storefront-invite-panel__tabs {
+ display: flex;
+ gap: 0.25rem;
+ margin: 0.75rem 1rem 0;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-invite-panel__tabs {
+ border-color: #374151;
+}
+
+.storefront-invite-panel__tab {
+ padding: 0.5rem 0.75rem;
+ margin-bottom: -1px;
+ border-bottom: 2px solid transparent;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #6b7280;
+ background: none;
+}
+
+.storefront-invite-panel__tab.is-active {
+ color: #0284c7;
+ border-bottom-color: #0284c7;
+}
+
+body[data-theme='dark'] .storefront-invite-panel__tab {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .storefront-invite-panel__tab.is-active {
+ color: #38bdf8;
+ border-bottom-color: #38bdf8;
+}
+
+.storefront-invite-panel__section {
+ padding: 1rem;
+}
+
+.storefront-invite-panel__flag {
+ margin-top: 0.375rem;
+ font-size: 0.75rem;
+ color: #b45309;
+}
+
+.storefront-invite-panel__flag--error {
+ color: #b91c1c;
+}
+
+.storefront-invite-panel__hint {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.storefront-invite-panel__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-invite-panel__footer {
+ border-color: #374151;
+}
+
+/* Network orders: grouped by checkout */
+.storefront-view-toggle {
+ display: inline-flex;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .storefront-view-toggle {
+ border-color: #4b5563;
+}
+
+.storefront-view-toggle__option {
+ padding: 0.25rem 0.625rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #6b7280;
+ background-color: #ffffff;
+}
+
+.storefront-view-toggle__option.is-active {
+ color: #ffffff;
+ background-color: #0284c7;
+}
+
+body[data-theme='dark'] .storefront-view-toggle__option {
+ color: #d1d5db;
+ background-color: #1f2937;
+}
+
+body[data-theme='dark'] .storefront-view-toggle__option.is-active {
+ background-color: #0369a1;
+}
+
+.storefront-order-ref {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.storefront-order-ref__id {
+ font-weight: 600;
+ color: #111827;
+ white-space: nowrap;
+}
+
+.storefront-order-ref__id--link {
+ color: #0284c7;
+}
+
+body[data-theme='dark'] .storefront-order-ref__id {
+ color: #f3f4f6;
+}
+
+body[data-theme='dark'] .storefront-order-ref__id--link {
+ color: #38bdf8;
+}
+
+.storefront-order-ref__meta,
+.storefront-order-total__tip,
+.storefront-order-driver__note,
+.storefront-order-driver__muted {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-order-ref__meta,
+body[data-theme='dark'] .storefront-order-total__tip,
+body[data-theme='dark'] .storefront-order-driver__note,
+body[data-theme='dark'] .storefront-order-driver__muted {
+ color: #9ca3af;
+}
+
+.storefront-identity-stack {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.storefront-identity-stack__text {
+ max-width: 10rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storefront-identity-stack__overflow {
+ padding: 0 0.375rem;
+ border-radius: 9999px;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ background-color: #f3f4f6;
+ color: #374151;
+}
+
+body[data-theme='dark'] .storefront-identity-stack__overflow {
+ background-color: #374151;
+ color: #e5e7eb;
+}
+
+.storefront-fulfillment {
+ display: inline-flex;
+ align-items: center;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.storefront-fulfillment--pickup {
+ color: #7c3aed;
+}
+
+.storefront-fulfillment--delivery {
+ color: #0284c7;
+}
+
+.storefront-order-total {
+ display: flex;
+ flex-direction: column;
+}
+
+.storefront-order-total__amount {
+ font-weight: 600;
+ color: #111827;
+}
+
+body[data-theme='dark'] .storefront-order-total__amount {
+ color: #f3f4f6;
+}
+
+.storefront-order-driver {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+
+.storefront-order-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.375rem;
+}
+
+.storefront-order-group {
+ display: flex;
+ flex-direction: column;
+ background-color: #f9fafb;
+ border-left: 3px solid #0284c7;
+}
+
+body[data-theme='dark'] .storefront-order-group {
+ background-color: rgb(55 65 81 / 40%);
+ border-left-color: #0369a1;
+}
+
+.storefront-order-group__row {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem 0.75rem 0.5rem 2.75rem;
+ border-bottom: 1px solid #e5e7eb;
+ font-size: 0.875rem;
+}
+
+body[data-theme='dark'] .storefront-order-group__row {
+ border-color: #374151;
+}
+
+.storefront-order-group__row:last-child {
+ border-bottom: 0;
+}
+
+.storefront-order-group__cell {
+ flex: 1 1 0;
+ min-width: 0;
+ padding: 0 0.5rem;
+}
+
+/* Order panel: header row, flow steps and identity strip */
+.storefront-order-panel-heading__row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.storefront-order-panel-heading__title {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.storefront-flow-steps {
+ margin-top: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .storefront-flow-steps {
+ border-color: #374151;
+ background-color: rgb(55 65 81 / 50%);
+}
+
+.storefront-flow-steps__list {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ overflow-x: auto;
+}
+
+.storefront-flow-step {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ flex: 1 1 0;
+ min-width: 0;
+ font-size: 0.75rem;
+ color: #6b7280;
+ position: relative;
+}
+
+.storefront-flow-step:not(:last-child)::after {
+ content: '';
+ flex: 1 1 auto;
+ height: 1px;
+ margin: 0 0.25rem;
+ background-color: #d1d5db;
+}
+
+body[data-theme='dark'] .storefront-flow-step:not(:last-child)::after {
+ background-color: #4b5563;
+}
+
+.storefront-flow-step.is-complete:not(:last-child)::after {
+ background-color: #10b981;
+}
+
+.storefront-flow-step__marker {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25rem;
+ height: 1.25rem;
+ flex-shrink: 0;
+ border-radius: 9999px;
+ border: 1px solid #d1d5db;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: #6b7280;
+ background-color: #ffffff;
+}
+
+body[data-theme='dark'] .storefront-flow-step__marker {
+ border-color: #4b5563;
+ color: #9ca3af;
+ background-color: #1f2937;
+}
+
+.storefront-flow-step.is-complete .storefront-flow-step__marker {
+ border-color: #10b981;
+ background-color: #10b981;
+ color: #ffffff;
+}
+
+.storefront-flow-step.is-current .storefront-flow-step__marker {
+ border-color: #0284c7;
+ background-color: #0284c7;
+ color: #ffffff;
+}
+
+.storefront-flow-step.is-current .storefront-flow-step__label {
+ color: #111827;
+ font-weight: 600;
+}
+
+body[data-theme='dark'] .storefront-flow-step.is-current .storefront-flow-step__label {
+ color: #f3f4f6;
+}
+
+.storefront-flow-step__label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.storefront-flow-steps.is-canceled .storefront-flow-steps__list {
+ opacity: 0.5;
+}
+
+.storefront-flow-steps__canceled {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 0.375rem;
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: #b91c1c;
+ background-color: #fee2e2;
+}
+
+body[data-theme='dark'] .storefront-flow-steps__canceled {
+ color: #fca5a5;
+ background-color: rgb(127 29 29 / 40%);
+}
+
+.storefront-identity-strip {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin-top: 0.75rem;
+}
+
+@media (max-width: 640px) {
+ .storefront-identity-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.storefront-identity-strip__cell {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.storefront-identity-strip__label {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-identity-strip__label {
+ color: #9ca3af;
+}
+
+.storefront-identity-strip__value {
+ font-size: 0.875rem;
+ color: #111827;
+}
+
+.storefront-identity-strip__value--strong {
+ font-weight: 600;
+}
+
+body[data-theme='dark'] .storefront-identity-strip__value {
+ color: #f3f4f6;
+}
+
+.storefront-identity-strip__muted {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-identity-strip__muted {
+ color: #9ca3af;
+}
+
+.storefront-order-tab > .content-panel {
+ margin-bottom: 0.5rem;
+}
+
+/* Settings: shell with identity header and section rail */
+.storefront-settings-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.storefront-settings-shell__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .storefront-settings-shell__header {
+ border-color: #374151;
+ background-color: rgb(55 65 81 / 50%);
+}
+
+.storefront-settings-shell__identity {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.storefront-settings-shell__logo {
+ width: 2.5rem;
+ height: 2.5rem;
+ flex-shrink: 0;
+ border-radius: 0.375rem;
+ object-fit: cover;
+ border: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-settings-shell__logo {
+ border-color: #4b5563;
+}
+
+.storefront-settings-shell__title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+body[data-theme='dark'] .storefront-settings-shell__title {
+ color: #f3f4f6;
+}
+
+.storefront-settings-shell__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.storefront-settings-shell__meta span + span::before {
+ content: '·';
+ margin-right: 0.5rem;
+}
+
+body[data-theme='dark'] .storefront-settings-shell__meta {
+ color: #9ca3af;
+}
+
+.storefront-settings-shell__body {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.storefront-settings-rail {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ width: 14rem;
+ flex-shrink: 0;
+ padding: 0.75rem 0.5rem;
+ border-right: 1px solid #e5e7eb;
+ background-color: #ffffff;
+ overflow-y: auto;
+}
+
+body[data-theme='dark'] .storefront-settings-rail {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+@media (max-width: 1024px) {
+ .storefront-settings-shell__body {
+ flex-direction: column;
+ }
+
+ .storefront-settings-rail {
+ width: auto;
+ flex-direction: row;
+ flex-wrap: wrap;
+ border-right: 0;
+ border-bottom: 1px solid #e5e7eb;
+ }
+}
+
+.storefront-settings-rail__item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.375rem 0.625rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ text-align: left;
+ color: #374151;
+ background: none;
+}
+
+.storefront-settings-rail__item:hover {
+ background-color: #f3f4f6;
+}
+
+.storefront-settings-rail__item.is-active {
+ background-color: #e0f2fe;
+ color: #0369a1;
+ font-weight: 600;
+}
+
+body[data-theme='dark'] .storefront-settings-rail__item {
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .storefront-settings-rail__item:hover {
+ background-color: #374151;
+}
+
+body[data-theme='dark'] .storefront-settings-rail__item.is-active {
+ background-color: rgb(12 74 110 / 50%);
+ color: #7dd3fc;
+}
+
+.storefront-settings-rail__icon {
+ width: 1rem;
+ flex-shrink: 0;
+ color: #6b7280;
+}
+
+.storefront-settings-rail__item.is-active .storefront-settings-rail__icon {
+ color: inherit;
+}
+
+.storefront-settings-rail__label {
+ flex: 1 1 auto;
+}
+
+.storefront-settings-rail__badge {
+ padding: 0 0.375rem;
+ border-radius: 9999px;
+ font-size: 0.6875rem;
+ background-color: #f3f4f6;
+ color: #374151;
+}
+
+.storefront-settings-content {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-y: auto;
+}
+
+.storefront-settings-page {
+ max-width: 56rem;
+ margin: 0 auto;
+ padding: 1rem;
+}
+
+.storefront-settings-section {
+ margin-bottom: 1rem;
+ scroll-margin-top: 1rem;
+}
+
+.storefront-settings-section__lede {
+ margin-bottom: 0.75rem;
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-settings-section__lede {
+ color: #9ca3af;
+}
+
+.storefront-settings-section__note {
+ margin-top: 0.75rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.storefront-settings-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0 1rem;
+}
+
+@media (max-width: 768px) {
+ .storefront-settings-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.storefront-settings-readonly {
+ cursor: copy;
+}
+
+/* Labelled rule rows */
+.storefront-rules {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .storefront-rules {
+ border-color: #374151;
+}
+
+.storefront-rule {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-rule {
+ border-color: #374151;
+}
+
+.storefront-rule:last-child {
+ border-bottom: 0;
+}
+
+.storefront-rule.is-on {
+ background-color: #f8fafc;
+}
+
+body[data-theme='dark'] .storefront-rule.is-on {
+ background-color: rgb(55 65 81 / 30%);
+}
+
+.storefront-rule__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ flex-shrink: 0;
+ border-radius: 0.375rem;
+ background-color: #f3f4f6;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-rule__icon {
+ background-color: #374151;
+ color: #9ca3af;
+}
+
+.storefront-rule.is-on .storefront-rule__icon {
+ background-color: #e0f2fe;
+ color: #0369a1;
+}
+
+body[data-theme='dark'] .storefront-rule.is-on .storefront-rule__icon {
+ background-color: rgb(12 74 110 / 50%);
+ color: #7dd3fc;
+}
+
+.storefront-rule__body {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.storefront-rule__label {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+body[data-theme='dark'] .storefront-rule__label {
+ color: #f3f4f6;
+}
+
+.storefront-rule__description {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-rule__description {
+ color: #9ca3af;
+}
+
+.storefront-rule__detail {
+ margin-top: 0.5rem;
+ max-width: 24rem;
+}
+
+.storefront-rule__control {
+ flex-shrink: 0;
+ padding-top: 0.125rem;
+}
+
+/* Sticky save bar */
+.storefront-save-bar {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: 1rem;
+ padding: 0.625rem 1rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #ffffff;
+ box-shadow: 0 -4px 12px rgb(0 0 0 / 6%);
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-save-bar {
+ border-color: #374151;
+ background-color: #1f2937;
+ color: #9ca3af;
+}
+
+.storefront-save-bar.is-dirty {
+ border-color: #fcd34d;
+}
+
+.storefront-save-bar__status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.storefront-save-bar__sections {
+ font-size: 0.75rem;
+ color: #9ca3af;
+}
+
+.storefront-save-bar__actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+/* Catalog editor: a catalog edited as a container */
+.storefront-catalog-editor {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.storefront-catalog-editor__header {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .storefront-catalog-editor__header {
+ border-color: #374151;
+ background-color: rgb(55 65 81 / 50%);
+}
+
+.storefront-catalog-editor__identity {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.storefront-catalog-editor__title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.storefront-catalog-editor__name {
+ max-width: 24rem;
+ font-weight: 600;
+}
+
+.storefront-catalog-editor__status {
+ width: 9rem;
+}
+
+.storefront-catalog-editor__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+ margin-top: 0.25rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.storefront-catalog-editor__meta span + span::before {
+ content: '·';
+ margin-right: 0.5rem;
+}
+
+body[data-theme='dark'] .storefront-catalog-editor__meta {
+ color: #9ca3af;
+}
+
+.storefront-catalog-editor__actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.storefront-catalog-editor__tabs {
+ display: flex;
+ gap: 0.25rem;
+ padding: 0.25rem 1rem 0;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-catalog-editor__tabs {
+ border-color: #374151;
+}
+
+.storefront-catalog-editor__body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 1rem;
+}
+
+.storefront-catalog-editor__panel {
+ max-width: 56rem;
+}
+
+.storefront-catalog-editor__columns {
+ display: grid;
+ grid-template-columns: 16rem minmax(0, 1fr) 16rem;
+ gap: 1rem;
+ align-items: start;
+}
+
+@media (max-width: 1200px) {
+ .storefront-catalog-editor__columns {
+ grid-template-columns: 14rem minmax(0, 1fr);
+ }
+
+ .storefront-catalog-health {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 768px) {
+ .storefront-catalog-editor__columns {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.storefront-catalog-categories,
+.storefront-catalog-products,
+.storefront-catalog-health,
+.storefront-catalog-picker {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #ffffff;
+}
+
+body[data-theme='dark'] .storefront-catalog-categories,
+body[data-theme='dark'] .storefront-catalog-products,
+body[data-theme='dark'] .storefront-catalog-health,
+body[data-theme='dark'] .storefront-catalog-picker {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+.storefront-catalog-categories__title,
+.storefront-catalog-health__title {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #6b7280;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-catalog-categories__title,
+body[data-theme='dark'] .storefront-catalog-health__title {
+ color: #9ca3af;
+ border-color: #374151;
+}
+
+.storefront-catalog-category {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ border-bottom: 1px solid #f3f4f6;
+}
+
+body[data-theme='dark'] .storefront-catalog-category {
+ border-color: #374151;
+}
+
+.storefront-catalog-category.is-selected {
+ background-color: #e0f2fe;
+}
+
+body[data-theme='dark'] .storefront-catalog-category.is-selected {
+ background-color: rgb(12 74 110 / 50%);
+}
+
+.storefront-catalog-category__select {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0.25rem;
+ font-size: 0.875rem;
+ text-align: left;
+ color: #111827;
+ background: none;
+}
+
+body[data-theme='dark'] .storefront-catalog-category__select {
+ color: #f3f4f6;
+}
+
+.storefront-catalog-category__name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storefront-catalog-category__actions {
+ display: none;
+ gap: 0.125rem;
+}
+
+.storefront-catalog-category:hover .storefront-catalog-category__actions,
+.storefront-catalog-category.is-selected .storefront-catalog-category__actions {
+ display: flex;
+}
+
+.storefront-catalog-category__rename {
+ flex: 1 1 auto;
+}
+
+.storefront-catalog-categories__new {
+ display: flex;
+ gap: 0.375rem;
+ padding: 0.5rem;
+}
+
+.storefront-catalog-products__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-catalog-products__header {
+ border-color: #374151;
+}
+
+.storefront-catalog-products__title {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+body[data-theme='dark'] .storefront-catalog-products__title {
+ color: #f3f4f6;
+}
+
+.storefront-catalog-products__meta {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.storefront-catalog-product-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.storefront-catalog-product {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 1rem;
+ border-bottom: 1px solid #f3f4f6;
+ font-size: 0.875rem;
+}
+
+body[data-theme='dark'] .storefront-catalog-product {
+ border-color: #374151;
+}
+
+.storefront-catalog-product__image,
+.storefront-catalog-picker__image {
+ width: 2.25rem;
+ height: 2.25rem;
+ flex-shrink: 0;
+ border-radius: 0.375rem;
+ object-fit: cover;
+ border: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-catalog-product__image,
+body[data-theme='dark'] .storefront-catalog-picker__image {
+ border-color: #4b5563;
+}
+
+.storefront-catalog-product__identity {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.storefront-catalog-product__name {
+ font-weight: 600;
+ color: #111827;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .storefront-catalog-product__name {
+ color: #f3f4f6;
+}
+
+.storefront-catalog-product__meta {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.storefront-catalog-product__price {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ font-weight: 600;
+ color: #111827;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .storefront-catalog-product__price {
+ color: #f3f4f6;
+}
+
+.storefront-catalog-product__price-note {
+ font-size: 0.6875rem;
+ font-weight: 400;
+ color: #9ca3af;
+}
+
+.storefront-catalog-picker {
+ margin: 0.75rem 1rem;
+}
+
+.storefront-catalog-picker__header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-catalog-picker__header {
+ border-color: #374151;
+}
+
+.storefront-catalog-picker__hint {
+ font-size: 0.75rem;
+ color: #6b7280;
+ white-space: nowrap;
+}
+
+.storefront-catalog-picker__list {
+ max-height: 18rem;
+ overflow-y: auto;
+}
+
+.storefront-catalog-picker__item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ width: 100%;
+ padding: 0.375rem 0.75rem;
+ border-bottom: 1px solid #f3f4f6;
+ font-size: 0.875rem;
+ text-align: left;
+ color: #111827;
+ background: none;
+}
+
+body[data-theme='dark'] .storefront-catalog-picker__item {
+ border-color: #374151;
+ color: #f3f4f6;
+}
+
+.storefront-catalog-picker__item:hover {
+ background-color: #f9fafb;
+}
+
+body[data-theme='dark'] .storefront-catalog-picker__item:hover {
+ background-color: #374151;
+}
+
+.storefront-catalog-picker__item.is-selected {
+ background-color: #e0f2fe;
+}
+
+body[data-theme='dark'] .storefront-catalog-picker__item.is-selected {
+ background-color: rgb(12 74 110 / 50%);
+}
+
+.storefront-catalog-picker__name {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.storefront-catalog-picker__price {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.storefront-catalog-picker__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .storefront-catalog-picker__footer {
+ border-color: #374151;
+}
+
+.storefront-catalog-health__row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid #f3f4f6;
+ font-size: 0.8125rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .storefront-catalog-health__row {
+ border-color: #374151;
+ color: #9ca3af;
+}
+
+.storefront-catalog-health__row strong {
+ color: #111827;
+ text-align: right;
+}
+
+body[data-theme='dark'] .storefront-catalog-health__row strong {
+ color: #f3f4f6;
+}
+
+.storefront-catalog-editor__details {
+ padding: 1rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+}
+
+body[data-theme='dark'] .storefront-catalog-editor__details {
+ border-color: #374151;
+}
+
+.storefront-catalog-card__served-by {
+ margin-top: 0.5rem;
+}
+
+/* Product editor: tabs, inline issues, save bar */
+.storefront-product-form__tabs {
+ margin: 0 0 0.75rem;
+}
+
+.storefront-status-tab__count--issue {
+ background-color: #fee2e2;
+ color: #b91c1c;
+}
+
+body[data-theme='dark'] .storefront-status-tab__count--issue {
+ background-color: rgb(127 29 29 / 40%);
+ color: #fca5a5;
+}
+
+.storefront-product-form__issues {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin-bottom: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #fcd34d;
+ border-radius: 0.375rem;
+ background-color: #fffbeb;
+ font-size: 0.8125rem;
+ color: #92400e;
+}
+
+body[data-theme='dark'] .storefront-product-form__issues {
+ border-color: #92400e;
+ background-color: rgb(120 53 15 / 30%);
+ color: #fcd34d;
+}
+
+.storefront-product-form__status {
+ width: 9rem;
+ margin-right: 0.5rem;
+}
+
+.storefront-product-form__save-bar {
+ margin-top: 1rem;
+}
+
+.storefront-save-bar__issues {
+ margin-left: 0.5rem;
+ font-size: 0.75rem;
+ color: #b91c1c;
+ text-decoration: underline;
+ background: none;
+}
+
+body[data-theme='dark'] .storefront-save-bar__issues {
+ color: #fca5a5;
+}
diff --git a/addon/templates/application.hbs b/addon/templates/application.hbs
index 3d592ac3..c662df0a 100644
--- a/addon/templates/application.hbs
+++ b/addon/templates/application.hbs
@@ -1,5 +1,15 @@
-
+
diff --git a/addon/templates/catalogs/index.hbs b/addon/templates/catalogs/index.hbs
index 148cbb92..54aa2f5b 100644
--- a/addon/templates/catalogs/index.hbs
+++ b/addon/templates/catalogs/index.hbs
@@ -1,47 +1,26 @@
-
-
-
-
+{{#if this.isEditing}}
+ {{outlet}}
+{{else}}
+
+
+
+
-
-
-
-
+
+
+
+
+
-{{outlet}}
\ No newline at end of file
+ {{outlet}}
+{{/if}}
diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs
new file mode 100644
index 00000000..9b1cb67a
--- /dev/null
+++ b/addon/templates/catalogs/index/edit.hbs
@@ -0,0 +1,197 @@
+
+
+
+
+ {{#each (array "categories" "hours" "served-by" "details") as |section|}}
+
+ {{t (concat "storefront.catalogs.editor.sections." section)}}
+ {{#if (eq section "served-by")}}{{this.servedBy.length}} {{/if}}
+
+ {{/each}}
+
+
+
+ {{#if (eq this.activeSection "categories")}}
+
+
+ {{t "storefront.catalogs.editor.sections.categories"}}
+ {{#each this.categoryRows as |row|}}
+
+ {{#if (eq this.renamingCategory row.category)}}
+
+ {{else}}
+
+ {{row.category.name}}
+ {{row.count}}
+
+
+
+
+
+
+
+ {{/if}}
+
+ {{else}}
+ {{t "storefront.catalogs.card.no-categories"}}
+ {{/each}}
+
+ {{t "storefront.catalogs.editor.categories-note"}}
+
+
+
+ {{#if this.selectedCategory}}
+
+
+ {{#if this.isPickerOpen}}
+
+
+
+ {{#each this.pickerProducts as |product|}}
+
+
+ {{product.name}}
+ {{format-currency product.price product.currency}}
+
+
+ {{else}}
+
{{t "storefront.catalogs.editor.no-products-to-add"}}
+ {{/each}}
+
+
+
+ {{/if}}
+
+ {{#if this.selectedProducts.length}}
+
+ {{#each this.selectedProducts as |product|}}
+
+
+
+
{{product.name}}
+
{{n-a product.sku}}{{#if product.variants.length}} · {{t "storefront.catalogs.editor.variants-count" count=product.variants.length}}{{/if}}{{#if product.addon_categories.length}} · {{t "storefront.catalogs.editor.addons-count" count=product.addon_categories.length}}{{/if}}
+
+
{{format-currency product.price product.currency}}{{t "storefront.catalogs.editor.store-price"}}
+
+
+
+ {{/each}}
+
+ {{t "storefront.catalogs.editor.products-note"}}
+ {{else unless this.isPickerOpen}}
+
+ {{t "storefront.catalogs.editor.no-products-in-category"}}
+
+
+ {{/if}}
+ {{else}}
+ {{t "storefront.catalogs.editor.pick-a-category"}}
+ {{/if}}
+
+
+
+ {{t "storefront.catalogs.editor.status"}}
+ {{t "storefront.catalogs.editor.visible-in-app"}} {{if this.isVisibleInApp (t "storefront.catalogs.editor.visible-yes") (t "storefront.catalogs.editor.visible-no")}}
+ {{t "storefront.catalogs.editor.out-of-stock"}} {{t "storefront.catalogs.card.products-count" count=this.outOfStockCount}}
+ {{t "storefront.catalogs.editor.empty-categories"}} {{this.emptyCategoriesCount}}
+ {{t "storefront.catalogs.editor.sections.hours"}} {{this.hoursSummary}}
+
+
+ {{else if (eq this.activeSection "hours")}}
+
+
{{t "storefront.catalogs.editor.hours-lede"}}
+
+
+
+ {{else if (eq this.activeSection "served-by")}}
+
+
+ {{#if this.servedBy.length}}
+
+ {{#each this.servedBy as |subject|}}
+
+
+
+
+ {{subject.name}}
+
+
+
{{if (eq subject.type "food-truck") (t "storefront.common.food-trucks") (t "storefront.common.store")}}{{#if subject.plate}} · {{subject.plate}}{{/if}}
+
+
+
+ {{/each}}
+
+ {{else}}
+
+ {{t "storefront.catalogs.editor.no-served-by"}}
+
+
+ {{/if}}
+
+ {{else}}
+
+
+
+
+
+
+
+ {{/if}}
+
+
+
+
+ {{#if this.isDirty}}
+
+ {{this.dirtySummary}}
+ {{else}}
+
+ {{t "storefront.settings.save-bar.saved"}}
+ {{/if}}
+
+
+
+
+
+
+
diff --git a/addon/templates/food-trucks/index.hbs b/addon/templates/food-trucks/index.hbs
index 61cf0250..a9d38462 100644
--- a/addon/templates/food-trucks/index.hbs
+++ b/addon/templates/food-trucks/index.hbs
@@ -1,49 +1,22 @@
-
+
-
-
-
- {{#each @model as |foodTruck|}}
-
-
-
-
-
-
-
{{n-a foodTruck.service_area.name}}
-
•
-
{{n-a foodTruck.zone.name}}
-
- {{!--
{{format-point foodTruck.vehicle.location}}
--}}
-
-
-
-
-
-
-
-
-
- {{/each}}
-
-
+
+
+
+
-{{outlet}}
\ No newline at end of file
+{{outlet}}
diff --git a/addon/templates/networks/index.hbs b/addon/templates/networks/index.hbs
index 010d79e7..9829f650 100644
--- a/addon/templates/networks/index.hbs
+++ b/addon/templates/networks/index.hbs
@@ -1,78 +1,29 @@
-
-
-
+{{#if this.storefront.isNetworkContext}}
+ {{outlet}}
+{{else}}
+
+
+
-
-
-
+
+
+
+
+
-{{outlet}}
\ No newline at end of file
+ {{outlet}}
+{{/if}}
diff --git a/addon/templates/networks/index/network.hbs b/addon/templates/networks/index/network.hbs
index a1511336..07cc6073 100644
--- a/addon/templates/networks/index/network.hbs
+++ b/addon/templates/networks/index/network.hbs
@@ -1,17 +1,18 @@
-
-
-
-
-