@africanies/angular-web-sdk 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/README.md +46 -0
  2. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +243 -0
  3. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -0
  4. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/package.json +35 -0
  5. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/tailwind-preset.cjs +330 -0
  6. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +178 -0
  7. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/README.md +68 -0
  8. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
  9. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
  10. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
  11. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
  12. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +19998 -0
  13. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
  14. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/package.json +35 -0
  15. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5616 -0
  16. package/.nx/cache/run.json +17 -17
  17. package/.nx/cache/terminalOutputs/1149535958171016935 +21 -0
  18. package/.nx/cache/terminalOutputs/3735952477975556635 +21 -0
  19. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db +0 -0
  20. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-shm +0 -0
  21. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-wal +0 -0
  22. package/.nx/workspace-data/d/daemon.log +1430 -0
  23. package/.nx/workspace-data/d/server-process.json +2 -2
  24. package/.nx/workspace-data/file-map.json +3332 -3334
  25. package/.nx/workspace-data/nx_files.nxt +0 -0
  26. package/.nx/workspace-data/project-graph.json +2 -2
  27. package/apps/playground/src/app/pages/chip-page.ts +11 -1
  28. package/apps/playground/src/app/pages/tokens-page.ts +2 -2
  29. package/apps/playground/src/app/snippets/chip.snippets.ts +5 -0
  30. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +8 -8
  31. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -1
  32. package/dist/libs/africanies-theme/tailwind-preset.cjs +3 -3
  33. package/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +5 -3
  34. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +869 -371
  35. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -1
  36. package/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +135 -40
  37. package/libs/africanies-theme/src/lib/mode-color.safelist.ts +2 -2
  38. package/libs/africanies-theme/src/lib/mode-color.service.spec.ts +9 -7
  39. package/libs/africanies-theme/src/lib/mode-color.service.ts +9 -7
  40. package/libs/africanies-theme/tailwind-preset.cjs +3 -3
  41. package/libs/africanies-ui/src/lib/avatar/avatar-menu.component.ts +25 -21
  42. package/libs/africanies-ui/src/lib/button/button.component.spec.ts +13 -8
  43. package/libs/africanies-ui/src/lib/chip/chip.component.ts +74 -6
  44. package/libs/africanies-ui/src/lib/layout/app-shell-header.component.ts +35 -12
  45. package/libs/africanies-ui/src/lib/layout/header-greeting.util.ts +2 -0
  46. package/libs/africanies-ui/src/lib/layout/header-weather.spec.ts +120 -9
  47. package/libs/africanies-ui/src/lib/layout/header-weather.ts +79 -18
  48. package/libs/africanies-ui/src/lib/navigation/segment/segment.component.ts +98 -22
  49. package/libs/africanies-ui/src/lib/navigation/shipping-mode-switch/shipping-mode-switch.component.ts +193 -65
  50. package/libs/africanies-ui/src/lib/navigation/side-nav/africanies-side-nav.component.html +4 -2
  51. package/libs/africanies-ui/src/lib/navigation/side-nav/africanies-side-nav.component.ts +3 -1
  52. package/libs/africanies-ui/src/lib/table/table.component.spec.ts +3 -0
  53. package/libs/africanies-ui/src/lib/table/table.component.ts +197 -57
  54. package/package.json +1 -1
  55. package/tools/build-icon-sprite.mjs +1 -1
@@ -2,12 +2,15 @@ import type { IconName } from '@africanies/africanies-icons';
2
2
 
3
3
  import type { HeaderWeather, HeaderWeatherKind } from './header-greeting.util';
4
4
 
5
- /** Bump when cache shape or lookup strategy changes. */
6
- const CACHE_KEY = 'africanies-header-weather-v5';
5
+ /** Bump when cache shape or city resolution strategy changes. */
6
+ const CACHE_KEY = 'africanies-header-weather-v7';
7
7
  const FETCH_MS = 4_000;
8
8
  const BROWSER_GEO_MS = 3_500;
