Compare commits

..

No commits in common. "main" and "1.0.0" have entirely different histories.
main ... 1.0.0

11 changed files with 118 additions and 246 deletions

View File

@ -3,10 +3,9 @@ PORT=8000
# Flask environment setting (production) # Flask environment setting (production)
FLASK_ENV=production FLASK_ENV=production
# Path to the SQLite database (this file will be stored in the mounted /data volume) # Path to the SQLite database (this file will be stored in the mounted /data volume)
DATABASE_PATH=/data/guestbook.db DATABASE_PATH=/data/scripts/guestbook.db
# Number of Gunicorn workers (adjust as needed) # Number of Gunicorn workers (adjust as needed)
GUNICORN_WORKERS=3 GUNICORN_WORKERS=3
PID=1000 PID=1000
GID=1000 GID=1000
SITE_TITLE="The Montana Dinosaur Center Visitor Log"
LOGO_URL="/static/images/logo.png"

View File

@ -1,32 +0,0 @@
name: Docker Image CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to DockerHub
if: github.event_name == 'push'
uses: docker/login-action@v2
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build the Docker image
id: build-image
run: |
IMAGE_TAG=my-image-name:${{ github.sha }}
docker build . --file Dockerfile --tag $IMAGE_TAG
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
# Uncomment below to push the image to Docker Hub (or another registry)
- name: Push the Docker image
if: github.event_name == 'push'
run: |
docker tag $IMAGE_TAG snachodog/kiosk-guestbook:latest
docker push snachodog/kiosk-guestbook:latest

2
.gitignore vendored
View File

@ -184,5 +184,3 @@ cython_debug/
# VS Code # VS Code
.vscode/ .vscode/
.env
docker-compose.yml

View File

@ -4,25 +4,18 @@ FROM python:3.9-slim
# Set the working directory # Set the working directory
WORKDIR /app WORKDIR /app
# Install system dependencies (including gettext for envsubst) # Install dependencies
RUN apt-get update && apt-get install -y gettext && rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code and template files # Copy the application code
COPY . . COPY . .
# Copy the entrypoint script into the container and make it executable
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Set environment variables (can be overridden by .env) # Set environment variables (can be overridden by .env)
ENV FLASK_ENV=production ENV FLASK_ENV=production
# Expose the port (Gunicorn will run on 8000) # Expose the port (Gunicorn will run on 8000)
EXPOSE 8000 EXPOSE 8000
# Use the entrypoint script as the container's command # Run the app with Gunicorn; use 3 workers (can be tuned via .env)
CMD ["/entrypoint.sh"] CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app", "--workers", "3"]

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Montana Dinosaur Center
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

128
README.md
View File

