@zonetrix/viewer 1.0.0 → 1.1.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.
- package/README.md +364 -78
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
# @zonetrix/viewer
|
|
2
2
|
|
|
3
|
-
Lightweight React component for rendering interactive seat maps
|
|
3
|
+
Lightweight React component for rendering interactive seat maps in your booking applications, event ticketing systems, and venue management platforms.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@zonetrix/viewer` is a production-ready React component that renders beautiful, interactive seat maps with support for selection, zooming, and real-time state updates. Perfect for theaters, stadiums, cinemas, and event venues.
|
|
4
8
|
|
|
5
9
|
## Features
|
|
6
10
|
|
|
7
|
-
- 🎯 **Read-only
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
11
|
+
- 🎯 **Read-only Display** - Optimized for end-user viewing and selection
|
|
12
|
+
- 🖱️ **Interactive Selection** - Click seats to select/deselect with visual feedback
|
|
13
|
+
- 🔄 **Real-time Updates** - Support for dynamic seat state changes
|
|
14
|
+
- 🌐 **Flexible Config Loading** - Load from JSON files or API endpoints
|
|
15
|
+
- 🔍 **Mouse Wheel Zoom** - Smooth zoom with configurable limits
|
|
16
|
+
- 🎨 **Customizable Colors** - Override default colors to match your brand
|
|
17
|
+
- 📱 **Responsive** - Works seamlessly across all screen sizes
|
|
18
|
+
- ⚡ **Lightweight** - Minimal dependencies (~95KB gzipped)
|
|
19
|
+
- 🔒 **Type-safe** - Full TypeScript support
|
|
13
20
|
|
|
14
21
|
## Installation
|
|
15
22
|
|
|
@@ -21,18 +28,24 @@ yarn add @zonetrix/viewer
|
|
|
21
28
|
pnpm add @zonetrix/viewer
|
|
22
29
|
```
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
### Peer Dependencies
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install react react-dom
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
25
38
|
|
|
26
|
-
### Basic
|
|
39
|
+
### Basic Usage with JSON Config
|
|
27
40
|
|
|
28
41
|
```tsx
|
|
29
42
|
import { SeatMapViewer } from '@zonetrix/viewer';
|
|
30
|
-
import
|
|
43
|
+
import venueConfig from './venue-config.json';
|
|
31
44
|
|
|
32
|
-
function
|
|
45
|
+
function BookingApp() {
|
|
33
46
|
return (
|
|
34
47
|
<SeatMapViewer
|
|
35
|
-
config={
|
|
48
|
+
config={venueConfig}
|
|
36
49
|
onSeatSelect={(seat) => console.log('Selected:', seat)}
|
|
37
50
|
onSeatDeselect={(seat) => console.log('Deselected:', seat)}
|
|
38
51
|
/>
|
|
@@ -40,90 +53,237 @@ function App() {
|
|
|
40
53
|
}
|
|
41
54
|
```
|
|
42
55
|
|
|
43
|
-
###
|
|
56
|
+
### Load Config from API
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { SeatMapViewer } from '@zonetrix/viewer';
|
|
60
|
+
|
|
61
|
+
function BookingApp() {
|
|
62
|
+
return (
|
|
63
|
+
<SeatMapViewer
|
|
64
|
+
configUrl="https://api.example.com/venues/123/config"
|
|
65
|
+
onSeatSelect={(seat) => addToCart(seat)}
|
|
66
|
+
onSeatDeselect={(seat) => removeFromCart(seat)}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Props API
|
|
73
|
+
|
|
74
|
+
### SeatMapViewerProps
|
|
75
|
+
|
|
76
|
+
| Prop | Type | Required | Description |
|
|
77
|
+
|------|------|----------|-------------|
|
|
78
|
+
| `config` | `SeatMapConfig` | No* | Seat map configuration object |
|
|
79
|
+
| `configUrl` | `string` | No* | URL to fetch configuration from |
|
|
80
|
+
| `reservedSeats` | `string[]` | No | Array of seat IDs/numbers to mark as reserved |
|
|
81
|
+
| `unavailableSeats` | `string[]` | No | Array of seat IDs/numbers to mark as unavailable |
|
|
82
|
+
| `onSeatSelect` | `(seat: SeatData) => void` | No | Callback when a seat is selected |
|
|
83
|
+
| `onSeatDeselect` | `(seat: SeatData) => void` | No | Callback when a seat is deselected |
|
|
84
|
+
| `onSelectionChange` | `(seats: SeatData[]) => void` | No | Callback when selection changes |
|
|
85
|
+
| `colorOverrides` | `Partial<ColorSettings>` | No | Custom colors for seat states |
|
|
86
|
+
| `zoomEnabled` | `boolean` | No | Enable/disable mouse wheel zoom (default: true) |
|
|
87
|
+
| `showSelectedCount` | `boolean` | No | Show selected seats counter (default: true) |
|
|
88
|
+
|
|
89
|
+
*Note: Either `config` or `configUrl` must be provided.
|
|
90
|
+
|
|
91
|
+
## Usage Examples
|
|
92
|
+
|
|
93
|
+
### 1. Booking System with Cart
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import { useState } from 'react';
|
|
97
|
+
import { SeatMapViewer } from '@zonetrix/viewer';
|
|
98
|
+
import type { SeatData } from '@zonetrix/viewer';
|
|
99
|
+
|
|
100
|
+
function TheaterBooking() {
|
|
101
|
+
const [cart, setCart] = useState<SeatData[]>([]);
|
|
102
|
+
|
|
103
|
+
const handleSeatSelect = (seat: SeatData) => {
|
|
104
|
+
setCart((prev) => [...prev, seat]);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleSeatDeselect = (seat: SeatData) => {
|
|
108
|
+
setCart((prev) => prev.filter((s) => s.seatNumber !== seat.seatNumber));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const totalPrice = cart.reduce((sum, seat) => sum + (seat.price || 0), 0);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div>
|
|
115
|
+
<SeatMapViewer
|
|
116
|
+
configUrl="https://api.theater.com/venues/main-hall/config"
|
|
117
|
+
reservedSeats={['A-1', 'A-2', 'B-5']}
|
|
118
|
+
onSeatSelect={handleSeatSelect}
|
|
119
|
+
onSeatDeselect={handleSeatDeselect}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<div className="cart">
|
|
123
|
+
<h3>Your Selection</h3>
|
|
124
|
+
<p>Seats: {cart.map(s => s.seatNumber).join(', ')}</p>
|
|
125
|
+
<p>Total: ${totalPrice.toFixed(2)}</p>
|
|
126
|
+
<button onClick={() => checkout(cart)}>Proceed to Checkout</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 2. Custom Colors (Brand Matching)
|
|
44
134
|
|
|
45
135
|
```tsx
|
|
46
136
|
import { SeatMapViewer } from '@zonetrix/viewer';
|
|
47
137
|
|
|
48
|
-
function
|
|
138
|
+
function BrandedVenue() {
|
|
139
|
+
const customColors = {
|
|
140
|
+
seatAvailable: '#10b981', // Green
|
|
141
|
+
seatReserved: '#ef4444', // Red
|
|
142
|
+
seatSelected: '#3b82f6', // Blue
|
|
143
|
+
canvasBackground: '#ffffff', // White
|
|
144
|
+
};
|
|
145
|
+
|
|
49
146
|
return (
|
|
50
147
|
<SeatMapViewer
|
|
51
|
-
|
|
148
|
+
config={venueConfig}
|
|
149
|
+
colorOverrides={customColors}
|
|
52
150
|
onSeatSelect={(seat) => console.log('Selected:', seat)}
|
|
53
151
|
/>
|
|
54
152
|
);
|
|
55
153
|
}
|
|
56
154
|
```
|
|
57
155
|
|
|
58
|
-
###
|
|
156
|
+
### 3. Real-time Updates from API
|
|
59
157
|
|
|
60
158
|
```tsx
|
|
61
|
-
import {
|
|
159
|
+
import { useState, useEffect } from 'react';
|
|
160
|
+
import { SeatMapViewer } from '@zonetrix/viewer';
|
|
161
|
+
|
|
162
|
+
function LiveEventSeating() {
|
|
163
|
+
const [reservedSeats, setReservedSeats] = useState<string[]>([]);
|
|
164
|
+
|
|
165
|
+
// Poll API every 5 seconds for reserved seats
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const interval = setInterval(async () => {
|
|
168
|
+
const response = await fetch('/api/venue/123/reserved-seats');
|
|
169
|
+
const data = await response.json();
|
|
170
|
+
setReservedSeats(data.reserved);
|
|
171
|
+
}, 5000);
|
|
62
172
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const [unavailableSeats] = useState(['C-1']);
|
|
173
|
+
return () => clearInterval(interval);
|
|
174
|
+
}, []);
|
|
66
175
|
|
|
67
176
|
return (
|
|
68
177
|
<SeatMapViewer
|
|
69
|
-
config={
|
|
178
|
+
config={venueConfig}
|
|
70
179
|
reservedSeats={reservedSeats}
|
|
71
|
-
|
|
72
|
-
onSeatSelect={(seat) => {
|
|
73
|
-
// Handle seat selection
|
|
74
|
-
}}
|
|
180
|
+
onSeatSelect={(seat) => bookSeat(seat)}
|
|
75
181
|
/>
|
|
76
182
|
);
|
|
77
183
|
}
|
|
78
184
|
```
|
|
79
185
|
|
|
80
|
-
###
|
|
186
|
+
### 4. Tracking Selection Changes
|
|
81
187
|
|
|
82
188
|
```tsx
|
|
83
|
-
import { SeatMapViewer } from '@
|
|
189
|
+
import { SeatMapViewer } from '@zonetrix/viewer';
|
|
190
|
+
|
|
191
|
+
function SelectionTracker() {
|
|
192
|
+
const handleSelectionChange = (selectedSeats) => {
|
|
193
|
+
console.log('Current selection:', selectedSeats);
|
|
194
|
+
|
|
195
|
+
// Update analytics
|
|
196
|
+
analytics.track('Seats Selected', {
|
|
197
|
+
count: selectedSeats.length,
|
|
198
|
+
seats: selectedSeats.map(s => s.seatNumber),
|
|
199
|
+
});
|
|
200
|
+
};
|
|
84
201
|
|
|
85
|
-
function App() {
|
|
86
202
|
return (
|
|
87
203
|
<SeatMapViewer
|
|
88
|
-
config={
|
|
89
|
-
|
|
90
|
-
seatAvailable: '#00ff00',
|
|
91
|
-
seatSelected: '#0000ff',
|
|
92
|
-
seatReserved: '#ffff00',
|
|
93
|
-
seatUnavailable: '#ff0000',
|
|
94
|
-
}}
|
|
204
|
+
config={venueConfig}
|
|
205
|
+
onSelectionChange={handleSelectionChange}
|
|
95
206
|
/>
|
|
96
207
|
);
|
|
97
208
|
}
|
|
98
209
|
```
|
|
99
210
|
|
|
100
|
-
|
|
211
|
+
### 5. Disable Zoom for Mobile
|
|
101
212
|
|
|
102
|
-
|
|
213
|
+
```tsx
|
|
214
|
+
import { SeatMapViewer } from '@zonetrix/viewer';
|
|
103
215
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
216
|
+
function MobileOptimized() {
|
|
217
|
+
const isMobile = window.innerWidth < 768;
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<SeatMapViewer
|
|
221
|
+
config={venueConfig}
|
|
222
|
+
zoomEnabled={!isMobile}
|
|
223
|
+
onSeatSelect={(seat) => handleSelection(seat)}
|
|
224
|
+
/>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Configuration Format
|
|
230
|
+
|
|
231
|
+
The viewer accepts a `SeatMapConfig` object. You can create these configurations using our creator studio or build them programmatically.
|
|
232
|
+
|
|
233
|
+
### Example Configuration
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"version": "1.0.0",
|
|
238
|
+
"metadata": {
|
|
239
|
+
"name": "Main Auditorium",
|
|
240
|
+
"venue": "Grand Theater",
|
|
241
|
+
"capacity": 500,
|
|
242
|
+
"createdAt": "2025-01-01T00:00:00Z",
|
|
243
|
+
"updatedAt": "2025-01-01T00:00:00Z"
|
|
244
|
+
},
|
|
245
|
+
"canvas": {
|
|
246
|
+
"width": 1200,
|
|
247
|
+
"height": 800,
|
|
248
|
+
"backgroundColor": "#1a1a1a"
|
|
249
|
+
},
|
|
250
|
+
"colors": {
|
|
251
|
+
"canvasBackground": "#1a1a1a",
|
|
252
|
+
"stageColor": "#808080",
|
|
253
|
+
"seatAvailable": "#2C2B30",
|
|
254
|
+
"seatReserved": "#FCEA00",
|
|
255
|
+
"seatSelected": "#3A7DE5",
|
|
256
|
+
"seatUnavailable": "#6b7280",
|
|
257
|
+
"gridLines": "#404040",
|
|
258
|
+
"currency": "USD"
|
|
259
|
+
},
|
|
260
|
+
"seats": [
|
|
261
|
+
{
|
|
262
|
+
"id": "seat_001",
|
|
263
|
+
"position": { "x": 100, "y": 100 },
|
|
264
|
+
"shape": "rounded-square",
|
|
265
|
+
"state": "available",
|
|
266
|
+
"sectionName": "Orchestra",
|
|
267
|
+
"rowLabel": "A",
|
|
268
|
+
"columnLabel": "1",
|
|
269
|
+
"seatNumber": "A-1",
|
|
270
|
+
"price": 50.00
|
|
271
|
+
}
|
|
272
|
+
],
|
|
273
|
+
"sections": [],
|
|
274
|
+
"stages": []
|
|
275
|
+
}
|
|
276
|
+
```
|
|
119
277
|
|
|
120
|
-
|
|
278
|
+
## TypeScript Types
|
|
279
|
+
|
|
280
|
+
### SeatData
|
|
121
281
|
|
|
122
|
-
#### SeatData
|
|
123
282
|
```typescript
|
|
124
283
|
interface SeatData {
|
|
125
|
-
|
|
126
|
-
|
|
284
|
+
id: string;
|
|
285
|
+
state: SeatState;
|
|
286
|
+
shape?: SeatShape;
|
|
127
287
|
sectionName?: string;
|
|
128
288
|
rowLabel?: string;
|
|
129
289
|
columnLabel?: string;
|
|
@@ -132,34 +292,160 @@ interface SeatData {
|
|
|
132
292
|
}
|
|
133
293
|
```
|
|
134
294
|
|
|
135
|
-
|
|
295
|
+
### SeatState
|
|
296
|
+
|
|
136
297
|
```typescript
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
298
|
+
type SeatState = 'available' | 'reserved' | 'selected' | 'unavailable';
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### SeatShape
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
type SeatShape = 'circle' | 'square' | 'rounded-square';
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### ColorSettings
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
interface ColorSettings {
|
|
311
|
+
canvasBackground: string;
|
|
312
|
+
stageColor: string;
|
|
313
|
+
seatAvailable: string;
|
|
314
|
+
seatReserved: string;
|
|
315
|
+
seatSelected: string;
|
|
316
|
+
seatUnavailable: string;
|
|
317
|
+
gridLines: string;
|
|
318
|
+
currency: string;
|
|
156
319
|
}
|
|
157
320
|
```
|
|
158
321
|
|
|
159
|
-
##
|
|
322
|
+
## Seat States Explained
|
|
160
323
|
|
|
161
|
-
|
|
324
|
+
| State | Description | User Can Select? | Visual |
|
|
325
|
+
|-------|-------------|------------------|--------|
|
|
326
|
+
| `available` | Seat is free and can be selected | ✅ Yes | Default color |
|
|
327
|
+
| `reserved` | Seat is booked by another user | ❌ No | Yellow/Warning color |
|
|
328
|
+
| `selected` | Seat is selected by current user | ✅ Yes (to deselect) | Primary/Blue color |
|
|
329
|
+
| `unavailable` | Seat is blocked (maintenance, etc.) | ❌ No | Gray color |
|
|
330
|
+
|
|
331
|
+
## Events & Callbacks
|
|
332
|
+
|
|
333
|
+
### onSeatSelect
|
|
334
|
+
|
|
335
|
+
Called when a user selects an available seat.
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
const handleSelect = (seat: SeatData) => {
|
|
339
|
+
console.log('Seat selected:', seat.seatNumber);
|
|
340
|
+
console.log('Price:', seat.price);
|
|
341
|
+
console.log('Section:', seat.sectionName);
|
|
342
|
+
};
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### onSeatDeselect
|
|
346
|
+
|
|
347
|
+
Called when a user deselects a previously selected seat.
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
const handleDeselect = (seat: SeatData) => {
|
|
351
|
+
console.log('Seat deselected:', seat.seatNumber);
|
|
352
|
+
};
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### onSelectionChange
|
|
356
|
+
|
|
357
|
+
Called whenever the selection changes (includes all selected seats).
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
const handleSelectionChange = (selectedSeats: SeatData[]) => {
|
|
361
|
+
console.log('Total selected:', selectedSeats.length);
|
|
362
|
+
const total = selectedSeats.reduce((sum, s) => sum + (s.price || 0), 0);
|
|
363
|
+
console.log('Total price:', total);
|
|
364
|
+
};
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Styling
|
|
368
|
+
|
|
369
|
+
The component uses inline styles generated from the configuration. To customize the container, wrap it in a styled div:
|
|
370
|
+
|
|
371
|
+
```tsx
|
|
372
|
+
<div style={{ width: '100%', height: '600px', border: '1px solid #ccc' }}>
|
|
373
|
+
<SeatMapViewer config={venueConfig} />
|
|
374
|
+
</div>
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## Performance Tips
|
|
378
|
+
|
|
379
|
+
1. **Large Venues** (500+ seats): Consider splitting into sections
|
|
380
|
+
2. **API Loading**: Show a loading spinner while `configUrl` is being fetched
|
|
381
|
+
3. **Mobile**: Disable zoom on mobile devices for better UX
|
|
382
|
+
4. **Memoization**: Wrap callbacks with `useCallback` to prevent unnecessary re-renders
|
|
383
|
+
|
|
384
|
+
## Browser Support
|
|
385
|
+
|
|
386
|
+
- Chrome (latest)
|
|
387
|
+
- Firefox (latest)
|
|
388
|
+
- Safari (latest)
|
|
389
|
+
- Edge (latest)
|
|
390
|
+
|
|
391
|
+
## Common Issues
|
|
392
|
+
|
|
393
|
+
### Configuration not loading from API
|
|
394
|
+
|
|
395
|
+
Ensure your API returns valid JSON and has proper CORS headers:
|
|
396
|
+
|
|
397
|
+
```javascript
|
|
398
|
+
// Server-side (Express example)
|
|
399
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
400
|
+
res.json(seatMapConfig);
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Seats not responding to clicks
|
|
404
|
+
|
|
405
|
+
Make sure seat states are correctly set. Only `available` and `selected` seats can be clicked.
|
|
406
|
+
|
|
407
|
+
### Canvas size issues
|
|
408
|
+
|
|
409
|
+
The canvas size is determined by the `canvas.width` and `canvas.height` in your configuration. Adjust these values or wrap the component in a responsive container.
|
|
410
|
+
|
|
411
|
+
## Related Packages
|
|
412
|
+
|
|
413
|
+
- **[@zonetrix/shared](https://www.npmjs.com/package/@zonetrix/shared)** - Shared types and utilities
|
|
414
|
+
|
|
415
|
+
## Examples Repository
|
|
416
|
+
|
|
417
|
+
Check out our [examples repository](https://github.com/fahadkhan1740/seat-map-studio/tree/main/examples) for more use cases:
|
|
418
|
+
|
|
419
|
+
- Booking system integration
|
|
420
|
+
- API polling for real-time updates
|
|
421
|
+
- Custom theming
|
|
422
|
+
- Mobile-optimized layouts
|
|
423
|
+
|
|
424
|
+
## Contributing
|
|
425
|
+
|
|
426
|
+
Contributions are welcome! Please read our contributing guidelines before submitting PRs.
|
|
162
427
|
|
|
163
428
|
## License
|
|
164
429
|
|
|
165
430
|
MIT
|
|
431
|
+
|
|
432
|
+
## Author
|
|
433
|
+
|
|
434
|
+
Fahad Khan ([@fahadkhan1740](https://github.com/fahadkhan1740))
|
|
435
|
+
|
|
436
|
+
## Links
|
|
437
|
+
|
|
438
|
+
- [npm Package](https://www.npmjs.com/package/@zonetrix/viewer)
|
|
439
|
+
- [GitHub Repository](https://github.com/fahadkhan1740/seat-map-studio)
|
|
440
|
+
- [Documentation](https://github.com/fahadkhan1740/seat-map-studio#readme)
|
|
441
|
+
- [Issue Tracker](https://github.com/fahadkhan1740/seat-map-studio/issues)
|
|
442
|
+
|
|
443
|
+
## Support
|
|
444
|
+
|
|
445
|
+
For questions and support:
|
|
446
|
+
- Open an [issue on GitHub](https://github.com/fahadkhan1740/seat-map-studio/issues)
|
|
447
|
+
- Email: fahadkhan1740@outlook.com
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
**Made with ❤️ by Fahad Khan**
|