@huloglobal/vendure-plugin-geo-block 0.1.0 โ†’ 0.2.1

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,22 @@ 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="updateBanner">
43
+ <div class="update-banner" [class.major]="updateBanner.isMajor">
44
+ <div>
45
+ <strong>๐Ÿ“ฆ Update available</strong>
46
+ {{ updateBanner.packageName }} {{ updateBanner.current }} โ†’ <strong>{{ updateBanner.latest }}</strong>
47
+ <span *ngIf="updateBanner.isMajor" class="major-pill">major</span>
48
+ </div>
49
+ <div class="actions">
50
+ <a [href]="'https://github.com/exceeded/vendure-plugin-geo-block/releases/tag/v' + updateBanner.latest" target="_blank" class="btn btn-sm btn-link">Release notes โ†—</a>
51
+ <button class="btn btn-sm" (click)="dismissUpdate()">Dismiss</button>
52
+ </div>
53
+ </div>
54
+ </vdr-page-block>
55
+
56
+ <vdr-page-block *ngIf="!loading && current">
57
+ <div class="card top-bar">
37
58
  <div class="card-block">
38
59
  <div class="chan-row">
39
60
  <label class="lbl">Channel</label>
@@ -44,172 +65,332 @@ interface CountryDef { value: string; label: string; flag: string; }
44
65
  <span class="status-pill" [class.on]="current.enabled" [class.off]="!current.enabled">
45
66
  {{ current.enabled ? 'GEO-BLOCK ON' : 'GEO-BLOCK OFF' }}
46
67
  </span>
47
- <button class="btn btn-sm btn-link" (click)="toggleEnabled()">
68
+ <button class="btn btn-sm" [class.btn-warning]="current.enabled" [class.btn-primary]="!current.enabled" (click)="toggleEnabled()">
48
69
  {{ current.enabled ? 'Turn off' : 'Turn on' }}
49
70
  </button>
71
+
72
+ <span class="mode-pill" *ngIf="current.enabled" [class.mode-block]="current.mode === 'block'" [class.mode-soft]="current.mode === 'soft'">
73
+ {{ current.mode === 'soft' ? 'Soft block (banner)' : 'Full block' }}
74
+ </span>
75
+
76
+ <span class="dirty-flag" *ngIf="dirty">โ— Unsaved</span>
50
77
  </div>
51
78
 
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>
79
+ <div class="tabs">
80
+ <button class="tab" [class.active]="tab === 'rules'" (click)="tab = 'rules'">Rules</button>
81
+ <button class="tab" [class.active]="tab === 'message'" (click)="tab = 'message'">Block page</button>
82
+ <button class="tab" [class.active]="tab === 'allowlist'" (click)="tab = 'allowlist'">IP allowlist</button>
83
+ <button class="tab" [class.active]="tab === 'simulate'" (click)="tab = 'simulate'">Simulate</button>
84
+ <button class="tab" [class.active]="tab === 'stats'" (click)="tab = 'stats'; loadStats()">Stats</button>
85
+ </div>
56
86
  </div>
57
87
  </div>
58
88
  </vdr-page-block>
59
89
 
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>
90
+ <!-- ============================================================= RULES TAB -->
91
+ <ng-container *ngIf="!loading && current && tab === 'rules'">
92
+ <vdr-page-block>
93
+ <div class="card">
94
+ <div class="card-block">
95
+ <h3 class="step-title">Mode</h3>
96
+ <div class="mode-grid">
97
+ <label class="mode-card" [class.active]="current.mode === 'block'">
98
+ <input type="radio" name="bmode" value="block" [(ngModel)]="current.mode" (ngModelChange)="markDirty()">
99
+ <div class="mode-title">Full block</div>
100
+ <div class="mode-body">Blocked visitors never see the storefront โ€” they get the block page (or are redirected).</div>
101
+ </label>
102
+ <label class="mode-card" [class.active]="current.mode === 'soft'">
103
+ <input type="radio" name="bmode" value="soft" [(ngModel)]="current.mode" (ngModelChange)="markDirty()">
104
+ <div class="mode-title">Soft block (browse-only)</div>
105
+ <div class="mode-body">Visitors can browse but a banner explains you don't ship to their country and checkout is hidden.</div>
106
+ </label>
107
+ </div>
78
108
  </div>
79
109
  </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>
110
+ </vdr-page-block>
111
+
112
+ <vdr-page-block>
113
+ <div class="card">
114
+ <div class="card-block">
115
+ <h3 class="step-title">Strategy</h3>
116
+ <div class="mode-grid">
117
+ <label class="mode-card" [class.active]="strategy === 'specific'">
118
+ <input type="radio" name="strat" value="specific" [(ngModel)]="strategy" (ngModelChange)="onStrategyChange()">
119
+ <div class="mode-title">Allow only specific places</div>
120
+ <div class="mode-body">Pick regions or individual countries โ€” everyone else is blocked.</div>
121
+ </label>
122
+ <label class="mode-card" [class.active]="strategy === 'worldwide'">
123
+ <input type="radio" name="strat" value="worldwide" [(ngModel)]="strategy" (ngModelChange)="onStrategyChange()">
124
+ <div class="mode-title">Worldwide except blocked</div>
125
+ <div class="mode-body">Allow everyone except the denylist below.</div>
126
+ </label>
127
+ </div>
95
128
  </div>
