monicka-booking 0.1.1__tar.gz

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.
Files changed (40) hide show
  1. monicka_booking-0.1.1/MANIFEST.in +3 -0
  2. monicka_booking-0.1.1/PKG-INFO +96 -0
  3. monicka_booking-0.1.1/README.md +83 -0
  4. monicka_booking-0.1.1/pyproject.toml +32 -0
  5. monicka_booking-0.1.1/setup.cfg +4 -0
  6. monicka_booking-0.1.1/src/monicka_booking/__init__.py +1 -0
  7. monicka_booking-0.1.1/src/monicka_booking/admin.py +23 -0
  8. monicka_booking-0.1.1/src/monicka_booking/apps.py +7 -0
  9. monicka_booking-0.1.1/src/monicka_booking/client.py +75 -0
  10. monicka_booking-0.1.1/src/monicka_booking/migrations/0001_initial.py +19 -0
  11. monicka_booking-0.1.1/src/monicka_booking/migrations/0002_alter_bookingwidgetconfig_currency_label.py +18 -0
  12. monicka_booking-0.1.1/src/monicka_booking/migrations/__init__.py +1 -0
  13. monicka_booking-0.1.1/src/monicka_booking/models.py +55 -0
  14. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/assessment.js +48 -0
  15. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/booking.css +20 -0
  16. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/booking.js +50 -0
  17. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/fonts/OFL.txt +29 -0
  18. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/fonts/Vazirmatn-Bold.ttf +0 -0
  19. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/fonts/Vazirmatn-Regular.ttf +0 -0
  20. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/fonts/Vazirmatn-SemiBold.ttf +0 -0
  21. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/monicka-logo.png +0 -0
  22. monicka_booking-0.1.1/src/monicka_booking/static/monicka_booking/therapist-placeholder.svg +13 -0
  23. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/assessment_access.html +17 -0
  24. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/assessment_complete.html +8 -0
  25. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/assessment_form.html +34 -0
  26. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/assessments.html +30 -0
  27. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/base.html +20 -0
  28. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/booking.html +52 -0
  29. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/complete.html +1 -0
  30. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/error.html +1 -0
  31. monicka_booking-0.1.1/src/monicka_booking/templates/monicka_booking/gateway.html +1 -0
  32. monicka_booking-0.1.1/src/monicka_booking/urls.py +17 -0
  33. monicka_booking-0.1.1/src/monicka_booking/views.py +205 -0
  34. monicka_booking-0.1.1/src/monicka_booking.egg-info/PKG-INFO +96 -0
  35. monicka_booking-0.1.1/src/monicka_booking.egg-info/SOURCES.txt +38 -0
  36. monicka_booking-0.1.1/src/monicka_booking.egg-info/dependency_links.txt +1 -0
  37. monicka_booking-0.1.1/src/monicka_booking.egg-info/requires.txt +6 -0
  38. monicka_booking-0.1.1/src/monicka_booking.egg-info/top_level.txt +1 -0
  39. monicka_booking-0.1.1/tests/test_booking_quality.py +146 -0
  40. monicka_booking-0.1.1/tests/test_plugin.py +351 -0
