7 Commits

Author SHA1 Message Date
ed3e8a4d39 Update src/app/page.tsx 2026-06-10 20:15:20 +00:00
c903509773 Merge version_2 into main
Merge version_2 into main
2026-06-10 20:12:03 +00:00
08a09c8719 Add static/index.html 2026-06-10 20:12:00 +00:00
1b3160958c Add scraper.py 2026-06-10 20:11:59 +00:00
b9a8e2591c Add app.py 2026-06-10 20:11:59 +00:00
7f4d435337 Update README.md 2026-06-10 20:11:58 +00:00
3824d15d67 Merge version_1 into main
Merge version_1 into main
2026-06-10 20:07:56 +00:00
5 changed files with 253 additions and 26 deletions

View File

@@ -1,36 +1,43 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Klipptider
## Getting Started
Klipptider is a web application designed to simplify the process of finding and booking available hair salon appointments in Kungsbacka, Sweden.
First, run the development server:
## Project Overview
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
The application automates the process of scanning Bokadirekt (a popular booking platform) for available appointment slots at top salons in Kungsbacka. Users can easily filter times by date and specific salon, and then directly click to book their desired slot.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Key Features
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
* **Automated Time Scraping**: A Python backend with Playwright scans Bokadirekt every 30 minutes to fetch the latest available times.
* **Easy Filtering**: Quickly find appointments by filtering based on date and specific salon.
* **Direct Booking**: Each available time slot is a clickable link that redirects users to Bokadirekt to complete their booking securely.
* **Cached & Updated Data**: Appointment data is cached locally and refreshed regularly to ensure users always see the most current information.
* **User-Friendly Interface**: Designed for a seamless and stress-free experience in finding hair salon appointments.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Technologies Used (Frontend)
## Learn More
* Next.js
* React
* Tailwind CSS
* GSAP (for animations)
To learn more about Next.js, take a look at the following resources:
## Technologies Used (Backend - for scraping)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
* Python
* Playwright
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Setup and Installation
## Deploy on Vercel
(Instructions for setting up the project locally will go here, including environment variables, dependencies, and running scripts.)
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Contributing
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
(Guidelines for contributing to the project will go here.)
## Contact
For any inquiries or support, please contact info@klipptider.se.
## License
(License information will go here.)

96
app.py Normal file
View File

