monicka-booking 0.1.0__py3-none-any.whl
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.
- monicka_booking/__init__.py +1 -0
- monicka_booking/admin.py +23 -0
- monicka_booking/apps.py +7 -0
- monicka_booking/client.py +66 -0
- monicka_booking/migrations/0001_initial.py +19 -0
- monicka_booking/migrations/0002_alter_bookingwidgetconfig_currency_label.py +18 -0
- monicka_booking/migrations/__init__.py +1 -0
- monicka_booking/models.py +55 -0
- monicka_booking/static/monicka_booking/assessment.js +48 -0
- monicka_booking/static/monicka_booking/booking.css +5 -0
- monicka_booking/static/monicka_booking/booking.js +32 -0
- monicka_booking/static/monicka_booking/monicka-logo.png +0 -0
- monicka_booking/templates/monicka_booking/assessment_access.html +17 -0
- monicka_booking/templates/monicka_booking/assessment_complete.html +8 -0
- monicka_booking/templates/monicka_booking/assessment_form.html +34 -0
- monicka_booking/templates/monicka_booking/assessments.html +30 -0
- monicka_booking/templates/monicka_booking/base.html +20 -0
- monicka_booking/templates/monicka_booking/booking.html +45 -0
- monicka_booking/templates/monicka_booking/complete.html +1 -0
- monicka_booking/templates/monicka_booking/error.html +1 -0
- monicka_booking/templates/monicka_booking/gateway.html +1 -0
- monicka_booking/urls.py +17 -0
- monicka_booking/views.py +196 -0
- monicka_booking-0.1.0.dist-info/METADATA +80 -0
- monicka_booking-0.1.0.dist-info/RECORD +27 -0
- monicka_booking-0.1.0.dist-info/WHEEL +5 -0
- monicka_booking-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
monicka_booking/admin.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import BookingWidgetConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(BookingWidgetConfig)
|
|
7
|
+
class BookingWidgetConfigAdmin(admin.ModelAdmin):
|
|
8
|
+
fieldsets = (
|
|
9
|
+
("API", {"fields": ("api_base_url", "primary_clinic_id"),
|
|
10
|
+
"description": "Set MONICKA_API_KEY in the website environment. The secret is never stored or shown here."}),
|
|
11
|
+
("Appearance", {"fields": ("title", "introduction", "theme", "font_scale", "currency_label")}),
|
|
12
|
+
("Contact", {"fields": ("show_phone_support", "support_phone")}),
|
|
13
|
+
)
|
|
14
|
+
readonly_fields = ("updated_at",)
|
|
15
|
+
|
|
16
|
+
def has_add_permission(self, request):
|
|
17
|
+
return not BookingWidgetConfig.objects.exists() and super().has_add_permission(request)
|
|
18
|
+
|
|
19
|
+
def has_delete_permission(self, request, obj=None):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
def has_module_permission(self, request):
|
|
23
|
+
return request.user.is_staff
|
monicka_booking/apps.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from urllib.parse import urljoin
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MonickaAPIError(Exception):
|
|
8
|
+
def __init__(self, status_code=None):
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
super().__init__("The booking service is temporarily unavailable.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MonickaAPIClient:
|
|
14
|
+
def __init__(self, config, *, session=None, api_key=None):
|
|
15
|
+
self.config = config
|
|
16
|
+
self.session = session or requests.Session()
|
|
17
|
+
self.api_key = api_key if api_key is not None else getattr(settings, "MONICKA_API_KEY", "")
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def configured(self):
|
|
21
|
+
return bool(self.api_key and self.config.primary_clinic_id)
|
|
22
|
+
|
|
23
|
+
def _request(self, method, path, **kwargs):
|
|
24
|
+
if not self.configured:
|
|
25
|
+
raise MonickaAPIError()
|
|
26
|
+
base = self.config.api_base_url.rstrip("/") + "/"
|
|
27
|
+
try:
|
|
28
|
+
response = self.session.request(method, urljoin(base, path.lstrip("/")),
|
|
29
|
+
headers={"X-Clinic-API-Key": self.api_key, "Accept": "application/json"},
|
|
30
|
+
timeout=(3.05, 15), **kwargs)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
return response.json()
|
|
33
|
+
except (requests.RequestException, ValueError) as exc:
|
|
34
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
35
|
+
raise MonickaAPIError(status) from None
|
|
36
|
+
|
|
37
|
+
def catalog(self):
|
|
38
|
+
data = self._request("GET", "/api/plugin/catalog/")
|
|
39
|
+
if data.get("home_clinic_id") != self.config.primary_clinic_id:
|
|
40
|
+
raise MonickaAPIError()
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
def availability(self, *, clinic, therapist, start, end):
|
|
44
|
+
return self._request("GET", "/api/plugin/availability/", params={
|
|
45
|
+
"clinic": clinic, "therapist": therapist, "start": start, "end": end})
|
|
46
|
+
|
|
47
|
+
def slots(self, *, clinic, therapist, date):
|
|
48
|
+
return self._request("GET", "/api/slots/", params={
|
|
49
|
+
"clinic": clinic, "therapist": therapist, "date": date})
|
|
50
|
+
|
|
51
|
+
def book(self, payload):
|
|
52
|
+
return self._request("POST", "/api/bookings/", json=payload)
|
|
53
|
+
|
|
54
|
+
def confirmation(self, code):
|
|
55
|
+
return self._request("GET", f"/api/plugin/confirmation/{code}/")
|
|
56
|
+
|
|
57
|
+
def assessment_assignments(self, *, code, phone):
|
|
58
|
+
return self._request("POST", "/api/plugin/assessments/", json={"code": code, "phone": phone})
|
|
59
|
+
|
|
60
|
+
def assessment_questionnaire(self, *, code, phone, assessment_type):
|
|
61
|
+
return self._request("POST", f"/api/plugin/assessments/{assessment_type}/form/",
|
|
62
|
+
json={"code": code, "phone": phone})
|
|
63
|
+
|
|
64
|
+
def submit_assessment(self, *, code, phone, assessment_type, answers):
|
|
65
|
+
return self._request("POST", f"/api/plugin/assessments/{assessment_type}/",
|
|
66
|
+
json={"action": "submit", "code": code, "phone": phone, "answers": answers})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
initial = True
|
|
6
|
+
dependencies = []
|
|
7
|
+
operations = [migrations.CreateModel(name="BookingWidgetConfig", fields=[
|
|
8
|
+
("id", models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False)),
|
|
9
|
+
("api_base_url", models.URLField(default="https://api.monicka.ir")),
|
|
10
|
+
("primary_clinic_id", models.PositiveIntegerField(blank=True, help_text="The clinic ID for the API key installed on this website.", null=True)),
|
|
11
|
+
("title", models.CharField(default="رزرو نوبت", max_length=120)),
|
|
12
|
+
("introduction", models.CharField(blank=True, default="درمانگر را انتخاب کنید و زمان مناسب را پیدا کنید.", max_length=240)),
|
|
13
|
+
("theme", models.CharField(choices=[("ivory", "Ivory"), ("sage", "Sage"), ("blue", "Blue")], default="ivory", max_length=12)),
|
|
14
|
+
("font_scale", models.PositiveSmallIntegerField(choices=[(100, "Normal"), (112, "Large"), (125, "Extra large")], default=100)),
|
|
15
|
+
("currency_label", models.CharField(default="ریال", max_length=16)),
|
|
16
|
+
("show_phone_support", models.BooleanField(default=False)),
|
|
17
|
+
("support_phone", models.CharField(blank=True, max_length=32)),
|
|
18
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
19
|
+
], options={"verbose_name": "booking page settings", "verbose_name_plural": "booking page settings"})]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 5.2.17 on 2026-09-30 13:24
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('monicka_booking', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterField(
|
|
14
|
+
model_name='bookingwidgetconfig',
|
|
15
|
+
name='currency_label',
|
|
16
|
+
field=models.CharField(default='ریال', help_text='Label only. Keep this consistent with the API and gateway currency.', max_length=16),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from django.core.exceptions import ValidationError
|
|
2
|
+
from django.db import models
|
|
3
|
+
from django.utils.translation import gettext_lazy as _
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BookingWidgetConfig(models.Model):
|
|
8
|
+
class Theme(models.TextChoices):
|
|
9
|
+
IVORY = "ivory", _("Ivory")
|
|
10
|
+
SAGE = "sage", _("Sage")
|
|
11
|
+
BLUE = "blue", _("Blue")
|
|
12
|
+
|
|
13
|
+
class FontScale(models.IntegerChoices):
|
|
14
|
+
NORMAL = 100, _("Normal")
|
|
15
|
+
LARGE = 112, _("Large")
|
|
16
|
+
EXTRA_LARGE = 125, _("Extra large")
|
|
17
|
+
|
|
18
|
+
id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False)
|
|
19
|
+
api_base_url = models.URLField(default="https://api.monicka.ir")
|
|
20
|
+
primary_clinic_id = models.PositiveIntegerField(null=True, blank=True,
|
|
21
|
+
help_text=_("The clinic ID for the API key installed on this website."))
|
|
22
|
+
title = models.CharField(max_length=120, default=_("رزرو نوبت"))
|
|
23
|
+
introduction = models.CharField(max_length=240, blank=True,
|
|
24
|
+
default=_("درمانگر را انتخاب کنید و زمان مناسب را پیدا کنید."))
|
|
25
|
+
theme = models.CharField(max_length=12, choices=Theme.choices, default=Theme.IVORY)
|
|
26
|
+
font_scale = models.PositiveSmallIntegerField(choices=FontScale.choices, default=FontScale.NORMAL)
|
|
27
|
+
currency_label = models.CharField(max_length=16, default=_("ریال"),
|
|
28
|
+
help_text=_("Label only. Keep this consistent with the API and gateway currency."))
|
|
29
|
+
show_phone_support = models.BooleanField(default=False)
|
|
30
|
+
support_phone = models.CharField(max_length=32, blank=True)
|
|
31
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
32
|
+
|
|
33
|
+
class Meta:
|
|
34
|
+
verbose_name = _("booking page settings")
|
|
35
|
+
verbose_name_plural = _("booking page settings")
|
|
36
|
+
|
|
37
|
+
def clean(self):
|
|
38
|
+
parts = urlsplit(self.api_base_url)
|
|
39
|
+
if parts.scheme != "https" and parts.hostname not in {"localhost", "127.0.0.1"}:
|
|
40
|
+
raise ValidationError({"api_base_url": _("Use HTTPS for the Monicka API outside local development.")})
|
|
41
|
+
if parts.query or parts.fragment or parts.path not in ("", "/"):
|
|
42
|
+
raise ValidationError({"api_base_url": _("Enter the API origin, without a path, query, or fragment.")})
|
|
43
|
+
|
|
44
|
+
def save(self, *args, **kwargs):
|
|
45
|
+
self.pk = 1
|
|
46
|
+
self.full_clean()
|
|
47
|
+
return super().save(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def get_solo(cls):
|
|
51
|
+
config, _ = cls.objects.get_or_create(pk=1)
|
|
52
|
+
return config
|
|
53
|
+
|
|
54
|
+
def __str__(self):
|
|
55
|
+
return str(_("Monicka booking page"))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const form = document.getElementById("assessment-form");
|
|
3
|
+
if (!form) return;
|
|
4
|
+
const pages = [...form.querySelectorAll(".assessment-question")];
|
|
5
|
+
const previous = document.getElementById("assessment-prev");
|
|
6
|
+
const next = document.getElementById("assessment-next");
|
|
7
|
+
const submit = document.getElementById("assessment-submit");
|
|
8
|
+
const progress = document.getElementById("assessment-progress");
|
|
9
|
+
const label = document.getElementById("assessment-progress-label");
|
|
10
|
+
const live = document.getElementById("assessment-live");
|
|
11
|
+
let current = 0;
|
|
12
|
+
function show(index, announce = true) {
|
|
13
|
+
current = index;
|
|
14
|
+
pages.forEach((page, i) => { page.hidden = i !== current; });
|
|
15
|
+
previous.disabled = current === 0;
|
|
16
|
+
next.hidden = current === pages.length - 1;
|
|
17
|
+
submit.hidden = current !== pages.length - 1;
|
|
18
|
+
progress.value = current + 1;
|
|
19
|
+
label.textContent = `پرسش ${current + 1} از ${pages.length}`;
|
|
20
|
+
if (announce) {
|
|
21
|
+
live.textContent = label.textContent;
|
|
22
|
+
pages[current].querySelector("legend").setAttribute("tabindex", "-1");
|
|
23
|
+
pages[current].querySelector("legend").focus({preventScroll: true});
|
|
24
|
+
pages[current].scrollIntoView({behavior: "smooth", block: "start"});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
next.addEventListener("click", () => {
|
|
28
|
+
const answer = pages[current].querySelector("input[type=radio]:checked");
|
|
29
|
+
if (!answer) {
|
|
30
|
+
const first = pages[current].querySelector("input[type=radio]");
|
|
31
|
+
first.focus();
|
|
32
|
+
live.textContent = "لطفاً به این پرسش پاسخ دهید.";
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
show(Math.min(current + 1, pages.length - 1));
|
|
36
|
+
});
|
|
37
|
+
previous.addEventListener("click", () => show(Math.max(current - 1, 0)));
|
|
38
|
+
form.addEventListener("submit", event => {
|
|
39
|
+
const unanswered = pages.findIndex(page => !page.querySelector("input[type=radio]:checked"));
|
|
40
|
+
if (unanswered !== -1) {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
show(unanswered);
|
|
43
|
+
live.textContent = "هنوز به همه پرسشها پاسخ ندادهاید.";
|
|
44
|
+
pages[unanswered].querySelector("input[type=radio]").focus();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
show(0, false);
|
|
48
|
+
})();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
:root{font-family:Tahoma,Arial,sans-serif;color:#20302c;background:#fff;font-size:var(--font-scale,100%);line-height:1.65}*{box-sizing:border-box}body{margin:0;background:var(--page);--page:#fffefa;--surface:#fff;--ink:#202b29;--muted:#53615e;--border:#cbd3cf;--accent:#365e50;--accent-ink:#fff;--soft:#edf3ef;color:var(--ink)}body.theme-sage{--page:#f7faf8;--accent:#315c4b;--soft:#eaf2ed}body.theme-blue{--page:#f8fbff;--accent:#24537c;--soft:#eaf2f8}.page-shell{max-width:1080px;margin:auto;padding:clamp(16px,4vw,42px)}.skip-link{position:absolute;top:-100px;right:12px;background:#fff;padding:12px;z-index:4}.skip-link:focus{top:12px}.page-heading{padding:14px 0 26px}.page-heading h1{font-size:clamp(1.7rem,4vw,2.5rem);margin:.15em 0}.page-heading p{color:var(--muted);margin:.3em 0}.eyebrow{font-size:.9rem;font-weight:bold;color:var(--accent)}.steps ol{display:flex;gap:10px;list-style:none;padding:0;margin:0 0 18px}.steps li{flex:1;border-bottom:3px solid var(--border);padding:8px;color:var(--muted)}.steps li.current{border-color:var(--accent);color:var(--ink);font-weight:bold}.panel{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:clamp(18px,3vw,30px);margin:16px 0;box-shadow:0 4px 18px #1f332510}.panel h2{font-size:1.35rem;margin-top:0}.filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px}label{display:grid;gap:6px;font-weight:600}input,select{width:100%;min-height:48px;border:1px solid #687571;border-radius:8px;padding:10px 12px;background:#fff;color:#15201d;font:inherit}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid #cd8b25;outline-offset:2px}.therapist-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,270px),1fr));gap:14px;margin-top:20px}.therapist-card{border:1px solid var(--border);border-radius:12px;padding:18px;display:flex;flex-direction:column;gap:8px}.therapist-card h3{margin:0}.therapist-card p{margin:0;color:var(--muted)}.tag{display:inline-block;background:var(--soft);border-radius:20px;padding:3px 10px;margin:3px}.button{font:inherit;font-weight:bold;border:0;border-radius:8px;padding:10px 16px;min-height:48px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center}.primary{background:var(--accent);color:var(--accent-ink)}.secondary{background:var(--soft);color:var(--ink);border:1px solid var(--border)}.section-top,.calendar-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.section-top p{color:var(--muted)}.branch-row{display:flex;align-items:center;gap:12px;max-width:460px;margin:18px 0}.branch-row select{flex:1}.calendar-head{margin:22px 0 12px}.calendar-head h3{margin:0;text-align:center}.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.weekday{text-align:center;color:var(--muted);font-weight:bold;padding:7px 0}.day{min-height:48px;border:1px solid var(--border);border-radius:7px;background:#fff;color:var(--ink);font:inherit}.day.available{background:var(--soft);border-color:var(--accent);font-weight:bold;cursor:pointer}.day.selected{background:var(--accent);color:#fff}.day:disabled{color:#89928f;background:#f3f4f3}.slot-list{display:flex;flex-wrap:wrap;gap:10px}.slot-button{border:1px solid var(--accent);background:#fff;border-radius:8px;padding:10px 16px;min-height:48px;font:inherit;cursor:pointer}.slot-button[aria-pressed=true]{background:var(--accent);color:white}form{display:grid;gap:16px}fieldset{border:1px solid var(--border);border-radius:10px;padding:16px}legend{font-weight:bold;padding:0 6px}.participant-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}.hint,.muted{color:var(--muted);font-size:.92rem}.notice{background:var(--soft);border-right:4px solid var(--accent);padding:12px 16px;border-radius:6px}.notice.error{background:#fff3f0;border-color:#ad3527;color:#641c15}.result{max-width:680px;margin:8vh auto}.confirmation{display:grid;gap:5px;background:var(--soft);padding:18px;border-radius:10px;margin:20px 0}.confirmation strong{font-size:1.6rem;letter-spacing:.08em}.support{padding:16px;text-align:center}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}@media(max-width:560px){.steps ol{font-size:.88rem}.steps li{padding:5px 2px}.section-top{align-items:flex-start;flex-direction:column}.calendar-head{gap:5px}.calendar-head .button{padding:8px}.calendar-grid{gap:3px}.day{min-height:42px;padding:2px}.weekday{font-size:.8rem}.participant-row{grid-template-columns:1fr}.page-shell{padding:14px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
|
2
|
+
/* Questionnaire pages share the booking app's restrained, legible visual system. */
|
|
3
|
+
.assessment-unavailable{margin-top:1.5rem;padding:1rem 1.2rem;border:1px solid #956124;border-right:5px solid #956124;border-radius:.75rem;background:#fff8e9;color:#49340f}.assessment-unavailable h2{margin:.1rem 0;font-size:1.1rem}.assessment-unavailable p{line-height:1.9}.assessment-unavailable ul{margin-bottom:.25rem}
|
|
4
|
+
.assessment-panel{max-width:54rem;margin-inline:auto}.assessment-access-form{display:grid;gap:.8rem;max-width:32rem;margin-top:2rem}.assessment-access-form input{font:inherit;font-size:1.1rem;min-height:3.25rem;padding:.7rem 1rem;border:1px solid #ccd7e1;border-radius:.8rem}.assessment-access-form label{font-weight:650;margin-top:.5rem}.assessment-list{list-style:none;padding:0;margin:1.5rem 0}.assessment-list li{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:1.1rem 0;border-bottom:1px solid #e6ebf0}.assessment-list h2{font-size:1.15rem;margin:0}.assessment-list p{margin:.35rem 0;color:#596b7b}.assessment-progress{display:grid;gap:.6rem;margin:2rem 0 1.5rem;font-weight:650}.assessment-progress progress{width:100%;height:.65rem;accent-color:#557fae}.assessment-question{border:0;padding:0;margin:0;min-height:16rem}.assessment-question legend{font-size:1.3rem;line-height:1.9;font-weight:650;width:100%;margin-bottom:1.25rem}.question-number{display:block;color:#557fae;font-size:.95rem;margin-bottom:.25rem}.assessment-choices{display:grid;gap:.7rem}.assessment-choice{display:flex;align-items:flex-start;gap:.8rem;padding:.85rem 1rem;border-radius:.85rem;background:#f5f8fb;cursor:pointer;font-size:1.05rem;line-height:1.9}.assessment-choice:focus-within{outline:3px solid #557fae;outline-offset:2px}.assessment-choice input{width:1.25rem;height:1.25rem;margin-top:.45rem;accent-color:#557fae;flex:none}.assessment-nav{display:flex;justify-content:space-between;gap:1rem;margin-top:2rem}.assessment-nav button[hidden]{display:none}.assessment-question[hidden]{display:none}.visually-hidden{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:640px){.assessment-list li{align-items:flex-start;flex-direction:column}.assessment-nav{position:sticky;bottom:.5rem;background:#fff;padding:.65rem;border-radius:1rem;box-shadow:0 6px 24px #182b3a1c}.assessment-nav .button{flex:1}.assessment-question legend{font-size:1.15rem}}
|
|
5
|
+
.monicka-powered{display:flex;justify-content:center;padding:18px 12px 28px}.monicka-powered a{display:inline-flex;align-items:center;gap:10px;color:#58675f;text-decoration:none}.monicka-powered img{width:36px;height:36px;object-fit:contain}.monicka-powered span{display:grid;line-height:1.25}.monicka-powered small{color:#6e7b74;font-size:.78rem}.monicka-powered strong{color:#355d4e;font-size:1rem;font-weight:700}.monicka-powered a:hover strong{text-decoration:underline}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
'use strict';
|
|
3
|
+
const catalog = JSON.parse(document.getElementById('monicka-catalog').textContent);
|
|
4
|
+
const url = id => JSON.parse(document.getElementById(id).textContent);
|
|
5
|
+
const availabilityUrl=url('availability-url'), slotsUrl=url('slots-url'), currency=url('currency-label');
|
|
6
|
+
const specialScope=url('booking-scope'), invalidScope=url('booking-scope-invalid');
|
|
7
|
+
if(invalidScope)return;
|
|
8
|
+
const $=id=>document.getElementById(id), state={therapist:null,branch:null,date:null,duration:'',branchFilter:'',month:null,available:new Set()};
|
|
9
|
+
const fa=new Intl.DateTimeFormat('fa-IR-u-ca-persian-nu-latn',{year:'numeric',month:'numeric',day:'numeric'});
|
|
10
|
+
const faMonth=new Intl.DateTimeFormat('fa-IR-u-ca-persian',{year:'numeric',month:'long'});
|
|
11
|
+
const parts=d=>Object.fromEntries(fa.formatToParts(d).map(p=>[p.type,p.value]));
|
|
12
|
+
const jdate=d=>{const p=parts(d);return `${p.year}/${String(p.month).padStart(2,'0')}/${String(p.day).padStart(2,'0')}`};
|
|
13
|
+
const jm=d=>{const p=parts(d);return {year:+p.year,month:+p.month}};
|
|
14
|
+
const say=t=>$('live-message').textContent=t;
|
|
15
|
+
const sections={therapist:'therapist',calendar:'calendar',details:'details'};
|
|
16
|
+
function show(section){Object.keys(sections).forEach(s=>$(`step-${sections[s]}`).hidden=s!==section);document.querySelectorAll('[data-step-label]').forEach((e,i)=>e.classList.toggle('current',i===['therapist','calendar','details'].indexOf(section)));}
|
|
17
|
+
function eligibleBranches(t){return t.branches.filter(b=>(!state.branchFilter||b.clinic_id===+state.branchFilter)&&(!state.duration||b.allowed_durations.includes(+state.duration)))}
|
|
18
|
+
function renderTherapists(){const q=$('therapist-search').value.toLocaleLowerCase('fa').trim(),list=$('therapist-list');list.replaceChildren();catalog.therapists.filter(t=>(!q||`${t.full_name} ${t.short_bio||''} ${(t.specialties||[]).join(' ')}`.toLocaleLowerCase('fa').includes(q))&&eligibleBranches(t).length>0).forEach(t=>{const card=document.createElement('article');card.className='therapist-card';const h=document.createElement('h3');h.textContent=t.full_name;card.append(h);if(t.short_bio){const p=document.createElement('p');p.textContent=t.short_bio;card.append(p)}(t.specialties||[]).forEach(s=>{const tag=document.createElement('span');tag.className='tag';tag.textContent=s;card.append(tag)});if(catalog.branches.length>1){const label=document.createElement('p');label.className='muted';label.textContent='شعبهها';card.append(label);const tags=document.createElement('div');t.branches.forEach(branch=>{const tag=document.createElement('span');tag.className='tag';tag.textContent=branch.clinic_name;tags.append(tag)});card.append(tags)}const b=document.createElement('button');b.type='button';b.className='button primary';b.textContent='انتخاب درمانگر';b.onclick=()=>selectTherapist(t);card.append(b);list.append(card)});if(!list.children.length)list.textContent='درمانگری با این مشخصات پیدا نشد.';}
|
|
19
|
+
function initFilters(){const branchFilter=$('branch-filter');if(branchFilter){branchFilter.onchange=()=>{state.branchFilter=branchFilter.value;renderTherapists()}}[...new Set(catalog.therapists.flatMap(t=>t.branches.flatMap(b=>b.allowed_durations)))].sort((a,b)=>a-b).forEach(n=>{const o=document.createElement('option');o.value=n;o.textContent=`${n} دقیقه`;$('duration-filter').append(o)});$('therapist-search').oninput=renderTherapists;$('duration-filter').onchange=()=>{state.duration=$('duration-filter').value;renderTherapists()};}
|
|
20
|
+
function selectTherapist(t,clinicId=null){state.therapist=t;const branches=clinicId===null?eligibleBranches(t):t.branches.filter(b=>b.clinic_id===clinicId);if(!branches.length)return;state.branch=branches[0];$('selected-therapist').textContent=`${t.full_name} · ${state.branch.clinic_name}`;const row=$('branch-row'),sel=$('branch-select');sel.replaceChildren();branches.forEach(b=>{const o=document.createElement('option');o.value=b.clinic_id;o.textContent=b.clinic_name;sel.append(o)});row.hidden=branches.length<2||clinicId!==null;sel.onchange=()=>{state.branch=branches.find(b=>b.clinic_id===+sel.value);state.date=null;$('slot-area').hidden=true;loadAvailability()};state.month=jm(new Date());show('calendar');loadAvailability();}
|
|
21
|
+
function findFirstDay(m){const now=new Date();for(let i=-500;i<500;i++){const d=new Date(now);d.setDate(d.getDate()+i);const p=parts(d),x=jm(d);if(x.year===m.year&&x.month===m.month&&+p.day===1)return d}return null}
|
|
22
|
+
function addMonth(m,n){const i=m.year*12+m.month-1+n;return {year:Math.floor(i/12),month:i%12+1}}
|
|
23
|
+
function bounds(){const first=findFirstDay(state.month);if(!first)return null;const start=new Date(first);start.setDate(first.getDate()-((first.getDay()+1)%7));const days=Array.from({length:42},(_,i)=>{const d=new Date(start);d.setDate(start.getDate()+i);return d});return {first,days}}
|
|
24
|
+
async function loadAvailability(){const b=bounds();if(!b)return;state.available.clear();const today=new Date();today.setHours(0,0,0,0);const begin=new Date(Math.max(b.first.getTime(),today.getTime()+86400000));const limit=new Date(today);limit.setDate(limit.getDate()+(+state.branch.booking_days||20));const end=new Date(Math.min(b.days[41].getTime(),limit.getTime()));if(begin<=end){try{const u=new URL(availabilityUrl,location.href);u.search=new URLSearchParams({clinic:state.branch.clinic_id,therapist:state.therapist.id,start:jdate(begin),end:jdate(end)});const r=await fetch(u,{headers:{Accept:'application/json'}});if(!r.ok)throw Error();(await r.json()).available_days.forEach(x=>state.available.add(x.date))}catch{say('روزهای آزاد بارگذاری نشد. دوباره تلاش کنید.')}}drawMonth(state.month)}
|
|
25
|
+
function drawMonth(m){state.month=m;const b=bounds();if(!b)return;$('month-label').textContent=faMonth.format(b.first);const grid=document.querySelector('.calendar-grid');grid.querySelectorAll('.day').forEach(x=>x.remove());const today=new Date();today.setHours(0,0,0,0);const horizon=new Date(today);horizon.setDate(horizon.getDate()+(+state.branch.booking_days||20));b.days.forEach(d=>{const p=parts(d),x=jm(d),same=x.year===m.year&&x.month===m.month,j=jdate(d),button=document.createElement('button');button.type='button';button.className='day';button.textContent=p.day;button.setAttribute('role','gridcell');button.setAttribute('aria-label',`${p.day} ${faMonth.format(d)}`);const avail=state.available.has(j);button.disabled=!same||!avail||d<=today||d>horizon;button.classList.toggle('available',avail&&!button.disabled);button.setAttribute('aria-pressed',state.date===j?'true':'false');button.onclick=()=>selectDate(j);grid.append(button)});const monthIdx=m.year*12+m.month-1,now=jm(today),maxDays=+state.branch.booking_days||20,last=jm(horizon);$('previous-month').disabled=monthIdx<=now.year*12+now.month-1;$('next-month').disabled=monthIdx>=last.year*12+last.month-1;}
|
|
26
|
+
async function selectDate(date){state.date=date;$('slot-area').hidden=false;$('slot-list').textContent='در حال دریافت ساعتهای آزاد…';try{const u=new URL(slotsUrl,location.href);u.search=new URLSearchParams({clinic:state.branch.clinic_id,therapist:state.therapist.id,date});const r=await fetch(u,{headers:{Accept:'application/json'}});if(!r.ok)throw Error();const data=await r.json(),list=$('slot-list');list.replaceChildren();data.slots.filter(s=>!state.duration||s.duration_minutes===+state.duration).forEach(s=>(s.available_rooms||[]).forEach(room=>{const button=document.createElement('button');button.type='button';button.className='slot-button';button.textContent=`${new Intl.DateTimeFormat('fa-IR',{hour:'2-digit',minute:'2-digit',timeZone:'Asia/Tehran'}).format(new Date(s.start))} — ${s.duration_minutes} دقیقه`;button.onclick=()=>chooseSlot(s,button);list.append(button)}));if(!list.children.length)list.textContent='برای این روز ساعتی باقی نمانده است.'}catch{$('slot-list').textContent='دریافت ساعتها ممکن نشد. دوباره تلاش کنید.'}}
|
|
27
|
+
function feeLabel(rawBase,minutes,perMinute,minimum){const [whole='',fraction='']=String(rawBase).split('.'),cents=BigInt(whole)*100n+BigInt((fraction+'00').slice(0,2));let total=cents;if(perMinute&&minutes>minimum){const numerator=cents*BigInt(minutes);total=numerator/60n;const remainder=numerator%60n;if(remainder*2n>60n||(remainder*2n===60n&&total%2n===1n))total+=1n}const integer=(total/100n).toLocaleString('fa-IR'),decimal=new Intl.NumberFormat('fa-IR',{minimumIntegerDigits:2,useGrouping:false}).format(Number(total%100n));return `${integer}٫${decimal}`}
|
|
28
|
+
function chooseSlot(s,button){document.querySelectorAll('.slot-button').forEach(b=>b.setAttribute('aria-pressed','false'));button.setAttribute('aria-pressed','true');$('form-clinic').value=state.branch.clinic_id;$('form-therapist').value=state.therapist.id;$('form-starts-at').value=s.start;$('form-duration').value=s.duration_minutes;const policy=state.therapist.branches.find(b=>b.clinic_id===state.branch.clinic_id),amount=feeLabel(policy.base_fee,s.duration_minutes,policy.calculate_per_minute,policy.minimum_length_of_appointments);$('booking-summary').textContent=`${state.therapist.full_name}، ${state.branch.clinic_name}، ${jdate(new Date(s.start))}، ${s.duration_minutes} دقیقه، مبلغ ${amount} ${currency}`;show('details')}
|
|
29
|
+
$('previous-month').onclick=()=>{state.month=addMonth(state.month,-1);loadAvailability()};$('next-month').onclick=()=>{state.month=addMonth(state.month,1);loadAvailability()};document.querySelectorAll('[data-back]').forEach(b=>b.onclick=()=>show(b.dataset.back));$('add-participant').onclick=()=>{const row=document.createElement('div');row.className='participant-row';const name=document.createElement('label');name.textContent='نام همراه';const ni=document.createElement('input');ni.name='participant_name';ni.maxLength=200;name.append(ni);const phone=document.createElement('label');phone.textContent='شماره همراه';const pi=document.createElement('input');pi.name='participant_phone';pi.inputMode='tel';pi.pattern='[+0-9۰-۹٠-٩\\- ]{10,16}';phone.append(pi);row.append(name,phone);$('participants').append(row)};
|
|
30
|
+
initFilters();renderTherapists();
|
|
31
|
+
if(specialScope){const therapist=catalog.therapists.find(t=>t.id===specialScope.therapist_id);if(therapist)selectTherapist(therapist,specialScope.clinic_id)}
|
|
32
|
+
})();
|
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<section class="panel assessment-panel" aria-labelledby="assessment-access-title">
|
|
4
|
+
<p class="eyebrow">آزمونهای شما</p>
|
|
5
|
+
<h1 id="assessment-access-title">ورود به آزمون</h1>
|
|
6
|
+
<p>برای دیدن آزمونهای اختصاصیافته، کد پیگیری نوبت و شماره همراه ثبتشده را وارد کنید.</p>
|
|
7
|
+
{% if error %}<p class="notice error" role="alert">{{ error }}</p>{% endif %}
|
|
8
|
+
<form method="post" action="{% url 'monicka_booking:assessments-list' %}" class="assessment-access-form">
|
|
9
|
+
{% csrf_token %}
|
|
10
|
+
<label for="confirmation-code">کد پیگیری نوبت</label>
|
|
11
|
+
<input id="confirmation-code" name="confirmation_code" autocomplete="off" dir="ltr" required maxlength="12">
|
|
12
|
+
<label for="assessment-phone">شماره همراه</label>
|
|
13
|
+
<input id="assessment-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" dir="ltr" required>
|
|
14
|
+
<button class="button primary" type="submit">نمایش آزمونها</button>
|
|
15
|
+
</form>
|
|
16
|
+
</section>
|
|
17
|
+
{% endblock %}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<section class="panel result" aria-labelledby="assessment-complete-title" role="status">
|
|
4
|
+
<p class="eyebrow">ثبت پاسخها</p>
|
|
5
|
+
<h1 id="assessment-complete-title">{% if result.status == 'already_completed' %}این آزمون پیشتر ثبت شده است{% else %}پاسخهای شما با موفقیت ثبت شد{% endif %}</h1>
|
|
6
|
+
<p>نتیجه برای درمانگر شما ثبت شد. برای بررسی یا تفسیر نتیجه با کلینیک تماس بگیرید.</p>
|
|
7
|
+
</section>
|
|
8
|
+
{% endblock %}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}
|
|
2
|
+
{% load static %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<section class="panel assessment-panel" aria-labelledby="assessment-title">
|
|
5
|
+
<p class="eyebrow">پاسخگویی به آزمون</p>
|
|
6
|
+
<h1 id="assessment-title">{{ form.title }}</h1>
|
|
7
|
+
<p>به همه پرسشها پاسخ دهید. پاسخها پس از ثبت برای درمانگر شما ارسال میشوند.</p>
|
|
8
|
+
<form method="post" action="{% url 'monicka_booking:assessment-submit' form.type %}" id="assessment-form">
|
|
9
|
+
{% csrf_token %}
|
|
10
|
+
<input type="hidden" name="confirmation_code" value="{{ confirmation_code }}">
|
|
11
|
+
<input type="hidden" name="phone" value="{{ phone }}">
|
|
12
|
+
<input type="hidden" name="question_count" value="{{ form.question_count }}">
|
|
13
|
+
<div class="assessment-progress" aria-label="پیشرفت آزمون"><span id="assessment-progress-label">پرسش ۱ از {{ form.question_count }}</span><progress id="assessment-progress" max="{{ form.question_count }}" value="1">۱ از {{ form.question_count }}</progress></div>
|
|
14
|
+
<div id="assessment-live" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
|
15
|
+
{% for question in form.questions %}
|
|
16
|
+
<fieldset class="assessment-question" data-question="{{ forloop.counter }}" {% if not forloop.first %}hidden{% endif %}>
|
|
17
|
+
<legend><span class="question-number">پرسش {{ forloop.counter }}</span>{{ question.text }}</legend>
|
|
18
|
+
<div class="assessment-choices">
|
|
19
|
+
{% for choice in question.choices %}
|
|
20
|
+
<label class="assessment-choice"><input type="radio" name="q{{ question.number }}" value="{{ choice.value }}" required><span>{{ choice.label }}</span></label>
|
|
21
|
+
{% endfor %}
|
|
22
|
+
</div>
|
|
23
|
+
</fieldset>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
<div class="assessment-nav">
|
|
26
|
+
<button class="button secondary" type="button" id="assessment-prev" disabled>پرسش پیشین</button>
|
|
27
|
+
<button class="button primary" type="button" id="assessment-next">پرسش بعدی</button>
|
|
28
|
+
<button class="button primary" type="submit" id="assessment-submit" hidden>ثبت پاسخها</button>
|
|
29
|
+
</div>
|
|
30
|
+
<noscript><p>برای پاسخگویی بدون جاوااسکریپت، همه پرسشها در همین صفحه نمایش داده میشوند.</p></noscript>
|
|
31
|
+
</form>
|
|
32
|
+
</section>
|
|
33
|
+
<script src="{% static 'monicka_booking/assessment.js' %}" defer></script>
|
|
34
|
+
{% endblock %}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<section class="panel assessment-panel" aria-labelledby="assessment-list-title">
|
|
4
|
+
<p class="eyebrow">آزمونهای شما</p>
|
|
5
|
+
<h1 id="assessment-list-title">آزمونهای اختصاصیافته</h1>
|
|
6
|
+
{% if assessments %}
|
|
7
|
+
<ul class="assessment-list">
|
|
8
|
+
{% for assessment in assessments %}
|
|
9
|
+
<li>
|
|
10
|
+
<div><h2>{{ assessment.title }}</h2><p>{{ assessment.question_count }} پرسش</p></div>
|
|
11
|
+
<form method="post" action="{% url 'monicka_booking:assessment-form' assessment.type %}">
|
|
12
|
+
{% csrf_token %}<input type="hidden" name="confirmation_code" value="{{ confirmation_code }}"><input type="hidden" name="phone" value="{{ phone }}">
|
|
13
|
+
<button class="button primary" type="submit">شروع آزمون</button>
|
|
14
|
+
</form>
|
|
15
|
+
</li>
|
|
16
|
+
{% endfor %}
|
|
17
|
+
</ul>
|
|
18
|
+
{% endif %}
|
|
19
|
+
{% if unavailable_assessments %}
|
|
20
|
+
<aside class="assessment-unavailable" role="status" aria-live="polite">
|
|
21
|
+
<h2>نیازمند پیگیری کلینیک</h2>
|
|
22
|
+
<p>برخی آزمونهای اختصاصیافته هنوز امکان تکمیل آنلاین ندارند. برای پیگیری با کلینیک تماس بگیرید.</p>
|
|
23
|
+
<ul>{% for assessment in unavailable_assessments %}<li>{{ assessment.title }}</li>{% endfor %}</ul>
|
|
24
|
+
</aside>
|
|
25
|
+
{% endif %}
|
|
26
|
+
{% if not assessments and not unavailable_assessments %}
|
|
27
|
+
<p role="status">در حال حاضر آزمون تکمیلنشدهای برای شما فعال نیست.</p>
|
|
28
|
+
{% endif %}
|
|
29
|
+
</section>
|
|
30
|
+
{% endblock %}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{% load static %}<!doctype html>
|
|
2
|
+
<html lang="fa" dir="rtl">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<meta name="color-scheme" content="light">
|
|
6
|
+
<title>{% block title %}{{ config.title|default:"رزرو نوبت" }}{% endblock %}</title>
|
|
7
|
+
<link rel="stylesheet" href="{% static 'monicka_booking/booking.css' %}">
|
|
8
|
+
</head>
|
|
9
|
+
<body class="theme-{{ config.theme|default:'ivory' }}" style="--font-scale: {{ config.font_scale|default:100 }}%">
|
|
10
|
+
<a class="skip-link" href="#main">رفتن به محتوای اصلی</a>
|
|
11
|
+
<main id="main" class="page-shell">{% block content %}{% endblock %}</main>
|
|
12
|
+
<footer class="monicka-powered">
|
|
13
|
+
<a href="https://www.monicka.ir/" target="_blank" rel="noopener noreferrer">
|
|
14
|
+
<img src="{% static 'monicka_booking/monicka-logo.png' %}" alt="نشان مونیکا">
|
|
15
|
+
<span><small>توانمند از</small><strong>مونیکا</strong></span>
|
|
16
|
+
</a>
|
|
17
|
+
</footer>
|
|
18
|
+
{% block scripts %}{% endblock %}
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}{% load static %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<header class="page-heading"><p class="eyebrow">نوبتدهی آنلاین</p><h1>{{ config.title }}</h1><p>{{ config.introduction }}</p></header>
|
|
4
|
+
{% if unavailable %}<div class="notice error" role="alert">در حال حاضر دریافت فهرست درمانگران ممکن نیست. لطفاً کمی بعد دوباره تلاش کنید.</div>{% endif %}
|
|
5
|
+
{% if invalid_scope %}<div class="notice error" role="alert">پیوند رزرو معتبر نیست یا این درمانگر در شعبهٔ انتخابشده فعالیت ندارد. لطفاً پیوند را از وبسایت کلینیک دوباره باز کنید.</div>{% endif %}
|
|
6
|
+
<div id="live-message" class="sr-only" role="status" aria-live="polite"></div>
|
|
7
|
+
{% if not invalid_scope %}<nav class="steps" aria-label="مراحل رزرو"><ol><li {% if not special_mode %}class="current"{% endif %} data-step-label="1">درمانگر و شعبه</li><li {% if special_mode %}class="current"{% endif %} data-step-label="2">روز و ساعت</li><li data-step-label="3">اطلاعات رزرو</li></ol></nav>{% endif %}
|
|
8
|
+
{% if not invalid_scope %}<section class="panel" id="step-therapist" aria-labelledby="therapist-title" {% if special_mode %}hidden{% endif %}>
|
|
9
|
+
<h2 id="therapist-title">درمانگر خود را انتخاب کنید</h2>
|
|
10
|
+
<div class="filters">
|
|
11
|
+
<label>جستوجوی نام یا تخصص<input id="therapist-search" type="search" autocomplete="off"></label>
|
|
12
|
+
{% if catalog.branches|length > 1 %}<label id="branch-filter-wrap">شعبه<select id="branch-filter"><option value="">همه شعبهها</option>{% for branch in catalog.branches %}<option value="{{ branch.id }}">{{ branch.name }}</option>{% endfor %}</select></label>{% endif %}
|
|
13
|
+
<label>مدت جلسه<select id="duration-filter"><option value="">همه مدتها</option></select></label>
|
|
14
|
+
</div>
|
|
15
|
+
<div id="therapist-list" class="therapist-grid" aria-live="polite">
|
|
16
|
+
{% for therapist in catalog.therapists %}<article class="therapist-card"><h3>{{ therapist.full_name }}</h3>{% if therapist.short_bio %}<p>{{ therapist.short_bio }}</p>{% endif %}<div>{% for specialty in therapist.specialties %}<span class="tag">{{ specialty }}</span>{% endfor %}</div>{% if catalog.branches|length > 1 %}<p class="muted">شعبهها</p><div>{% for branch in therapist.branches %}<span class="tag">{{ branch.clinic_name }}</span>{% endfor %}</div>{% endif %}<button type="button" class="button primary" data-therapist-id="{{ therapist.id }}">انتخاب درمانگر</button></article>{% empty %}<p>درمانگری برای نمایش وجود ندارد.</p>{% endfor %}
|
|
17
|
+
</div>
|
|
18
|
+
</section>
|
|
19
|
+
<section class="panel" id="step-calendar" aria-labelledby="calendar-title" {% if not special_mode %}hidden{% endif %}>
|
|
20
|
+
<div class="section-top"><div><h2 id="calendar-title">انتخاب روز و ساعت</h2><p id="selected-therapist"></p></div>{% if not special_mode %}<button type="button" class="button secondary" data-back="therapist">تغییر درمانگر</button>{% endif %}</div>
|
|
21
|
+
<div class="branch-row" id="branch-row" hidden><label for="branch-select">شعبه</label><select id="branch-select"></select></div>
|
|
22
|
+
<div class="calendar-head"><button type="button" id="previous-month" class="button secondary" aria-label="ماه قبل">ماه قبل</button><h3 id="month-label" aria-live="polite"></h3><button type="button" id="next-month" class="button secondary" aria-label="ماه بعد">ماه بعد</button></div>
|
|
23
|
+
<div class="calendar-grid" role="grid" aria-label="تقویم شمسی"><div class="weekday">شنبه</div><div class="weekday">یکشنبه</div><div class="weekday">دوشنبه</div><div class="weekday">سهشنبه</div><div class="weekday">چهارشنبه</div><div class="weekday">پنجشنبه</div><div class="weekday">جمعه</div></div>
|
|
24
|
+
<p id="calendar-help" class="muted">روزهای دارای نوبت با دکمه قابل انتخاب هستند.</p>
|
|
25
|
+
<div id="slot-area" hidden><h3>ساعتهای آزاد</h3><div id="slot-list" class="slot-list"></div></div>
|
|
26
|
+
</section>
|
|
27
|
+
<section class="panel" id="step-details" aria-labelledby="details-title" hidden>
|
|
28
|
+
<div class="section-top"><div><h2 id="details-title">اطلاعات رزرو</h2><p id="booking-summary"></p></div><button type="button" class="button secondary" data-back="calendar">تغییر ساعت</button></div>
|
|
29
|
+
<form method="post" action="{{ submit_url }}" id="booking-form">{% csrf_token %}
|
|
30
|
+
<input type="hidden" name="clinic" id="form-clinic"><input type="hidden" name="therapist" id="form-therapist"><input type="hidden" name="starts_at" id="form-starts-at"><input type="hidden" name="duration_minutes" id="form-duration"><input type="hidden" name="idempotency_key" value="{{ idempotency_key }}"><input type="hidden" name="booking_scope" value="{{ scope_token }}">
|
|
31
|
+
<label>نام و نام خانوادگی رزروکننده<input name="patient_name" required maxlength="200" autocomplete="name"></label>
|
|
32
|
+
<label>شماره تلفن همراه<input name="patient_phone" required inputmode="tel" autocomplete="tel" pattern="[+0-9۰-۹٠-٩\- ]{10,16}" aria-describedby="phone-hint"><span id="phone-hint" class="hint">شماره تلفن همراه ایران را وارد کنید.</span></label>
|
|
33
|
+
<label>نوع جلسه<select name="session_type"><option value="in_person">حضوری</option><option value="online">آنلاین</option></select><span class="hint">برای هر دو نوع جلسه، اتاق مناسب بهصورت خودکار رزرو میشود.</span></label>
|
|
34
|
+
<fieldset><legend>افراد همراه در همین جلسه <span class="muted">(در صورت نیاز)</span></legend><div id="participants"></div><button type="button" id="add-participant" class="button secondary">افزودن همراه</button></fieldset>
|
|
35
|
+
<p class="notice">پرداخت امن در صفحه درگاه بانکی انجام میشود. برای رزرو گروهی، نام و شماره هر فرد را وارد کنید.</p>
|
|
36
|
+
<button class="button primary" type="submit">ادامه به پرداخت</button>
|
|
37
|
+
</form>
|
|
38
|
+
</section>
|
|
39
|
+
{% endif %}
|
|
40
|
+
{% if config.show_phone_support and config.support_phone %}<footer class="support">نیاز به راهنمایی دارید؟ <a href="tel:{{ config.support_phone|urlencode }}">{{ config.support_phone }}</a></footer>{% endif %}
|
|
41
|
+
{{ catalog|json_script:"monicka-catalog" }}
|
|
42
|
+
{{ special_scope|json_script:"booking-scope" }}{{ invalid_scope|json_script:"booking-scope-invalid" }}
|
|
43
|
+
{{ availability_url|json_script:"availability-url" }}{{ slots_url|json_script:"slots-url" }}{{ config.currency_label|json_script:"currency-label" }}
|
|
44
|
+
{% endblock %}
|
|
45
|
+
{% block scripts %}<script src="{% static 'monicka_booking/booking.js' %}" defer></script>{% endblock %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}{% block content %}<section class="panel result" aria-labelledby="complete-title"><p class="eyebrow">پیگیری نوبت</p><h1 id="complete-title">{% if booking.status == 'paid' %}پرداخت و رزرو شما ثبت شد{% elif booking.status == 'awaiting_payment' %}پرداخت در حال بررسی است{% elif booking.status == 'payment_failed' %}پرداخت تأیید نشد{% else %}وضعیت رزرو{% endif %}</h1><p>درمانگر: {{ booking.therapist }}</p><p>شعبه: {{ booking.clinic }}</p><p>وضعیت: {{ booking.get_status_display|default:booking.status }}</p><div class="confirmation"><span>کد پیگیری شما</span><strong dir="ltr">{{ booking.confirmation_code }}</strong></div><p>این کد را برای پیگیری نزد خود نگه دارید.</p></section>{% endblock %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}{% block content %}<section class="panel result"><p class="eyebrow">رزرو نوبت</p><h1>تکمیل درخواست ممکن نشد</h1><p>ارتباط با سامانه رزرو برقرار نشد یا اطلاعات درخواست کامل نیست. لطفاً دوباره تلاش کنید یا با کلینیک تماس بگیرید.</p>{% if confirmation_code %}<p>کد پیگیری: <strong dir="ltr">{{ confirmation_code }}</strong></p>{% endif %}<a class="button primary" href="{% url 'monicka_booking:booking' %}">بازگشت به نوبتدهی</a></section>{% endblock %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{% extends "monicka_booking/base.html" %}{% block content %}<section class="panel result"><p class="eyebrow">پرداخت امن</p><h1>در حال انتقال به درگاه بانکی</h1><p>برای ادامه، دکمه زیر را انتخاب کنید. پس از پرداخت به این صفحه بازمیگردید.</p><form id="gateway-form" method="{{ gateway.method|default:'post'|lower }}" action="{{ gateway.url }}" autocomplete="off">{% for name, value in gateway.params.items %}<input type="hidden" name="{{ name }}" value="{{ value }}">{% endfor %}<button class="button primary" type="submit">ورود به درگاه بانکی</button></form>{% if confirmation_code %}<p class="muted">کد پیگیری: <strong dir="ltr">{{ confirmation_code }}</strong></p>{% endif %}</section>{% endblock %}
|
monicka_booking/urls.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from . import views
|
|
4
|
+
|
|
5
|
+
app_name = "monicka_booking"
|
|
6
|
+
|
|
7
|
+
urlpatterns = [
|
|
8
|
+
path("", views.booking_page, name="booking"),
|
|
9
|
+
path("availability/", views.availability, name="availability"),
|
|
10
|
+
path("slots/", views.slots, name="slots"),
|
|
11
|
+
path("submit/", views.submit_booking, name="submit"),
|
|
12
|
+
path("complete/", views.complete, name="complete"),
|
|
13
|
+
path("assessments/", views.assessments_access, name="assessments-access"),
|
|
14
|
+
path("assessments/list/", views.assessments_list, name="assessments-list"),
|
|
15
|
+
path("assessments/<slug:assessment_type>/", views.assessment_form, name="assessment-form"),
|
|
16
|
+
path("assessments/<slug:assessment_type>/submit/", views.assessment_submit, name="assessment-submit"),
|
|
17
|
+
]
|
monicka_booking/views.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from django.core import signing
|
|
4
|
+
from django.core.signing import BadSignature
|
|
5
|
+
from django.http import JsonResponse
|
|
6
|
+
from django.shortcuts import redirect, render
|
|
7
|
+
from django.urls import reverse
|
|
8
|
+
from django.views.decorators.http import require_GET, require_POST
|
|
9
|
+
|
|
10
|
+
from .client import MonickaAPIClient, MonickaAPIError
|
|
11
|
+
from .models import BookingWidgetConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _client():
|
|
15
|
+
config = BookingWidgetConfig.get_solo()
|
|
16
|
+
return config, MonickaAPIClient(config)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _error_response():
|
|
20
|
+
return JsonResponse({"error": "سرویس رزرو موقتاً در دسترس نیست. کمی بعد دوباره تلاش کنید."}, status=503)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@require_GET
|
|
24
|
+
def booking_page(request):
|
|
25
|
+
config, api = _client()
|
|
26
|
+
scope_requested = "therapist" in request.GET or "clinic" in request.GET
|
|
27
|
+
scope = None
|
|
28
|
+
invalid_scope = False
|
|
29
|
+
try:
|
|
30
|
+
catalog = api.catalog()
|
|
31
|
+
except MonickaAPIError:
|
|
32
|
+
catalog = {"branches": [], "therapists": []}
|
|
33
|
+
unavailable = True
|
|
34
|
+
else:
|
|
35
|
+
unavailable = False
|
|
36
|
+
if scope_requested:
|
|
37
|
+
therapist_id = request.GET.get("therapist", "")
|
|
38
|
+
clinic_id = request.GET.get("clinic", "")
|
|
39
|
+
if not (therapist_id.isascii() and therapist_id.isdecimal()
|
|
40
|
+
and clinic_id.isascii() and clinic_id.isdecimal()):
|
|
41
|
+
invalid_scope = True
|
|
42
|
+
else:
|
|
43
|
+
therapist_id, clinic_id = int(therapist_id), int(clinic_id)
|
|
44
|
+
therapist = next((item for item in catalog.get("therapists", [])
|
|
45
|
+
if item.get("id") == therapist_id), None)
|
|
46
|
+
branch = next((item for item in (therapist or {}).get("branches", [])
|
|
47
|
+
if item.get("clinic_id") == clinic_id), None)
|
|
48
|
+
if therapist and branch:
|
|
49
|
+
scope = {"therapist_id": therapist_id, "clinic_id": clinic_id}
|
|
50
|
+
catalog = {
|
|
51
|
+
**catalog,
|
|
52
|
+
"branches": [branch],
|
|
53
|
+
"therapists": [{**therapist, "branches": [branch]}],
|
|
54
|
+
}
|
|
55
|
+
else:
|
|
56
|
+
invalid_scope = True
|
|
57
|
+
if invalid_scope:
|
|
58
|
+
catalog = {**catalog, "branches": [], "therapists": []}
|
|
59
|
+
scope_token = signing.dumps(scope, salt="monicka-booking-scope") if scope else ""
|
|
60
|
+
return render(request, "monicka_booking/booking.html", {
|
|
61
|
+
"config": config, "catalog": catalog, "unavailable": unavailable,
|
|
62
|
+
"special_mode": scope is not None, "special_scope": scope,
|
|
63
|
+
"scope_token": scope_token, "invalid_scope": invalid_scope,
|
|
64
|
+
"availability_url": reverse("monicka_booking:availability"),
|
|
65
|
+
"slots_url": reverse("monicka_booking:slots"),
|
|
66
|
+
"submit_url": reverse("monicka_booking:submit"),
|
|
67
|
+
"idempotency_key": uuid.uuid4(),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@require_GET
|
|
72
|
+
def availability(request):
|
|
73
|
+
config, api = _client()
|
|
74
|
+
try:
|
|
75
|
+
data = api.availability(clinic=request.GET["clinic"], therapist=request.GET["therapist"],
|
|
76
|
+
start=request.GET["start"], end=request.GET["end"])
|
|
77
|
+
except (MonickaAPIError, KeyError, ValueError):
|
|
78
|
+
return _error_response()
|
|
79
|
+
return JsonResponse(data)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@require_GET
|
|
83
|
+
def slots(request):
|
|
84
|
+
_, api = _client()
|
|
85
|
+
try:
|
|
86
|
+
data = api.slots(clinic=request.GET["clinic"], therapist=request.GET["therapist"], date=request.GET["date"])
|
|
87
|
+
except (MonickaAPIError, KeyError, ValueError):
|
|
88
|
+
return _error_response()
|
|
89
|
+
return JsonResponse({"slots": data})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@require_POST
|
|
93
|
+
def submit_booking(request):
|
|
94
|
+
try:
|
|
95
|
+
starts_at = request.POST["starts_at"]
|
|
96
|
+
scope_token = request.POST.get("booking_scope", "")
|
|
97
|
+
if scope_token:
|
|
98
|
+
scope = signing.loads(scope_token, salt="monicka-booking-scope")
|
|
99
|
+
if (int(request.POST["clinic"]) != scope["clinic_id"]
|
|
100
|
+
or int(request.POST["therapist"]) != scope["therapist_id"]):
|
|
101
|
+
return render(request, "monicka_booking/error.html", status=400)
|
|
102
|
+
payload = {
|
|
103
|
+
"clinic": int(request.POST["clinic"]), "therapist": int(request.POST["therapist"]),
|
|
104
|
+
"patient_name": request.POST["patient_name"].strip(),
|
|
105
|
+
"patient_phone": request.POST["patient_phone"].strip(),
|
|
106
|
+
"duration_minutes": int(request.POST["duration_minutes"]), "starts_at": starts_at,
|
|
107
|
+
"idempotency_key": request.POST.get("idempotency_key") or str(uuid.uuid4()),
|
|
108
|
+
"session_type": request.POST.get("session_type", "in_person"),
|
|
109
|
+
"return_url": request.build_absolute_uri(reverse("monicka_booking:complete")),
|
|
110
|
+
}
|
|
111
|
+
names = request.POST.getlist("participant_name")
|
|
112
|
+
phones = request.POST.getlist("participant_phone")
|
|
113
|
+
if len(names) != len(phones):
|
|
114
|
+
return render(request, "monicka_booking/error.html", status=400)
|
|
115
|
+
payload["participant_contacts"] = [{"patient_name": name.strip(), "patient_phone": phone.strip()}
|
|
116
|
+
for name, phone in zip(names, phones) if name.strip() or phone.strip()]
|
|
117
|
+
_, api = _client()
|
|
118
|
+
response = api.book(payload)
|
|
119
|
+
except BadSignature:
|
|
120
|
+
return render(request, "monicka_booking/error.html", status=400)
|
|
121
|
+
except (MonickaAPIError, KeyError, TypeError, ValueError):
|
|
122
|
+
return render(request, "monicka_booking/error.html", status=503)
|
|
123
|
+
gateway = response.get("gateway")
|
|
124
|
+
if gateway and gateway.get("url"):
|
|
125
|
+
return render(request, "monicka_booking/gateway.html", {"gateway": gateway, "confirmation_code": response.get("confirmation_code")})
|
|
126
|
+
return redirect(f"{reverse('monicka_booking:complete')}?confirmation={response.get('confirmation_code', '')}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@require_GET
|
|
130
|
+
def complete(request):
|
|
131
|
+
code = request.GET.get("confirmation", "").strip().upper()
|
|
132
|
+
if not code:
|
|
133
|
+
return render(request, "monicka_booking/error.html", status=400)
|
|
134
|
+
_, api = _client()
|
|
135
|
+
try:
|
|
136
|
+
booking = api.confirmation(code)
|
|
137
|
+
except MonickaAPIError:
|
|
138
|
+
return render(request, "monicka_booking/error.html", {"confirmation_code": code}, status=503)
|
|
139
|
+
return render(request, "monicka_booking/complete.html", {"booking": booking})
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@require_GET
|
|
143
|
+
def assessments_access(request):
|
|
144
|
+
return render(request, "monicka_booking/assessment_access.html")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@require_POST
|
|
148
|
+
def assessments_list(request):
|
|
149
|
+
code = request.POST.get("confirmation_code", "").strip().upper()
|
|
150
|
+
phone = request.POST.get("phone", "").strip()
|
|
151
|
+
if not code or not phone:
|
|
152
|
+
return render(request, "monicka_booking/error.html", status=400)
|
|
153
|
+
_, api = _client()
|
|
154
|
+
try:
|
|
155
|
+
data = api.assessment_assignments(code=code, phone=phone)
|
|
156
|
+
except MonickaAPIError as exc:
|
|
157
|
+
return render(request, "monicka_booking/assessment_access.html", {
|
|
158
|
+
"error": "کد پیگیری یا شماره همراه درست نیست، یا آزمونی برای شما فعال نشده است."},
|
|
159
|
+
status=403 if exc.status_code in (401, 403, 404) else 503)
|
|
160
|
+
return render(request, "monicka_booking/assessments.html", {
|
|
161
|
+
"assessments": data.get("assessments", []),
|
|
162
|
+
"unavailable_assessments": data.get("unavailable_assessments", []),
|
|
163
|
+
"confirmation_code": code, "phone": phone})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@require_POST
|
|
167
|
+
def assessment_form(request, assessment_type):
|
|
168
|
+
code = request.POST.get("confirmation_code", "").strip().upper()
|
|
169
|
+
phone = request.POST.get("phone", "").strip()
|
|
170
|
+
if not code or not phone:
|
|
171
|
+
return render(request, "monicka_booking/error.html", status=400)
|
|
172
|
+
_, api = _client()
|
|
173
|
+
try:
|
|
174
|
+
form = api.assessment_questionnaire(code=code, phone=phone, assessment_type=assessment_type)
|
|
175
|
+
except MonickaAPIError as exc:
|
|
176
|
+
return render(request, "monicka_booking/error.html", status=403 if exc.status_code in (401, 403, 404) else 503)
|
|
177
|
+
return render(request, "monicka_booking/assessment_form.html", {
|
|
178
|
+
"form": form, "confirmation_code": code, "phone": phone})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@require_POST
|
|
182
|
+
def assessment_submit(request, assessment_type):
|
|
183
|
+
code = request.POST.get("confirmation_code", "").strip().upper()
|
|
184
|
+
phone = request.POST.get("phone", "").strip()
|
|
185
|
+
try:
|
|
186
|
+
count = int(request.POST.get("question_count", "0"))
|
|
187
|
+
answers = [int(request.POST[f"q{number}"]) for number in range(1, count + 1)]
|
|
188
|
+
except (ValueError, KeyError):
|
|
189
|
+
return render(request, "monicka_booking/error.html", status=400)
|
|
190
|
+
_, api = _client()
|
|
191
|
+
try:
|
|
192
|
+
result = api.submit_assessment(code=code, phone=phone,
|
|
193
|
+
assessment_type=assessment_type, answers=answers)
|
|
194
|
+
except MonickaAPIError as exc:
|
|
195
|
+
return render(request, "monicka_booking/error.html", status=400 if exc.status_code == 400 else 503)
|
|
196
|
+
return render(request, "monicka_booking/assessment_complete.html", {"result": result})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monicka-booking
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Accessible Persian clinic booking pages for Monicka API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: Django<6,>=4.2
|
|
9
|
+
Requires-Dist: requests<3,>=2.31
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest<9,>=8; extra == "test"
|
|
12
|
+
Requires-Dist: pytest-django<5,>=4.8; extra == "test"
|
|
13
|
+
|
|
14
|
+
# Monicka Booking
|
|
15
|
+
|
|
16
|
+
An accessible, Persian right-to-left booking app for Django clinic websites. It is a server-rendered Django app distributed as a pip package; it does not expose clinic API keys to the visitor's browser and uses no JavaScript framework or external font service.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install monicka-booking
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Add the app, set its API key in the website process environment, and mount its URLs:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
# settings.py
|
|
28
|
+
INSTALLED_APPS += ["monicka_booking"]
|
|
29
|
+
MONICKA_API_KEY = "<the clinic API key>" # Prefer an environment variable/secret manager.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# urls.py
|
|
34
|
+
from django.urls import include, path
|
|
35
|
+
|
|
36
|
+
urlpatterns += [path("booking/", include("monicka_booking.urls"))]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then run `python manage.py migrate`. A staff user can configure title, colors, text size, displayed currency label, and optional support phone in Django admin under **Booking page settings**. Set the API origin and home clinic ID there. The API key is intentionally configured outside admin and is never sent to the browser.
|
|
40
|
+
|
|
41
|
+
## API setup
|
|
42
|
+
|
|
43
|
+
Issue a clinic API key in Monicka and set the key's home clinic `website_url` to this site's HTTPS origin (including the same origin used by the plugin payment return URL). Set each clinic's `plugin_booking_days` in API admin. The default booking window is 20 future days. All clinics with the same owner are offered as branches in one catalog. Therapist profile fields (short bio and specialties) are maintained in API admin.
|
|
44
|
+
|
|
45
|
+
The package talks to the API over HTTPS using `X-Clinic-API-Key`. Website hosting must allow outbound HTTPS requests to the configured API. The payment callback returns to the plugin completion page, where the package verifies the confirmation code server-to-server before showing appointment status.
|
|
46
|
+
|
|
47
|
+
### Direct therapist booking links
|
|
48
|
+
|
|
49
|
+
To link directly to one therapist at one branch, use the regular booking URL with both public IDs:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
/booking/?therapist=133&clinic=5
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The plugin checks that this therapist is actually offered at that clinic before entering the calendar step. The page then contains only that therapist and branch, skips the therapist/branch directory, and continues through the normal availability, booking, and payment checks. Invalid or mismatched IDs show an error instead of opening the full directory. These IDs are selectors, not credentials; do not put a patient's name, phone number, or other personal details in a URL. The API still validates every selected time and booking.
|
|
56
|
+
|
|
57
|
+
## Booking flow and accessibility
|
|
58
|
+
|
|
59
|
+
The initial therapist catalog is server-rendered. JavaScript progressively loads the Jalali calendar's available days and slots from same-origin Django views; dates and slots are always validated again by the API at booking time. The responsive UI uses semantic form labels, keyboard-operable buttons, visible focus rings, large controls, live status announcements, high-contrast light themes, adjustable type size, and reduced-motion support.
|
|
60
|
+
|
|
61
|
+
Theme choices are intentionally CSS-variable based so future themes can be added without introducing a UI framework. The calendar uses the browser's built-in Persian calendar formatter.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pip install -e '.[test]'
|
|
67
|
+
python -m django test --settings tests.settings
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
You can also run the source checkout as a standalone local Django site without installing `monicka-booking`. Use an existing Python environment with Django and Requests available:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
export PYTHONPATH="$PWD/src"
|
|
74
|
+
DJANGO_PYTHON="/path/to/python-with-django"
|
|
75
|
+
"$DJANGO_PYTHON" -m django migrate --settings tests.dev_settings
|
|
76
|
+
"$DJANGO_PYTHON" -m django createsuperuser --settings tests.dev_settings
|
|
77
|
+
"$DJANGO_PYTHON" -m django runserver 127.0.0.1:8000 --settings tests.dev_settings
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Open `http://localhost:8000/booking/` for the booking page or `/admin/` to configure its title and theme. This local setup uses a persistent SQLite database at `dev.sqlite3`; the test settings continue to use an in-memory database. Without `MONICKA_API_KEY` and a reachable API the page shows its friendly unavailable state. To try real slots and payment handoff, point it at a staging API and test gateway. A production project supplies its own Django settings, database, and admin configuration.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
monicka_booking/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
monicka_booking/admin.py,sha256=gjH91uoefiQwAGNMKdJfZ5gFq2UwP_QLYqsvlJ1awes,898
|
|
3
|
+
monicka_booking/apps.py,sha256=zptjrq2ARBjQr9Q9V-lGWxaZyIpJd3ErkLEG-IqZ_Gw,210
|
|
4
|
+
monicka_booking/client.py,sha256=LUZdSPhaDIygMFUzwMGElUtzOKXGLyTAJF9Ql_DP0wk,2773
|
|
5
|
+
monicka_booking/models.py,sha256=z84rZpYcNXZrZSBCYQA0GTtCZAhw7RRs9jYHc1H62zI,2504
|
|
6
|
+
monicka_booking/urls.py,sha256=nnuPprBO__sLiIf3bfhVAIahv7Kd-Vzin6QTe03bpeQ,740
|
|
7
|
+
monicka_booking/views.py,sha256=Kh5NEbeX9d5WzzUr689zfG6h-4VJ37exIS8Ox-6A-98,8751
|
|
8
|
+
monicka_booking/migrations/0001_initial.py,sha256=7vsltIVlF7JC-Ltbj7dBYHrh04WyOcJF3vldNJsYsvM,1457
|
|
9
|
+
monicka_booking/migrations/0002_alter_bookingwidgetconfig_currency_label.py,sha256=oG2n3EVYp7BN-YTWHhiRcmGPRfKlAT5ycwNHpKo04K8,506
|
|
10
|
+
monicka_booking/migrations/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
+
monicka_booking/static/monicka_booking/assessment.js,sha256=95YvznpRuDLTgLr2WjkdDCE9v8Ttu0PTVRFHyqHj2gY,2117
|
|
12
|
+
monicka_booking/static/monicka_booking/booking.css,sha256=jcy8GvIQGKpHT7S3k60NM7Eb0WLjx6fHFbLn61z98PY,7772
|
|
13
|
+
monicka_booking/static/monicka_booking/booking.js,sha256=Cm93mTi2yqmkgPsYuvjxQjdSguVXwlL8rXCp33Py9Bc,10363
|
|
14
|
+
monicka_booking/static/monicka_booking/monicka-logo.png,sha256=0dra11Nw8Td_-d2lHCMk5_3mJKK9TH32a00WuVx1cCw,73664
|
|
15
|
+
monicka_booking/templates/monicka_booking/assessment_access.html,sha256=24Ur8s67cATGi6tohfc1zc6AkLJrmOL3VqfUC3Iw_Ko,1120
|
|
16
|
+
monicka_booking/templates/monicka_booking/assessment_complete.html,sha256=yb_hhHE5YBnXXiFMfIkY4zB7oYkH4f2DVlyHRhf0rsI,592
|
|
17
|
+
monicka_booking/templates/monicka_booking/assessment_form.html,sha256=o3adY5lr7r9u3Rj-_v_vmDaRxNGyjmNRu-g_YZ-NDSg,2407
|
|
18
|
+
monicka_booking/templates/monicka_booking/assessments.html,sha256=Rv--09A_4_QyVjOAliaXwZlD70T0PLr4VXimZY_ZuXU,1607
|
|
19
|
+
monicka_booking/templates/monicka_booking/base.html,sha256=px-eawdvFZgQc96oXm8nuqAxaH021fw0HwKoHPQwiFM,1019
|
|
20
|
+
monicka_booking/templates/monicka_booking/booking.html,sha256=NSbgVFULxVLYYxxNVd2RYsXjm9nx0AU2irD1GUsPovU,6928
|
|
21
|
+
monicka_booking/templates/monicka_booking/complete.html,sha256=qgTREUxRbXWxAiUVRmCAzwRO2_-7qabXVYpXg-sbCDQ,863
|
|
22
|
+
monicka_booking/templates/monicka_booking/error.html,sha256=q-qVmHQqhudVoL_pLLls-pC_p30eY2XKc0Nsyel35Gc,634
|
|
23
|
+
monicka_booking/templates/monicka_booking/gateway.html,sha256=5Zvd0TTReYhycgJaYnKHlQuR1wGG-Bt0qPv_9e7x7aA,818
|
|
24
|
+
monicka_booking-0.1.0.dist-info/METADATA,sha256=2KerO-tIHkCF4euWYk0RMxzpZtoATFaaKNo_KlkN4sI,4640
|
|
25
|
+
monicka_booking-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
26
|
+
monicka_booking-0.1.0.dist-info/top_level.txt,sha256=YYSFIy-3am7GWKAT9CVV6cfsSZCa035mbZsnp-X4Qgc,16
|
|
27
|
+
monicka_booking-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
monicka_booking
|