@africanies/angular-web-sdk 0.1.8 → 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 (91) hide show
  1. package/.angular/cache/22.0.9/playground/.tsbuildinfo +1 -1
  2. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/README.md +46 -0
  3. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +243 -0
  4. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -0
  5. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/package.json +35 -0
  6. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/tailwind-preset.cjs +330 -0
  7. package/.nx/cache/1149535958171016935/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +178 -0
  8. package/.nx/cache/17289624717336768573/dist/libs/africanies-core/README.md +85 -0
  9. package/.nx/cache/17289624717336768573/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs +4639 -0
  10. package/.nx/cache/17289624717336768573/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs.map +1 -0
  11. package/.nx/cache/17289624717336768573/dist/libs/africanies-core/package.json +31 -0
  12. package/.nx/cache/17289624717336768573/dist/libs/africanies-core/types/africanies-africanies-core.d.ts +2748 -0
  13. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/README.md +68 -0
  14. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
  15. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
  16. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
  17. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
  18. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +19998 -0
  19. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
  20. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/package.json +35 -0
  21. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5616 -0
  22. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/README.md +68 -0
  23. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
  24. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
  25. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
  26. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
  27. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +19500 -0
  28. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
  29. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/package.json +35 -0
  30. package/.nx/cache/3953970051755345887/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5521 -0
  31. package/.nx/cache/4225324930345532464/dist/libs/africanies-theme/README.md +46 -0
  32. package/.nx/cache/4225324930345532464/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +243 -0
  33. package/.nx/cache/4225324930345532464/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -0
  34. package/.nx/cache/4225324930345532464/dist/libs/africanies-theme/package.json +35 -0
  35. package/.nx/cache/4225324930345532464/dist/libs/africanies-theme/tailwind-preset.cjs +330 -0
  36. package/.nx/cache/4225324930345532464/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +176 -0
  37. package/.nx/cache/run.json +26 -26
  38. package/.nx/cache/terminalOutputs/1149535958171016935 +21 -0
  39. package/.nx/cache/terminalOutputs/12565224028063674529 +37 -0
  40. package/.nx/cache/terminalOutputs/13478445034325959094 +176 -0
  41. package/.nx/cache/terminalOutputs/15835338512221847842 +184 -182
  42. package/.nx/cache/terminalOutputs/17289624717336768573 +21 -0
  43. package/.nx/cache/terminalOutputs/17571479482482478257 +35 -0
  44. package/.nx/cache/terminalOutputs/3088264223473143358 +8 -0
  45. package/.nx/cache/terminalOutputs/3735952477975556635 +21 -0
  46. package/.nx/cache/terminalOutputs/3953970051755345887 +21 -0
  47. package/.nx/cache/terminalOutputs/4225324930345532464 +21 -0
  48. package/.nx/cache/terminalOutputs/4363587767476846758 +176 -0
  49. package/.nx/cache/terminalOutputs/8193462679279018087 +39 -0
  50. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db +0 -0
  51. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-shm +0 -0
  52. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-wal +0 -0
  53. package/.nx/workspace-data/d/daemon.log +5890 -0
  54. package/.nx/workspace-data/d/server-process.json +2 -2
  55. package/.nx/workspace-data/file-map.json +3610 -3620
  56. package/.nx/workspace-data/nx_files.nxt +0 -0
  57. package/.nx/workspace-data/project-graph.json +2 -2
  58. package/apps/playground/src/app/pages/chip-page.ts +11 -1
  59. package/apps/playground/src/app/pages/tokens-page.ts +2 -2
  60. package/apps/playground/src/app/snippets/chip.snippets.ts +5 -0
  61. package/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs +6 -1
  62. package/dist/libs/africanies-core/fesm2022/africanies-africanies-core.mjs.map +1 -1
  63. package/dist/libs/africanies-core/types/africanies-africanies-core.d.ts +6 -1
  64. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +8 -8
  65. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -1
  66. package/dist/libs/africanies-theme/tailwind-preset.cjs +3 -3
  67. package/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +5 -3
  68. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +848 -375
  69. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -1
  70. package/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +123 -30
  71. package/libs/africanies-core/src/index.ts +1 -1
  72. package/libs/africanies-core/src/lib/notification/notification-link.ts +6 -1
  73. package/libs/africanies-theme/src/lib/mode-color.safelist.ts +2 -2
  74. package/libs/africanies-theme/src/lib/mode-color.service.spec.ts +9 -7
  75. package/libs/africanies-theme/src/lib/mode-color.service.ts +9 -7
  76. package/libs/africanies-theme/tailwind-preset.cjs +3 -3
  77. package/libs/africanies-ui/src/lib/avatar/avatar-menu.component.ts +25 -21
  78. package/libs/africanies-ui/src/lib/button/button.component.spec.ts +13 -8
  79. package/libs/africanies-ui/src/lib/chip/chip.component.ts +74 -6
  80. package/libs/africanies-ui/src/lib/layout/app-shell-header.component.ts +39 -16
  81. package/libs/africanies-ui/src/lib/layout/header-greeting.util.ts +1 -1
  82. package/libs/africanies-ui/src/lib/layout/header-weather.spec.ts +87 -5
  83. package/libs/africanies-ui/src/lib/layout/header-weather.ts +35 -15
  84. package/libs/africanies-ui/src/lib/navigation/segment/segment.component.ts +98 -22
  85. package/libs/africanies-ui/src/lib/navigation/shipping-mode-switch/shipping-mode-switch.component.ts +193 -65
  86. package/libs/africanies-ui/src/lib/navigation/side-nav/africanies-side-nav.component.html +4 -2
  87. package/libs/africanies-ui/src/lib/navigation/side-nav/africanies-side-nav.component.ts +3 -1
  88. package/libs/africanies-ui/src/lib/table/table.component.spec.ts +3 -0
  89. package/libs/africanies-ui/src/lib/table/table.component.ts +197 -57
  90. package/package.json +1 -1
  91. package/tools/build-icon-sprite.mjs +1 -1