96
129
  </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>
130
+ </vdr-page-block>
131
+
132
+ <vdr-page-block *ngIf="strategy === 'specific'">
133
+ <div class="card">
134
+ <div class="card-block">
135
+ <h3 class="step-title">Allowed regions <small>({{ pickedRegionCount() }} picked)</small></h3>
136
+ <p class="hint">One-click presets. Tick as many as you want โ€” they stack.</p>
137
+
138
+ <input class="form-input filter-input" placeholder="Filter presetsโ€ฆ" [(ngModel)]="presetFilter">
139
+
140
+ <div class="preset-section" *ngFor="let group of presetGroups">
141
+ <h4 class="group-title">{{ group.label }}</h4>
142
+ <div class="preset-grid">
143
+ <label *ngFor="let p of filteredPresets(group.kind)" class="preset-card" [class.active]="isRegionPicked(p.key)">
144
+ <input type="checkbox" [checked]="isRegionPicked(p.key)" (change)="toggleRegion(p.key)">
145
+ <div class="preset-label">{{ p.label }}</div>
146
+ <div class="preset-hint">{{ p.description }}<span *ngIf="p.countryCount"> ยท {{ p.countryCount }} countries</span></div>
147
+ </label>
148
+ </div>
149
+ </div>
112
150
  </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>
151
+ </div>
152
+ </vdr-page-block>
153
+
154
+ <vdr-page-block *ngIf="strategy === 'specific'">
155
+ <div class="card">
156
+ <div class="card-block">
157
+ <h3 class="step-title">Extra allowed countries <small>(optional)</small></h3>
158
+ <p class="hint">Add countries that aren't covered by a preset above.</p>
159
+ <div class="chip-row">
160
+ <span class="chip" *ngFor="let cc of current.extraAllowed">
161
+ {{ countryLabel(cc) }}
162
+ <button class="chip-x" (click)="removeExtra(cc)" title="Remove">ร—</button>
163
+ </span>
164
+ <span *ngIf="!current.extraAllowed.length" class="hint inline">None yet.</span>
165
+ </div>
166
+ <div class="picker">
167
+ <input class="form-input" placeholder="Country code (e.g. JP, IL, BR)" [(ngModel)]="newExtra" (keyup.enter)="addExtra()" maxlength="2" style="text-transform: uppercase">
168
+ <button class="btn btn-secondary btn-sm" (click)="addExtra()" [disabled]="!newExtra">+ Add</button>
169
+ </div>
120
170
  </div>
121
171
  </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>
172
+ </vdr-page-block>
173
+
174
+ <vdr-page-block>
175
+ <div class="card">
176
+ <div class="card-block">
177
+ <h3 class="step-title">Always-blocked countries</h3>
178
+ <p class="hint" *ngIf="strategy === 'specific'">Subtracted from the allow-list โ€” e.g. block ๐Ÿ‡ท๐Ÿ‡บ while allowing &ldquo;Europe&rdquo;.</p>
179
+ <p class="hint" *ngIf="strategy === 'worldwide'">In worldwide mode this is the <em>only</em> filter โ€” everyone except these countries is allowed.</p>
180
+ <div class="chip-row">
181
+ <span class="chip blocked" *ngFor="let cc of current.blockedCountries">
182
+ {{ countryLabel(cc) }}
183
+ <button class="chip-x" (click)="removeBlocked(cc)" title="Remove">ร—</button>
184
+ </span>
185
+ <span *ngIf="!current.blockedCountries.length" class="hint inline">None yet.</span>
186
+ </div>
187
+ <div class="picker">
188
+ <input class="form-input" placeholder="Country code (e.g. RU, IR)" [(ngModel)]="newBlocked" (keyup.enter)="addBlocked()" maxlength="2" style="text-transform: uppercase">
189
+ <button class="btn btn-secondary btn-sm" (click)="addBlocked()" [disabled]="!newBlocked">+ Add</button>
190
+ <span class="hint inline" style="margin-left: 12px">Common: RU, BY, IR, KP, SY, CU, MM</span>
191
+ </div>
138
192
  </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>
193
+ </div>
194
+ </vdr-page-block>
195
+
196
+ <vdr-page-block *ngIf="isUkResolved()">
197
+ <div class="card">
198
+ <div class="card-block">
199
+ <h3 class="step-title">UK subdivisions <small>(only applies when GB is allowed)</small></h3>
200
+ <p class="hint">Tighten to specific UK regions. Leave empty or pick all four to allow the whole UK.</p>
201
+ <div class="uk-row">
202
+ <label *ngFor="let r of ukRegions" class="uk-pill" [class.active]="current.allowedGbRegions.includes(r.value)">
203
+ <input type="checkbox" [checked]="current.allowedGbRegions.includes(r.value)" (change)="toggleUkRegion(r.value)">
204
+ {{ r.label }}
205
+ </label>
206
+ </div>
146
207
  </div>