9
- /** IP fallback for coordinates only city strings are often wrong. */
9
+ /** IP fallback city string is used only when reverse-geocode is empty. */
10
10
  const GEO_URL = 'https://get.geojs.io/v1/ip/geo.json';
11
+ /** Client reverse-geocode so the label matches the forecast coordinates. */
12
+ const REVERSE_GEO_URL =
13
+ 'https://api.bigdatacloud.net/data/reverse-geocode-client';
11
14
 
12
15
  const WEATHER_LABELS: Record<HeaderWeatherKind, string> = {
13
16
  clear: 'Clear',
@@ -23,13 +26,21 @@ interface CachedWeather {
23
26
  hour: string;
24
27
  kind: HeaderWeatherKind;
25
28
  temperatureC?: number;
29
+ city?: string;
26
30
  }
27
31
 
28
32
  interface GeoJsPayload {
33
+ city?: string;
29
34
  latitude?: string | number;
30
35
  longitude?: string | number;
31
36
  }
32
37
 
38
+ interface ReverseGeoPayload {
39
+ city?: string;
40
+ locality?: string;
41
+ principalSubdivision?: string;
42
+ }
43
+
33
44
  interface OpenMeteoPayload {
34
45
  current?: {
35
46
  weather_code?: number;
@@ -108,12 +119,11 @@ export function headerWeatherIcon(kind: HeaderWeatherKind, hour: number): IconNa
108
119
  }
109
120
 
110
121
  /**
111
- * Local forecast via browser / IP coordinates + Open-Meteo (no API key).
122
+ * City-level forecast via browser / IP geolocation + Open-Meteo (no API key).
112
123
  *
113
- * Coordinates prefer the browser Geolocation API (may prompt once).
114
- * Otherwise IP geo is used for lat/lon only — city labels are not shown
115
- * because IP and reverse-geocode cities are often wrong (e.g. NG IPs
116
- * labelled Lagos).
124
+ * Coordinates prefer the browser Geolocation API (accurate for travellers;
125
+ * may prompt once). Otherwise IP geo is used. The city label is reverse-
126
+ * geocoded from those coordinates, with the IP city as a fallback.
117
127
  *
118
128
  * Fails closed: missing browser APIs, timeouts, and HTTP errors all return
119
129
  * `null` so the greeting can stay time-of-day only. Cached per local hour
@@ -158,8 +168,11 @@ export async function loadHeaderWeather(
158
168
  }
159
169
 
160
170
  const temperatureC = asNumber(forecast?.current?.temperature_2m) ?? undefined;
161
- const weather: HeaderWeather = { kind, temperatureC };
162
- writeCache({ hour, kind, temperatureC });
171
+ const city =
172
+ (await reverseGeocodeCity(run, coords.latitude, coords.longitude)) ??
173
+ coords.fallbackCity;
174
+ const weather: HeaderWeather = { kind, temperatureC, city };
175
+ writeCache({ hour, kind, temperatureC, city });
163
176
  return weather;
164
177
  } catch {
165
178
  return null;
@@ -184,9 +197,16 @@ function readCache(hour: string): HeaderWeather | null {
184
197
  if (parsed.hour !== hour || !parsed.kind) {
185
198
  return null;
186
199
  }
200
+ // City-less entries (e.g. browser geo + failed reverse) used to stick for
201
+ // the hour — treat them as a miss so we can recover the location label.
202
+ const city = asCity(parsed.city);
203
+ if (!city) {
204
+ return null;
205
+ }
187
206
  return {
188
207
  kind: parsed.kind,
189
208
  temperatureC: parsed.temperatureC,
209
+ city,
190
210
  };
191
211
  } catch {
192
212
  return null;
@@ -203,23 +223,36 @@ function writeCache(value: CachedWeather): void {
203
223
 
204
224
  /**
205
225
  * Prefer device coordinates when available (may prompt once); otherwise fall
206
- * back to IP lat/lon for the forecast only.
226
+ * back to IP geo. IP is always fetched in parallel so its city can backfill
227
+ * the label when reverse-geocode is empty (browser geo has no city string).
207
228
  */
208
229
  async function resolveCoordinates(
209
230
  fetchFn: typeof fetch,
210
- ): Promise<Coordinates | null> {
211
- const browser = await tryBrowserGeolocation();
231
+ ): Promise<(Coordinates & { fallbackCity?: string }) | null> {
232
+ const [browser, ipGeo] = await Promise.all([
233
+ tryBrowserGeolocation(),
234
+ fetchIpGeo(fetchFn),
235
+ ]);
236
+ const ipCity = asCity(ipGeo?.city);
237
+
212
238
  if (browser) {
213
- return browser;
239
+ return { ...browser, fallbackCity: ipCity };
214
240
  }
215
241
 
216
- const geo = (await fetchJson(fetchFn, GEO_URL)) as GeoJsPayload | null;
217
- const latitude = asNumber(geo?.latitude);
218
- const longitude = asNumber(geo?.longitude);
242
+ const latitude = asNumber(ipGeo?.latitude);
243
+ const longitude = asNumber(ipGeo?.longitude);
219
244
  if (latitude === null || longitude === null) {
220
245
  return null;
221
246
  }
222
- return { latitude, longitude };
247
+ return {
248
+ latitude,
249
+ longitude,
250
+ fallbackCity: ipCity,
251
+ };
252
+ }
253
+
254
+ async function fetchIpGeo(fetchFn: typeof fetch): Promise<GeoJsPayload | null> {
255
+ return (await fetchJson(fetchFn, GEO_URL)) as GeoJsPayload | null;
223
256
  }
224
257
 
225
258
  async function tryBrowserGeolocation(): Promise<Coordinates | null> {
@@ -253,6 +286,23 @@ async function tryBrowserGeolocation(): Promise<Coordinates | null> {
253
286
  });
254
287
  }
255
288
 
289
+ async function reverseGeocodeCity(
290
+ fetchFn: typeof fetch,
291
+ latitude: number,
292
+ longitude: number,
293
+ ): Promise<string | undefined> {
294
+ const url =
295
+ `${REVERSE_GEO_URL}?latitude=${encodeURIComponent(String(latitude))}` +
296
+ `&longitude=${encodeURIComponent(String(longitude))}` +
297
+ `&localityLanguage=en`;
298
+ const payload = (await fetchJson(fetchFn, url)) as ReverseGeoPayload | null;
299
+ return (
300
+ asCity(payload?.city) ??
301
+ asCity(payload?.locality) ??
302
+ asCity(payload?.principalSubdivision)
303
+ );
304
+ }
305
+
256
306
  function asNumber(value: unknown): number | null {
257
307
  if (typeof value === 'number' && Number.isFinite(value)) {
258
308
  return value;
@@ -264,6 +314,17 @@ function asNumber(value: unknown): number | null {
264
314
  return null;
265
315
  }
266
316
 
317
+ function asCity(value: unknown): string | undefined {
318
+ if (typeof value !== 'string') {
319
+ return undefined;
320
+ }
321
+ const trimmed = value.trim();
322
+ if (!trimmed || trimmed.toLowerCase() === 'n/a') {
323
+ return undefined;
324
+ }
325
+ return trimmed;
326
+ }
327
+
267
328
  async function fetchJson(
268
329
  fetchFn: typeof fetch,
269
330
  url: string,
@@ -38,6 +38,10 @@ const DEFAULT_LINK_ACTIVE: IsActiveMatchOptions = {
38
38
  /**
39
39
  * Compact segmented control for view modes and filters.
40
40
  *
41
+ * Selected option uses a **solid brand pill** (white label) on a neutral track —
42
+ * same pattern as the portal shipping-mode toggle. With exactly two options the
43
+ * pill slides; with more options each selected chip fills in place.
44
+ *
41
45
  * ## Router mode (URL is source of truth)
42
46
  *
43
47
  * Set `routerLink` (and optional `queryParams`) on items. The selected pill
@@ -65,20 +69,38 @@ const DEFAULT_LINK_ACTIVE: IsActiveMatchOptions = {
65
69
  standalone: true,
66
70
  changeDetection: ChangeDetectionStrategy.OnPush,
67
71
  imports: [AfricaniesIconComponent],
72
+ styles: `
73
+ :host .africanies-segment-thumb {
74
+ width: calc((100% - 0.5rem) / 2);
75
+ }
76
+ `,
68
77
  template: `
69
78
  <div
70
- class="inline-flex max-w-full flex-wrap gap-1 rounded-lg bg-background-welcome p-1 dark:bg-ink-950"
79
+ class="relative inline-grid max-w-full rounded-lg bg-[#f0f2f5] p-1 dark:bg-[#2a2c31]"
71
80
  role="group"
72
81
  [attr.aria-label]="ariaLabel()"
82
+ [class.grid-cols-2]="useSlidingThumb()"
83
+ [class.inline-flex]="!useSlidingThumb()"
84
+ [class.flex-wrap]="!useSlidingThumb()"
85
+ [class.gap-1]="!useSlidingThumb()"
73
86
  >
87
+ @if (useSlidingThumb()) {
88
+ <div
89
+ class="africanies-segment-thumb pointer-events-none absolute top-1 bottom-1 left-1 rounded-md shadow-sm transition-transform duration-200 ease-out motion-reduce:transition-none"
90
+ [class]="thumbFillClass()"
91
+ [style.transform]="
92
+ activeIndex() === 1
93
+ ? 'translateX(calc(100% + 0.25rem))'
94
+ : 'translateX(0)'
95
+ "
96
+ aria-hidden="true"
97
+ ></div>
98
+ }
99
+
74
100
  @for (item of items(); track item.id) {
75
101
  @if (item.routerLink !== null && item.routerLink !== undefined) {
76
102
  <a
77
- [class]="
78
- segmentChrome() +
79
- ' ' +
80
- (isItemActive(item) ? selectedClass() : idleClass())
81
- "
103
+ [class]="optionChrome(item)"
82
104
  [class.cursor-pointer]="!item.disabled"
83
105
  [class.cursor-not-allowed]="item.disabled"
84
106
  [class.opacity-50]="item.disabled"
@@ -95,11 +117,7 @@ const DEFAULT_LINK_ACTIVE: IsActiveMatchOptions = {
95
117
  } @else {
96
118
  <button
97
119
  type="button"
98
- [class]="
99
- segmentChrome() +
100
- ' ' +
101
- (isItemActive(item) ? selectedClass() : idleClass())
102
- "
120
+ [class]="optionChrome(item)"
103
121
  [disabled]="!!item.disabled"
104
122
  [class.cursor-pointer]="!item.disabled"
105
123
  [class.cursor-not-allowed]="item.disabled"
@@ -154,21 +172,18 @@ export class SegmentComponent {
154
172
  { initialValue: this.router.url },
155
173
  );
156
174
 
157
- protected readonly segmentChrome = computed(
158
- () =>
159
- 'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-body-sm transition-colors duration-150 no-underline',
175
+ /** Two equal options → portal-style sliding brand pill. */
176
+ protected readonly useSlidingThumb = computed(
177
+ () => this.items().length === 2,
160
178
  );
161
179
 
162
- protected readonly selectedClass = computed(() => {
163
- const colors = this.modeColor.classes();
164
- return `${colors.soft} ${colors.text} shadow-sm font-semibold`;
180
+ /** Index of the active option (0 / 1 for the sliding thumb). */
181
+ protected readonly activeIndex = computed(() => {
182
+ const list = this.items();
183
+ const idx = list.findIndex((item) => this.isItemActive(item));
184
+ return idx < 0 ? 0 : idx;
165
185
  });
166
186
 
167
- protected readonly idleClass = computed(
168
- () =>
169
- 'font-medium text-neutral-600 hover:text-ink dark:text-neutral-400 dark:hover:text-white',
170
- );
171
-
172
187
  constructor() {
173
188
  effect(() => {
174
189
  this.url();
@@ -198,6 +213,67 @@ export class SegmentComponent {
198
213
  return this.activeId() === item.id;
199
214
  }
200
215
 
216
+ /**
217
+ * Sliding thumb fill. STN / SFN options keep their own brand color so the
218
+ * pill matches mode identity; other pairs follow the active theme primary.
219
+ */
220
+ protected thumbFillClass(): string {
221
+ const active = this.items()[this.activeIndex()];
222
+ return this.brandFillFor(active?.id ?? null);
223
+ }
224
+
225
+ /**
226
+ * Option chrome: sliding mode keeps labels transparent over the thumb;
227
+ * multi-option mode paints a solid selected chip in place.
228
+ *
229
+ * @param item - Option being rendered.
230
+ * @returns Class string for the control.
231
+ */
232
+ protected optionChrome(item: AfricaniesNavItem): string {
233
+ const base =
234
+ 'relative z-10 inline-flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-center text-body-sm no-underline transition-colors duration-150';
235
+ const active = this.isItemActive(item);
236
+
237
+ if (this.useSlidingThumb()) {
238
+ return (
239
+ base +
240
+ ' w-full ' +
241
+ (active
242
+ ? 'font-semibold text-white'
243
+ : 'font-medium text-ink hover:text-ink dark:text-neutral-300 dark:hover:text-white')
244
+ );
245
+ }
246
+
247
+ if (active) {
248
+ return `${base} font-semibold text-white shadow-sm ${this.brandFillFor(item.id)}`;
249
+ }
250
+
251
+ return (
252
+ base +
253
+ ' font-medium text-ink hover:bg-white/70 hover:text-ink dark:text-neutral-300 dark:hover:bg-white/10 dark:hover:text-white'
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Solid brand fill for a selected option / sliding thumb.
259
+ *
260
+ * Light + dark use the same SFN green / STN orange primaries so the control
261
+ * stays mode-identifiable on both surfaces (portal primary, not soft tint).
262
+ * Class strings are full literals so Tailwind keeps them.
263
+ *
264
+ * @param id - Option id (`stn` / `sfn` get fixed brands; else theme primary).
265
+ * @returns Background utility classes.
266
+ */
267
+ protected brandFillFor(id: string | null): string {
268
+ if (id === 'stn') {
269
+ return 'bg-import dark:bg-import';
270
+ }
271
+ if (id === 'sfn') {
272
+ return 'bg-export dark:bg-export';
273
+ }
274
+ return this.modeColor.classes().activeFill;
275
+ }
276
+
201
277
  protected selectLocal(item: AfricaniesNavItem): void {
202
278
  if (item.disabled || item.routerLink != null) {
203
279
  return;
@@ -3,13 +3,46 @@ import {
3
3
  ChangeDetectionStrategy,
4
4
  Component,
5
5
  computed,
6
+ effect,
6
7
  inject,
7
8
  input,
9
+ model,
8
10
  } from '@angular/core';
9
11
 
10
12
  import { ShippingModeService } from '@africanies/africanies-core';
13
+ import { AfricaniesIconComponent } from '@africanies/africanies-icons';
11
14
  import type { ShippingMode } from '@africanies/africanies-models';
12
15
 
16
+ import { ChipComponent, type ChipVariant } from '../../chip/chip.component';
17
+
18
+ const PANEL_OPEN_STORAGE_KEY = 'africanies-shipping-mode-panel-open';
19
+
20
+ function readStoredPanelOpen(): boolean {
21
+ if (typeof localStorage === 'undefined') {
22
+ return true;
23
+ }
24
+ try {
25
+ const raw = localStorage.getItem(PANEL_OPEN_STORAGE_KEY);
26
+ if (raw === null) {
27
+ return true;
28
+ }
29
+ return raw !== '0' && raw !== 'false';
30
+ } catch {
31
+ return true;
32
+ }
33
+ }
34
+
35
+ function writeStoredPanelOpen(open: boolean): void {
36
+ if (typeof localStorage === 'undefined') {
37
+ return;
38
+ }
39
+ try {
40
+ localStorage.setItem(PANEL_OPEN_STORAGE_KEY, open ? '1' : '0');
41
+ } catch {
42
+ // Private mode / quota — panel still toggles in-session.
43
+ }
44
+ }
45
+
13
46
  /**
14
47
  * Shared globe + plane glyph for STN / SFN. STN paints it rotated 180°.
15
48
  *
@@ -25,12 +58,13 @@ const SHIPPING_MODE_GLYPH = `M88.42,68.75a48.8,48.8,0,0,1-4.74,11.1h9.89a7.67,7.
25
58
  * import orange; **from Nigeria** uses export green. Both tiles keep equal
26
59
  * chrome so unselected does not look like floating text.
27
60
  *
28
- * Project into `<africanies-side-nav>` with the `footer` attribute.
61
+ * Project into `<africanies-side-nav>` with the `pre-nav` attribute (above the
62
+ * nav list). `footer` still works for legacy placement under the list.
29
63
  *
30
64
  * @example
31
65
  * ```html
32
66
  * <africanies-side-nav [items]="nav" [(collapsed)]="collapsed">
33
- * <africanies-shipping-mode-switch footer [collapsed]="collapsed()" />
67
+ * <africanies-shipping-mode-switch pre-nav [collapsed]="collapsed()" />
34
68
  * </africanies-side-nav>
35
69
  * ```
36
70
  */
@@ -38,71 +72,140 @@ const SHIPPING_MODE_GLYPH = `M88.42,68.75a48.8,48.8,0,0,1-4.74,11.1h9.89a7.67,7.
38
72
  selector: 'africanies-shipping-mode-switch',
39
73
  standalone: true,
40
74
  changeDetection: ChangeDetectionStrategy.OnPush,
75
+ imports: [AfricaniesIconComponent, ChipComponent],
41
76
  host: {
42
77
  '[class]': 'hostClass()',
43
78
  },
44
79
  template: `
45
- @if (!collapsed()) {
46
- <p
47
- class="m-0 mb-2 px-1 text-caption font-semibold uppercase tracking-wide text-neutral-600 dark:text-neutral-400"
80
+ @if (collapsed()) {
81
+ <!-- Narrow rail: icon tiles only — chip + chevron reads as cramped. -->
82
+ <div
83
+ class="grid grid-cols-1 gap-1.5"
84
+ role="radiogroup"
85
+ aria-label="Shipping mode"
48
86
  >
49
- Shipping mode
50
- </p>
51
- }
52
- <div
53
- class="grid gap-1.5"
54
- [class.grid-cols-1]="collapsed()"
55
- [class.grid-cols-2]="!collapsed()"
56
- role="radiogroup"
57
- aria-label="Shipping mode"
58
- >
59
- <button
60
- type="button"
61
- role="radio"
62
- [attr.aria-checked]="mode() === 'stn'"
63
- aria-label="Shipping to Nigeria"
64
- [class]="cardClass('stn')"
65
- (click)="select('stn')"
66
- >
67
- <svg
68
- viewBox="0 0 122.88 107.54"
69
- class="h-6 w-6 shrink-0 rotate-180"
70
- aria-hidden="true"
71
- focusable="false"
87
+ <button
88
+ type="button"
89
+ role="radio"
90
+ [attr.aria-checked]="mode() === 'stn'"
91
+ aria-label="Shipping to Nigeria"
92
+ title="Shipping to Nigeria"
93
+ [class]="cardClass('stn')"
94
+ (click)="select('stn')"
72
95
  >
73
- <path [attr.d]="glyphPath()" fill="currentColor" />
74
- </svg>
75
- @if (!collapsed()) {
76
- <span class="flex flex-col leading-tight">
77
- <span>Shipping</span>
78
- <span>to Nigeria</span>
79
- </span>
80
- }
81
- </button>
82
- <button
83
- type="button"
84
- role="radio"
85
- [attr.aria-checked]="mode() === 'sfn'"
86
- aria-label="Shipping from Nigeria"
87
- [class]="cardClass('sfn')"
88
- (click)="select('sfn')"
89
- >
90
- <svg
91
- viewBox="0 0 122.88 107.54"
92
- class="h-6 w-6 shrink-0"
93
- aria-hidden="true"
94
- focusable="false"
96
+ <svg
97
+ viewBox="0 0 122.88 107.54"
98
+ class="h-5 w-5 shrink-0 rotate-180"
99
+ aria-hidden="true"
100
+ focusable="false"
101
+ >
102
+ <path [attr.d]="glyphPath()" fill="currentColor" />
103
+ </svg>
104
+ </button>
105
+ <button
106
+ type="button"
107
+ role="radio"
108
+ [attr.aria-checked]="mode() === 'sfn'"
109
+ aria-label="Shipping from Nigeria"
110
+ title="Shipping from Nigeria"
111
+ [class]="cardClass('sfn')"
112
+ (click)="select('sfn')"
113
+ >
114
+ <svg
115
+ viewBox="0 0 122.88 107.54"
116
+ class="h-5 w-5 shrink-0"
117
+ aria-hidden="true"
118
+ focusable="false"
119
+ >
120
+ <path [attr.d]="glyphPath()" fill="currentColor" />
121
+ </svg>
122
+ </button>
123
+ </div>
124
+ } @else {
125
+ <div class="mb-2 flex items-center gap-1.5">
126
+ <p
127
+ class="m-0 shrink-0 px-1 text-caption font-semibold uppercase tracking-wide text-neutral-600 dark:text-neutral-400"
95
128
  >
96
- <path [attr.d]="glyphPath()" fill="currentColor" />
97
- </svg>
98
- @if (!collapsed()) {
99
- <span class="flex flex-col leading-tight">
100
- <span>Shipping</span>
101
- <span>from Nigeria</span>
102
- </span>
103
- }
104
- </button>
105
- </div>
129
+ Shipping mode
130
+ </p>
131
+ <africanies-chip [variant]="modeChipVariant()" size="sm">
132
+ {{ modeChipLabel() }}
133
+ </africanies-chip>
134
+ <span class="min-w-0 flex-1" aria-hidden="true"></span>
135
+ <button
136
+ type="button"
137
+ class="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-600 transition-colors hover:bg-background-hover hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus dark:text-neutral-400 dark:hover:bg-white/10 dark:hover:text-white"
138
+ [attr.aria-label]="
139
+ panelOpen() ? 'Hide shipping modes' : 'Show shipping modes'
140
+ "
141
+ [attr.aria-expanded]="panelOpen()"
142
+ (click)="togglePanel()"
143
+ >
144
+ <africanies-icon
145
+ [name]="panelOpen() ? 'chevron-up' : 'chevron-down'"
146
+ [size]="14"
147
+ />
148
+ </button>
149
+ </div>
150
+
151
+ <div
152
+ class="grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
153
+ [style.grid-template-rows]="panelOpen() ? '1fr' : '0fr'"
154
+ [attr.aria-hidden]="!panelOpen()"
155
+ [class.pointer-events-none]="!panelOpen()"
156
+ >
157
+ <div class="min-h-0 overflow-hidden">
158
+ <div
159
+ class="grid grid-cols-2 gap-1.5"
160
+ role="radiogroup"
161
+ aria-label="Shipping mode"
162
+ >
163
+ <button
164
+ type="button"
165
+ role="radio"
166
+ [attr.aria-checked]="mode() === 'stn'"
167
+ aria-label="Shipping to Nigeria"
168
+ [class]="cardClass('stn')"
169
+ (click)="select('stn')"
170
+ >
171
+ <svg
172
+ viewBox="0 0 122.88 107.54"
173
+ class="h-6 w-6 shrink-0 rotate-180"
174
+ aria-hidden="true"
175
+ focusable="false"
176
+ >
177
+ <path [attr.d]="glyphPath()" fill="currentColor" />
178
+ </svg>
179
+ <span class="flex flex-col leading-tight">
180
+ <span>Shipping</span>
181
+ <span>to Nigeria</span>
182
+ </span>
183
+ </button>
184
+ <button
185
+ type="button"
186
+ role="radio"
187
+ [attr.aria-checked]="mode() === 'sfn'"
188
+ aria-label="Shipping from Nigeria"
189
+ [class]="cardClass('sfn')"
190
+ (click)="select('sfn')"
191
+ >
192
+ <svg
193
+ viewBox="0 0 122.88 107.54"
194
+ class="h-6 w-6 shrink-0"
195
+ aria-hidden="true"
196
+ focusable="false"
197
+ >
198
+ <path [attr.d]="glyphPath()" fill="currentColor" />
199
+ </svg>
200
+ <span class="flex flex-col leading-tight">
201
+ <span>Shipping</span>
202
+ <span>from Nigeria</span>
203
+ </span>
204
+ </button>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ }
106
209
  `,
107
210
  })
108
211
  export class ShippingModeSwitchComponent {
@@ -125,16 +228,41 @@ export class ShippingModeSwitchComponent {
125
228
  */
126
229
  readonly collapsed = input(false, { transform: booleanAttribute });
127
230
 
231
+ /**
232
+ * When true, the mode tiles are visible. Persisted in `localStorage`.
233
+ * Two-way bindable for hosts that need to force-open (e.g. onboarding).
234
+ */
235
+ readonly panelOpen = model(readStoredPanelOpen());
236
+
128
237
  /** Active mode from {@link ShippingModeService}. */
129
238
  protected readonly mode = this.shipping.mode;
130
239
 
131
- /** Host padding / divider — tighter when the rail is collapsed. */
240
+ protected readonly modeChipLabel = computed(() =>
241
+ this.mode() === 'stn' ? 'STN' : 'SFN',
242
+ );
243
+
244
+ protected readonly modeChipVariant = computed(
245
+ (): ChipVariant => (this.mode() === 'stn' ? 'import' : 'export'),
246
+ );
247
+
248
+ /** Host padding / divider — sits above the nav list (`border-b`). */
132
249
  protected readonly hostClass = computed(() =>
133
250
  this.collapsed()
134
- ? 'block shrink-0 border-t border-border px-1.5 py-3 dark:border-white/10'
135
- : 'block shrink-0 border-t border-border px-2 py-3 dark:border-white/10',
251
+ ? 'block shrink-0 border-b border-border px-1.5 py-2 dark:border-white/10'
252
+ : 'block shrink-0 border-b border-border px-2 py-2.5 dark:border-white/10',
136
253
  );
137
254
 
255
+ constructor() {
256
+ effect(() => {
257
+ writeStoredPanelOpen(this.panelOpen());
258
+ });
259
+ }
260
+
261
+ /** Show or hide the mode tiles. */
262
+ protected togglePanel(): void {
263
+ this.panelOpen.update((open) => !open);
264
+ }
265
+
138
266
  /**
139
267
  * Requests a mode change through {@link ShippingModeService.requestModeChange}
140
268
  * so feature guards (e.g. Create Shipment confirm) can run first.
@@ -160,7 +288,7 @@ export class ShippingModeSwitchComponent {
160
288
  const base =
161
289
  'flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border text-center text-caption font-medium transition-colors ' +
162
290
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus ' +
163
- (compact ? 'min-h-9 px-1 py-1.5' : 'min-h-[4.5rem] px-1.5 py-2');
291
+ (compact ? 'size-9 min-h-0 px-0 py-0' : 'min-h-[4.5rem] px-1.5 py-2');
164
292
  const idle =
165
293
  'border-control bg-surface text-ink hover:bg-background-hover dark:border-white/15 dark:bg-ink-950 dark:text-white dark:hover:bg-white/10';
166
294
 
@@ -170,7 +298,7 @@ export class ShippingModeSwitchComponent {
170
298
  : `${base} ${idle}`;
171
299
  }
172
300
  return selected
173
- ? `${base} border-export-strong bg-export-strong text-white dark:border-export dark:bg-export`
301
+ ? `${base} border-export bg-export text-white dark:border-export dark:bg-export`
174
302
  : `${base} ${idle}`;
175
303
  }
176
304
  }