@@ -13,10 +13,15 @@ import { AfricaniesIconComponent } from '@africanies/africanies-icons';
13
13
  /**
14
14
  * Semantic tone for {@link ChipComponent}.
15
15
  *
16
+ * Prefer these for common statuses. For one-off accents, pass {@link ChipComponent.color}
17
+ * instead — any CSS color with a faded background.
18
+ *
16
19
  * - `neutral` — generic label / count
17
20
  * - `success` — positive outcome (same green family as export)
18
21
  * - `warning` — caution / pending
19
22
  * - `danger` — error / exception
23
+ * - `info` — informational (theme blue)
24
+ * - `violet` / `teal` / `rose` / `cyan` — extra categorical tones
20
25
  * - `export` — SFN / outbound accent
21
26
  * - `import` — STN / inbound accent
22
27
  */
@@ -25,6 +30,11 @@ export type ChipVariant =
25
30
  | 'success'
26
31
  | 'warning'
27
32
  | 'danger'
33
+ | 'info'
34
+ | 'violet'
35
+ | 'teal'
36
+ | 'rose'
37
+ | 'cyan'
28
38
  | 'export'
29
39
  | 'import';
30
40
 
@@ -37,12 +47,14 @@ export type ChipSize = 'sm' | 'md';
37
47
  * Compact status / category label.
38
48
  *
39
49
  * Soft fills stay readable in dark mode (translucent accent tints, not
40
- * light-only subtle backgrounds).
50
+ * light-only subtle backgrounds). Pass {@link color} for a custom accent —
51
+ * text uses that color and the background is a faded mix of the same.
41
52
  *
42
53
  * @example