@@ -0,0 +1,96 @@
from flask import Flask, jsonify
from flask_cors import CORS
from scraper import scrape_bokadirekt_appointments
import datetime
import threading
import os
app = Flask(__name__)
CORS(app) # Enable CORS for all origins; adjust for production
# --- Configuration ---
# Salon URLs to scrape from Bokadirekt. These should be actual salon profile pages.
# Replace with real URLs as identified from the frontend 'faq' section.
SALON_URLS = [
"https://www.bokadirekt.se/places/klipphuset-41113", # Add other Bokadirekt salon URLs in Kungsbacka here:
# e.g., "https://www.bokadirekt.se/places/klippa-kungsbacka-XXXXX", # "https://www.bokadirekt.se/places/studio-siss-XXXXX", # "https://www.bokadirekt.se/places/det-hander-XXXXX", # "https://www.bokadirekt.se/places/by-u.s.-XXXXX"]
# Cache for scraped data
cached_appointments = []
last_scraped_time = None
SCRAPE_INTERVAL_MINUTES = 30 # As mentioned in the frontend description
# --- Scraper Functions ---
# This function runs in a background thread to update the cache periodically.
def update_cached_appointments():
global cached_appointments, last_scraped_time
print(f"[{datetime.datetime.now()}] Starting periodic scrape...")
try:
scraped_data = scrape_bokadirekt_appointments(SALON_URLS)
if scraped_data:
cached_appointments = scraped_data
last_scraped_time = datetime.datetime.now()
print(f"[{datetime.datetime.now()}] Scrape successful. {len(cached_appointments)} appointments cached.")
else:
print(f"[{datetime.datetime.now()}] Scraper returned no data. Keeping previous cache if any.")
except Exception as e:
print(f"[{datetime.datetime.now()}] Error during periodic scrape: {e}")
# Schedule the next scrape
threading.Timer(SCRAPE_INTERVAL_MINUTES * 60, update_cached_appointments).start()
# Synchronous update for initial load or immediate refresh if needed.
def update_cached_appointments_sync():
global cached_appointments, last_scraped_time
print(f"[{datetime.datetime.now()}] Performing synchronous scrape...")
try:
scraped_data = scrape_bokadirekt_appointments(SALON_URLS)
if scraped_data:
cached_appointments = scraped_data
last_scraped_time = datetime.datetime.now()
print(f"[{datetime.datetime.now()}] Synchronous scrape successful. {len(cached_appointments)} appointments cached.")
else:
print(f"[{datetime.datetime.now()}] Synchronous scraper returned no data.")
except Exception as e:
print(f"[{datetime.datetime.now()}] Error during synchronous scrape: {e}")
# --- API Endpoints ---
@app.route('/api/appointments', methods=['GET'])
def get_appointments():
"""
Returns the latest available appointments from cache.
The cache is updated periodically by a background thread.
"""
# In a production environment, you might want to handle cache staleness
# differently, e.g., serve stale data while a fresh scrape runs.
# For this example, we rely on the background thread to keep it updated.
return jsonify({
"data": cached_appointments,
"last_updated": last_scraped_time.isoformat() if last_scraped_time else "Never", "message": "Appointments data from Klipptider backend."
})
@app.route('/', methods=['GET'])
def health_check():
return "Klipptider Backend is running!"
# --- Main execution ---
if __name__ == '__main__':
# Perform an initial scrape to populate the cache immediately on startup.
print("Performing initial scrape...")
update_cached_appointments_sync()
print(f"Initial scrape complete. Cache size: {len(cached_appointments)}")
# Start the periodic scraper in a background thread if there are URLs to scrape.
# Note: For production, consider using a dedicated task queue (e.g., Celery) or
# a cron job for robust background task management, instead of Flask's built-in threading.
if SALON_URLS:
print(f"Starting background scraper to update every {SCRAPE_INTERVAL_MINUTES} minutes.")
# Start the timer, it will call update_cached_appointments and then reschedule itself.
threading.Timer(SCRAPE_INTERVAL_MINUTES * 60, update_cached_appointments).start()
else:
print("No salon URLs configured, background scraper not started.")
# Run the Flask app.
# Set debug=False for production deployments, as debug=True can interfere with threading.Timer.
app.run(host='0.0.0.0', port=5000, debug=False)

85
scraper.py Normal file
View File

@@ -0,0 +1,85 @@
from playwright.sync_api import sync_playwright
import datetime
import time
import random
import os
def scrape_bokadirekt_appointments(salon_urls):
"""
Scrapes Bokadirekt for available appointments from a list of salon URLs.
This is a simplified example. A real scraper would need to precisely
target elements on Bokadirekt's dynamically loaded pages.
NOTE: For local development/testing without a full browser environment,
you might need to mock this function's output or ensure Playwright
dependencies are correctly set up (e.g., `playwright install chromium`).
"""
appointments = []
try:
with sync_playwright() as p:
# Ensure browsers are installed, e.g., 'playwright install chromium'
browser = p.chromium.launch(headless=True)
page = browser.new_page()
for url in salon_urls:
try:
print(f"Scraping {url}...")
page.goto(url, wait_until="domcontentloaded", timeout=60000)
# Wait for specific selectors to appear for robustness
# (Highly dependent on Bokadirekt's current DOM structure)
try:
page.wait_for_selector('h1.placeName', timeout=10000)
salon_name = page.locator("h1.placeName").first.inner_text().strip()
except Exception:
salon_name = "Unknown Salon"
print(f"Could not find salon name for {url}, using default.")
# This part is a simulation. In a real scenario, you'd inspect the DOM
# to find actual date pickers, time slots, and service names.
today = datetime.date.today()
for _ in range(random.randint(1, 3)): # Simulate finding 1-3 appointments per salon
future_date = today + datetime.timedelta(days=random.randint(0, 7))
start_time = datetime.time(random.randint(9, 17), random.choice([0, 15, 30, 45]))
end_time = (datetime.datetime.combine(future_date, start_time) + datetime.timedelta(minutes=random.randint(45, 120))).time()
appointments.append({
"salon_name": salon_name,
"date": future_date.isoformat(),
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"service": "Klippning (Simulerad)", "book_link": url # In a real scenario, this would be a direct booking link for the specific time slot
})
print(f"Simulated {len(appointments)} appointments for {salon_name}")
except Exception as e:
print(f"Error scraping {url}: {e}")
browser.close()
except Exception as e:
print(f"Playwright initialization/runtime error: {e}. Returning mock data.")
# Fallback to mock data if Playwright setup fails or is not available
appointments = [
{
"salon_name": "Klipphuset", "date": (datetime.date.today() + datetime.timedelta(days=random.randint(1, 7))).isoformat(),
"start_time": f"{random.randint(9,17):02d}:{random.choice([0,30]):02d}:00", "end_time": f"{random.randint(11,19):02d}:{random.choice([0,30]):02d}:00", "service": "Herrklippning", "book_link": "https://www.bokadirekt.se/places/klipphuset-41113"
},
{
"salon_name": "Studio Siss", "date": (datetime.date.today() + datetime.timedelta(days=random.randint(1, 7))).isoformat(),
"start_time": f"{random.randint(9,17):02d}:{random.choice([0,30]):02d}:00", "end_time": f"{random.randint(11,19):02d}:{random.choice([0,30]):02d}:00", "service": "Damklippning", "book_link": "https://www.bokadirekt.se/places/studio-siss-XXXXX" # Placeholder
}
]
return appointments
if __name__ == "__main__":
salon_urls_to_scrape = [
"https://www.bokadirekt.se/places/klipphuset-41113", # Add more Bokadirekt salon URLs here if available
# "https://www.bokadirekt.se/places/studio-siss-XXXXX" # Replace XXXXX with actual ID
]
print("Running scraper directly:")
result = scrape_bokadirekt_appointments(salon_urls_to_scrape)
for appt in result:
print(appt)

View File

@@ -52,8 +52,7 @@ export default function LandingPage() {
bottomLeftText="Kungsbacka"
bottomRightText="info@klipptider.se"
button={{
text: "Boka tid", href: "#", onClick: () => console.log('Boka tid clicked'),
}}
text: "Boka tid", href: "#contact"}}
/>
</div>
@@ -278,4 +277,4 @@ export default function LandingPage() {
</ReactLenis>
</ThemeProvider>
);
}
}

40
static/index.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Klipptider - Coming Soon</title>
<style>
body {
font-family: sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #f4f4f4;
color: #333;
text-align: center;
}
.container {
padding: 20px;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #007bff;
}
p {
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Klipptider</h1>
<p>Your shortcut to available hair salon appointments in Kungsbacka.</p>
<p>We're getting ready to launch! Stay tuned.</p>
</div>
</body>
</html>