147
208
  </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>
209
+ </vdr-page-block>
210
+
211
+ <vdr-page-block>
212
+ <div class="card preview-card">
213
+ <div class="card-block">
214
+ <h3 class="step-title">Resolved allow-list</h3>
215
+ <div *ngIf="!current.enabled" class="preview-banner preview-off">
216
+ <strong>Geo-block is OFF</strong> โ€” everyone can visit.
217
+ </div>
218
+ <div *ngIf="current.enabled">
219
+ <div class="preview-banner preview-allow">
220
+ <strong *ngIf="resolvedAllowed() === null">โœ… Allow visitors from anywhere</strong>
221
+ <strong *ngIf="resolvedAllowed() !== null && resolvedAllowed()!.length">
222
+ โœ… Allow visitors from {{ resolvedAllowed()!.length }} {{ resolvedAllowed()!.length === 1 ? 'country' : 'countries' }}
223
+ </strong>
224
+ <strong *ngIf="resolvedAllowed() !== null && !resolvedAllowed()!.length" class="warn">
225
+ โš ๏ธ Nothing is allowed โ€” every visitor will be blocked.
226
+ </strong>
227
+ <div class="country-chips" *ngIf="resolvedAllowed() !== null && resolvedAllowed()!.length">
228
+ <span class="mini-chip" *ngFor="let cc of resolvedAllowed()!">{{ cc }}</span>
229
+ </div>
230
+ </div>
231
+ <div class="preview-banner preview-block" *ngIf="current.blockedCountries.length">
232
+ <strong>๐Ÿšซ Always block</strong>
233
+ <div class="country-chips">
234
+ <span class="mini-chip blocked" *ngFor="let cc of current.blockedCountries">{{ cc }}</span>
235
+ </div>
236
+ </div>
237
+ </div>
164
238
  </div>
165
239
  </div>
166
- </div>
167
- </vdr-page-block>
240
+ </vdr-page-block>
241
+ </ng-container>
242
+
243
+ <!-- ============================================================= BLOCK PAGE TAB -->
244
+ <ng-container *ngIf="!loading && current && tab === 'message'">
245
+ <vdr-page-block>
246
+ <div class="card">
247
+ <div class="card-block">
248
+ <h3 class="step-title">Block page</h3>
249
+ <p class="hint">Customise what blocked visitors see. Leave blank for sensible defaults.</p>
250
+
251
+ <div class="form-row">
252
+ <label>Custom message <small>(optional)</small></label>
253
+ <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>
254
+ </div>
168
255
 
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>
256
+ <div class="form-row">
257
+ <label>Redirect URL <small>(optional)</small></label>
258
+ <input class="form-input" [(ngModel)]="current.blockRedirectUrl" (ngModelChange)="markDirty()" placeholder="https://example.com/sorry">
259
+ <p class="hint">When set, blocked visitors are redirected here instead of seeing the block page.</p>
260
+ </div>
173
261
 
174
- <div *ngIf="!current.enabled" class="preview-banner preview-off">
175
- <strong>Geo-block is OFF</strong> โ€” everyone can visit. Nothing below applies.
262
+ <div class="form-row">
263
+ <label>Logo URL <small>(optional)</small></label>
264
+ <input class="form-input" [(ngModel)]="current.blockLogoUrl" (ngModelChange)="markDirty()" placeholder="https://example.com/logo.svg">
265
+ </div>
176
266
  </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>
267
+ </div>
268
+ </vdr-page-block>
269
+ </ng-container>
270
+
271
+ <!-- ============================================================= IP ALLOWLIST TAB -->
272
+ <ng-container *ngIf="!loading && current && tab === 'allowlist'">
273
+ <vdr-page-block>
274
+ <div class="card">
275
+ <div class="card-block">
276
+ <h3 class="step-title">IP allowlist <small>(overrides every rule)</small></h3>
277
+ <p class="hint">IPs or IPv4 CIDR ranges that bypass all country / region rules. Use for your office, oncall engineers, payment processor probes.</p>
278
+ <div class="chip-row">
279
+ <span class="chip mono" *ngFor="let ip of current.ipAllowlist">
280
+ {{ ip }}
281
+ <button class="chip-x" (click)="removeIp(ip)" title="Remove">ร—</button>
282
+ </span>
283
+ <span *ngIf="!current.ipAllowlist.length" class="hint inline">No bypass IPs configured.</span>
284
+ </div>
285
+ <div class="picker">
286
+ <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">
287
+ <button class="btn btn-secondary btn-sm" (click)="addIp()" [disabled]="!newIp">+ Add</button>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ </vdr-page-block>
292
+ </ng-container>
293
+
294
+ <!-- ============================================================= SIMULATE TAB -->
295
+ <ng-container *ngIf="!loading && current && tab === 'simulate'">
296
+ <vdr-page-block>
297
+ <div class="card">
298
+ <div class="card-block">
299
+ <h3 class="step-title">Simulate a visitor</h3>
300
+ <p class="hint">Test exactly what your current rules will do for a hypothetical visitor โ€” without saving anything to the storefront.</p>
301
+ <div class="sim-grid">
302
+ <div>
303
+ <label>Country code</label>
304
+ <input class="form-input" [(ngModel)]="sim.country" placeholder="US" maxlength="2" style="text-transform: uppercase">
305
+ </div>
306
+ <div>
307
+ <label>UK region <small>(optional)</small></label>
308
+ <input class="form-input" [(ngModel)]="sim.region" placeholder="ENG / WLS / SCT / NIR" maxlength="3" style="text-transform: uppercase">
309
+ </div>
310
+ <div>
311
+ <label>IP address <small>(optional)</small></label>
312
+ <input class="form-input" [(ngModel)]="sim.ip" placeholder="203.0.113.42">
192
313
  </div>