@ -9,16 +9,37 @@ A simple Flask-based guestbook application designed for an internal museum kiosk
- Dynamic Form Behavior: - Dynamic Form Behavior:
The comment field is hidden by default and only revealed when the first name, last name, and location fields each contain at least 3 characters. The comment field is hidden by default and only revealed when the first name, last name, and location fields each contain at least 3 characters.
-Input Validation: -Input Validation:
Ensures required fields (first name, last name, and location) are filled and validates email format (if provided). Ensures required fields (first name, last name, and location) are filled.
- Validates email format (if provided).
Uses a profanity filter loaded from en.txt to prevent inappropriate language in comments. Uses a profanity filter loaded from en.txt to prevent inappropriate language in comments.
- Logging: - Logging:
Logs key events and validation errors for debugging and monitoring. Logs key events and validation errors to help with debugging and monitoring.
- SQLite Database: - SQLite Database:
Stores guest entries locally, with persistence ensured by mounting a Docker volume. Stores guest entries locally, with persistence ensured by mounting a Docker volume.
- Containerized Deployment: - Containerized Deployment:
Uses Docker and Docker Compose for a production-ready environment with Gunicorn as the WSGI server. Uses Docker and Docker Compose to create a production-ready environment with Gunicorn as the WSGI server.
- Configurable Template:
The applications title and logo can be dynamically configured via environment variables without rebuilding the image. ## Project Structure
``` bash
kiosk-guestbook/
├── scripts/
│ ├── guestbook_export.py # Script to export guest entries (e.g., for Mailchimp)
│ └── guestbook.db # SQLite database file (if stored here, mainly for development)
├── static/
│ └── images/
│ └── logo.png # Logo for display in the application
├── templates/
│ └── index.html # Main HTML template for the guestbook
├── .env # Environment variables for Docker Compose (production settings)
├── app.py # Main Flask application code
├── docker-compose.yml # Docker Compose configuration for container orchestration
├── Dockerfile # Default Dockerfile (development or general usage)
├── en.txt # Profanity list file (one banned word per line)
├── production.Dockerfile # Optional Dockerfile optimized for production
├── README.md # Project documentation
└── requirements.txt # Python dependencies (Flask, Gunicorn, etc.)
```
## Getting Started ## Getting Started
@ -26,95 +47,40 @@ The applications title and logo can be dynamically configured via environment
- Docker - Docker
- Docker Compose - Docker Compose
- Optionally, Portainer for GUI-based container management
## Running the Application ### Building and Running the Application
Before proceeding, youll need to have the example configuration files. These files—example.docker-compose.yml and example.env—are included in the repository. You can download them by cloning the entire repository: ### Build and Start Containers
```bash 1. From the project root, run:
git clone https://github.com/tmdinosaurcenter/kiosk-guestbook.git `docker-compose up --build -d`
cd kiosk-guestbook This command will build the Docker image, start the container in detached mode, and mount the persistent volume at `/data` for the SQLite database.
```
If you dont wish to clone the entire repo, you can also download the two files individually from GitHub. Once you have them, follow the steps below. 2. Access the Application:
Open a web browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your .env file).
### Method 1: Using Docker on the CLI ### Deployment with Docker Compose
1. Copy Example Files: The `docker-compose.yml` is configured to:
From the project root, copy the example files:
``` bash - Build the image from the Dockerfile.
cp example.docker-compose.yml docker-compose.yml - Expose the service on the specified port.
cp example.env .env - Mount a volume (named `guestbook_data`) at `/data` to persist your database.
``` - Load environment variables from the `.env` file
2. **Edit the `.env` File (Optional)** ### Logging and Monitoring
Modify `.env` to customize settings such as `SITE_TITLE`, `LOGO_URL`, `PORT`, etc.
3. **Start the Application**
Run the following command to pull (or use) the pre-built image and start the container:
`docker-compose up -d`
This command starts the container in detached mode, mounts the persistent volume for the SQLite database, and uses your environment variable settings.
4. **Access the Application**
Open your browser and navigate to `http://<your-server-ip>:8000` (or the port specified in your `.env` file).
### Method 2: Running in Portainer
1. **Copy Example Files**
```bash
cp example.docker-compose.yml docker-compose.yml
cp example.env stack.env
```
*Note*: Portainer expects the environment file to be named stack.env rather than .env
2. **Edit `docker-compose.yml`**
In the `docker-compose.yml` file, update the environment file reference from `.env` to `stack.env`
3. **Deploy via Portainer**
- Log in to Portainer and navigate to the "Stacks" section.
- Create a new stack and upload or paste your modified docker-compose.yml along with the `stack.env` file.
- Deploy the stack. Portainer will use stack.env for the environment variables.
4. **Access the Application:**
Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (or your specified port) to view the application.
## Logging and Monitoring
- The application uses Python's built-in logging module. - The application uses Python's built-in logging module.
- Key events, such as database initialization, form submissions, and validation errors, are logged. - Key events (like database initialization, form submissions, and validation errors) are logged.
- View logs with: - Logs can be viewed by running:
`docker-compose logs -f` `docker-compose logs -f`
## API Access
Access the API endpoint to export guest entries by navigating to:
`http://your-server-ip:8000/guests/api`
This endpoint can be integrated with on-prem automation tools like n8n.
## Additional Notes ## Additional Notes
- **Intranet-Only Deployment** - Intranet-Only Deployment:
This application is designed for internal use only and is not exposed to the public internet. This application is designed for internal use only. It is not exposed to the public internet.
- Database Persistence:
- **Database Persistence**
The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt. The SQLite database is stored in a Docker volume (guestbook_data), ensuring that data persists even if containers are rebuilt.
- Production Considerations:
- **Production Considerations** The app runs with Gunicorn as a production-ready WSGI server. Make sure to adjust worker counts and resource limits as needed based on your servers specifications.
The app runs with Gunicorn as a production-ready WSGI server. Adjust worker counts and resource limits as needed based on your servers specifications.
## License
This project is licensed under the [MIT License](LICENSE).

