@huloglobal/vendure-plugin-geo-block 0.1.0 → 0.2.0

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.
@@ -7,15 +7,19 @@ interface ChannelRow {
7
7
  code: string;
8
8
  token: string;
9
9
  enabled: boolean;
10
+ mode: 'block' | 'soft';
10
11
  allowedRegions: string[];
11
12
  extraAllowed: string[];
12
13
  blockedCountries: string[];
13
14
  allowedGbRegions: string[];
15
+ ipAllowlist: string[];
16
+ blockMessage: string;
17
+ blockRedirectUrl: string;
18
+ blockLogoUrl: string;
14
19
  resolved: { allowedCountries: string[] | null; blockedCountries: string[] };
15
20
  }
16
21
 
17
- interface RegionDef { value: string; label: string; hint: string; countries: number; }
18
- interface CountryDef { value: string; label: string; flag: string; }
22
+ interface PresetMeta { key: string; label: string; kind: string; description: string; countryCount: number | null; }
19
23
 
20
24
  @Component({
21
25
  selector: 'ees-geo-block',
@@ -23,7 +27,10 @@ interface CountryDef { value: string; label: string; flag: string; }
23
27
  template: `
24
28
  <vdr-page-block>
25
29
  <vdr-action-bar>
26
- <vdr-ab-left><h2>Site access (geo-block)</h2></vdr-ab-left>
30
+ <vdr-ab-left>
31
+ <h2>Site access</h2>
32
+ <p class="subtitle">Per-channel geo-restrictions, IP allowlist, audit log.</p>
33
+ </vdr-ab-left>
27
34
  <vdr-ab-right>
28
35
  <button class="btn btn-link" (click)="reload()" [disabled]="loading">
29
36
  <clr-icon shape="refresh"></clr-icon> Refresh
@@ -32,8 +39,8 @@ interface CountryDef { value: string; label: string; flag: string; }
32
39
  </vdr-action-bar>
33
40
  </vdr-page-block>
34
41
 
35
- <vdr-page-block>
36
- <div class="card" *ngIf="!loading && current">
42
+ <vdr-page-block *ngIf="!loading && current">
43
+ <div class="card top-bar">
37
44
  <div class="card-block">
38
45
  <div class="chan-row">
39
46
  <label class="lbl">Channel</label>
@@ -44,172 +51,332 @@ interface CountryDef { value: string; label: string; flag: string; }
44
51
  <span class="status-pill" [class.on]="current.enabled" [class.off]="!current.enabled">
45
52
  {{ current.enabled ? 'GEO-BLOCK ON' : 'GEO-BLOCK OFF' }}
46
53
  </span>
47
- <button class="btn btn-sm btn-link" (click)="toggleEnabled()">
54
+ <button class="btn btn-sm" [class.btn-warning]="current.enabled" [class.btn-primary]="!current.enabled" (click)="toggleEnabled()">
48
55
  {{ current.enabled ? 'Turn off' : 'Turn on' }}
49
56
  </button>
57
+
58
+ <span class="mode-pill" *ngIf="current.enabled" [class.mode-block]="current.mode === 'block'" [class.mode-soft]="current.mode === 'soft'">
59
+ {{ current.mode === 'soft' ? 'Soft block (banner)' : 'Full block' }}
60
+ </span>
61
+
62
+ <span class="dirty-flag" *ngIf="dirty">● Unsaved</span>
50
63
  </div>
51
64
 
52
- <p class="hint" *ngIf="!current.enabled">
53
- Geo-block is <strong>off</strong> everyone can visit the storefront, regardless of the settings below.
54
- Use the toggle above to turn it on.
55
- </p>
65
+ <div class="tabs">
66
+ <button class="tab" [class.active]="tab === 'rules'" (click)="tab = 'rules'">Rules</button>
67
+ <button class="tab" [class.active]="tab === 'message'" (click)="tab = 'message'">Block page</button>
68
+ <button class="tab" [class.active]="tab === 'allowlist'" (click)="tab = 'allowlist'">IP allowlist</button>
69
+ <button class="tab" [class.active]="tab === 'simulate'" (click)="tab = 'simulate'">Simulate</button>
70
+ <button class="tab" [class.active]="tab === 'stats'" (click)="tab = 'stats'; loadStats()">Stats</button>
71
+ </div>
56
72
  </div>
57
73
  </div>
58
74
  </vdr-page-block>
59
75
 
60
- <vdr-page-block *ngIf="!loading && current">
61
- <div class="card">
62
- <div class="card-block">
63
- <h3 class="step-title">1 · Choose a mode</h3>
64
- <p class="hint">Pick one — the rest of the page changes based on what you select.</p>
65
-
66
- <div class="mode-grid">
67
- <label class="mode-card" [class.active]="mode === 'specific'">
68
- <input type="radio" name="mode" value="specific" [(ngModel)]="mode" (ngModelChange)="onModeChange()">
69
- <div class="mode-title">Allow only specific places</div>
70
- <div class="mode-body">Pick regions and / or individual countries. Everyone else is blocked. Best for &ldquo;UK only&rdquo; or &ldquo;UK + Europe&rdquo;.</div>
71
- </label>
72
-
73
- <label class="mode-card" [class.active]="mode === 'worldwide'">
74
- <input type="radio" name="mode" value="worldwide" [(ngModel)]="mode" (ngModelChange)="onModeChange()">
75
- <div class="mode-title">Allow worldwide, except blocked</div>
76
- <div class="mode-body">Everyone is welcome <em>except</em> the countries you add to the block list. Best for &ldquo;serve everyone but sanction-targeted countries&rdquo;.</div>
77
- </label>
76
+ <!-- ============================================================= RULES TAB -->
77
+ <ng-container *ngIf="!loading && current && tab === 'rules'">
78
+ <vdr-page-block>
79
+ <div class="card">
80
+ <div class="card-block">
81
+ <h3 class="step-title">Mode</h3>
82
+ <div class="mode-grid">
83
+ <label class="mode-card" [class.active]="current.mode === 'block'">
84
+ <input type="radio" name="bmode" value="block" [(ngModel)]="current.mode" (ngModelChange)="markDirty()">
85
+ <div class="mode-title">Full block</div>
86
+ <div class="mode-body">Blocked visitors never see the storefront they get the block page (or are redirected).</div>
87
+ </label>
88
+ <label class="mode-card" [class.active]="current.mode === 'soft'">
89
+ <input type="radio" name="bmode" value="soft" [(ngModel)]="current.mode" (ngModelChange)="markDirty()">
90
+ <div class="mode-title">Soft block (browse-only)</div>
91
+ <div class="mode-body">Visitors can browse but a banner explains you don't ship to their country and checkout is hidden.</div>
92
+ </label>
93
+ </div>
78
94
  </div>
79
95
  </div>
80
- </div>
81
- </vdr-page-block>
82
-
83
- <vdr-page-block *ngIf="!loading && current && mode === 'specific'">
84
- <div class="card">
85
- <div class="card-block">
86
- <h3 class="step-title">2 · Allowed regions <small>(one click)</small></h3>
87
- <p class="hint">Each preset adds a whole group of countries. Tick as many as you want — they stack.</p>
88
-
89
- <div class="preset-grid">
90
- <label *ngFor="let r of regionDefs" class="preset-card" [class.active]="isRegionPicked(r.value)">
91
- <input type="checkbox" [checked]="isRegionPicked(r.value)" (change)="toggleRegion(r.value)">
92
- <div class="preset-label">{{ r.label }}</div>
93
- <div class="preset-hint">{{ r.hint }}<span *ngIf="r.countries"> · {{ r.countries }} countries</span></div>
94
- </label>
96
+ </vdr-page-block>
97
+
98
+ <vdr-page-block>
99
+ <div class="card">
100
+ <div class="card-block">
101
+ <h3 class="step-title">Strategy</h3>
102
+ <div class="mode-grid">
103
+ <label class="mode-card" [class.active]="strategy === 'specific'">
104
+ <input type="radio" name="strat" value="specific" [(ngModel)]="strategy" (ngModelChange)="onStrategyChange()">
105
+ <div class="mode-title">Allow only specific places</div>
106
+ <div class="mode-body">Pick regions or individual countries — everyone else is blocked.</div>
107
+ </label>
108
+ <label class="mode-card" [class.active]="strategy === 'worldwide'">
109
+ <input type="radio" name="strat" value="worldwide" [(ngModel)]="strategy" (ngModelChange)="onStrategyChange()">
110
+ <div class="mode-title">Worldwide except blocked</div>
111
+ <div class="mode-body">Allow everyone except the denylist below.</div>
112
+ </label>
113
+ </div>
95
114
  </div>
96
115
  </div>
97
- </div>
98
- </vdr-page-block>
99
-
100
- <vdr-page-block *ngIf="!loading && current && mode === 'specific'">
101
- <div class="card">
102
- <div class="card-block">
103
- <h3 class="step-title">3 · Add extra countries <small>(optional)</small></h3>
104
- <p class="hint">Countries that aren't in any preset above — for example: add 🇯🇵 Japan or 🇮🇱 Israel while keeping &ldquo;UK only&rdquo; selected.</p>
105
-
106
- <div class="chip-row">
107
- <span class="chip" *ngFor="let cc of current.extraAllowed">
108
- {{ flag(cc) }} {{ countryLabel(cc) }}
109
- <button class="chip-x" (click)="removeExtra(cc)" title="Remove">×</button>
110
- </span>
111
- <span *ngIf="!current.extraAllowed.length" class="hint inline">None yet.</span>
116
+ </vdr-page-block>
117
+
118
+ <vdr-page-block *ngIf="strategy === 'specific'">
119
+ <div class="card">
120
+ <div class="card-block">
121
+ <h3 class="step-title">Allowed regions <small>({{ pickedRegionCount() }} picked)</small></h3>
122
+ <p class="hint">One-click presets. Tick as many as you want — they stack.</p>
123
+
124
+ <input class="form-input filter-input" placeholder="Filter presets…" [(ngModel)]="presetFilter">
125
+
126
+ <div class="preset-section" *ngFor="let group of presetGroups">
127
+ <h4 class="group-title">{{ group.label }}</h4>
128
+ <div class="preset-grid">
129
+ <label *ngFor="let p of filteredPresets(group.kind)" class="preset-card" [class.active]="isRegionPicked(p.key)">
130
+ <input type="checkbox" [checked]="isRegionPicked(p.key)" (change)="toggleRegion(p.key)">
131
+ <div class="preset-label">{{ p.label }}</div>
132
+ <div class="preset-hint">{{ p.description }}<span *ngIf="p.countryCount"> · {{ p.countryCount }} countries</span></div>
133
+ </label>
134
+ </div>
135
+ </div>
112
136
  </div>
113
-
114
- <div class="picker">
115
- <select class="form-select" [(ngModel)]="newExtra">
116
- <option value="">— pick a country —</option>
117
- <option *ngFor="let c of unpickedExtras()" [value]="c.value">{{ c.flag }} {{ c.label }}</option>
118
- </select>
119
- <button class="btn btn-secondary btn-sm" (click)="addExtra()" [disabled]="!newExtra">+ Add</button>
137
+ </div>
138
+ </vdr-page-block>
139
+
140
+ <vdr-page-block *ngIf="strategy === 'specific'">
141
+ <div class="card">
142
+ <div class="card-block">
143
+ <h3 class="step-title">Extra allowed countries <small>(optional)</small></h3>
144
+ <p class="hint">Add countries that aren't covered by a preset above.</p>
145
+ <div class="chip-row">
146
+ <span class="chip" *ngFor="let cc of current.extraAllowed">
147
+ {{ countryLabel(cc) }}
148
+ <button class="chip-x" (click)="removeExtra(cc)" title="Remove">×</button>
149
+ </span>
150
+ <span *ngIf="!current.extraAllowed.length" class="hint inline">None yet.</span>
151
+ </div>
152
+ <div class="picker">
153
+ <input class="form-input" placeholder="Country code (e.g. JP, IL, BR)" [(ngModel)]="newExtra" (keyup.enter)="addExtra()" maxlength="2" style="text-transform: uppercase">
154
+ <button class="btn btn-secondary btn-sm" (click)="addExtra()" [disabled]="!newExtra">+ Add</button>
155
+ </div>
120
156
  </div>
121
157
  </div>
122
- </div>
123
- </vdr-page-block>
124
-
125
- <vdr-page-block *ngIf="!loading && current">
126
- <div class="card">
127
- <div class="card-block">
128
- <h3 class="step-title">{{ mode === 'specific' ? '4' : '2' }} · Block specific countries</h3>
129
- <p class="hint" *ngIf="mode === 'specific'">Countries in this list are always blocked, even if they would otherwise be allowed by a preset. Useful for sanctioned countries inside a region you allow (e.g. block 🇷🇺 Russia while allowing &ldquo;Europe&rdquo;).</p>
130
- <p class="hint" *ngIf="mode === 'worldwide'">In worldwide mode this list is the <em>only</em> filter — everyone except these countries can visit.</p>
131
-
132
- <div class="chip-row">
133
- <span class="chip blocked" *ngFor="let cc of current.blockedCountries">
134
- {{ flag(cc) }} {{ countryLabel(cc) }}
135
- <button class="chip-x" (click)="removeBlocked(cc)" title="Remove">×</button>
136
- </span>
137
- <span *ngIf="!current.blockedCountries.length" class="hint inline">None yet.</span>
158
+ </vdr-page-block>
159
+
160
+ <vdr-page-block>
161
+ <div class="card">
162
+ <div class="card-block">
163
+ <h3 class="step-title">Always-blocked countries</h3>
164
+ <p class="hint" *ngIf="strategy === 'specific'">Subtracted from the allow-list e.g. block 🇷🇺 while allowing &ldquo;Europe&rdquo;.</p>
165
+ <p class="hint" *ngIf="strategy === 'worldwide'">In worldwide mode this is the <em>only</em> filter everyone except these countries is allowed.</p>
166
+ <div class="chip-row">
167
+ <span class="chip blocked" *ngFor="let cc of current.blockedCountries">
168
+ {{ countryLabel(cc) }}
169
+ <button class="chip-x" (click)="removeBlocked(cc)" title="Remove">×</button>
170
+ </span>
171
+ <span *ngIf="!current.blockedCountries.length" class="hint inline">None yet.</span>
172
+ </div>
173
+ <div class="picker">
174
+ <input class="form-input" placeholder="Country code (e.g. RU, IR)" [(ngModel)]="newBlocked" (keyup.enter)="addBlocked()" maxlength="2" style="text-transform: uppercase">
175
+ <button class="btn btn-secondary btn-sm" (click)="addBlocked()" [disabled]="!newBlocked">+ Add</button>
176
+ <span class="hint inline" style="margin-left: 12px">Common: RU, BY, IR, KP, SY, CU, MM</span>
177
+ </div>
138
178
  </div>
139
-
140
- <div class="picker">
141
- <select class="form-select" [(ngModel)]="newBlocked">
142
- <option value="">— pick a country —</option>
143
- <option *ngFor="let c of unpickedBlocked()" [value]="c.value">{{ c.flag }} {{ c.label }}</option>
144
- </select>
145
- <button class="btn btn-secondary btn-sm" (click)="addBlocked()" [disabled]="!newBlocked">+ Add</button>
179
+ </div>
180
+ </vdr-page-block>
181
+
182
+ <vdr-page-block *ngIf="isUkResolved()">
183
+ <div class="card">
184
+ <div class="card-block">
185
+ <h3 class="step-title">UK subdivisions <small>(only applies when GB is allowed)</small></h3>
186
+ <p class="hint">Tighten to specific UK regions. Leave empty or pick all four to allow the whole UK.</p>
187
+ <div class="uk-row">
188
+ <label *ngFor="let r of ukRegions" class="uk-pill" [class.active]="current.allowedGbRegions.includes(r.value)">
189
+ <input type="checkbox" [checked]="current.allowedGbRegions.includes(r.value)" (change)="toggleUkRegion(r.value)">
190
+ {{ r.label }}
191
+ </label>
192
+ </div>
146
193
  </div>
147
194
  </div>
148
- </div>
149
- </vdr-page-block>
150
-
151
- <vdr-page-block *ngIf="!loading && current && mode === 'specific' && isUkResolved()">
152
- <div class="card">
153
- <div class="card-block">
154
- <h3 class="step-title">5 · UK subdivisions <small>(only if 🇬🇧 is allowed)</small></h3>
155
- <p class="hint">Tighten the UK further. Leave all four ticked (or all unchecked) to allow the whole of the UK.</p>
156
-
157
- <div class="uk-row">
158
- <label *ngFor="let r of ukRegions" class="uk-pill" [class.active]="current.allowedGbRegions.includes(r.value)">
159
- <input type="checkbox"
160
- [checked]="current.allowedGbRegions.includes(r.value)"
161
- (change)="toggleUkRegion(r.value)">
162
- {{ r.label }}
163
- </label>
195
+ </vdr-page-block>
196
+
197
+ <vdr-page-block>
198
+ <div class="card preview-card">
199
+ <div class="card-block">
200
+ <h3 class="step-title">Resolved allow-list</h3>
201
+ <div *ngIf="!current.enabled" class="preview-banner preview-off">
202
+ <strong>Geo-block is OFF</strong> everyone can visit.
203
+ </div>
204
+ <div *ngIf="current.enabled">
205
+ <div class="preview-banner preview-allow">
206
+ <strong *ngIf="resolvedAllowed() === null">✅ Allow visitors from anywhere</strong>
207
+ <strong *ngIf="resolvedAllowed() !== null && resolvedAllowed()!.length">
208
+ ✅ Allow visitors from {{ resolvedAllowed()!.length }} {{ resolvedAllowed()!.length === 1 ? 'country' : 'countries' }}
209
+ </strong>
210
+ <strong *ngIf="resolvedAllowed() !== null && !resolvedAllowed()!.length" class="warn">
211
+ ⚠️ Nothing is allowed — every visitor will be blocked.
212
+ </strong>
213
+ <div class="country-chips" *ngIf="resolvedAllowed() !== null && resolvedAllowed()!.length">
214
+ <span class="mini-chip" *ngFor="let cc of resolvedAllowed()!">{{ cc }}</span>
215
+ </div>
216
+ </div>
217
+ <div class="preview-banner preview-block" *ngIf="current.blockedCountries.length">
218
+ <strong>🚫 Always block</strong>
219
+ <div class="country-chips">
220
+ <span class="mini-chip blocked" *ngFor="let cc of current.blockedCountries">{{ cc }}</span>
221
+ </div>
222
+ </div>
223
+ </div>
164
224
  </div>
165
225
  </div>
166
- </div>
167
- </vdr-page-block>
226
+ </vdr-page-block>
227
+ </ng-container>
228
+
229
+ <!-- ============================================================= BLOCK PAGE TAB -->
230
+ <ng-container *ngIf="!loading && current && tab === 'message'">
231
+ <vdr-page-block>
232
+ <div class="card">
233
+ <div class="card-block">
234
+ <h3 class="step-title">Block page</h3>
235
+ <p class="hint">Customise what blocked visitors see. Leave blank for sensible defaults.</p>
236
+
237
+ <div class="form-row">
238
+ <label>Custom message <small>(optional)</small></label>
239
+ <textarea class="form-input" rows="4" [(ngModel)]="current.blockMessage" (ngModelChange)="markDirty()" placeholder="We're sorry — we don't ship to your country yet. Get in touch if you'd like to be notified when we expand."></textarea>
240
+ </div>
168
241
 
169
- <vdr-page-block *ngIf="!loading && current">
170
- <div class="card preview-card">
171
- <div class="card-block">
172
- <h3 class="step-title">Resolved allow-list <small>(what will happen when you save)</small></h3>
242
+ <div class="form-row">
243
+ <label>Redirect URL <small>(optional)</small></label>
244
+ <input class="form-input" [(ngModel)]="current.blockRedirectUrl" (ngModelChange)="markDirty()" placeholder="https://example.com/sorry">
245
+ <p class="hint">When set, blocked visitors are redirected here instead of seeing the block page.</p>
246
+ </div>
173
247
 
174
- <div *ngIf="!current.enabled" class="preview-banner preview-off">
175
- <strong>Geo-block is OFF</strong> — everyone can visit. Nothing below applies.
248
+ <div class="form-row">
249
+ <label>Logo URL <small>(optional)</small></label>
250
+ <input class="form-input" [(ngModel)]="current.blockLogoUrl" (ngModelChange)="markDirty()" placeholder="https://example.com/logo.svg">
251
+ </div>
176
252
  </div>
177
-
178
- <div *ngIf="current.enabled">
179
- <div class="preview-banner preview-allow">
180
- <strong *ngIf="resolvedAllowed() === null">
181
- Allow visitors from anywhere
182
- </strong>
183
- <strong *ngIf="resolvedAllowed() !== null && resolvedAllowed()!.length">
184
- Allow visitors from {{ resolvedAllowed()!.length }} {{ resolvedAllowed()!.length === 1 ? 'country' : 'countries' }}
185
- </strong>
186
- <strong *ngIf="resolvedAllowed() !== null && !resolvedAllowed()!.length" class="warn">
187
- ⚠️ Nothing is allowed every visitor will see the maintenance page.
188
- </strong>
189
-
190
- <div class="country-chips" *ngIf="resolvedAllowed() !== null && resolvedAllowed()!.length">
191
- <span class="mini-chip" *ngFor="let cc of resolvedAllowed()!">{{ flag(cc) }} {{ cc }}</span>
253
+ </div>
254
+ </vdr-page-block>
255
+ </ng-container>
256
+
257
+ <!-- ============================================================= IP ALLOWLIST TAB -->
258
+ <ng-container *ngIf="!loading && current && tab === 'allowlist'">
259
+ <vdr-page-block>
260
+ <div class="card">
261
+ <div class="card-block">
262
+ <h3 class="step-title">IP allowlist <small>(overrides every rule)</small></h3>
263
+ <p class="hint">IPs or IPv4 CIDR ranges that bypass all country / region rules. Use for your office, oncall engineers, payment processor probes.</p>
264
+ <div class="chip-row">
265
+ <span class="chip mono" *ngFor="let ip of current.ipAllowlist">
266
+ {{ ip }}
267
+ <button class="chip-x" (click)="removeIp(ip)" title="Remove">×</button>
268
+ </span>
269
+ <span *ngIf="!current.ipAllowlist.length" class="hint inline">No bypass IPs configured.</span>
270
+ </div>
271
+ <div class="picker">
272
+ <input class="form-input mono" placeholder="203.0.113.42 or 203.0.113.0/24" [(ngModel)]="newIp" (keyup.enter)="addIp()" style="min-width: 260px">
273
+ <button class="btn btn-secondary btn-sm" (click)="addIp()" [disabled]="!newIp">+ Add</button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </vdr-page-block>
278
+ </ng-container>
279
+
280
+ <!-- ============================================================= SIMULATE TAB -->
281
+ <ng-container *ngIf="!loading && current && tab === 'simulate'">
282
+ <vdr-page-block>
283
+ <div class="card">
284
+ <div class="card-block">
285
+ <h3 class="step-title">Simulate a visitor</h3>
286
+ <p class="hint">Test exactly what your current rules will do for a hypothetical visitor — without saving anything to the storefront.</p>
287
+ <div class="sim-grid">
288
+ <div>
289
+ <label>Country code</label>
290
+ <input class="form-input" [(ngModel)]="sim.country" placeholder="US" maxlength="2" style="text-transform: uppercase">
291
+ </div>
292
+ <div>
293
+ <label>UK region <small>(optional)</small></label>
294
+ <input class="form-input" [(ngModel)]="sim.region" placeholder="ENG / WLS / SCT / NIR" maxlength="3" style="text-transform: uppercase">
295
+ </div>
296
+ <div>
297
+ <label>IP address <small>(optional)</small></label>
298
+ <input class="form-input" [(ngModel)]="sim.ip" placeholder="203.0.113.42">
192
299
  </div>
193
300
  </div>
301
+ <button class="btn btn-primary" (click)="runSim()" [disabled]="simBusy">
302
+ {{ simBusy ? 'Running…' : 'Run simulation' }}
303
+ </button>
194
304
 
195
- <div class="preview-banner preview-block" *ngIf="current.blockedCountries.length">
196
- <strong>🚫 Block {{ current.blockedCountries.length }} {{ current.blockedCountries.length === 1 ? 'country' : 'countries' }}</strong>
197
- <div class="country-chips">
198
- <span class="mini-chip blocked" *ngFor="let cc of current.blockedCountries">{{ flag(cc) }} {{ cc }}</span>
305
+ <div class="sim-result" *ngIf="simResult">
306
+ <div *ngIf="simResult.ipMatchesAllowlist" class="sim-banner allow">
307
+ <strong>✅ Allowed</strong> — IP matches the allowlist, every other rule is bypassed.
308
+ </div>
309
+ <div *ngIf="!simResult.ipMatchesAllowlist && simResult.verdict.allowed" class="sim-banner allow">
310
+ <strong>✅ Allowed</strong> ({{ simResult.verdict.reason }})
311
+ </div>
312
+ <div *ngIf="!simResult.ipMatchesAllowlist && !simResult.verdict.allowed" class="sim-banner deny">
313
+ <strong>🚫 Blocked</strong> ({{ simResult.verdict.reason }})
199
314
  </div>
200
315
  </div>
316
+ </div>
317
+ </div>
318
+ </vdr-page-block>
319
+ </ng-container>
320
+
321
+ <!-- ============================================================= STATS TAB -->
322
+ <ng-container *ngIf="!loading && current && tab === 'stats'">
323
+ <vdr-page-block>
324
+ <div class="card">
325
+ <div class="card-block">
326
+ <h3 class="step-title">Block statistics <small>last {{ statsDays }} days</small></h3>
327
+
328
+ <div *ngIf="!stats" class="hint">Loading…</div>
329
+ <div *ngIf="stats">
330
+ <div class="stats-grid">
331
+ <div class="stat-card">
332
+ <div class="num">{{ stats.totals.blocked || 0 }}</div>
333
+ <div class="lbl">Full blocks</div>
334
+ </div>
335
+ <div class="stat-card">
336
+ <div class="num">{{ stats.totals.softBlocked || 0 }}</div>
337
+ <div class="lbl">Soft blocks</div>
338
+ </div>
339
+ <div class="stat-card">
340
+ <div class="num">{{ stats.totals.total || 0 }}</div>
341
+ <div class="lbl">Total events</div>
342
+ </div>
343
+ <div class="stat-card">
344
+ <div class="num">{{ stats.totals.uniqueIps || 0 }}</div>
345
+ <div class="lbl">Unique IPs</div>
346
+ </div>
347
+ </div>
201
348
 
202
- <div class="preview-banner preview-uk" *ngIf="isUkResolved() && current.allowedGbRegions.length">
203
- <strong>🏴 Within the UK, allow only:</strong>
204
- <span class="mini-chip" *ngFor="let r of current.allowedGbRegions">{{ ukLabel(r) }}</span>
349
+ <h4 style="margin-top: 24px">Top blocked countries</h4>
350
+ <table class="table table-compact" *ngIf="stats.topCountries?.length">
351
+ <thead><tr><th>Country</th><th style="width: 100px">Blocked</th></tr></thead>
352
+ <tbody>
353
+ <tr *ngFor="let r of stats.topCountries">
354
+ <td>{{ r.country || '—' }}</td>
355
+ <td>{{ r.n }}</td>
356
+ </tr>
357
+ </tbody>
358
+ </table>
359
+ <p *ngIf="!stats.topCountries?.length" class="hint">No blocks recorded yet.</p>
360
+
361
+ <h4 style="margin-top: 24px">By reason</h4>
362
+ <table class="table table-compact" *ngIf="stats.reasons?.length">
363
+ <thead><tr><th>Reason</th><th style="width: 100px">Count</th></tr></thead>
364
+ <tbody>
365
+ <tr *ngFor="let r of stats.reasons">
366
+ <td>{{ r.reason }}</td>
367
+ <td>{{ r.n }}</td>
368
+ </tr>
369
+ </tbody>
370
+ </table>
205
371
  </div>
206
372
  </div>
207
373
  </div>
208
- </div>
209
- </vdr-page-block>
374
+ </vdr-page-block>
375
+ </ng-container>
210
376
 
211
- <vdr-page-block *ngIf="!loading && current">
212
- <div style="display:flex;gap:8px;align-items:center">
377
+ <!-- ============================================================= SAVE BAR -->
378
+ <vdr-page-block *ngIf="!loading && current && tab !== 'simulate' && tab !== 'stats'">
379
+ <div class="save-bar">
213
380
  <button class="btn btn-primary" (click)="save()" [disabled]="saving">
214
381
  {{ saving ? 'Saving…' : 'Save changes' }}
215
382
  </button>
@@ -220,26 +387,55 @@ interface CountryDef { value: string; label: string; flag: string; }
220
387
  `,
221
388
  styles: [`
222
389
  :host { color: var(--color-text-100, inherit); display: block; }
390
+ .subtitle { font-size: 13px; color: var(--color-component-color-300); margin: 2px 0 0; }
391
+
392
+ .top-bar { border-top: 3px solid var(--color-primary-500, #1d4ed8); }
223
393
  .chan-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
224
- .chan-row .lbl { font-size: 12px; color: var(--color-component-color-300); }
225
- .form-select {
226
- padding: 6px 10px; border-radius: 4px; min-width: 200px;
394
+ .lbl { font-size: 12px; color: var(--color-component-color-300); }
395
+ .form-select, .form-input {
396
+ padding: 6px 10px; border-radius: 4px; min-width: 180px;
227
397
  border: 1px solid var(--color-component-border-200);
228
398
  background: var(--color-component-bg-100);
229
399
  color: var(--color-text-100, inherit);
230
400
  }
401
+ .form-input.mono { font-family: var(--clr-font-family-monospace, monospace); }
402
+ .filter-input { width: 100%; max-width: 360px; margin-bottom: 12px; }
403
+ textarea.form-input { font-family: inherit; min-height: 80px; width: 100%; max-width: 600px; }
404
+ .form-row { margin: 12px 0; }
405
+ .form-row label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 4px; }
406
+ .form-row label small { color: var(--color-component-color-300); font-weight: 400; margin-left: 4px; }
407
+
231
408
  .status-pill {
232
409
  display: inline-block; padding: 3px 12px; border-radius: 12px;
233
410
  font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
234
411
  }
235
412
  .status-pill.on { background: #10b981; color: #fff; }
236
413
  .status-pill.off { background: var(--color-component-bg-200); color: var(--color-component-color-300); }
414
+ .mode-pill {
415
+ display: inline-block; padding: 3px 10px; border-radius: 10px;
416
+ font-size: 11px; font-weight: 600;
417
+ }
418
+ .mode-pill.mode-block { background: #fee2e2; color: #991b1b; }
419
+ .mode-pill.mode-soft { background: #fef3c7; color: #92400e; }
420
+ .dirty-flag { color: #f59e0b; font-weight: 600; font-size: 12px; }
421
+
422
+ .tabs { display: flex; gap: 4px; margin-top: 16px; border-bottom: 1px solid var(--color-component-border-200); }
423
+ .tab {
424
+ padding: 8px 16px;
425
+ background: transparent; border: 0; border-bottom: 2px solid transparent;
426
+ font-size: 13px; font-weight: 500;
427
+ color: var(--color-component-color-300); cursor: pointer;
428
+ margin-bottom: -1px;
429
+ }
430
+ .tab:hover { color: var(--color-text-100); }
431
+ .tab.active { border-bottom-color: var(--color-primary-500, #1d4ed8); color: var(--color-text-100); font-weight: 600; }
237
432
 
238
433
  .hint { font-size: 13px; color: var(--color-component-color-300); margin: 6px 0 12px; }
239
434
  .hint.inline { display: inline; margin: 0; }
240
435
 
241
436
  .step-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
242
- .step-title small { color: var(--color-component-color-300); font-weight: 400; }
437
+ .step-title small { color: var(--color-component-color-300); font-weight: 400; margin-left: 6px; }
438
+ .group-title { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--color-component-color-300); margin: 14px 0 8px; }
243
439
 
244
440
  .mode-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
245
441
  .mode-card {
@@ -254,6 +450,7 @@ interface CountryDef { value: string; label: string; flag: string; }
254
450
  .mode-card .mode-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
255
451
  .mode-card .mode-body { font-size: 12px; color: var(--color-component-color-300); line-height: 1.5; }
256
452
 
453
+ .preset-section { margin-top: 8px; }
257
454
  .preset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
258
455
  .preset-card {
259
456
  display: block; padding: 12px;
@@ -264,7 +461,7 @@ interface CountryDef { value: string; label: string; flag: string; }
264
461
  .preset-card.active { border-color: var(--color-primary-500, #1d4ed8); background: var(--color-component-bg-200); }
265
462
  .preset-card input { float: right; }
266
463
  .preset-label { font-weight: 600; font-size: 13px; }
267
- .preset-hint { font-size: 11px; color: var(--color-component-color-300); margin-top: 4px; }
464
+ .preset-hint { font-size: 11px; color: var(--color-component-color-300); margin-top: 4px; line-height: 1.4; }
268
465
 
269
466
  .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; min-height: 30px; }
270
467
  .chip {
@@ -274,6 +471,7 @@ interface CountryDef { value: string; label: string; flag: string; }
274
471
  border: 1px solid #93c5fd;
275
472
  }
276
473
  .chip.blocked { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
474
+ .chip.mono { font-family: var(--clr-font-family-monospace, monospace); background: var(--color-component-bg-200); color: var(--color-text-100); border-color: var(--color-component-border-200); }
277
475
  .chip-x {
278
476
  background: transparent; border: none; cursor: pointer; padding: 0 0 0 2px;
279
477
  color: inherit; font-size: 16px; line-height: 1;
@@ -304,6 +502,24 @@ interface CountryDef { value: string; label: string; flag: string; }
304
502
  font-family: var(--clr-font-family-monospace, monospace);
305
503
  }
306
504
  .mini-chip.blocked { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
505
+
506
+ .sim-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 12px 0 16px; max-width: 700px; }
507
+ .sim-result { margin-top: 16px; }
508
+ .sim-banner { padding: 12px 16px; border-radius: 6px; font-size: 14px; }
509
+ .sim-banner.allow { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
510
+ .sim-banner.deny { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
511
+
512
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
513
+ .stat-card {
514
+ padding: 14px 18px;
515
+ border: 1px solid var(--color-component-border-200);
516
+ border-radius: 6px;
517
+ background: var(--color-component-bg-100);
518
+ }
519
+ .stat-card .num { font-size: 24px; font-weight: 700; line-height: 1.2; color: var(--color-primary-500, #1d4ed8); }
520
+ .stat-card .lbl { font-size: 11px; color: var(--color-component-color-300); margin-top: 2px; }
521
+
522
+ .save-bar { display: flex; gap: 8px; align-items: center; padding: 12px; background: var(--color-component-bg-100); border: 1px solid var(--color-component-border-200); border-radius: 6px; }
307
523
  `],
308
524
  })