193
314
  </div>
315
+ <button class="btn btn-primary" (click)="runSim()" [disabled]="simBusy">
316
+ {{ simBusy ? 'Runningโ€ฆ' : 'Run simulation' }}
317
+ </button>
194
318
 
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>
319
+ <div class="sim-result" *ngIf="simResult">
320
+ <div *ngIf="simResult.ipMatchesAllowlist" class="sim-banner allow">
321
+ <strong>โœ… Allowed</strong> โ€” IP matches the allowlist, every other rule is bypassed.
322
+ </div>
323
+ <div *ngIf="!simResult.ipMatchesAllowlist && simResult.verdict.allowed" class="sim-banner allow">
324
+ <strong>โœ… Allowed</strong> ({{ simResult.verdict.reason }})
325
+ </div>
326
+ <div *ngIf="!simResult.ipMatchesAllowlist && !simResult.verdict.allowed" class="sim-banner deny">
327
+ <strong>๐Ÿšซ Blocked</strong> ({{ simResult.verdict.reason }})
199
328
  </div>
200
329
  </div>
330
+ </div>
331
+ </div>
332
+ </vdr-page-block>
333
+ </ng-container>
334
+
335
+ <!-- ============================================================= STATS TAB -->
336
+ <ng-container *ngIf="!loading && current && tab === 'stats'">
337
+ <vdr-page-block>
338
+ <div class="card">
339
+ <div class="card-block">
340
+ <h3 class="step-title">Block statistics <small>last {{ statsDays }} days</small></h3>
341
+
342
+ <div *ngIf="!stats" class="hint">Loadingโ€ฆ</div>
343
+ <div *ngIf="stats">
344
+ <div class="stats-grid">
345
+ <div class="stat-card">
346
+ <div class="num">{{ stats.totals.blocked || 0 }}</div>
347
+ <div class="lbl">Full blocks</div>
348
+ </div>
349
+ <div class="stat-card">
350
+ <div class="num">{{ stats.totals.softBlocked || 0 }}</div>
351
+ <div class="lbl">Soft blocks</div>
352
+ </div>
353
+ <div class="stat-card">
354
+ <div class="num">{{ stats.totals.total || 0 }}</div>
355
+ <div class="lbl">Total events</div>
356
+ </div>
357
+ <div class="stat-card">
358
+ <div class="num">{{ stats.totals.uniqueIps || 0 }}</div>
359
+ <div class="lbl">Unique IPs</div>
360
+ </div>
361
+ </div>
201
362
 
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>
363
+ <h4 style="margin-top: 24px">Top blocked countries</h4>
364
+ <table class="table table-compact" *ngIf="stats.topCountries?.length">
365
+ <thead><tr><th>Country</th><th style="width: 100px">Blocked</th></tr></thead>
366
+ <tbody>
367
+ <tr *ngFor="let r of stats.topCountries">
368
+ <td>{{ r.country || 'โ€”' }}</td>
369
+ <td>{{ r.n }}</td>
370
+ </tr>
371
+ </tbody>
372
+ </table>
373
+ <p *ngIf="!stats.topCountries?.length" class="hint">No blocks recorded yet.</p>
374
+
375
+ <h4 style="margin-top: 24px">By reason</h4>
376
+ <table class="table table-compact" *ngIf="stats.reasons?.length">
377
+ <thead><tr><th>Reason</th><th style="width: 100px">Count</th></tr></thead>
378
+ <tbody>
379
+ <tr *ngFor="let r of stats.reasons">
380
+ <td>{{ r.reason }}</td>
381
+ <td>{{ r.n }}</td>
382
+ </tr>
383
+ </tbody>
384
+ </table>
205
385
  </div>
206
386
  </div>
207
387
  </div>
208
- </div>
209
- </vdr-page-block>
388
+ </vdr-page-block>
389
+ </ng-container>
210
390
 