74
app.py
View File

@ -1,17 +1,24 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort from flask import Flask, render_template, request, redirect, url_for
import sqlite3 import sqlite3
import re import re
import logging import logging
import os import os
# Set up logging # Set up basic logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
# Use an environment variable for the database path (defaulting to 'guestbook.db')
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db') DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
def load_banned_words(): def load_banned_words():
"""Load a set of banned words from a local file.
Expects 'en.txt' to be in the same directory as this script.
If the file is missing, a minimal fallback set is used.
"""
banned_words = set() banned_words = set()
file_path = os.path.join(os.path.dirname(__file__), 'en.txt') file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
if os.path.exists(file_path): if os.path.exists(file_path):
@ -26,13 +33,15 @@ def load_banned_words():
logger.error("Error reading banned words file: %s", e) logger.error("Error reading banned words file: %s", e)
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"} banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
else: else:
logger.warning("Banned words file not found. Using fallback list.") logger.warning("Banned words file not found. Using fallback minimal list.")
banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"} banned_words = {"fuck", "shit", "damn", "bitch", "asshole", "cunt", "dick", "piss", "crap", "hell"}
return banned_words return banned_words
# Load the banned words using the helper function.
BANNED_WORDS = load_banned_words() BANNED_WORDS = load_banned_words()
def contains_banned_words(text): def contains_banned_words(text):
"""Check if the provided text contains any banned words."""
words = text.lower().split() words = text.lower().split()
for word in words: for word in words:
word_clean = word.strip(".,!?;:\"'") word_clean = word.strip(".,!?;:\"'")
@ -41,6 +50,7 @@ def contains_banned_words(text):
return False return False
def init_db(): def init_db():
"""Initialize the SQLite database and create the guests table if it doesn't exist."""
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
@ -51,7 +61,6 @@ def init_db():
email TEXT, email TEXT,
location TEXT NOT NULL, location TEXT NOT NULL,
comment TEXT, comment TEXT,
newsletter_opt_in BOOLEAN DEFAULT 1,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
) )
''') ''')
@ -60,34 +69,35 @@ def init_db():
logger.info("Database initialized.") logger.info("Database initialized.")
def is_valid_email(email): def is_valid_email(email):
"""Simple regex-based email validation."""
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$' pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return re.match(pattern, email) return re.match(pattern, email)
@app.before_first_request @app.before_first_request
def initialize_database(): def initialize_database():
"""Ensure the database is initialized before handling the first request."""
init_db() init_db()
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
def index(): def index():
error = None error = None
if request.method == 'POST': if request.method == 'POST':
logger.info("Received POST request.") logger.info("Received POST request with form data.")
first_name = request.form.get('first_name', '').strip() first_name = request.form.get('first_name', '').strip()
last_name = request.form.get('last_name', '').strip() last_name = request.form.get('last_name', '').strip()
email = request.form.get('email', '').strip() email = request.form.get('email', '').strip()
location = request.form.get('location', '').strip() location = request.form.get('location', '').strip()
comment = request.form.get('comment', '').strip() comment = request.form.get('comment', '').strip()
newsletter_opt_in = request.form.get('newsletter_opt_in') == 'on'
if not (first_name and last_name and location): if not (first_name and last_name and location):
error = "First name, last name, and location are required." error = "First name, last name, and location are required."
logger.warning("Missing required fields.") logger.warning("Validation error: Missing required fields.")
elif email and not is_valid_email(email): elif email and not is_valid_email(email):
error = "Invalid email address." error = "Invalid email address."
logger.warning("Invalid email: %s", email) logger.warning("Validation error: Invalid email address '%s'.", email)
elif comment and contains_banned_words(comment): elif comment and contains_banned_words(comment):
error = "Your comment contains inappropriate language. Please revise." error = "Your comment contains inappropriate language. Please revise."
logger.warning("Profanity detected in comment.") logger.warning("Validation error: Inappropriate language detected in comment.")
if error: if error:
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
@ -100,57 +110,25 @@ def index():
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
c = conn.cursor() c = conn.cursor()
c.execute( c.execute(
''' 'INSERT INTO guests (first_name, last_name, email, location, comment) VALUES (?, ?, ?, ?, ?)',
INSERT INTO guests (first_name, last_name, email, location, comment, newsletter_opt_in) (first_name, last_name, email, location, comment)
VALUES (?, ?, ?, ?, ?, ?)
''',
(first_name, last_name, email, location, comment, newsletter_opt_in)
) )
conn.commit() conn.commit()
conn.close() conn.close()
logger.info("Added guest: %s %s from %s", first_name, last_name, location) logger.info("New guest entry added: %s from %s.", first_name, location)
return redirect(url_for('index')) return redirect(url_for('index'))
# For GET requests, retrieve guest entries to display.
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
c = conn.cursor() c = conn.cursor()
c.execute('SELECT first_name, location FROM guests ORDER BY id DESC') c.execute('SELECT first_name, location FROM guests ORDER BY id DESC')
guests = c.fetchall() guests = c.fetchall()
conn.close() conn.close()
logger.info("Rendering index with %d guests.", len(guests)) logger.info("Rendering guestbook page with %d entries.", len(guests))
return render_template('index.html', error=error, guests=guests) return render_template('index.html', error=error, guests=guests)
@app.route('/api/guests', methods=['GET'])
def api_guests():
api_key = request.headers.get('X-API-Key')
if api_key != os.environ.get("API_KEY"):
abort(403)
conn = sqlite3.connect(DATABASE)
c = conn.cursor()
c.execute('''
SELECT first_name, last_name, email, location, comment, newsletter_opt_in, timestamp
FROM guests
WHERE email IS NOT NULL AND email != ''
ORDER BY id DESC
''')
rows = c.fetchall()
conn.close()
guests = [
{
"first_name": row[0],
"last_name": row[1],
"email": row[2],
"location": row[3],
"comment": row[4],
"newsletter_opt_in": bool(row[5]),
"timestamp": row[6]
}
for row in rows
]
return jsonify(guests)
if __name__ == '__main__': if __name__ == '__main__':
# For development use; production (gunicorn) will not execute this block.
init_db() init_db()
logger.info("Starting development server at http://0.0.0.0:8000") logger.info("Starting Flask app on host 0.0.0.0, port 8000.")
app.run(host='0.0.0.0', port=8000) app.run(host='0.0.0.0', port=8000)