43
54
  * ```html
44
55
  * <africanies-chip variant="success">Delivered</africanies-chip>
45
- * <africanies-chip variant="import" icon="truck">In transit</africanies-chip>
56
+ * <africanies-chip variant="violet">Priority</africanies-chip>
57
+ * <africanies-chip color="#0f766e">Pay Later</africanies-chip>
46
58
  * <africanies-chip variant="warning" [removable]="true" (removed)="clear()">Pending</africanies-chip>
47
59
  * ```
48
60
  */
@@ -53,8 +65,38 @@ export type ChipSize = 'sm' | 'md';
53
65
  imports: [AfricaniesIconComponent],
54
66
  host: {
55
67
  '[class]': 'hostClass()',
68
+ '[style.--africanies-chip-accent]': 'customAccent()',
56
69
  role: 'status',
57
70
  },
71
+ styles: `
72
+ :host.africanies-chip--custom {
73
+ color: var(--africanies-chip-accent);
74
+ background-color: color-mix(
75
+ in srgb,
76
+ var(--africanies-chip-accent) 14%,
77
+ transparent
78
+ );
79
+ border-color: color-mix(
80
+ in srgb,
81
+ var(--africanies-chip-accent) 28%,
82
+ transparent
83
+ );
84
+ }
85
+ :host-context(.dark):host.africanies-chip--custom {
86
+ /* Lighten the accent so labels stay readable on dark surfaces. */
87
+ color: color-mix(in srgb, var(--africanies-chip-accent) 68%, white);
88
+ background-color: color-mix(
89
+ in srgb,
90
+ var(--africanies-chip-accent) 22%,
91
+ transparent
92
+ );
93
+ border-color: color-mix(
94
+ in srgb,
95
+ var(--africanies-chip-accent) 40%,
96
+ transparent
97
+ );
98
+ }
99
+ `,
58
100
  template: `
59
101
  @if (icon(); as iconName) {
60
102
  <africanies-icon [name]="iconName" [size]="iconSize()" class="shrink-0" />
@@ -73,9 +115,15 @@ export type ChipSize = 'sm' | 'md';
73
115
  `,
74
116
  })
