@africanies/angular-web-sdk 0.1.9 → 0.1.11

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 (70) 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/14892813664845064572/dist/libs/africanies-ui/README.md +68 -0
  8. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
  9. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
  10. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
  11. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
  12. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +20013 -0
  13. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
  14. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/package.json +35 -0
  15. package/.nx/cache/14892813664845064572/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5617 -0
  16. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/README.md +68 -0
  17. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo-mini.png +0 -0
  18. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo.png +0 -0
  19. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/brand/africanies-logo.svg +5 -0
  20. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/assets/carriers/dhl.svg +7 -0
  21. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +19998 -0
  22. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -0
  23. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/package.json +35 -0
  24. package/.nx/cache/3735952477975556635/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +5616 -0
  25. package/.nx/cache/run.json +18 -18
  26. package/.nx/cache/terminalOutputs/1149535958171016935 +21 -0
  27. package/.nx/cache/terminalOutputs/14892813664845064572 +21 -0
  28. package/.nx/cache/terminalOutputs/17909810599991457481 +40 -0
  29. package/.nx/cache/terminalOutputs/2927637324428060203 +42 -0
  30. package/.nx/cache/terminalOutputs/3735952477975556635 +21 -0
  31. package/.nx/cache/terminalOutputs/4787481949164239892 +8 -0
  32. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db +0 -0
  33. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-shm +0 -0
  34. package/.nx/workspace-data/0091ABA9-5C90-580D-953C-8C94E763ADB5-v3.db-wal +0 -0
  35. package/.nx/workspace-data/d/daemon.log +3807 -0
  36. package/.nx/workspace-data/d/server-process.json +2 -2
  37. package/.nx/workspace-data/file-map.json +3254 -3260
  38. package/.nx/workspace-data/nx_files.nxt +0 -0
  39. package/.nx/workspace-data/project-graph.json +2 -2
  40. package/README.md +0 -17
  41. package/apps/playground/src/app/pages/chip-page.ts +11 -1
  42. package/apps/playground/src/app/pages/tokens-page.ts +2 -2
  43. package/apps/playground/src/app/snippets/chip.snippets.ts +5 -0
  44. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs +8 -8
  45. package/dist/libs/africanies-theme/fesm2022/africanies-africanies-theme.mjs.map +1 -1
  46. package/dist/libs/africanies-theme/tailwind-preset.cjs +3 -3
  47. package/dist/libs/africanies-theme/types/africanies-africanies-theme.d.ts +5 -3
  48. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs +986 -473
  49. package/dist/libs/africanies-ui/fesm2022/africanies-africanies-ui.mjs.map +1 -1
  50. package/dist/libs/africanies-ui/types/africanies-africanies-ui.d.ts +135 -39
  51. package/libs/africanies-theme/src/lib/mode-color.safelist.ts +2 -2
  52. package/libs/africanies-theme/src/lib/mode-color.service.spec.ts +9 -7
  53. package/libs/africanies-theme/src/lib/mode-color.service.ts +9 -7
  54. package/libs/africanies-theme/tailwind-preset.cjs +3 -3
  55. package/libs/africanies-ui/src/lib/avatar/avatar-menu.component.ts +25 -21
  56. package/libs/africanies-ui/src/lib/button/button.component.spec.ts +13 -8
  57. package/libs/africanies-ui/src/lib/chip/chip.component.ts +74 -6
  58. package/libs/africanies-ui/src/lib/layout/app-shell-header.component.ts +35 -12
  59. package/libs/africanies-ui/src/lib/layout/header-greeting.util.ts +2 -0
  60. package/libs/africanies-ui/src/lib/layout/header-weather.spec.ts +120 -9
  61. package/libs/africanies-ui/src/lib/layout/header-weather.ts +79 -18
  62. package/libs/africanies-ui/src/lib/navigation/segment/segment.component.ts +98 -22
  63. package/libs/africanies-ui/src/lib/navigation/shipping-mode-switch/shipping-mode-switch.component.spec.ts +15 -0
  64. package/libs/africanies-ui/src/lib/navigation/shipping-mode-switch/shipping-mode-switch.component.ts +195 -64
  65. package/libs/africanies-ui/src/lib/navigation/side-nav/africanies-side-nav.component.html +4 -2
  66. package/libs/africanies-ui/src/lib/navigation/side-nav/africanies-side-nav.component.ts +3 -1
  67. package/libs/africanies-ui/src/lib/table/table.component.spec.ts +3 -0
  68. package/libs/africanies-ui/src/lib/table/table.component.ts +254 -109
  69. package/package.json +1 -1
  70. package/tools/build-icon-sprite.mjs +1 -1