309
525
  export class GeoBlockComponent implements OnInit {
@@ -312,61 +528,30 @@ export class GeoBlockComponent implements OnInit {
312
528
  channels: ChannelRow[] = [];
313
529
  currentToken = '';
314
530
  current: ChannelRow | null = null;
315
- /** UI mode derived from underlying fields. */
316
- mode: 'specific' | 'worldwide' = 'specific';
317
531
  dirty = false;
318
- newExtra = '';
319
- newBlocked = '';
320
532
 
321
- /** Canonical region definitions must match values in the channel
322
- * customField options so existing rows round-trip cleanly. */
323
- regionDefs: RegionDef[] = [
324
- { value: 'UK_ONLY', label: '🇬🇧 United Kingdom only', hint: 'Just GB.', countries: 1 },
325
- { value: 'BRITISH_ISLES', label: '🏴 British Isles', hint: 'UK, Ireland, Isle of Man, Channel Islands, Faroes.', countries: 6 },
326
- { value: 'EU', label: '🇪🇺 European Union (27)', hint: 'All current EU member states.', countries: 27 },
327
- { value: 'EEA', label: '🇪🇺 EEA', hint: 'EU + Iceland, Liechtenstein, Norway.', countries: 30 },
328
- { value: 'EUROPE', label: '🌍 All of Europe', hint: 'Whole continent incl. UK, RU, UA, micro-states.', countries: 51 },
329
- { value: 'NORTH_AMERICA', label: '🌎 North America', hint: 'US, Canada, Mexico.', countries: 3 },
330
- { value: 'OCEANIA', label: '🌏 Oceania', hint: 'Australia, New Zealand.', countries: 2 },
331
- ];
533
+ tab: 'rules' | 'message' | 'allowlist' | 'simulate' | 'stats' = 'rules';
534
+ strategy: 'specific' | 'worldwide' = 'specific';
535
+ presetFilter = '';
332
536
 
333
- /** Curated country list — matches the dropdown options on the
334
- * channel customField, plus a few common extras. */
335
- countryDefs: CountryDef[] = [
336
- { value: 'GB', label: 'United Kingdom', flag: '🇬🇧' },
337
- { value: 'IE', label: 'Ireland', flag: '🇮🇪' },
338
- { value: 'FR', label: 'France', flag: '🇫🇷' },
339
- { value: 'DE', label: 'Germany', flag: '🇩🇪' },
340
- { value: 'NL', label: 'Netherlands', flag: '🇳🇱' },
341
- { value: 'BE', label: 'Belgium', flag: '🇧🇪' },
342
- { value: 'LU', label: 'Luxembourg', flag: '🇱🇺' },
343
- { value: 'ES', label: 'Spain', flag: '🇪🇸' },
344
- { value: 'PT', label: 'Portugal', flag: '🇵🇹' },
345
- { value: 'IT', label: 'Italy', flag: '🇮🇹' },
346
- { value: 'AT', label: 'Austria', flag: '🇦🇹' },
347
- { value: 'CH', label: 'Switzerland', flag: '🇨🇭' },
348
- { value: 'DK', label: 'Denmark', flag: '🇩🇰' },
349
- { value: 'SE', label: 'Sweden', flag: '🇸🇪' },
350
- { value: 'NO', label: 'Norway', flag: '🇳🇴' },
351
- { value: 'FI', label: 'Finland', flag: '🇫🇮' },
352
- { value: 'IS', label: 'Iceland', flag: '🇮🇸' },
353
- { value: 'PL', label: 'Poland', flag: '🇵🇱' },
354
- { value: 'CZ', label: 'Czechia', flag: '🇨🇿' },
355
- { value: 'US', label: 'United States', flag: '🇺🇸' },
356
- { value: 'CA', label: 'Canada', flag: '🇨🇦' },
357
- { value: 'AU', label: 'Australia', flag: '🇦🇺' },
358
- { value: 'NZ', label: 'New Zealand', flag: '🇳🇿' },
359
- { value: 'JP', label: 'Japan', flag: '🇯🇵' },
360
- { value: 'IL', label: 'Israel', flag: '🇮🇱' },
361
- // Sanction-list defaults — useful in the block picker.
362
- { value: 'RU', label: 'Russia', flag: '🇷🇺' },
363
- { value: 'BY', label: 'Belarus', flag: '🇧🇾' },
364
- { value: 'UA', label: 'Ukraine', flag: '🇺🇦' },
365
- { value: 'IR', label: 'Iran', flag: '🇮🇷' },
366
- { value: 'KP', label: 'North Korea', flag: '🇰🇵' },
367
- { value: 'SY', label: 'Syria', flag: '🇸🇾' },
368
- { value: 'CU', label: 'Cuba', flag: '🇨🇺' },
369
- { value: 'CN', label: 'China', flag: '🇨🇳' },
537
+ newExtra = '';
538
+ newBlocked = '';
539
+ newIp = '';
540
+
541
+ sim = { country: '', region: '', ip: '' };
542
+ simBusy = false;
543
+ simResult: any = null;
544
+
545
+ stats: any = null;
546
+ statsDays = 30;
547
+
548
+ presets: PresetMeta[] = [];
549
+ presetGroups = [
550
+ { kind: 'all', label: 'Everywhere' },
551
+ { kind: 'geography', label: 'By geography' },
552
+ { kind: 'trade', label: 'Trade blocs' },
553
+ { kind: 'political', label: 'Political / economic groups' },
554
+ { kind: 'language', label: 'Language / cultural' },
370
555
  ];
371
556
 
372
557
  ukRegions = [
@@ -382,19 +567,32 @@ export class GeoBlockComponent implements OnInit {
382
567
  private cdr: ChangeDetectorRef,
383
568
  ) {}
384
569
 
385
- ngOnInit() { this.reload(); }
570
+ ngOnInit() {
571
+ this.http.get<{ presets: PresetMeta[] }>('/geo-block/presets').subscribe({
572
+ next: r => { this.presets = r.presets || []; this.cdr.markForCheck(); },
573
+ error: () => { /* presets are nice-to-have, not required */ },
574
+ });
575
+ this.reload();
576
+ }
386
577
 
387
578
  reload() {
388
579
  this.loading = true;
389
580
  this.dirty = false;
390
- this.http.get<{ channels: ChannelRow[] }>('/ees/geo-block/admin/channels').subscribe({
581
+ this.http.get<{ channels: ChannelRow[] }>('/geo-block/admin/channels').subscribe({
391
582
  next: (res) => {
392
- this.channels = res.channels || [];
583
+ this.channels = (res.channels || []).map(c => ({
584
+ ...c,
585
+ mode: c.mode || 'block',
586
+ ipAllowlist: c.ipAllowlist || [],
587
+ blockMessage: c.blockMessage || '',
588
+ blockRedirectUrl: c.blockRedirectUrl || '',
589
+ blockLogoUrl: c.blockLogoUrl || '',
590
+ }));
393
591
  if (!this.currentToken && this.channels.length) {
394
592
  this.currentToken = this.channels[0].token;
395
593
  }
396
594
  this.current = this.channels.find(c => c.token === this.currentToken) || null;
397
- this.deriveMode();
595
+ this.deriveStrategy();
398
596
  this.loading = false;
399
597
  this.cdr.markForCheck();
400
598
  },
@@ -408,24 +606,24 @@ export class GeoBlockComponent implements OnInit {
408
606
  onChannelChange() {
409
607
  this.current = this.channels.find(c => c.token === this.currentToken) || null;
410
608
  this.dirty = false;
411
- this.deriveMode();
609
+ this.deriveStrategy();
610
+ this.stats = null;
611
+ this.simResult = null;
412
612
  }
413
613
 
414
- private deriveMode() {
614
+ private deriveStrategy() {
415
615
  if (!this.current) return;
416
- this.mode = this.current.allowedRegions.includes('WORLDWIDE') ? 'worldwide' : 'specific';
616
+ this.strategy = this.current.allowedRegions.includes('WORLDWIDE') ? 'worldwide' : 'specific';
417
617
  }
418
618
 
419
- onModeChange() {
619
+ onStrategyChange() {
420
620
  if (!this.current) return;
421
- if (this.mode === 'worldwide') {
422
- // Replace presets with just WORLDWIDE (the resolver short-circuits).
621
+ if (this.strategy === 'worldwide') {
423
622
  this.current.allowedRegions = ['WORLDWIDE'];
424
623
  this.current.extraAllowed = [];
425
624
  } else {
426
625
  this.current.allowedRegions = this.current.allowedRegions.filter(r => r !== 'WORLDWIDE');
427
626
  if (!this.current.allowedRegions.length && !this.current.extraAllowed.length) {
428
- // Sensible default when switching back.
429
627
  this.current.allowedRegions = ['UK_ONLY'];
430
628
  }
431
629
  }
@@ -452,17 +650,22 @@ export class GeoBlockComponent implements OnInit {
452
650
  this.markDirty();
453
651
  }
454
652
 
455
- unpickedExtras(): CountryDef[] {
456
- return this.countryDefs.filter(c => !this.current?.extraAllowed.includes(c.value));
653
+ pickedRegionCount(): number {
654
+ return this.current?.allowedRegions.length || 0;
457
655
  }
458
- unpickedBlocked(): CountryDef[] {
459
- return this.countryDefs.filter(c => !this.current?.blockedCountries.includes(c.value));
656
+
657
+ filteredPresets(kind: string): PresetMeta[] {
658
+ const filter = this.presetFilter.trim().toLowerCase();
659
+ return this.presets
660
+ .filter(p => p.kind === kind)
661
+ .filter(p => !filter || p.label.toLowerCase().includes(filter) || p.description.toLowerCase().includes(filter));
460
662
  }
461
663
 
462
664
  addExtra() {
463
665
  if (!this.current || !this.newExtra) return;
464
- if (!this.current.extraAllowed.includes(this.newExtra)) {
465
- this.current.extraAllowed = [...this.current.extraAllowed, this.newExtra];
666
+ const cc = this.newExtra.trim().toUpperCase();
667
+ if (cc.length === 2 && !this.current.extraAllowed.includes(cc)) {
668
+ this.current.extraAllowed = [...this.current.extraAllowed, cc];
466
669
  this.markDirty();
467
670
  }
468
671
  this.newExtra = '';
@@ -475,8 +678,9 @@ export class GeoBlockComponent implements OnInit {
475
678
 
476
679
  addBlocked() {
477
680
  if (!this.current || !this.newBlocked) return;
478
- if (!this.current.blockedCountries.includes(this.newBlocked)) {
479
- this.current.blockedCountries = [...this.current.blockedCountries, this.newBlocked];
681
+ const cc = this.newBlocked.trim().toUpperCase();
682
+ if (cc.length === 2 && !this.current.blockedCountries.includes(cc)) {
683
+ this.current.blockedCountries = [...this.current.blockedCountries, cc];
480
684
  this.markDirty();
481
685
  }
482
686
  this.newBlocked = '';
@@ -487,6 +691,21 @@ export class GeoBlockComponent implements OnInit {
487
691
  this.markDirty();
488
692
  }
489
693
 
694
+ addIp() {
695
+ if (!this.current || !this.newIp) return;
696
+ const ip = this.newIp.trim();
697
+ if (ip && !this.current.ipAllowlist.includes(ip)) {
698
+ this.current.ipAllowlist = [...this.current.ipAllowlist, ip];
699
+ this.markDirty();
700
+ }
701
+ this.newIp = '';
702
+ }
703
+ removeIp(ip: string) {
704
+ if (!this.current) return;
705
+ this.current.ipAllowlist = this.current.ipAllowlist.filter(i => i !== ip);
706
+ this.markDirty();
707
+ }
708
+
490
709
  toggleUkRegion(r: string) {
491
710
  if (!this.current) return;
492
711
  if (this.current.allowedGbRegions.includes(r)) {
@@ -502,34 +721,17 @@ export class GeoBlockComponent implements OnInit {
502
721
  return allowed === null || allowed.includes('GB');
503
722
  }
504
723
 
505
- flag(cc: string): string {
506
- return this.countryDefs.find(c => c.value === cc)?.flag || cc;
507
- }
508
- countryLabel(cc: string): string {
509
- return this.countryDefs.find(c => c.value === cc)?.label || cc;
510
- }
511
- ukLabel(r: string): string {
512
- return this.ukRegions.find(u => u.value === r)?.label || r;
513
- }
724
+ countryLabel(cc: string): string { return cc; }
514
725
 
515
- /** Re-compute the resolved allow-list locally so the preview matches
516
- * exactly what the backend resolver will produce on save. */
726
+ /** Local preview — uses the server-resolved allowed list when no
727
+ * rule changes are pending. Best-effort otherwise. */
517
728
  resolvedAllowed(): string[] | null {
518
729
  if (!this.current) return [];
519
- const regions = this.current.allowedRegions.map(r => r.toUpperCase());
520
- if (regions.includes('WORLDWIDE')) return null;
521
- const RM = REGION_TO_COUNTRIES;
522
- const set = new Set<string>();
523
- for (const r of regions) {
524
- const cs = RM[r as keyof typeof RM];
525
- if (cs) for (const c of cs) set.add(c);
526
- }
527
- for (const c of this.current.extraAllowed) set.add(c.toUpperCase());
528
- for (const c of this.current.blockedCountries) set.delete(c.toUpperCase());
529
- return Array.from(set).sort();
730
+ if (this.current.allowedRegions.includes('WORLDWIDE')) return null;
731
+ return this.current.resolved?.allowedCountries ?? null;
530
732
  }
531
733
 
532
- private markDirty() { this.dirty = true; }
734
+ markDirty() { this.dirty = true; }
533
735
 
534
736
  save() {
535
737
  if (!this.current) return;
@@ -537,16 +739,22 @@ export class GeoBlockComponent implements OnInit {
537
739
  const body = {
538
740
  token: this.current.token,
539
741
  enabled: this.current.enabled,
742
+ mode: this.current.mode,
540
743
  allowedRegions: this.current.allowedRegions,
541
744
  extraAllowed: this.current.extraAllowed,
542
745
  blockedCountries: this.current.blockedCountries,
543
746
  allowedGbRegions: this.current.allowedGbRegions,
747
+ ipAllowlist: this.current.ipAllowlist,
748
+ blockMessage: this.current.blockMessage,
749
+ blockRedirectUrl: this.current.blockRedirectUrl,
750
+ blockLogoUrl: this.current.blockLogoUrl,
544
751
  };
545
- this.http.post<any>('/ees/geo-block/admin/save', body).subscribe({
752
+ this.http.post<any>('/geo-block/admin/save', body).subscribe({
546
753
  next: () => {
547
754
  this.saving = false;
548
755
  this.dirty = false;
549
- this.notify.success('Geo-block settings saved');
756
+ this.notify.success('Site access settings saved');
757
+ this.reload();
550
758
  },
551
759
  error: (err) => {
552
760
  this.saving = false;
@@ -554,20 +762,28 @@ export class GeoBlockComponent implements OnInit {
554
762
  },
555
763
  });
556
764
  }
557
- }
558
765
 
559
- // Mirrors src/plugins/ees/geo-regions.ts so the live preview matches the
560
- // backend resolver exactly. Kept in-file to avoid an admin-UI import of
561
- // backend code.
562
- const EU_27 = ['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES','SE'];
563
- const EEA_EXTRA = ['IS','LI','NO'];
564
- const NON_EU_EUROPE = ['GB','CH','NO','IS','LI','AL','AD','BA','BY','FO','GI','IM','JE','GG','MC','ME','MD','MK','RS','SM','UA','VA','XK','RU'];
565
- const REGION_TO_COUNTRIES: Record<string, string[]> = {
566
- EUROPE: Array.from(new Set([...EU_27, ...NON_EU_EUROPE])),
567
- EU: EU_27,
568
- EEA: Array.from(new Set([...EU_27, ...EEA_EXTRA])),
569
- BRITISH_ISLES: ['GB','IE','IM','JE','GG','FO'],
570
- UK_ONLY: ['GB'],
571
- NORTH_AMERICA: ['US','CA','MX'],
572
- OCEANIA: ['AU','NZ'],
573
- };
766
+ runSim() {
767
+ if (!this.current) return;
768
+ this.simBusy = true;
769
+ this.simResult = null;
770
+ this.http.post<any>('/geo-block/admin/simulate', {
771
+ token: this.current.token,
772
+ country: this.sim.country.trim().toUpperCase() || null,
773
+ region: this.sim.region.trim().toUpperCase() || null,
774
+ ip: this.sim.ip.trim() || null,
775
+ }).subscribe({
776
+ next: r => { this.simResult = r; this.simBusy = false; this.cdr.markForCheck(); },
777
+ error: () => { this.simBusy = false; this.notify.error('Simulation failed'); },
778
+ });
779
+ }
780
+
781
+ loadStats() {
782
+ if (!this.current) return;
783
+ if (this.stats) return; // load once on first visit
784
+ this.http.get<any>(`/geo-block/admin/stats?days=${this.statsDays}&channelId=${this.current.id}`).subscribe({
785
+ next: s => { this.stats = s; this.cdr.markForCheck(); },
786
+ error: () => this.notify.error('Failed to load stats'),
787
+ });
788
+ }
789
+ }