211
- <vdr-page-block *ngIf="!loading && current">
212
- <div style="display:flex;gap:8px;align-items:center">
391
+ <!-- ============================================================= SAVE BAR -->
392
+ <vdr-page-block *ngIf="!loading && current && tab !== 'simulate' && tab !== 'stats'">
393
+ <div class="save-bar">
213
394
  <button class="btn btn-primary" (click)="save()" [disabled]="saving">
214
395
  {{ saving ? 'Savingโ€ฆ' : 'Save changes' }}
215
396
  </button>
@@ -220,26 +401,55 @@ interface CountryDef { value: string; label: string; flag: string; }
220
401
  `,
221
402
  styles: [`
222
403
  :host { color: var(--color-text-100, inherit); display: block; }
404
+ .subtitle { font-size: 13px; color: var(--color-component-color-300); margin: 2px 0 0; }
405
+
406
+ .top-bar { border-top: 3px solid var(--color-primary-500, #1d4ed8); }
223
407
  .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;
408
+ .lbl { font-size: 12px; color: var(--color-component-color-300); }
409
+ .form-select, .form-input {
410
+ padding: 6px 10px; border-radius: 4px; min-width: 180px;
227
411
  border: 1px solid var(--color-component-border-200);
228
412
  background: var(--color-component-bg-100);
229
413
  color: var(--color-text-100, inherit);
230
414
  }
415
+ .form-input.mono { font-family: var(--clr-font-family-monospace, monospace); }
416
+ .filter-input { width: 100%; max-width: 360px; margin-bottom: 12px; }
417
+ textarea.form-input { font-family: inherit; min-height: 80px; width: 100%; max-width: 600px; }
418
+ .form-row { margin: 12px 0; }
419
+ .form-row label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 4px; }
420
+ .form-row label small { color: var(--color-component-color-300); font-weight: 400; margin-left: 4px; }
421
+
231
422
  .status-pill {
232
423
  display: inline-block; padding: 3px 12px; border-radius: 12px;
233
424
  font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
234
425
  }
235
426
  .status-pill.on { background: #10b981; color: #fff; }
236
427
  .status-pill.off { background: var(--color-component-bg-200); color: var(--color-component-color-300); }
428
+ .mode-pill {
429
+ display: inline-block; padding: 3px 10px; border-radius: 10px;
430
+ font-size: 11px; font-weight: 600;
431
+ }
432
+ .mode-pill.mode-block { background: #fee2e2; color: #991b1b; }
433
+ .mode-pill.mode-soft { background: #fef3c7; color: #92400e; }
434
+ .dirty-flag { color: #f59e0b; font-weight: 600; font-size: 12px; }
435
+
436
+ .tabs { display: flex; gap: 4px; margin-top: 16px; border-bottom: 1px solid var(--color-component-border-200); }
437
+ .tab {
438
+ padding: 8px 16px;
439
+ background: transparent; border: 0; border-bottom: 2px solid transparent;
440
+ font-size: 13px; font-weight: 500;
441
+ color: var(--color-component-color-300); cursor: pointer;
442
+ margin-bottom: -1px;
443
+ }
444
+ .tab:hover { color: var(--color-text-100); }
445
+ .tab.active { border-bottom-color: var(--color-primary-500, #1d4ed8); color: var(--color-text-100); font-weight: 600; }
237
446
 
238
447
  .hint { font-size: 13px; color: var(--color-component-color-300); margin: 6px 0 12px; }
239
448
  .hint.inline { display: inline; margin: 0; }
240
449
 
241
450
  .step-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
242
- .step-title small { color: var(--color-component-color-300); font-weight: 400; }
451
+ .step-title small { color: var(--color-component-color-300); font-weight: 400; margin-left: 6px; }
452
+ .group-title { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--color-component-color-300); margin: 14px 0 8px; }
243
453
 
244
454
  .mode-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
245
455
  .mode-card {
@@ -254,6 +464,7 @@ interface CountryDef { value: string; label: string; flag: string; }
254
464
  .mode-card .mode-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
255
465
  .mode-card .mode-body { font-size: 12px; color: var(--color-component-color-300); line-height: 1.5; }
256
466
 
467
+ .preset-section { margin-top: 8px; }
257
468
  .preset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
258
469
  .preset-card {
259
470
  display: block; padding: 12px;
@@ -264,7 +475,7 @@ interface CountryDef { value: string; label: string; flag: string; }
264
475
  .preset-card.active { border-color: var(--color-primary-500, #1d4ed8); background: var(--color-component-bg-200); }
265
476
  .preset-card input { float: right; }
266
477
  .preset-label { font-weight: 600; font-size: 13px; }
267
- .preset-hint { font-size: 11px; color: var(--color-component-color-300); margin-top: 4px; }
478
+ .preset-hint { font-size: 11px; color: var(--color-component-color-300); margin-top: 4px; line-height: 1.4; }
268
479
 
269
480
  .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; min-height: 30px; }
270
481
  .chip {
@@ -274,6 +485,7 @@ interface CountryDef { value: string; label: string; flag: string; }
274
485
  border: 1px solid #93c5fd;
275
486
  }
276
487
  .chip.blocked { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
488
+ .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
489
  .chip-x {
278
490
  background: transparent; border: none; cursor: pointer; padding: 0 0 0 2px;
279
491
  color: inherit; font-size: 16px; line-height: 1;
@@ -304,6 +516,35 @@ interface CountryDef { value: string; label: string; flag: string; }
304
516
  font-family: var(--clr-font-family-monospace, monospace);
305
517
  }
306
518
  .mini-chip.blocked { background: #fee2e2; color: #991b1b; border-color: #fca5a5; }
519
+
520
+ .sim-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 12px 0 16px; max-width: 700px; }
521
+ .sim-result { margin-top: 16px; }
522
+ .sim-banner { padding: 12px 16px; border-radius: 6px; font-size: 14px; }
523
+ .sim-banner.allow { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
524
+ .sim-banner.deny { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
525
+
526
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
527
+ .stat-card {
528
+ padding: 14px 18px;
529
+ border: 1px solid var(--color-component-border-200);
530
+ border-radius: 6px;
531
+ background: var(--color-component-bg-100);
532
+ }
533
+ .stat-card .num { font-size: 24px; font-weight: 700; line-height: 1.2; color: var(--color-primary-500, #1d4ed8); }
534
+ .stat-card .lbl { font-size: 11px; color: var(--color-component-color-300); margin-top: 2px; }
535
+
536
+ .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; }
537
+
538
+ .update-banner {
539
+ display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap;
540
+ padding: 12px 16px; border-radius: 8px;
541
+ background: #ecfeff; border: 1px solid #67e8f9;
542
+ color: #155e75; font-size: 13px;
543
+ }
544
+ .update-banner.major { background: #fef3c7; border-color: #fde68a; color: #92400e; }
545
+ .update-banner strong { font-weight: 700; }
546
+ .update-banner .major-pill { display: inline-block; margin-left: 6px; padding: 1px 8px; border-radius: 8px; background: #f59e0b; color: #fff; font-size: 10px; font-weight: 700; text-transform: uppercase; }
547
+ .update-banner .actions { display: flex; gap: 8px; align-items: center; }
307
548
  `],
308
549
  })
309
550
  export class GeoBlockComponent implements OnInit {
@@ -312,61 +553,33 @@ export class GeoBlockComponent implements OnInit {
312
553
  channels: ChannelRow[] = [];
313
554
  currentToken = '';
314
555
  current: ChannelRow | null = null;
315
- /** UI mode derived from underlying fields. */
316
- mode: 'specific' | 'worldwide' = 'specific';
317
556
  dirty = false;
557
+
558
+ tab: 'rules' | 'message' | 'allowlist' | 'simulate' | 'stats' = 'rules';
559
+ strategy: 'specific' | 'worldwide' = 'specific';
560
+ presetFilter = '';
561
+
318
562
  newExtra = '';
319
563
  newBlocked = '';
564
+ newIp = '';
320
565
 
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
- ];
566
+ sim = { country: '', region: '', ip: '' };
567
+ simBusy = false;
568
+ simResult: any = null;
569
+
570
+ stats: any = null;
571
+ statsDays = 30;
572
+
573
+ updateBanner: { packageName: string; current: string; latest: string; isMajor: boolean } | null = null;
574
+ private dismissKey = 'huloglobal-geo-block-update-dismissed';
332
575
 
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: '๐Ÿ‡จ๐Ÿ‡ณ' },
576
+ presets: PresetMeta[] = [];
577
+ presetGroups = [
578
+ { kind: 'all', label: 'Everywhere' },
579
+ { kind: 'geography', label: 'By geography' },
580
+ { kind: 'trade', label: 'Trade blocs' },
581
+ { kind: 'political', label: 'Political / economic groups' },
582
+ { kind: 'language', label: 'Language / cultural' },
370
583
  ];
371
584
 
372
585
  ukRegions = [
@@ -382,19 +595,54 @@ export class GeoBlockComponent implements OnInit {
382
595
  private cdr: ChangeDetectorRef,
383
596
  ) {}
384
597
 
385
- ngOnInit() { this.reload(); }
598
+ ngOnInit() {
599
+ this.http.get<{ presets: PresetMeta[] }>('/geo-block/presets').subscribe({
600
+ next: r => { this.presets = r.presets || []; this.cdr.markForCheck(); },
601
+ error: () => { /* presets are nice-to-have, not required */ },
602
+ });
603
+ this.loadStatus();
604
+ this.reload();
605
+ }
606
+
607
+ loadStatus() {
608
+ this.http.get<any>('/geo-block/status').subscribe({
609
+ next: (s) => {
610
+ const u = s?.update;
611
+ if (!u?.updateAvailable || !u.latest) return;
612
+ let dismissed = '';
613
+ try { dismissed = localStorage.getItem(this.dismissKey) || ''; } catch {}
614
+ if (dismissed === u.latest) return;
615
+ this.updateBanner = { packageName: u.packageName, current: u.current, latest: u.latest, isMajor: !!u.isMajor };
616
+ this.cdr.markForCheck();
617
+ },
618
+ error: () => { /* nice-to-have */ },
619
+ });
620
+ }
621
+
622
+ dismissUpdate() {
623
+ if (!this.updateBanner) return;
624
+ try { localStorage.setItem(this.dismissKey, this.updateBanner.latest); } catch {}
625
+ this.updateBanner = null;
626
+ }
386
627
 
387
628
  reload() {
388
629
  this.loading = true;
389
630
  this.dirty = false;
390
- this.http.get<{ channels: ChannelRow[] }>('/ees/geo-block/admin/channels').subscribe({
631
+ this.http.get<{ channels: ChannelRow[] }>('/geo-block/admin/channels').subscribe({
391
632
  next: (res) => {
392
- this.channels = res.channels || [];
633
+ this.channels = (res.channels || []).map(c => ({
634
+ ...c,
635
+ mode: c.mode || 'block',
636
+ ipAllowlist: c.ipAllowlist || [],
637
+ blockMessage: c.blockMessage || '',
638
+ blockRedirectUrl: c.blockRedirectUrl || '',
639
+ blockLogoUrl: c.blockLogoUrl || '',
640
+ }));
393
641
  if (!this.currentToken && this.channels.length) {
394
642
  this.currentToken = this.channels[0].token;
395
643
  }
396
644
  this.current = this.channels.find(c => c.token === this.currentToken) || null;
397
- this.deriveMode();
645
+ this.deriveStrategy();
398
646
  this.loading = false;
399
647
  this.cdr.markForCheck();
400
648
  },
@@ -408,24 +656,24 @@ export class GeoBlockComponent implements OnInit {
408
656
  onChannelChange() {
409
657
  this.current = this.channels.find(c => c.token === this.currentToken) || null;
410
658
  this.dirty = false;
411
- this.deriveMode();
659
+ this.deriveStrategy();
660
+ this.stats = null;
661
+ this.simResult = null;
412
662
  }
413
663
 
414
- private deriveMode() {
664
+ private deriveStrategy() {
415
665
  if (!this.current) return;
416
- this.mode = this.current.allowedRegions.includes('WORLDWIDE') ? 'worldwide' : 'specific';
666
+ this.strategy = this.current.allowedRegions.includes('WORLDWIDE') ? 'worldwide' : 'specific';
417
667
  }
418
668
 
419
- onModeChange() {
669
+ onStrategyChange() {
420
670
  if (!this.current) return;
421
- if (this.mode === 'worldwide') {
422
- // Replace presets with just WORLDWIDE (the resolver short-circuits).
671
+ if (this.strategy === 'worldwide') {
423
672
  this.current.allowedRegions = ['WORLDWIDE'];
424
673
  this.current.extraAllowed = [];
425
674
  } else {
426
675
  this.current.allowedRegions = this.current.allowedRegions.filter(r => r !== 'WORLDWIDE');
427
676
  if (!this.current.allowedRegions.length && !this.current.extraAllowed.length) {
428
- // Sensible default when switching back.
429
677
  this.current.allowedRegions = ['UK_ONLY'];
430
678
  }
431
679
  }
@@ -452,17 +700,22 @@ export class GeoBlockComponent implements OnInit {
452
700
  this.markDirty();
453
701
  }
454
702
 
455
- unpickedExtras(): CountryDef[] {
456
- return this.countryDefs.filter(c => !this.current?.extraAllowed.includes(c.value));
703
+ pickedRegionCount(): number {
704
+ return this.current?.allowedRegions.length || 0;
457
705
  }
458
- unpickedBlocked(): CountryDef[] {
459
- return this.countryDefs.filter(c => !this.current?.blockedCountries.includes(c.value));
706
+
707
+ filteredPresets(kind: string): PresetMeta[] {
708
+ const filter = this.presetFilter.trim().toLowerCase();
709
+ return this.presets
710
+ .filter(p => p.kind === kind)
711
+ .filter(p => !filter || p.label.toLowerCase().includes(filter) || p.description.toLowerCase().includes(filter));
460
712
  }
461
713
 
462
714
  addExtra() {
463
715
  if (!this.current || !this.newExtra) return;
464
- if (!this.current.extraAllowed.includes(this.newExtra)) {
465
- this.current.extraAllowed = [...this.current.extraAllowed, this.newExtra];
716
+ const cc = this.newExtra.trim().toUpperCase();
717
+ if (cc.length === 2 && !this.current.extraAllowed.includes(cc)) {
718
+ this.current.extraAllowed = [...this.current.extraAllowed, cc];
466
719
  this.markDirty();
467
720
  }
468
721
  this.newExtra = '';
@@ -475,8 +728,9 @@ export class GeoBlockComponent implements OnInit {
475
728
 
476
729
  addBlocked() {
477
730
  if (!this.current || !this.newBlocked) return;
478
- if (!this.current.blockedCountries.includes(this.newBlocked)) {
479
- this.current.blockedCountries = [...this.current.blockedCountries, this.newBlocked];
731
+ const cc = this.newBlocked.trim().toUpperCase();
732
+ if (cc.length === 2 && !this.current.blockedCountries.includes(cc)) {
733
+ this.current.blockedCountries = [...this.current.blockedCountries, cc];
480
734
  this.markDirty();
481
735
  }
482
736
  this.newBlocked = '';
@@ -487,6 +741,21 @@ export class GeoBlockComponent implements OnInit {
487
741
  this.markDirty();
488
742
  }
489
743
 
744
+ addIp() {
745
+ if (!this.current || !this.newIp) return;
746
+ const ip = this.newIp.trim();
747
+ if (ip && !this.current.ipAllowlist.includes(ip)) {
748
+ this.current.ipAllowlist = [...this.current.ipAllowlist, ip];
749
+ this.markDirty();
750
+ }
751
+ this.newIp = '';
752
+ }
753
+ removeIp(ip: string) {
754
+ if (!this.current) return;
755
+ this.current.ipAllowlist = this.current.ipAllowlist.filter(i => i !== ip);
756
+ this.markDirty();
757
+ }
758
+
490
759
  toggleUkRegion(r: string) {
491
760
  if (!this.current) return;
492
761
  if (this.current.allowedGbRegions.includes(r)) {
@@ -502,34 +771,17 @@ export class GeoBlockComponent implements OnInit {
502
771
  return allowed === null || allowed.includes('GB');
503
772
  }
504
773
 
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
- }
774
+ countryLabel(cc: string): string { return cc; }
514
775
 
515
- /** Re-compute the resolved allow-list locally so the preview matches
516
- * exactly what the backend resolver will produce on save. */
776
+ /** Local preview โ€” uses the server-resolved allowed list when no
777
+ * rule changes are pending. Best-effort otherwise. */
517
778
  resolvedAllowed(): string[] | null {
518
779
  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();
780
+ if (this.current.allowedRegions.includes('WORLDWIDE')) return null;
781
+ return this.current.resolved?.allowedCountries ?? null;
530
782
  }
531
783
 
532
- private markDirty() { this.dirty = true; }
784
+ markDirty() { this.dirty = true; }
533
785
 
534
786
  save() {
535
787
  if (!this.current) return;
@@ -537,16 +789,22 @@ export class GeoBlockComponent implements OnInit {
537
789
  const body = {
538
790
  token: this.current.token,
539
791
  enabled: this.current.enabled,
792
+ mode: this.current.mode,
540
793
  allowedRegions: this.current.allowedRegions,
541
794
  extraAllowed: this.current.extraAllowed,
542
795
  blockedCountries: this.current.blockedCountries,
543
796
  allowedGbRegions: this.current.allowedGbRegions,
797
+ ipAllowlist: this.current.ipAllowlist,
798
+ blockMessage: this.current.blockMessage,
799
+ blockRedirectUrl: this.current.blockRedirectUrl,
800
+ blockLogoUrl: this.current.blockLogoUrl,
544
801
  };
545
- this.http.post<any>('/ees/geo-block/admin/save', body).subscribe({
802
+ this.http.post<any>('/geo-block/admin/save', body).subscribe({
546
803
  next: () => {
547
804
  this.saving = false;
548
805
  this.dirty = false;
549
- this.notify.success('Geo-block settings saved');
806
+ this.notify.success('Site access settings saved');
807
+ this.reload();
550
808
  },
551
809
  error: (err) => {
552
810
  this.saving = false;
@@ -554,20 +812,28 @@ export class GeoBlockComponent implements OnInit {
554
812
  },
555
813
  });
556
814
  }
557
- }
558
815
 
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
- };
816
+ runSim() {
817
+ if (!this.current) return;
818
+ this.simBusy = true;
819
+ this.simResult = null;
820
+ this.http.post<any>('/geo-block/admin/simulate', {
821
+ token: this.current.token,
822
+ country: this.sim.country.trim().toUpperCase() || null,
823
+ region: this.sim.region.trim().toUpperCase() || null,
824
+ ip: this.sim.ip.trim() || null,
825
+ }).subscribe({
826
+ next: r => { this.simResult = r; this.simBusy = false; this.cdr.markForCheck(); },
827
+ error: () => { this.simBusy = false; this.notify.error('Simulation failed'); },
828
+ });
829
+ }
830
+
831
+ loadStats() {
832
+ if (!this.current) return;
833
+ if (this.stats) return; // load once on first visit
834
+ this.http.get<any>(`/geo-block/admin/stats?days=${this.statsDays}&channelId=${this.current.id}`).subscribe({
835
+ next: s => { this.stats = s; this.cdr.markForCheck(); },
836
+ error: () => this.notify.error('Failed to load stats'),
837
+ });
838
+ }
839
+ }