@@ -12,7 +12,7 @@
12
12
  * text-export-strong bg-export-strong hover:bg-export-hover border-export-strong
13
13
  * text-export bg-export bg-export-subtle bg-export-light border-export
14
14
  * hover:bg-export-light hover:bg-export-subtle dark:bg-export/15 dark:hover:bg-export/15
15
- * dark:bg-[color-mix(in_srgb,#1cbd5d_15%,#212529)] dark:bg-export dark:text-export dark:border-export
15
+ * dark:bg-[color-mix(in_srgb,#26bd5d_15%,#212529)] dark:bg-export dark:text-export dark:border-export
16
16
  *
17
17
  * STN (import / orange):
18
18
  * text-import-strong bg-import-strong hover:bg-import-hover border-import-strong
@@ -35,7 +35,7 @@ export const MODE_COLOR_SAFELIST = [
35
35
  'dark:bg-export/15',
36
36
  'dark:hover:bg-export/15',
37
37
  'dark:hover:bg-export-light',
38
- 'dark:bg-[color-mix(in_srgb,#1cbd5d_15%,#212529)]',
38
+ 'dark:bg-[color-mix(in_srgb,#26bd5d_15%,#212529)]',
39
39
  'dark:bg-export',
40
40
  'dark:text-export',
41
41
  'dark:border-export',
@@ -26,14 +26,16 @@ describe('ModeColorService', () => {
26
26
  service = TestBed.inject(ModeColorService);
27
27
  });
28
28
 
29
- it('uses accessible export-strong fills and text in SFN light mode', () => {
29
+ it('uses export brand fills and text for SFN in light and dark', () => {
30
30
  const classes = service.classes();
31
- expect(classes.text).toContain('text-export-strong');
32
- expect(classes.primary).toContain('bg-export-strong');
33
- expect(classes.ghostPrimary).toContain('text-export-strong');
34
- expect(classes.activeFill).toContain('bg-export-strong');
35
- expect(classes.stroked).toContain('text-export-strong');
36
- expect(classes.stroked).toContain('border-export-strong');
31
+ const primaryTokens = classes.primary.split(/\s+/);
32
+ expect(classes.text.split(/\s+/)).toContain('text-export');
33
+ expect(primaryTokens).toContain('bg-export');
34
+ expect(primaryTokens).not.toContain('bg-export-strong');
35
+ expect(classes.ghostPrimary.split(/\s+/)).toContain('text-export');
36
+ expect(classes.activeFill.split(/\s+/)).toContain('bg-export');
37
+ expect(classes.stroked.split(/\s+/)).toContain('text-export');
38
+ expect(classes.stroked.split(/\s+/)).toContain('border-export');
37
39
  });
38
40
 
39
41
  it('keeps bright export accents for dark-mode companions in SFN', () => {
@@ -9,7 +9,9 @@ import { ShippingModeService } from '@africanies/africanies-core';
9
9
  * and accent text. Semantic colors (danger / warning) stay mode-independent.
10
10
  */
11
11
  export interface ModeColorClasses {
12
- /** Accessible accent text — `text-export-strong` in light, `text-export` in dark. */
12
+ /**
13
+ * Accessible accent text — mode brand color in light and dark.
14
+ */
13
15
  text: string;
14
16
  /** Solid background utility, e.g. `bg-export`. */
15
17
  bg: string;
@@ -80,22 +82,22 @@ export class ModeColorService {
80
82
  // Literal strings required for Tailwind content detection — do not interpolate.
81
83
  if (this.shippingMode.mode() === 'sfn') {
82
84
  return {
83
- text: 'text-export-strong dark:text-export',
85
+ text: 'text-export dark:text-export',
84
86
  bg: 'bg-export',
85
87
  bgSubtle: 'bg-export-subtle',
86
88
  border: 'border-export',
87
89
  primary:
88
- 'bg-export-strong text-white border-transparent hover:bg-export-hover dark:bg-export dark:hover:bg-export-light',
90
+ 'bg-export text-white border-transparent hover:bg-export-strong dark:bg-export dark:hover:bg-export-light',
89
91
  ghostPrimary:
90
- 'bg-transparent text-export-strong border-transparent hover:bg-export-subtle dark:text-export dark:hover:bg-export/15',
92
+ 'bg-transparent text-export border-transparent hover:bg-export-subtle dark:text-export dark:hover:bg-export/15',
91
93
  soft: 'bg-export-subtle dark:bg-export/15',
92
94
  softSolid:
93
- 'bg-export-subtle dark:bg-[color-mix(in_srgb,#1cbd5d_15%,#212529)]',
95
+ 'bg-export-subtle dark:bg-[color-mix(in_srgb,#26bd5d_15%,#212529)]',
94
96
  softHover: 'hover:bg-export-subtle dark:hover:bg-export/15',
95
97
  activeFill:
96
- 'bg-export-strong text-white border-transparent hover:bg-export-hover dark:bg-export dark:text-white dark:hover:bg-export-light',
98
+ 'bg-export text-white border-transparent hover:bg-export-strong dark:bg-export dark:text-white dark:hover:bg-export-light',
97
99
  stroked:
98
- 'bg-surface text-export-strong border-export-strong hover:bg-export-subtle dark:bg-ink-950 dark:text-export dark:border-export dark:hover:bg-export/15',
100
+ 'bg-surface text-export border-export hover:bg-export-subtle dark:bg-ink-950 dark:text-export dark:border-export dark:hover:bg-export/15',
99
101
  };
100
102
  }
101
103
  return {
@@ -270,7 +270,7 @@ module.exports = {
270
270
  hover: '#e9eef5',
271
271
  },
272
272
  export: {
273
- DEFAULT: '#1cbd5d',
273
+ DEFAULT: '#26bd5d',
274
274
  light: '#24dc6d',
275
275
  subtle: '#e4fff3',
276
276
  tint: '#f2fff8',
@@ -282,8 +282,8 @@ module.exports = {
282
282
  DEFAULT: '#f08829',
283
283
  light: '#ffa95b',
284
284
  subtle: '#fffcef',
285
- // Accessible normal-text / white-text-control foreground (6.20:1).
286
- strong: '#9a4b0b',
285
+ // Brand orange (same as DEFAULT) — portal STN primary.
286
+ strong: '#f08829',
287
287
  hover: '#7f3e09',
288
288
  },
289
289
  danger: {
@@ -37,28 +37,32 @@ import { AvatarComponent, type AvatarSize } from './avatar.component';
37
37
  AfricaniesIconComponent,
38
38
  ],
39
39
  template: `
40
- <africanies-action-menu
41
- [items]="menuItems()"
42
- [ariaLabel]="ariaLabel()"
43
- [disabled]="disabled()"
44
- >
45
- <button
46
- type="button"
47
- africaniesActionMenuTrigger
48
- class="inline-flex cursor-pointer items-center gap-1 rounded-lg p-0.5 pr-1 transition-colors hover:bg-background-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
49
- [attr.aria-label]="ariaLabel()"
40
+ @if (menuItems().length > 0) {
41
+ <africanies-action-menu
42
+ [items]="menuItems()"
43
+ [ariaLabel]="ariaLabel()"
44
+ [disabled]="disabled()"
50
45
  >
51
- <africanies-avatar [name]="name()" [src]="src()" [size]="size()" />
52
- @if (showCaret()) {
53
- <africanies-icon
54
- name="chevron-down"
55
- [size]="14"
56
- class="shrink-0 text-neutral-600 dark:text-neutral-400"
57
- aria-hidden="true"
58
- />
59
- }
60
- </button>
61
- </africanies-action-menu>
46
+ <button
47
+ type="button"
48
+ africaniesActionMenuTrigger
49
+ class="inline-flex cursor-pointer items-center gap-1 rounded-lg p-0.5 pr-1 transition-colors hover:bg-background-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
50
+ [attr.aria-label]="ariaLabel()"
51
+ >
52
+ <africanies-avatar [name]="name()" [src]="src()" [size]="size()" />
53
+ @if (showCaret()) {
54
+ <africanies-icon
55
+ name="chevron-down"
56
+ [size]="14"
57
+ class="shrink-0 text-neutral-600 dark:text-neutral-400"
58
+ aria-hidden="true"
59
+ />
60
+ }
61
+ </button>
62
+ </africanies-action-menu>
63
+ } @else {
64
+ <africanies-avatar [name]="name()" [src]="src()" [size]="size()" />
65
+ }
62
66
  `,
63
67
  })
64
68
  export class AvatarMenuComponent {
@@ -65,29 +65,34 @@ describe('ButtonComponent', () => {
65
65
  });
66
66
 
67
67
  it('should apply primary variant classes by default (SFN / export)', () => {
68
- expect(button.className).toContain('bg-export-strong');
69
- expect(button.className).toContain('text-white');
68
+ const tokens = button.className.split(/\s+/);
69
+ expect(tokens).toContain('bg-export');
70
+ expect(tokens).toContain('text-white');
71
+ expect(tokens).not.toContain('bg-export-strong');
70
72
  });
71
73
 
72
74
  it('should switch primary to import orange when mode is STN', () => {
73
75
  mode.set('stn');
74
76
  fixture.detectChanges();
75
77
 
76
- expect(button.className).toContain('bg-import-strong');
77
- expect(button.className).not.toContain('bg-export-strong');
78
+ const tokens = button.className.split(/\s+/);
79
+ expect(tokens).toContain('bg-import-strong');
80
+ expect(tokens).not.toContain('bg-export');
78
81
  });
79
82
 
80
83
  it('should switch ghost-primary text accent with shipping mode', () => {
81
84
  host.variant.set('ghost-primary');
82
85
  fixture.detectChanges();
83
- expect(button.className).toContain('text-export-strong');
84
- expect(button.className).toContain('bg-transparent');
86
+ let tokens = button.className.split(/\s+/);
87
+ expect(tokens).toContain('text-export');
88
+ expect(tokens).toContain('bg-transparent');
85
89
 
86
90
  mode.set('stn');
87
91
  fixture.detectChanges();
88
92
 
89
- expect(button.className).toContain('text-import-strong');
90
- expect(button.className).not.toContain('text-export-strong');
93
+ tokens = button.className.split(/\s+/);
94
+ expect(tokens).toContain('text-import-strong');
95
+ expect(tokens).not.toContain('text-export');
91
96
  });
92
97
 
93
98
  it('should switch variant and size classes', () => {
@@ -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"
@@ -119,7 +124,9 @@ export class AppShellHeaderEndDirective {}
119
124
  <span
120
125
  class="hidden text-caption text-neutral-600 md:inline dark:text-neutral-400"
121
126
  >
122
- {{ weatherCondition() }}
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
  }
@@ -285,6 +300,11 @@ export class AppShellHeaderComponent {
285
300
  return headerWeatherLabel(forecast.kind, this.now().getHours());
286
301
  });
287
302
 
303
+ protected readonly weatherCity = computed(() => {
304
+ const city = this.weather()?.city?.trim();
305
+ return city || null;
306
+ });
307
+
288
308
  protected readonly weatherAriaLabel = computed(() => {
289
309
  const forecast = this.weather();
290
310
  if (!forecast) {
@@ -292,9 +312,12 @@ export class AppShellHeaderComponent {
292
312
  }
293
313
  const condition = this.weatherCondition().toLowerCase();
294
314
  const temp = this.weatherTemp();
295
- return temp === null
296
- ? `Weather: ${condition}`
297
- : `Weather: ${temp} degrees Celsius, ${condition}`;
315
+ const city = this.weatherCity();
316
+ const degrees =
317
+ temp === null
318
+ ? condition
319
+ : `${temp} degrees Celsius, ${condition}`;
320
+ return city ? `Weather in ${city}: ${degrees}` : `Weather: ${degrees}`;
298
321
  });
299
322
 
300
323
  protected readonly unreadCount = computed(
@@ -33,6 +33,8 @@ export interface HeaderWeather {
33
33
  kind: HeaderWeatherKind;
34
34
  /** Celsius, when the forecast includes it. */
35
35
  temperatureC?: number;
36
+ /** City from reverse-geocode / IP geolocation, when known. */
37
+ city?: string;
36
38
  }
37
39
 
38
40
  const PERIOD_KICKERS: Record<HeaderGreetingPeriod, readonly string[]> = {
@@ -66,7 +66,7 @@ describe('loadHeaderWeather', () => {
66
66
  ).resolves.toBeNull();
67
67
  });
68
68
 
69
- it('reads Open-Meteo after IP coordinates', async () => {
69
+ it('reads Open-Meteo after IP geolocation and reverse-geocodes the city', async () => {
70
70
  const fetchFn = jest.fn(async (url: string) => {
71
71
  if (String(url).includes('geojs')) {
72
72
  return jsonResponse({
@@ -75,6 +75,9 @@ describe('loadHeaderWeather', () => {
75
75
  city: 'Lagos',
76
76
  });
77
77
  }
78
+ if (String(url).includes('bigdatacloud')) {
79
+ return jsonResponse({ city: 'Abuja', locality: 'Abuja' });
80
+ }
78
81
  return jsonResponse({
79
82
  current: { weather_code: 61, temperature_2m: 27.4 },
80
83
  });
@@ -85,8 +88,29 @@ describe('loadHeaderWeather', () => {
85
88
  ).resolves.toEqual({
86
89
  kind: 'rain',
87
90
  temperatureC: 27.4,
91
+ city: 'Abuja',
92
+ });
93
+ expect(fetchFn).toHaveBeenCalledTimes(3);
94
+ });
95
+
96
+ it('falls back to the IP city when reverse geocode is empty', async () => {
97
+ const fetchFn = jest.fn(async (url: string) => {
98
+ if (String(url).includes('geojs')) {
99
+ return jsonResponse({ latitude: 1, longitude: 2, city: 'Lagos' });
100
+ }
101
+ if (String(url).includes('bigdatacloud')) {
102
+ return jsonResponse({});
103
+ }
104
+ return jsonResponse({ current: { weather_code: 0, temperature_2m: 30 } });
105
+ });
106
+
107
+ await expect(
108
+ loadHeaderWeather(fetchFn as unknown as typeof fetch),
109
+ ).resolves.toEqual({
110
+ kind: 'clear',
111
+ temperatureC: 30,
112
+ city: 'Lagos',
88
113
  });
89
- expect(fetchFn).toHaveBeenCalledTimes(2);
90
114
  });
91
115
 
92
116
  it('reuses the same-hour session cache', async () => {
@@ -94,6 +118,9 @@ describe('loadHeaderWeather', () => {
94
118
  if (String(url).includes('geojs')) {
95
119
  return jsonResponse({ latitude: 1, longitude: 2 });
96
120
  }
121
+ if (String(url).includes('bigdatacloud')) {
122
+ return jsonResponse({ city: 'Accra' });
123
+ }
97
124
  return jsonResponse({ current: { weather_code: 0, temperature_2m: 30 } });
98
125
  });
99
126
 
@@ -101,13 +128,16 @@ describe('loadHeaderWeather', () => {
101
128
  const second = await loadHeaderWeather(fetchFn as unknown as typeof fetch);
102
129
 
103
130
  expect(first).toEqual(second);
104
- expect(fetchFn).toHaveBeenCalledTimes(2);
131
+ expect(fetchFn).toHaveBeenCalledTimes(3);
105
132
  });
106
133
 
107
134
  it('fetches again when the cached hour no longer matches', async () => {
108
135
  const fetchFn = jest.fn(async (url: string) => {
109
136
  if (String(url).includes('geojs')) {
110
- return jsonResponse({ latitude: 1, longitude: 2 });
137
+ return jsonResponse({ latitude: 1, longitude: 2, city: 'Lagos' });
138
+ }
139
+ if (String(url).includes('bigdatacloud')) {
140
+ return jsonResponse({ city: 'Lagos' });
111
141
  }
112
142
  return jsonResponse({ current: { weather_code: 0, temperature_2m: 30.2 } });
113
143
  });
@@ -124,7 +154,7 @@ describe('loadHeaderWeather', () => {
124
154
  sessionStorage.setItem(cacheKey!, JSON.stringify(stored));
125
155
 
126
156
  await loadHeaderWeather(fetchFn as unknown as typeof fetch);
127
- expect(fetchFn).toHaveBeenCalledTimes(4);
157
+ expect(fetchFn).toHaveBeenCalledTimes(6);
128
158
  });
129
159
 
130
160
  it('uses browser coordinates when geolocation succeeds', async () => {
@@ -151,8 +181,61 @@ describe('loadHeaderWeather', () => {
151
181
  });
152
182
 
153
183
  const fetchFn = jest.fn(async (url: string) => {
184
+ if (String(url).includes('bigdatacloud')) {
185
+ return jsonResponse({ city: 'Abuja' });
186
+ }
187
+ if (String(url).includes('geojs')) {
188
+ return jsonResponse({
189
+ latitude: 6.5,
190
+ longitude: 3.4,
191
+ city: 'Lagos',
192
+ });
193
+ }
194
+ return jsonResponse({ current: { weather_code: 0, temperature_2m: 31 } });
195
+ });
196
+
197
+ await expect(
198
+ loadHeaderWeather(fetchFn as unknown as typeof fetch),
199
+ ).resolves.toEqual({
200
+ kind: 'clear',
201
+ temperatureC: 31,
202
+ city: 'Abuja',
203
+ });
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
+ }
154
233
  if (String(url).includes('geojs')) {
155
- throw new Error('should not call IP geo');
234
+ return jsonResponse({
235
+ latitude: 6.5,
236
+ longitude: 3.4,
237
+ city: 'Lagos',
238
+ });
156
239
  }
157
240
  return jsonResponse({ current: { weather_code: 0, temperature_2m: 31 } });
158
241
  });
@@ -162,10 +245,38 @@ describe('loadHeaderWeather', () => {
162
245
  ).resolves.toEqual({
163
246
  kind: 'clear',
164
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',
165
278
  });
166
- expect(
167
- fetchFn.mock.calls.some((call) => String(call[0]).includes('geojs')),
168
- ).toBe(false);
279
+ expect(fetchFn).toHaveBeenCalled();
169
280
  });
170
281
  });
171
282