@@ -0,0 +1,3 @@
1
+ recursive-include src/monicka_booking/templates *.html
2
+ recursive-include src/monicka_booking/static *.css *.js *.png
3
+ recursive-include src/monicka_booking/migrations *.py
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: monicka-booking
3
+ Version: 0.1.1
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
+ ### Static files in production
42
+
43
+ The package contains its CSS, JavaScript, and logo under Django's app static directory. Django's development server discovers these directly when `django.contrib.staticfiles` and `monicka_booking` are in `INSTALLED_APPS`. Production servers do not serve app assets automatically: configure `STATIC_ROOT` and run `collectstatic` whenever the package is installed or upgraded, then serve that directory through Nginx or WhiteNoise.
44
+
45
+ ```python
46
+ # settings.py
47
+ STATIC_URL = "/static/"
48
+ STATIC_ROOT = BASE_DIR / "staticfiles"
49
+ ```
50
+
51
+ ```sh
52
+ python manage.py collectstatic --noinput
53
+ ```
54
+
55
+ For Nginx, map `/static/` to the `STATIC_ROOT` directory. If the site uses a manifest-based static storage backend, keep its collected manifest and hashed assets together. A successful `collectstatic` should include `monicka_booking/booking.css`, `monicka_booking/booking.js`, `monicka_booking/assessment.js`, and `monicka_booking/monicka-logo.png`.
56
+
57
+ ## API setup
58
+
59
+ 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.
60
+
61
+ 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.
62
+
63
+ ### Direct therapist booking links
64
+
65
+ To link directly to one therapist at one branch, use the regular booking URL with both public IDs:
66
+
67
+ ```text
68
+ /booking/?therapist=133&clinic=5
69
+ ```
70
+
71
+ 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.
72
+
73
+ ## Booking flow and accessibility
74
+
75
+ 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.
76
+
77
+ 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.
78
+
79
+ ## Development
80
+
81
+ ```sh
82
+ pip install -e '.[test]'
83
+ python -m django test --settings tests.settings
84
+ ```
85
+
86
+ 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:
87
+
88
+ ```sh
89
+ export PYTHONPATH="$PWD/src"
90
+ DJANGO_PYTHON="/path/to/python-with-django"
91
+ "$DJANGO_PYTHON" -m django migrate --settings tests.dev_settings
92
+ "$DJANGO_PYTHON" -m django createsuperuser --settings tests.dev_settings
93
+ "$DJANGO_PYTHON" -m django runserver 127.0.0.1:8000 --settings tests.dev_settings
94
+ ```
95
+
96
+ 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,83 @@
1
+ # Monicka Booking
2
+
3
+ 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.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install monicka-booking
9
+ ```
10
+
11
+ Add the app, set its API key in the website process environment, and mount its URLs:
12
+
13
+ ```python
14
+ # settings.py
15
+ INSTALLED_APPS += ["monicka_booking"]
16
+ MONICKA_API_KEY = "<the clinic API key>" # Prefer an environment variable/secret manager.
17
+ ```
18
+
19
+ ```python
20
+ # urls.py
21
+ from django.urls import include, path
22
+
23
+ urlpatterns += [path("booking/", include("monicka_booking.urls"))]
24
+ ```
25
+
26
+ 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.
27
+
28
+ ### Static files in production
29
+
30
+ The package contains its CSS, JavaScript, and logo under Django's app static directory. Django's development server discovers these directly when `django.contrib.staticfiles` and `monicka_booking` are in `INSTALLED_APPS`. Production servers do not serve app assets automatically: configure `STATIC_ROOT` and run `collectstatic` whenever the package is installed or upgraded, then serve that directory through Nginx or WhiteNoise.
31
+
32
+ ```python
33
+ # settings.py
34
+ STATIC_URL = "/static/"
35
+ STATIC_ROOT = BASE_DIR / "staticfiles"
36
+ ```
37
+
38
+ ```sh
39
+ python manage.py collectstatic --noinput
40
+ ```
41
+
42
+ For Nginx, map `/static/` to the `STATIC_ROOT` directory. If the site uses a manifest-based static storage backend, keep its collected manifest and hashed assets together. A successful `collectstatic` should include `monicka_booking/booking.css`, `monicka_booking/booking.js`, `monicka_booking/assessment.js`, and `monicka_booking/monicka-logo.png`.
43
+
44
+ ## API setup
45
+
46
+ 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.
47
+
48
+ 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.
49
+
50
+ ### Direct therapist booking links
51
+
52
+ To link directly to one therapist at one branch, use the regular booking URL with both public IDs:
53
+
54
+ ```text
55
+ /booking/?therapist=133&clinic=5
56
+ ```
57
+
58
+ 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.
59
+
60
+ ## Booking flow and accessibility
61
+
62
+ 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.
63
+
64
+ 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.
65
+
66
+ ## Development
67
+
68
+ ```sh
69
+ pip install -e '.[test]'
70
+ python -m django test --settings tests.settings
71
+ ```
72
+
73
+ 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:
74
+
75
+ ```sh
76
+ export PYTHONPATH="$PWD/src"
77
+ DJANGO_PYTHON="/path/to/python-with-django"
78
+ "$DJANGO_PYTHON" -m django migrate --settings tests.dev_settings
79
+ "$DJANGO_PYTHON" -m django createsuperuser --settings tests.dev_settings
80
+ "$DJANGO_PYTHON" -m django runserver 127.0.0.1:8000 --settings tests.dev_settings
81
+ ```
82
+
83
+ 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,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "monicka-booking"
7
+ version = "0.1.1"
8
+ description = "Accessible Persian clinic booking pages for Monicka API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = [
13
+ "Django>=4.2,<6",
14
+ "requests>=2.31,<3",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9", "pytest-django>=4.8,<5"]
19
+
20
+ [tool.setuptools]
21
+ package-dir = {"" = "src"}
22
+ include-package-data = true
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.setuptools.package-data]
28
+ monicka_booking = ["templates/**/*.html", "static/**/*", "migrations/*.py"]
29
+
30
+ [tool.pytest.ini_options]
31
+ DJANGO_SETTINGS_MODULE = "tests.settings"
32
+ python_files = ["test_*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -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
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class MonickaBookingConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "monicka_booking"
7
+ verbose_name = "Monicka appointment booking"
@@ -0,0 +1,75 @@
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, reason="upstream_error"):
9
+ self.status_code = status_code
10
+ self.reason = reason
11
+ super().__init__("The booking service is temporarily unavailable.")
12
+
13
+
14
+ class MonickaAPIClient:
15
+ def __init__(self, config, *, session=None, api_key=None):
16
+ self.config = config
17
+ self.session = session or requests.Session()
18
+ self.api_key = api_key if api_key is not None else getattr(settings, "MONICKA_API_KEY", "")
19
+
20
+ @property
21
+ def configured(self):
22
+ return bool(self.api_key and self.config.primary_clinic_id)
23
+
24
+ def _request(self, method, path, **kwargs):
25
+ if not self.api_key:
26
+ raise MonickaAPIError(reason="missing_api_key")
27
+ if not self.config.primary_clinic_id:
28
+ raise MonickaAPIError(reason="missing_primary_clinic_id")
29
+ base = self.config.api_base_url.rstrip("/") + "/"
30
+ try:
31
+ response = self.session.request(method, urljoin(base, path.lstrip("/")),
32
+ headers={"X-Clinic-API-Key": self.api_key, "Accept": "application/json"},
33
+ timeout=(3.05, 15), **kwargs)
34
+ response.raise_for_status()
35
+ return response.json()
36
+ except requests.HTTPError as exc:
37
+ status = getattr(getattr(exc, "response", None), "status_code", None)
38
+ raise MonickaAPIError(status, "http_error") from None
39
+ except requests.RequestException as exc:
40
+ # Keep the failure class for server-side diagnosis, never log request
41
+ # headers (which contain the clinic API key) or response bodies.
42
+ raise MonickaAPIError(reason=type(exc).__name__) from None
43
+ except ValueError:
44
+ raise MonickaAPIError(reason="invalid_json") from None
45
+
46
+ def catalog(self):
47
+ data = self._request("GET", "/api/plugin/catalog/")
48
+ if data.get("home_clinic_id") != self.config.primary_clinic_id:
49
+ raise MonickaAPIError(reason="primary_clinic_id_mismatch")
50
+ return data
51
+
52
+ def availability(self, *, clinic, therapist, start, end):
53
+ return self._request("GET", "/api/plugin/availability/", params={
54
+ "clinic": clinic, "therapist": therapist, "start": start, "end": end})
55
+
56
+ def slots(self, *, clinic, therapist, date):
57
+ return self._request("GET", "/api/slots/", params={
58
+ "clinic": clinic, "therapist": therapist, "date": date})
59
+
60
+ def book(self, payload):
61
+ return self._request("POST", "/api/bookings/", json=payload)
62
+
63
+ def confirmation(self, code):
64
+ return self._request("GET", f"/api/plugin/confirmation/{code}/")
65
+
66
+ def assessment_assignments(self, *, code, phone):
67
+ return self._request("POST", "/api/plugin/assessments/", json={"code": code, "phone": phone})
68
+
69
+ def assessment_questionnaire(self, *, code, phone, assessment_type):
70
+ return self._request("POST", f"/api/plugin/assessments/{assessment_type}/form/",
71
+ json={"code": code, "phone": phone})
72
+
73
+ def submit_assessment(self, *, code, phone, assessment_type, answers):
74
+ return self._request("POST", f"/api/plugin/assessments/{assessment_type}/",
75
+ 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,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,20 @@
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}
6
+
7
+ @font-face{font-family:'Vazirmatn';src:url('./fonts/Vazirmatn-Regular.ttf') format('truetype');font-style:normal;font-weight:400;font-display:swap}
8
+ @font-face{font-family:'Vazirmatn';src:url('./fonts/Vazirmatn-SemiBold.ttf') format('truetype');font-style:normal;font-weight:600;font-display:swap}
9
+ @font-face{font-family:'Vazirmatn';src:url('./fonts/Vazirmatn-Bold.ttf') format('truetype');font-style:normal;font-weight:700;font-display:swap}
10
+ :root,body,input,select,option,button,textarea{font-family:'Vazirmatn',sans-serif}
11
+ .therapist-grid{align-items:stretch;grid-auto-rows:1fr}
12
+ .therapist-card{height:100%;min-height:390px;align-items:stretch;gap:12px;padding:20px;background:linear-gradient(160deg,#fff 0%,var(--surface) 100%);box-shadow:0 5px 18px #23382e0b}
13
+ .therapist-card h3{text-align:center;font-size:1.16rem;line-height:1.7}
14
+ .therapist-photo{display:block;width:116px;height:116px;flex:none;align-self:center;object-fit:cover;border-radius:50%;border:4px solid #fff;box-shadow:0 3px 14px #1d38221c;background:var(--soft)}
15
+ .therapist-card__bio{min-height:3em;text-align:center;line-height:1.8}
16
+ .therapist-card__specialties{display:flex;flex-wrap:wrap;justify-content:center;align-content:flex-start;gap:4px;min-height:34px}
17
+ .therapist-card__branches{min-height:30px}
18
+ .therapist-card__branches .tag{margin:2px 3px}
19
+ .therapist-select{width:100%;margin-top:auto}
20
+ @media(max-width:560px){.therapist-card{min-height:360px;padding:16px}.therapist-photo{width:100px;height:100px}}
@@ -0,0 +1,50 @@
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(){
19
+ const q=$('therapist-search').value.toLocaleLowerCase('fa').trim(),list=$('therapist-list'),placeholder=list.dataset.photoPlaceholder;
20
+ list.replaceChildren();
21
+ catalog.therapists.filter(t=>(!q||`${t.full_name} ${t.short_bio||''} ${(t.specialties||[]).join(' ')}`.toLocaleLowerCase('fa').includes(q))&&eligibleBranches(t).length>0).forEach(t=>{
22
+ const card=document.createElement('article');card.className='therapist-card';
23
+ const photo=document.createElement('img');photo.className='therapist-photo';photo.alt=`تصویر ${t.full_name}`;photo.loading='lazy';photo.decoding='async';
24
+ try{const image=new URL(t.photo_url||placeholder,location.href);photo.src=image.protocol==='https:'?image.href:placeholder}catch{photo.src=placeholder}
25
+ photo.onerror=()=>{photo.onerror=null;photo.src=placeholder};
26
+ const h=document.createElement('h3');h.textContent=t.full_name;
27
+ const bio=document.createElement('p');bio.className='therapist-card__bio';bio.textContent=t.short_bio||'';
28
+ const specialties=document.createElement('div');specialties.className='therapist-card__specialties';
29
+ (t.specialties||[]).forEach(name=>{const tag=document.createElement('span');tag.className='tag';tag.textContent=name;specialties.append(tag)});
30
+ const branches=document.createElement('div');branches.className='therapist-card__branches';branches.hidden=catalog.branches.length<=1;
31
+ if(catalog.branches.length>1)eligibleBranches(t).forEach(branch=>{const tag=document.createElement('span');tag.className='tag';tag.textContent=branch.clinic_short_name||branch.clinic_name;branches.append(tag)});
32
+ const button=document.createElement('button');button.type='button';button.className='button primary therapist-select';button.textContent='انتخاب درمانگر';button.onclick=()=>selectTherapist(t);
33
+ card.append(photo,h,bio,specialties,branches,button);list.append(card);
34
+ });
35
+ if(!list.children.length)list.textContent='درمانگری با این مشخصات پیدا نشد.';
36
+ }
37
+ 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()};}
38
+ 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();}
39
+ 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}
40
+ function addMonth(m,n){const i=m.year*12+m.month-1+n;return {year:Math.floor(i/12),month:i%12+1}}
41
+ 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}}
42
+ 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)}
43
+ 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;}
44
+ 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='دریافت ساعت‌ها ممکن نشد. دوباره تلاش کنید.'}}
45
+ 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}`}
46
+ 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')}
47
+ $('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)};
48
+ initFilters();renderTherapists();
49
+ if(specialScope){const therapist=catalog.therapists.find(t=>t.id===specialScope.therapist_id);if(therapist)selectTherapist(therapist,specialScope.clinic_id)}
50
+ })();
@@ -0,0 +1,29 @@
1
+ Copyright 2015 The Vazirmatn Project Authors (https://github.com/rastikerdar/vazirmatn)
2
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
3
+
4
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
5
+
6
+ PREAMBLE
7
+ The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
8
+ The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
9
+
10
+ DEFINITIONS
11
+ "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
12
+ "Reserved Font Name" refers to any names specified as such after the copyright statement(s).
13
+ "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
14
+ "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
15
+ "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
16
+
17
+ PERMISSION & CONDITIONS
18
+ Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
19
+ 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
20
+ 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within binary files as long as those fields can be easily viewed by the user.
21
+ 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
22
+ 4) The name(s) of the Copyright Holder or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) or with explicit written permission.
23
+ 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the fonts.
24
+
25
+ TERMINATION
26
+ This license becomes null and void if any of the above conditions are not met.
27
+
28
+ DISCLAIMER
29
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.