75
117
  export class ChipComponent {
76
- /** Semantic tone. Defaults to `neutral`. */
118
+ /** Semantic tone. Defaults to `neutral`. Ignored when {@link color} is set. */
77
119
  readonly variant = input<ChipVariant>('neutral');
78
120
 
121
+ /**
122
+ * Optional CSS color (`#7c3aed`, `rgb(…)`, `oklch(…)`, …). When set, overrides
123
+ * {@link variant}: label uses the color and the fill is a faded mix of it.
124
+ */
125
+ readonly color = input<string | null>(null);
126
+
79
127
  /** Padding / type scale. Defaults to `sm` (table / dense UI). */
80
128
  readonly size = input<ChipSize>('sm');
81
129
 
@@ -96,6 +144,12 @@ export class ChipComponent {
96
144
 
97
145
  protected readonly iconSize = computed(() => (this.size() === 'md' ? 14 : 12));
98
146
 
147
+ /** Trimmed custom accent, or `null` when using a named {@link variant}. */
148
+ protected readonly customAccent = computed(() => {
149
+ const value = this.color()?.trim() ?? '';
150
+ return value.length > 0 ? value : null;
151
+ });
152
+
99
153
  protected readonly hostClass = computed(() => {
100
154
  const base =
101
155
  'inline-flex max-w-full items-center gap-1 rounded-md font-sans font-medium ' +
@@ -110,11 +164,15 @@ export class ChipComponent {
110
164
  });
111
165
 
112
166
  /**
113
- * Soft background + text per variant. Dark mode uses translucent accents so
114
- * chips never wash out as near-white blobs.
115
- * @returns Tailwind class string for the active variant.
167
+ * Soft background + text per variant, or the custom-color host class.
168
+ * Dark mode uses translucent accents so chips never wash out as near-white
169
+ * blobs.
170
+ * @returns Tailwind / host class string for the active tone.
116
171
  */
117
172
  private toneClass(): string {
173
+ if (this.customAccent()) {
174
+ return 'africanies-chip--custom';
175
+ }
118
176
  switch (this.variant()) {
119
177
  case 'success':
120
178
  case 'export':
@@ -125,6 +183,16 @@ export class ChipComponent {
125
183
  return 'bg-warning-subtle text-warning-dark dark:bg-warning/15 dark:text-warning';
126
184
  case 'danger':
127
185
  return 'bg-danger-subtle text-danger-dark dark:bg-danger/15 dark:text-danger';
186
+ case 'info':
187
+ return 'bg-info-subtle text-info dark:bg-info/20 dark:text-[#93c5fd]';
188
+ case 'violet':
189
+ return 'bg-violet-100 text-violet-800 dark:bg-violet-400/15 dark:text-violet-300';
190
+ case 'teal':
191
+ return 'bg-teal-100 text-teal-800 dark:bg-teal-400/15 dark:text-teal-300';
192
+ case 'rose':
193
+ return 'bg-rose-100 text-rose-800 dark:bg-rose-400/15 dark:text-rose-300';
194
+ case 'cyan':
195
+ return 'bg-cyan-100 text-cyan-800 dark:bg-cyan-400/15 dark:text-cyan-300';
128
196
  default:
129
197
  return 'bg-border/60 text-ink dark:bg-white/10 dark:text-neutral-200';
130
198
  }
@@ -17,7 +17,7 @@ import type { Observable } from 'rxjs';
17
17
  import { AfricaniesIconComponent } from '@africanies/africanies-icons';
18
18
 
19
19
  import type { AfricaniesMenuItem } from '../action-menu/menu-item';
20
- import { AvatarMenuComponent } from '../avatar';
20
+ import { AvatarComponent, AvatarMenuComponent } from '../avatar';
21
21
  import type { AfricaniesNotification, NotificationPageResult } from '../notifications';
22
22
  import { NotificationDrawerService } from '../notifications';
23
23
  import type { HeaderWeather } from './header-greeting.util';
@@ -70,7 +70,12 @@ export class AppShellHeaderEndDirective {}
70
70
  selector: 'africanies-app-shell-header',
71
71
  standalone: true,
72
72
  changeDetection: ChangeDetectionStrategy.OnPush,
73
- imports: [AfricaniesIconComponent, AvatarMenuComponent, DatePipe],
73
+ imports: [
74
+ AfricaniesIconComponent,
75
+ AvatarComponent,
76
+ AvatarMenuComponent,
77
+ DatePipe,
78
+ ],
74
79
  template: `
75
80
  <div
76
81
  class="flex min-w-0 items-center gap-2 px-3 py-2.5 sm:gap-4 sm:px-6 sm:py-3"
@@ -113,13 +118,15 @@ export class AppShellHeaderEndDirective {}
113
118
  <span
114
119
  class="text-body-sm font-medium tabular-nums tracking-tight text-ink dark:text-white"
115
120
  >
116
- {{ temp }}°
121
+ {{ temp }}°C
117
122
  </span>
118
123
  }
119
124
  <span
120
125
  class="hidden text-caption text-neutral-600 md:inline dark:text-neutral-400"
121
126
  >
122
- {{ weatherPlace() }}
127
+ {{ weatherCondition() }}@if (weatherCity(); as city) {
128
+ · {{ city }}
129
+ }
123
130
  </span>
124
131
  </div>
125
132
  }
@@ -178,12 +185,20 @@ export class AppShellHeaderEndDirective {}
178
185
  }
179
186
 
180
187
  @if (userName()) {
181
- <africanies-avatar-menu
182
- [name]="userName()!"
183
- [src]="userAvatarSrc()"
184
- [menuItems]="userMenuItems()"
185
- [size]="'md'"
186
- />
188
+ @if (userMenuItems().length > 0) {
189
+ <africanies-avatar-menu
190
+ [name]="userName()!"
191
+ [src]="userAvatarSrc()"
192
+ [menuItems]="userMenuItems()"
193
+ [size]="'md'"
194
+ />
195
+ } @else {
196
+ <africanies-avatar
197
+ [name]="userName()!"
198
+ [src]="userAvatarSrc()"
199
+ size="md"
200
+ />
201
+ }
187
202
  }
188
203
  </div>
189
204
  }
@@ -274,15 +289,20 @@ export class AppShellHeaderComponent {
274
289
  if (temp === undefined || !Number.isFinite(temp)) {
275
290
  return null;
276
291
  }
277
- return temp.toFixed(1);
292
+ return String(Math.round(temp));
278
293
  });
279
294
 
280
- protected readonly weatherPlace = computed(() => {
295
+ protected readonly weatherCondition = computed(() => {
281
296
  const forecast = this.weather();
282
297
  if (!forecast) {
283
298
  return '';
284
299
  }
285
- return forecast.city?.trim() || headerWeatherLabel(forecast.kind);
300
+ return headerWeatherLabel(forecast.kind, this.now().getHours());
301
+ });
302
+
303
+ protected readonly weatherCity = computed(() => {
304
+ const city = this.weather()?.city?.trim();
305
+ return city || null;
286
306
  });
287
307
 
288
308
  protected readonly weatherAriaLabel = computed(() => {
@@ -290,10 +310,13 @@ export class AppShellHeaderComponent {
290
310
  if (!forecast) {
291
311
  return 'Weather';
292
312
  }
293
- const condition = headerWeatherLabel(forecast.kind).toLowerCase();
313
+ const condition = this.weatherCondition().toLowerCase();
294
314
  const temp = this.weatherTemp();
295
- const city = forecast.city?.trim();
296
- const degrees = temp === null ? condition : `${temp} degrees, ${condition}`;
315
+ const city = this.weatherCity();
316
+ const degrees =
317
+ temp === null
318
+ ? condition
319
+ : `${temp} degrees Celsius, ${condition}`;
297
320
  return city ? `Weather in ${city}: ${degrees}` : `Weather: ${degrees}`;
298
321
  });
299
322
 
@@ -33,7 +33,7 @@ export interface HeaderWeather {
33
33
  kind: HeaderWeatherKind;
34
34
  /** Celsius, when the forecast includes it. */
35
35
  temperatureC?: number;
36
- /** City from IP geolocation, when known. */
36
+ /** City from reverse-geocode / IP geolocation, when known. */
37
37
  city?: string;
38
38
  }
39
39
 
@@ -185,7 +185,11 @@ describe('loadHeaderWeather', () => {
185
185
  return jsonResponse({ city: 'Abuja' });
186
186
  }
187
187
  if (String(url).includes('geojs')) {
188
- throw new Error('should not call IP geo');
188
+ return jsonResponse({
189
+ latitude: 6.5,
190
+ longitude: 3.4,
191
+ city: 'Lagos',
192
+ });
189
193
  }
190
194
  return jsonResponse({ current: { weather_code: 0, temperature_2m: 31 } });
191
195
  });
@@ -197,16 +201,94 @@ describe('loadHeaderWeather', () => {
197
201
  temperatureC: 31,
198
202
  city: 'Abuja',
199
203
  });
200
- expect(
201
- fetchFn.mock.calls.some((call) => String(call[0]).includes('geojs')),
202
- ).toBe(false);
204
+ });
205
+
206
+ it('uses the IP city when browser geo succeeds but reverse geocode is empty', async () => {
207
+ Object.defineProperty(navigator, 'geolocation', {
208
+ configurable: true,
209
+ value: {
210
+ getCurrentPosition: (success: PositionCallback) => {
211
+ success({
212
+ coords: {
213
+ latitude: 9.0765,
214
+ longitude: 7.3986,
215
+ accuracy: 10,
216
+ altitude: null,
217
+ altitudeAccuracy: null,
218
+ heading: null,
219
+ speed: null,
220
+ toJSON: () => ({}),
221
+ },
222
+ timestamp: Date.now(),
223
+ toJSON: () => ({}),
224
+ } as GeolocationPosition);
225
+ },
226
+ },
227
+ });
228
+
229
+ const fetchFn = jest.fn(async (url: string) => {
230
+ if (String(url).includes('bigdatacloud')) {
231
+ return jsonResponse({ city: '' });
232
+ }
233
+ if (String(url).includes('geojs')) {
234
+ return jsonResponse({
235
+ latitude: 6.5,
236
+ longitude: 3.4,
237
+ city: 'Lagos',
238
+ });
239
+ }
240
+ return jsonResponse({ current: { weather_code: 0, temperature_2m: 31 } });
241
+ });
242
+
243
+ await expect(
244
+ loadHeaderWeather(fetchFn as unknown as typeof fetch),
245
+ ).resolves.toEqual({
246
+ kind: 'clear',
247
+ temperatureC: 31,
248
+ city: 'Lagos',
249
+ });
250
+ });
251
+
252
+ it('refetches when the hour cache has weather but no city', async () => {
253
+ sessionStorage.setItem(
254
+ 'africanies-header-weather-v7',
255
+ JSON.stringify({
256
+ hour: `${new Date().getFullYear()}-${new Date().getMonth()}-${new Date().getDate()}-${new Date().getHours()}`,
257
+ kind: 'cloudy',
258
+ temperatureC: 25,
259
+ }),
260
+ );
261
+
262
+ const fetchFn = jest.fn(async (url: string) => {
263
+ if (String(url).includes('geojs')) {
264
+ return jsonResponse({ latitude: 1, longitude: 2, city: 'Lagos' });
265
+ }
266
+ if (String(url).includes('bigdatacloud')) {
267
+ return jsonResponse({ city: 'Lagos' });
268
+ }
269
+ return jsonResponse({ current: { weather_code: 3, temperature_2m: 25 } });
270
+ });
271
+
272
+ await expect(
273
+ loadHeaderWeather(fetchFn as unknown as typeof fetch),
274
+ ).resolves.toEqual({
275
+ kind: 'cloudy',
276
+ temperatureC: 25,
277
+ city: 'Lagos',
278
+ });
279
+ expect(fetchFn).toHaveBeenCalled();
203
280
  });
204
281
  });
205
282
 
206
283
  describe('headerWeatherLabel', () => {
207
284
  it('returns a short condition', () => {
208
285
  expect(headerWeatherLabel('rain')).toBe('Rain');
209
- expect(headerWeatherLabel('clear')).toBe('Clear');
286
+ expect(headerWeatherLabel('cloudy')).toBe('Cloudy');
287
+ });
288
+
289
+ it('uses Sunny by day and Clear at night', () => {
290
+ expect(headerWeatherLabel('clear', 10)).toBe('Sunny');
291
+ expect(headerWeatherLabel('clear', 22)).toBe('Clear');
210
292
  });
211
293
  });
212
294
 
@@ -3,10 +3,10 @@ import type { IconName } from '@africanies/africanies-icons';
3
3
  import type { HeaderWeather, HeaderWeatherKind } from './header-greeting.util';
4
4
 
5
5
  /** Bump when cache shape or city resolution strategy changes. */
6
- const CACHE_KEY = 'africanies-header-weather-v4';
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 — city string is often wrong (e.g. NG IPs labelled Lagos). */
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
11
  /** Client reverse-geocode so the label matches the forecast coordinates. */
12
12
  const REVERSE_GEO_URL =
@@ -88,9 +88,16 @@ export function mapWmoWeatherCode(code: number): HeaderWeatherKind | null {
88
88
  * Short condition label for the header weather chip.
89
89
  *
90
90
  * @param kind - Coarse forecast kind.
91
- * @returns Display label such as `Rain` or `Clear`.
91
+ * @param hour - Local hour; clear days show as Sunny, nights as Clear.
92
+ * @returns Display label such as `Rain` or `Sunny`.
92
93
  */
93
- export function headerWeatherLabel(kind: HeaderWeatherKind): string {
94
+ export function headerWeatherLabel(
95
+ kind: HeaderWeatherKind,
96
+ hour = new Date().getHours(),
97
+ ): string {
98
+ if (kind === 'clear') {
99
+ return hour >= 19 || hour < 6 ? 'Clear' : 'Sunny';
100
+ }
94
101
  return WEATHER_LABELS[kind];
95
102
  }
96
103
 
@@ -116,8 +123,7 @@ export function headerWeatherIcon(kind: HeaderWeatherKind, hour: number): IconNa
116
123
  *
117
124
  * Coordinates prefer the browser Geolocation API (accurate for travellers;
118
125
  * may prompt once). Otherwise IP geo is used. The city label is reverse-
119
- * geocoded from those coordinates IP `city` strings are often wrong for
120
- * Nigeria (many networks resolve as Lagos).
126
+ * geocoded from those coordinates, with the IP city as a fallback.
121
127
  *
122
128
  * Fails closed: missing browser APIs, timeouts, and HTTP errors all return
123
129
  * `null` so the greeting can stay time-of-day only. Cached per local hour
@@ -191,10 +197,16 @@ function readCache(hour: string): HeaderWeather | null {
191
197
  if (parsed.hour !== hour || !parsed.kind) {
192
198
  return null;
193
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
+ }
194
206
  return {
195
207
  kind: parsed.kind,
196
208
  temperatureC: parsed.temperatureC,
197
- city: parsed.city,
209
+ city,
198
210
  };
199
211
  } catch {
200
212
  return null;
@@ -211,30 +223,38 @@ function writeCache(value: CachedWeather): void {
211
223
 
212
224
  /**
213
225
  * Prefer device coordinates when available (may prompt once); otherwise fall
214
- * back to IP geo. IP city labels for Nigeria are often wrong reverse
215
- * geocoding from these coordinates still depends on accurate lat/lon.
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).
216
228
  */
217
229
  async function resolveCoordinates(
218
230
  fetchFn: typeof fetch,
219
231
  ): Promise<(Coordinates & { fallbackCity?: string }) | null> {
220
- const browser = await tryBrowserGeolocation();
232
+ const [browser, ipGeo] = await Promise.all([
233
+ tryBrowserGeolocation(),
234
+ fetchIpGeo(fetchFn),
235
+ ]);
236
+ const ipCity = asCity(ipGeo?.city);
237
+
221
238
  if (browser) {
222
- return browser;
239
+ return { ...browser, fallbackCity: ipCity };
223
240
  }
224
241
 
225
- const geo = (await fetchJson(fetchFn, GEO_URL)) as GeoJsPayload | null;
226
- const latitude = asNumber(geo?.latitude);
227
- const longitude = asNumber(geo?.longitude);
242
+ const latitude = asNumber(ipGeo?.latitude);
243
+ const longitude = asNumber(ipGeo?.longitude);
228
244
  if (latitude === null || longitude === null) {
229
245
  return null;
230
246
  }
231
247
  return {
232
248
  latitude,
233
249
  longitude,
234
- fallbackCity: asCity(geo?.city),
250
+ fallbackCity: ipCity,
235
251
  };
236
252
  }
237
253
 
254
+ async function fetchIpGeo(fetchFn: typeof fetch): Promise<GeoJsPayload | null> {
255
+ return (await fetchJson(fetchFn, GEO_URL)) as GeoJsPayload | null;
256
+ }
257
+
238
258
  async function tryBrowserGeolocation(): Promise<Coordinates | null> {
239
259
  if (
240
260
  typeof navigator === 'undefined' ||
@@ -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;