Sahanabg commited on
Commit
c910874
·
verified ·
1 Parent(s): e937cd9

Upload 8 files

Browse files
Files changed (8) hide show
  1. Dockerfile +17 -0
  2. README.md +4 -5
  3. SumUp%20logo.png +0 -0
  4. app (1).py +173 -0
  5. gitattributes +35 -0
  6. index.html +170 -0
  7. requirements.txt +4 -0
  8. style.css +180 -0
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.11-slim
3
+
4
+ # Set the working directory in the container
5
+ WORKDIR /app
6
+
7
+ # Copy the current directory contents into the container at /app
8
+ COPY . /app
9
+
10
+ # Install dependencies
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ # Expose the port FastAPI will run on
14
+ EXPOSE 7860
15
+
16
+ # Command to run the FastAPI app with uvicorn
17
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,10 @@
1
  ---
2
- title: Scheduler
3
- emoji: 💻
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: docker
7
  pinned: false
8
- short_description: meetingtobescheduledwithsales
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: MeetingScheduler
3
+ emoji: 🐨
4
+ colorFrom: indigo
5
+ colorTo: pink
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
SumUp%20logo.png ADDED
app (1).py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request
2
+ from fastapi.responses import HTMLResponse, JSONResponse
3
+ from fastapi.templating import Jinja2Templates
4
+ from fastapi.responses import FileResponse
5
+ from fastapi import HTTPException
6
+ import requests
7
+ import json
8
+ import logging
9
+ import os
10
+
11
+ # ------------------------------
12
+ # Setup basic logging
13
+ # ------------------------------
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format="%(asctime)s - %(levelname)s - %(message)s"
17
+ )
18
+ logger = logging.getLogger(__name__)
19
+
20
+ app = FastAPI()
21
+
22
+ templates = Jinja2Templates(directory=".")
23
+ SECURE_API_KEY = os.getenv("SECURE_API_KEY")
24
+
25
+ @app.get("/style.css")
26
+ async def get_style():
27
+ logger.info("Serving style.css")
28
+ return FileResponse("style.css", media_type="text/css")
29
+
30
+ BASE_API_URL = "https://script.google.com/macros/s/AKfycbwuRcqUlVroX4qGzBKNpC9mVa6ftGlNL2adJmVBxNW5-VFhj67WRTbFYh-QYXxobCC5ew/exec"
31
+
32
+ @app.get("/", response_class=HTMLResponse)
33
+ async def index(request: Request):
34
+ logger.info("Serving index.html")
35
+ return templates.TemplateResponse("index.html", {"request": request})
36
+
37
+
38
+ @app.get("/available")
39
+ async def available(name: str, email: str):
40
+ logger.info(f"Checking availability for name={name}, email={email}")
41
+ try:
42
+ rep_resp = requests.get(f"{BASE_API_URL}?action=getAvailableSalesRep")
43
+ rep_data = rep_resp.json()
44
+ logger.debug(f"Sales rep response: {rep_data}")
45
+
46
+ if "error" in rep_data:
47
+ logger.error(f"Sales rep error: {rep_data['error']}")
48
+ return {
49
+ "message": (
50
+ "We couldn't find an available sales representative at the moment. "
51
+ "Our team will reach out to you shortly."
52
+ )
53
+ }
54
+
55
+ rep_email = rep_data.get("email")
56
+ if not rep_email:
57
+ logger.error("Sales rep email missing from response")
58
+ return {
59
+ "message": (
60
+ "We couldn't assign a sales representative right now. "
61
+ "Our team will contact you shortly."
62
+ )
63
+ }
64
+
65
+ slot_resp = requests.get(f"{BASE_API_URL}?action=getAvailableSlots&salesRepEmail={rep_email}")
66
+ slots = slot_resp.json()
67
+ logger.debug(f"Slots response: {slots}")
68
+
69
+ if isinstance(slots, dict) and "error" in slots:
70
+ logger.error(f"Slot error: {slots['error']}")
71
+ return {
72
+ "message": (
73
+ "We currently don't have any available time slots for booking a meeting. "
74
+ "Our team will reach out to you to find a suitable time."
75
+ )
76
+ }
77
+
78
+ if not slots:
79
+ logger.warning("No available slots found")
80
+ return {
81
+ "message": (
82
+ "There are no open time slots at the moment. "
83
+ "Our team will be in touch to coordinate a meeting."
84
+ )
85
+ }
86
+
87
+ logger.info(f"Returning {len(slots)} available slots for {rep_email}")
88
+ return {
89
+ "slots": slots,
90
+ "repEmail": rep_email
91
+ }
92
+
93
+ except Exception as e:
94
+ logger.exception("Error in /available endpoint")
95
+ return {
96
+ "message": (
97
+ "Something went wrong while checking availability. "
98
+ "Our team has been notified and will follow up with you."
99
+ ),
100
+ "error": str(e)
101
+ }
102
+
103
+ @app.post("/book")
104
+ async def book_meeting(booking: dict):
105
+ name = booking.get("name")
106
+ email = booking.get("email")
107
+ salesRepEmail = booking.get("salesRepEmail")
108
+ selectedSlot = booking.get("selectedSlot")
109
+
110
+ logger.info(f"Booking request received: name={name}, email={email}, slot={selectedSlot}")
111
+ try:
112
+ response = requests.get(f"{BASE_API_URL}?action=createBooking&customerName={name}&customerEmail={email}&salesRepEmail={salesRepEmail}&selectedSlot={selectedSlot}")
113
+ logger.debug(f"Raw booking response: {response.text}")
114
+ if response.content:
115
+ response_data = response.json()
116
+ else:
117
+ logger.error("Empty response from Apps Script")
118
+ return JSONResponse(status_code=500, content={"error": "Empty response from booking script"})
119
+ logger.debug(f"Create booking response: {response_data}")
120
+
121
+ if "error" in response_data:
122
+ logger.error(f"Booking error: {response_data['error']}")
123
+ return JSONResponse(status_code=400, content={"message": response_data["error"]})
124
+
125
+ logger.info("Booking confirmed")
126
+ return JSONResponse(status_code=200, content={"message": "Booking confirmed!", "data": response_data})
127
+
128
+ except Exception as e:
129
+ logger.exception("Error while creating booking")
130
+ return JSONResponse(status_code=500, content={"message": f"Failed to create booking. {str(e)}"})
131
+
132
+ @app.post("/createBooking")
133
+ async def create_booking(firstName: str, email: str, salesRepEmail: str, selectedSlot: str):
134
+ logger.info(f"createBooking called for {email}")
135
+ try:
136
+ response = requests.post(f"{BASE_API_URL}?action=createBooking", data={
137
+ 'customerName': firstName,
138
+ 'customerEmail': email,
139
+ 'salesRepEmail': salesRepEmail,
140
+ 'selectedSlot': selectedSlot
141
+ })
142
+ logger.debug(f"createBooking response: {response.text}")
143
+ return response.text
144
+ except Exception as e:
145
+ logger.exception("Error in createBooking")
146
+ return {"error": f"Failed to create booking: {str(e)}"}
147
+
148
+ @app.post("/rescheduleBooking")
149
+ async def reschedule_booking(bookingId: int, newSlot: str):
150
+ logger.info(f"Rescheduling bookingId={bookingId} to newSlot={newSlot}")
151
+ try:
152
+ response = requests.post(f"{BASE_API_URL}?action=rescheduleBooking", data={
153
+ 'bookingId': bookingId,
154
+ 'newSlot': newSlot
155
+ })
156
+ logger.debug(f"Reschedule response: {response.text}")
157
+ return response.text
158
+ except Exception as e:
159
+ logger.exception("Error in rescheduleBooking")
160
+ return {"error": f"Failed to reschedule booking: {str(e)}"}
161
+
162
+ @app.post("/cancelBooking")
163
+ async def cancel_booking(bookingId: int):
164
+ logger.info(f"Cancelling bookingId={bookingId}")
165
+ try:
166
+ response = requests.post(f"{BASE_API_URL}?action=cancelBooking", data={
167
+ 'bookingId': bookingId
168
+ })
169
+ logger.debug(f"Cancel response: {response.text}")
170
+ return response.text
171
+ except Exception as e:
172
+ logger.exception("Error in cancelBooking")
173
+ return {"error": f"Failed to cancel booking: {str(e)}"}
gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
index.html ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Book a meeting with SumUp</title>
6
+ <link rel="stylesheet" href="style.css" />
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
9
+ </head>
10
+ <body>
11
+ <div class="container">
12
+ <h2>Book a meeting with SumUp</h2>
13
+ <!-- SumUp Logo -->
14
+ <form id="booking-form" aria-labelledby="form-heading">
15
+ <input type="text" id="name" placeholder="Your First Name" required />
16
+ <input type="email" id="email" placeholder="Your Email" required />
17
+ <button type="submit">Check availability</button>
18
+
19
+ <select id="slots" style="display:none;" aria-label="Available Time Slots"></select>
20
+ <button type="button" id="bookBtn" style="display:none;" aria-label="Confirm Booking">Confirm booking</button>
21
+ </form>
22
+
23
+ <div id="spinner" aria-live="polite"></div>
24
+ <p id="status" aria-live="polite"></p>
25
+
26
+ <div id="confirmation" class="hidden" aria-live="polite">
27
+ <h3>✅ Meeting confirmed with SumUp</h3>
28
+ <p><strong>Name:</strong> <span id="conf-name"></span></p>
29
+ <p><strong>Email:</strong> <span id="conf-email"></span></p>
30
+ <p><strong>Time:</strong> <span id="conf-slot"></span></p>
31
+ </div>
32
+ </div>
33
+ <!-- Embed the SVG directly in the HTML -->
34
+ <!-- svg class="logo" xmlns="http://www.w3.org/2000/svg" width="80" height="auto" viewBox="0 0 100 100"-->
35
+ <!-- Paste the entire SVG content here -->
36
+ <!-- Example of a simple SVG (replace with your actual SVG code) -->
37
+ <!-- path d="M58.2.5H5.1C2.7.5.7 2.5.7 4.9v52.8c0 2.4 2 4.4 4.4 4.4h53.1c2.4 0 4.4-2 4.4-4.4V4.9c0-2.5-2-4.4-4.4-4.4zM39.5 46.8c-5.4 5.4-14 5.6-19.7.7l-.1-.1c-.3-.3-.4-.9 0-1.3L38.9 27c.4-.3.9-.3 1.3 0 5 5.8 4.8 14.4-.7 19.8zm4-30.5L24.3 35.4c-.4.3-.9.3-1.3 0-5-5.7-4.8-14.3.6-19.7 5.4-5.4 14-5.6 19.7-.7 0 0 .1 0 .1.1.5.3.5.9.1 1.2z" stroke="black" fill="transparent"/-->
38
+ <!-- /svg-->
39
+ <img src="SumUp logo.png" alt="SumUp logo" class="logo" />
40
+
41
+ <script>
42
+ let repEmail = ""; // Stored internally
43
+ let selectedSlotInfo = null;
44
+
45
+ const form = document.getElementById("booking-form");
46
+ const slotsDropdown = document.getElementById("slots");
47
+ const bookBtn = document.getElementById("bookBtn");
48
+ const statusEl = document.getElementById("status");
49
+ const spinner = document.getElementById("spinner");
50
+
51
+ function showSpinner() {
52
+ spinner.style.display = "block";
53
+ }
54
+
55
+ function hideSpinner() {
56
+ spinner.style.display = "none";
57
+ }
58
+
59
+ function setStatus(message, type = "info") {
60
+ statusEl.className = "";
61
+ statusEl.classList.add(`status-${type}`);
62
+ statusEl.innerText = message;
63
+ statusEl.style.display = "block";
64
+ }
65
+
66
+ form.addEventListener("submit", async (e) => {
67
+ e.preventDefault();
68
+ const name = document.getElementById("name").value;
69
+ const email = document.getElementById("email").value;
70
+
71
+ if (!name || !email) {
72
+ setStatus("Please enter your name and email.", "error");
73
+ return;
74
+ }
75
+
76
+ showSpinner();
77
+ setStatus("Checking availability...", "info");
78
+
79
+ try {
80
+ const res = await fetch(`/available?name=${encodeURIComponent(name)}&email=${encodeURIComponent(email)}`);
81
+ const data = await res.json();
82
+ hideSpinner();
83
+
84
+ if (data.error) {
85
+ setStatus(data.error, "error");
86
+ return;
87
+ }
88
+
89
+ repEmail = data.repEmail;
90
+ const slots = data.slots;
91
+ slotsDropdown.innerHTML = "";
92
+
93
+ if (slots.length === 0) {
94
+ setStatus("No available time slots found.", "error");
95
+ return;
96
+ }
97
+
98
+ slots.forEach(slot => {
99
+ const option = document.createElement("option");
100
+ option.value = slot.start;
101
+ option.text = new Date(slot.start).toLocaleString();
102
+ slotsDropdown.appendChild(option);
103
+ });
104
+
105
+ slotsDropdown.style.display = "block";
106
+ bookBtn.style.display = "inline-block";
107
+ setStatus("Select a time slot to book.", "success");
108
+
109
+ } catch (error) {
110
+ hideSpinner();
111
+ setStatus("Failed to retrieve availability.", "error");
112
+ }
113
+ });
114
+
115
+ bookBtn.addEventListener("click", async () => {
116
+ const name = document.getElementById("name").value;
117
+ const email = document.getElementById("email").value;
118
+ const selectedSlot = slotsDropdown.value;
119
+
120
+ if (!selectedSlot || !repEmail) {
121
+ setStatus("Please select a time slot first.", "error");
122
+ return;
123
+ }
124
+
125
+ showSpinner();
126
+ setStatus("Booking your meeting...", "info");
127
+
128
+ try {
129
+ const res = await fetch("/book", {
130
+ method: "POST",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify({
133
+ name,
134
+ email,
135
+ salesRepEmail: repEmail,
136
+ selectedSlot
137
+ })
138
+ });
139
+
140
+ const resultText = await res.text();
141
+ hideSpinner();
142
+
143
+ setStatus("", "success");
144
+
145
+ // 🔥 Apply fade-in effect
146
+ const confirmationPanel = document.getElementById("confirmation");
147
+ confirmationPanel.classList.add("visible");
148
+
149
+ document.getElementById("conf-name").innerText = name;
150
+ document.getElementById("conf-email").innerText = email;
151
+ document.getElementById("conf-slot").innerText = new Date(selectedSlot).toLocaleString();
152
+
153
+ slotsDropdown.style.display = "none";
154
+ bookBtn.style.display = "none";
155
+ form.reset();
156
+
157
+ // Optional: hide status after a few seconds
158
+ setTimeout(() => {
159
+ statusEl.style.display = "none";
160
+ }, 3000);
161
+
162
+ } catch (error) {
163
+ hideSpinner();
164
+ setStatus("Booking failed. Please try again.", "error");
165
+ }
166
+ });
167
+
168
+ </script>
169
+ </body>
170
+ </html>
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ jinja2
4
+ requests
style.css ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* style.css */
2
+
3
+ /* Reset & Font */
4
+ * {
5
+ box-sizing: border-box;
6
+ margin: 0;
7
+ padding: 0;
8
+ font-family: 'Inter', sans-serif;
9
+ }
10
+
11
+ /* Page background */
12
+ body {
13
+ background: #f0f1f5;
14
+ min-height: 100vh;
15
+ display: flex;
16
+ justify-content: center;
17
+ align-items: center;
18
+ padding: 2rem;
19
+ }
20
+
21
+ /* Logo */
22
+ .logo {
23
+ position: fixed;
24
+ top: 20px;
25
+ left: 20px;
26
+ width: 140px;
27
+ height: auto;
28
+ z-index: 1000;
29
+ }
30
+
31
+ /* Card container */
32
+ .container {
33
+ background: #ffffff;
34
+ padding: 2.5rem;
35
+ border-radius: 16px;
36
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
37
+ width: 100%;
38
+ max-width: 420px;
39
+ text-align: center;
40
+ }
41
+
42
+ /* Title heading */
43
+ h2 {
44
+ font-size: 2rem;
45
+ font-weight: 600;
46
+ color: #1a1a1a;
47
+ margin-bottom: 2rem;
48
+ line-height: 1.3;
49
+ }
50
+
51
+ /* Input fields & dropdown */
52
+ form input,
53
+ form select {
54
+ display: block;
55
+ width: 100%;
56
+ padding: 0.75rem 1rem;
57
+ font-size: 1rem;
58
+ font-weight: 400;
59
+ margin-bottom: 1rem;
60
+ border: 1px solid #d0d4d9;
61
+ border-radius: 10px;
62
+ background: #ffffff;
63
+ color: #1a1a1a;
64
+ transition: border 0.2s ease;
65
+ }
66
+
67
+ form input::placeholder {
68
+ color: #a0aec0;
69
+ font-weight: 400;
70
+ }
71
+
72
+ form input:focus,
73
+ form select:focus {
74
+ border-color: #000000;
75
+ box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.2);
76
+ outline: none;
77
+ }
78
+
79
+ /* Buttons */
80
+ form button,
81
+ #bookBtn {
82
+ width: 100%;
83
+ padding: 0.75rem 1rem;
84
+ background: #000000;
85
+ color: #ffffff;
86
+ font-weight: 600;
87
+ font-size: 1rem;
88
+ border: none;
89
+ border-radius: 10px;
90
+ cursor: pointer;
91
+ transition: background 0.3s ease;
92
+ }
93
+
94
+ form button:hover,
95
+ #bookBtn:hover {
96
+ background: #1a1a1a;
97
+ }
98
+
99
+ /* Spinner */
100
+ #spinner {
101
+ margin: 1rem auto;
102
+ width: 32px;
103
+ height: 32px;
104
+ border: 4px solid #ccc;
105
+ border-top: 4px solid #000000;
106
+ border-radius: 50%;
107
+ animation: spin 1s linear infinite;
108
+ display: none;
109
+ }
110
+
111
+ @keyframes spin {
112
+ to {
113
+ transform: rotate(360deg);
114
+ }
115
+ }
116
+
117
+ /* Status message */
118
+ #status {
119
+ display: none;
120
+ margin-top: 1rem;
121
+ padding: 0.75rem 1rem;
122
+ text-align: center;
123
+ font-size: 0.95rem;
124
+ border-radius: 8px;
125
+ }
126
+
127
+ .status-info {
128
+ background: #e6f0ff;
129
+ color: #1e4b91;
130
+ }
131
+
132
+ .status-success {
133
+ background: #e6ffed;
134
+ color: #2e7d32;
135
+ }
136
+
137
+ .status-error {
138
+ background: #ffe6e6;
139
+ color: #c62828;
140
+ }
141
+
142
+ /* Confirmation */
143
+ #confirmation {
144
+ display: none;
145
+ margin-top: 2rem;
146
+ background: #f0fff4;
147
+ border-left: 5px solid #52c41a;
148
+ padding: 1rem;
149
+ border-radius: 10px;
150
+ opacity: 0;
151
+ transform: translateY(20px);
152
+ transition: all 0.4s ease;
153
+ }
154
+
155
+ #confirmation.visible {
156
+ display: block;
157
+ opacity: 1;
158
+ transform: translateY(0);
159
+ }
160
+
161
+ #confirmation h3 {
162
+ color: #2e7d32;
163
+ margin-bottom: 0.5rem;
164
+ }
165
+
166
+ #confirmation p {
167
+ margin: 0.25rem 0;
168
+ }
169
+
170
+ /* Utility */
171
+ .hidden {
172
+ display: none;
173
+ }
174
+
175
+ /* Responsive padding for smaller screens */
176
+ @media (max-width: 480px) {
177
+ .container {
178
+ padding: 1.5rem;
179
+ }
180
+ }