View File

@ -1,15 +1,15 @@
version: "3.8" version: "3.8"
services: services:
guestbook: guestbook:
image: snachodog/kiosk-guestbook:latest build: .
container_name: guestbook container_name: guestbook
ports: ports:
- "${PORT:-8000}:8000" - "${PORT:-8000}:8000"
env_file: env_file:
- .env - .env
volumes: volumes:
# Mount your local directory to persist data; adjust if you prefer a named volume # Mount a named volume at /data so that the database file (configured in .env) persists
- /path/to/guestbook_data:/data - /home/steve/kiosk-guestbook:/data
volumes: volumes:
guestbook_data: guestbook_data:

View File

@ -1,7 +0,0 @@
#!/bin/sh
# Process index.html.template to create index.html
# Adjust the path if your template is located somewhere else
envsubst < /app/templates/index.html.template > /app/templates/index.html
# Start Gunicorn; using an environment variable for workers (default is 3)
exec gunicorn --bind 0.0.0.0:8000 app:app --workers ${WORKERS:-3}

14
production.Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM python:3.9-slim
WORKDIR /app
# Copy and install Python dependencies.
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the app code.
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]

View File

@ -2,12 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${SITE_TITLE}</title> <title>The Montana Dinosaur Center Visitor Log</title>
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style> <style>
/* Scrolling marquee styles */ /* Scrolling marquee styles */
.scrolling-wrapper { .scrolling-wrapper {
@ -41,8 +40,8 @@
<body> <body>
<div class="container mt-5 mb-5"> <div class="container mt-5 mb-5">
<header class="d-flex align-items-center mb-4"> <header class="d-flex align-items-center mb-4">
<img src="${LOGO_URL}" alt="Logo" class="me-3" style="height: 50px;" /> <img src="static/images/logo.png" alt="Museum Logo" class="me-3" style="height: 50px;">
<h1 class="h3 mb-0">${SITE_TITLE}</h1> <h1 class="h3 mb-0">The Montana Dinosaur Center Visitor Log</h1>
</header> </header>
<!-- Brief instructions for the form --> <!-- Brief instructions for the form -->
@ -60,38 +59,25 @@
<form method="post" action="/" class="mb-4"> <form method="post" action="/" class="mb-4">
<div class="mb-3"> <div class="mb-3">
<label for="first_name" class="form-label">First Name(s):</label> <label for="first_name" class="form-label">First Name(s):</label>
<input type="text" class="form-control" id="first_name" name="first_name" required /> <input type="text" class="form-control" id="first_name" name="first_name" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="last_name" class="form-label">Last Name:</label> <label for="last_name" class="form-label">Last Name:</label>
<input type="text" class="form-control" id="last_name" name="last_name" required /> <input type="text" class="form-control" id="last_name" name="last_name" required>
</div> </div>
<!-- Email + Newsletter Block (fully fixed) -->
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email (Optional):</label> <label for="email" class="form-label">Email (Optional):</label>
<input type="email" class="form-control" id="email" name="email" /> <input type="email" class="form-control" id="email" name="email">
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="newsletter_opt_in" id="newsletter_opt_in"
checked />
<label class="form-check-label" for="newsletter_opt_in">
Subscribe our newsletter
</label>
</div> </div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="location" class="form-label">Location:</label> <label for="location" class="form-label">Location:</label>
<input type="text" class="form-control" id="location" name="location" required /> <input type="text" class="form-control" id="location" name="location" required>
</div> </div>
<!-- Comment field hidden by default --> <!-- Comment field hidden by default -->
<div class="mb-3" id="comment-field" style="display: none;"> <div class="mb-3" id="comment-field" style="display: none;">
<label for="comment" class="form-label">Comment (Optional):</label> <label for="comment" class="form-label">Comment (Optional):</label>
<textarea class="form-control" id="comment" name="comment" rows="3"></textarea> <textarea class="form-control" id="comment" name="comment" rows="3"></textarea>
</div> </div>
<button type="submit" class="btn btn-primary">Submit</button> <button type="submit" class="btn btn-primary">Submit</button>
</form> </form>
</div> </div>
@ -110,26 +96,24 @@
<!-- JavaScript to reveal the comment field --> <!-- JavaScript to reveal the comment field -->
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const firstNameInput = document.getElementById("first_name"); const firstNameInput = document.getElementById('first_name');
const lastNameInput = document.getElementById("last_name"); const lastNameInput = document.getElementById('last_name');
const locationInput = document.getElementById("location"); const locationInput = document.getElementById('location');
const commentField = document.getElementById("comment-field"); const commentField = document.getElementById('comment-field');
function checkFields() { function checkFields() {
if ( if (firstNameInput.value.trim().length >= 3 &&
firstNameInput.value.trim().length >= 3 &&
lastNameInput.value.trim().length >= 3 && lastNameInput.value.trim().length >= 3 &&
locationInput.value.trim().length >= 3 locationInput.value.trim().length >= 3) {
) { commentField.style.display = 'block';
commentField.style.display = "block";
} else { } else {
commentField.style.display = "none"; commentField.style.display = 'none';
} }
} }
firstNameInput.addEventListener("input", checkFields); firstNameInput.addEventListener('input', checkFields);
lastNameInput.addEventListener("input", checkFields); lastNameInput.addEventListener('input', checkFields);
locationInput.addEventListener("input", checkFields); locationInput.addEventListener('input', checkFields);
}); });
</script> </script>