feat: implement appointment rescheduling and cancellation features with email notifications
This commit is contained in:
parent
5f4ab934cb
commit
f73fc31a0e
@ -350,6 +350,33 @@ def api_root(request, format=None):
|
|||||||
"has_jitsi_meeting": "true"
|
"has_jitsi_meeting": "true"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"reschedule_appointment": {
|
||||||
|
"description": "Reschedule an existing appointment (Admin only)",
|
||||||
|
"url": request.build_absolute_uri("/api/meetings/appointments/<uuid:pk>/reschedule/"),
|
||||||
|
"methods": ["POST"],
|
||||||
|
"authentication": "Required (Staff users only)",
|
||||||
|
"required_fields": ["scheduled_datetime"],
|
||||||
|
"optional_fields": ["scheduled_duration", "date_str", "time_slot"],
|
||||||
|
"prerequisites": "Appointment must be in 'scheduled' status",
|
||||||
|
"scheduling_options": {
|
||||||
|
"direct_datetime": {
|
||||||
|
"example": {
|
||||||
|
"scheduled_datetime": "2025-12-10T11:00:00Z",
|
||||||
|
"scheduled_duration": 45,
|
||||||
|
"timezone": timezone.get_current_timezone_name()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"date_and_slot": {
|
||||||
|
"example": {"date_str": "2024-01-20", "time_slot": "afternoon", "scheduled_duration": 60}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": "Validates against admin availability when using date_str + time_slot",
|
||||||
|
"side_effects": [
|
||||||
|
"Updates scheduled_datetime and scheduled_duration",
|
||||||
|
"Clears Jitsi meeting information",
|
||||||
|
"Sends rescheduled email to user"
|
||||||
|
]
|
||||||
|
},
|
||||||
"reject_appointment": {
|
"reject_appointment": {
|
||||||
"description": "Reject an appointment request (Admin only)",
|
"description": "Reject an appointment request (Admin only)",
|
||||||
"url": request.build_absolute_uri("/api/meetings/appointments/<uuid:pk>/reject/"),
|
"url": request.build_absolute_uri("/api/meetings/appointments/<uuid:pk>/reject/"),
|
||||||
@ -390,6 +417,18 @@ def api_root(request, format=None):
|
|||||||
"Sends completion email to user"
|
"Sends completion email to user"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"cancel_meeting": {
|
||||||
|
"description": "Cancel a scheduled appointment and its Jitsi meeting",
|
||||||
|
"url": request.build_absolute_uri("/api/meetings/appointments/<uuid:pk>/cancel/"),
|
||||||
|
"methods": ["POST"],
|
||||||
|
"authentication": "Required",
|
||||||
|
"prerequisites": "Appointment must be in 'scheduled' or 'active' status",
|
||||||
|
"side_effects": [
|
||||||
|
"Updates meeting status to 'cancelled'",
|
||||||
|
"Clears Jitsi meeting information",
|
||||||
|
"Sends cancellation email to user",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
"appointment_stats": {
|
"appointment_stats": {
|
||||||
"description": "Get appointment statistics and analytics with availability metrics (Admin only)",
|
"description": "Get appointment statistics and analytics with availability metrics (Admin only)",
|
||||||
|
|||||||
@ -92,6 +92,42 @@ class EmailService:
|
|||||||
print(f"Failed to send scheduled notification: {e}")
|
print(f"Failed to send scheduled notification: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_appointment_rescheduled(appointment):
|
||||||
|
subject = "Your Appointment Has Been Rescheduled"
|
||||||
|
|
||||||
|
user_timezone = getattr(appointment, 'user_timezone', 'UTC')
|
||||||
|
|
||||||
|
formatted_datetime = EmailService._format_datetime_for_user(
|
||||||
|
appointment.scheduled_datetime,
|
||||||
|
user_timezone
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'appointment': appointment,
|
||||||
|
'scheduled_datetime': formatted_datetime,
|
||||||
|
'user_dashboard_url': f"{settings.FRONTEND_URL}/dashboard" if hasattr(settings, 'FRONTEND_URL') else '/dashboard/',
|
||||||
|
'settings': {
|
||||||
|
'SITE_NAME': getattr(settings, 'SITE_NAME', 'Attune Heart Therapy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string('emails/appointment_rescheduled.html', context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=f"Your appointment has been rescheduled for {formatted_datetime}. Please view this email in an HTML-compatible client for more details.",
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[appointment.email],
|
||||||
|
)
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send rescheduled notification: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_appointment_rejected(appointment):
|
def send_appointment_rejected(appointment):
|
||||||
subject = "Update on Your Appointment Request"
|
subject = "Update on Your Appointment Request"
|
||||||
|
|||||||
@ -662,7 +662,6 @@ class AppointmentRequest(models.Model):
|
|||||||
|
|
||||||
def cancel_appointment(self, reason='', commit=True):
|
def cancel_appointment(self, reason='', commit=True):
|
||||||
self.status = 'cancelled'
|
self.status = 'cancelled'
|
||||||
self.rejection_reason = reason
|
|
||||||
if commit:
|
if commit:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|||||||
@ -242,7 +242,6 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
|
|||||||
ZoneInfo(value)
|
ZoneInfo(value)
|
||||||
return value
|
return value
|
||||||
except Exception:
|
except Exception:
|
||||||
# If invalid, default to UTC but don't raise error
|
|
||||||
return 'UTC'
|
return 'UTC'
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
@ -323,13 +322,10 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
|
|||||||
return time_slots
|
return time_slots
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
# Extract timezone before creating
|
|
||||||
timezone = validated_data.pop('timezone', 'UTC')
|
timezone = validated_data.pop('timezone', 'UTC')
|
||||||
|
|
||||||
# Create appointment
|
|
||||||
appointment = super().create(validated_data)
|
appointment = super().create(validated_data)
|
||||||
|
|
||||||
# Set timezone on the created appointment
|
|
||||||
appointment.user_timezone = timezone
|
appointment.user_timezone = timezone
|
||||||
appointment.save(update_fields=['user_timezone'])
|
appointment.save(update_fields=['user_timezone'])
|
||||||
|
|
||||||
@ -438,6 +434,35 @@ class AppointmentScheduleSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
return representation
|
return representation
|
||||||
|
|
||||||
|
class RecheduleAppointmentSerializer(serializers.Serializer):
|
||||||
|
new_scheduled_datetime = serializers.DateTimeField()
|
||||||
|
new_scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240)
|
||||||
|
timezone = serializers.CharField(required=False, default='UTC')
|
||||||
|
|
||||||
|
def validate_new_scheduled_datetime(self, value):
|
||||||
|
if value <= timezone.now():
|
||||||
|
raise serializers.ValidationError("New scheduled datetime must be in the future.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_new_scheduled_duration(self, value):
|
||||||
|
if value < 30:
|
||||||
|
raise serializers.ValidationError("Duration must be at least 30 minutes.")
|
||||||
|
if value > 240:
|
||||||
|
raise serializers.ValidationError("Duration cannot exceed 4 hours.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def save(self, appointment):
|
||||||
|
new_datetime = self.validated_data['new_scheduled_datetime']
|
||||||
|
new_duration = self.validated_data.get('new_scheduled_duration', 60)
|
||||||
|
|
||||||
|
appointment.reschedule_appointment(
|
||||||
|
new_datetime=new_datetime,
|
||||||
|
new_duration=new_duration,
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return appointment
|
||||||
|
|
||||||
class AppointmentDetailSerializer(serializers.ModelSerializer):
|
class AppointmentDetailSerializer(serializers.ModelSerializer):
|
||||||
meeting_info = serializers.SerializerMethodField()
|
meeting_info = serializers.SerializerMethodField()
|
||||||
meeting_analytics = serializers.SerializerMethodField()
|
meeting_analytics = serializers.SerializerMethodField()
|
||||||
|
|||||||
@ -18,7 +18,9 @@ from .views import (
|
|||||||
MeetingAnalyticsView,
|
MeetingAnalyticsView,
|
||||||
availability_overview,
|
availability_overview,
|
||||||
EndMeetingView,
|
EndMeetingView,
|
||||||
StartMeetingView
|
StartMeetingView,
|
||||||
|
CancelMeetingView,
|
||||||
|
RescheduleAppointmentView
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -33,6 +35,7 @@ urlpatterns = [
|
|||||||
# Appointment Request URLs
|
# Appointment Request URLs
|
||||||
path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'),
|
path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'),
|
||||||
path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'),
|
path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'),
|
||||||
|
path('appointments/<uuid:pk>/reschedule/', RescheduleAppointmentView.as_view(), name='reschedule-appointment'),
|
||||||
path('appointments/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'),
|
path('appointments/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'),
|
||||||
path('appointments/<uuid:pk>/matching-availability/', MatchingAvailabilityView.as_view(), name='matching-availability'),
|
path('appointments/<uuid:pk>/matching-availability/', MatchingAvailabilityView.as_view(), name='matching-availability'),
|
||||||
|
|
||||||
@ -57,4 +60,5 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('meetings/<uuid:pk>/end/', EndMeetingView.as_view(), name='end-meeting-simple'),
|
path('meetings/<uuid:pk>/end/', EndMeetingView.as_view(), name='end-meeting-simple'),
|
||||||
path('meetings/<uuid:pk>/start/', StartMeetingView.as_view(), name='start-meeting-simple'),
|
path('meetings/<uuid:pk>/start/', StartMeetingView.as_view(), name='start-meeting-simple'),
|
||||||
|
path('meetings/<uuid:pk>/cancel/', CancelMeetingView.as_view(), name='cancel-meeting'),
|
||||||
]
|
]
|
||||||
@ -18,6 +18,7 @@ from .serializers import (
|
|||||||
AppointmentDetailSerializer,
|
AppointmentDetailSerializer,
|
||||||
MeetingJoinSerializer,
|
MeetingJoinSerializer,
|
||||||
MeetingActionSerializer,
|
MeetingActionSerializer,
|
||||||
|
RecheduleAppointmentSerializer
|
||||||
)
|
)
|
||||||
from .email_service import EmailService
|
from .email_service import EmailService
|
||||||
from users.models import CustomUser
|
from users.models import CustomUser
|
||||||
@ -180,6 +181,36 @@ class RejectAppointmentView(generics.GenericAPIView):
|
|||||||
response_serializer = AppointmentDetailSerializer(appointment)
|
response_serializer = AppointmentDetailSerializer(appointment)
|
||||||
return Response(response_serializer.data)
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
class RescheduleAppointmentView(generics.GenericAPIView):
|
||||||
|
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||||
|
serializer_class = RecheduleAppointmentSerializer
|
||||||
|
queryset = AppointmentRequest.objects.all()
|
||||||
|
lookup_field = 'pk'
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
appointment = self.get_object()
|
||||||
|
|
||||||
|
if appointment.status != 'scheduled':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only scheduled appointments can be rescheduled'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
new_datetime = serializer.validated_data['new_scheduled_datetime']
|
||||||
|
new_duration = serializer.validated_data.get('new_scheduled_duration', appointment.scheduled_duration)
|
||||||
|
|
||||||
|
appointment.reschedule_appointment(
|
||||||
|
new_datetime=new_datetime,
|
||||||
|
new_duration=new_duration
|
||||||
|
)
|
||||||
|
|
||||||
|
EmailService.send_appointment_rescheduled(appointment)
|
||||||
|
|
||||||
|
response_serializer = AppointmentDetailSerializer(appointment)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
class AvailableDatesView(generics.GenericAPIView):
|
class AvailableDatesView(generics.GenericAPIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@ -478,6 +509,34 @@ class EndMeetingView(generics.GenericAPIView):
|
|||||||
'meeting_ended_at': appointment.meeting_ended_at,
|
'meeting_ended_at': appointment.meeting_ended_at,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
class CancelMeetingView(generics.GenericAPIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = MeetingActionSerializer
|
||||||
|
queryset = AppointmentRequest.objects.all()
|
||||||
|
lookup_field = 'pk'
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
appointment = self.get_object()
|
||||||
|
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only staff members can cancel meetings'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
if appointment.status != 'scheduled':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only scheduled appointments can be canceled'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
appointment.cancel_appointment()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'appointment_id': str(appointment.id),
|
||||||
|
'message': 'Meeting canceled successfully',
|
||||||
|
})
|
||||||
|
|
||||||
class JoinMeetingView(generics.GenericAPIView):
|
class JoinMeetingView(generics.GenericAPIView):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
serializer_class = MeetingJoinSerializer
|
serializer_class = MeetingJoinSerializer
|
||||||
|
|||||||
294
templates/emails/appointment_rescheduled.html
Normal file
294
templates/emails/appointment_rescheduled.html
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Appointment Confirmed</title>
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-card {
|
||||||
|
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 25px 0;
|
||||||
|
border: 2px dashed #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-title {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #22543d;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-time {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #22543d;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.therapist-info {
|
||||||
|
color: #2d3748;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #f7fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: #2d3748;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preparation {
|
||||||
|
background: #fffaf0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 25px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-left: 4px solid #ed8936;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #744210;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-list li {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 25px;
|
||||||
|
position: relative;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-list li:before {
|
||||||
|
content: "✓";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #48bb78;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
text-align: center;
|
||||||
|
margin: 35px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-info {
|
||||||
|
color: #2d3748;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 15px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header,
|
||||||
|
.email-body,
|
||||||
|
.email-footer {
|
||||||
|
padding: 25px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-card {
|
||||||
|
padding: 25px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-time {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>Appointment Time Updated!</h1>
|
||||||
|
<p>Your therapy session has been rescheduled</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<div class="greeting">
|
||||||
|
Hello <strong>{{ appointment.full_name }}</strong>,<br />
|
||||||
|
Great news! Your appointment request has been confirmed. We're looking
|
||||||
|
forward to seeing you.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirmation-card">
|
||||||
|
<div class="confirmation-title">Your Appointment Details</div>
|
||||||
|
<div class="appointment-time">
|
||||||
|
{{ scheduled_datetime }}
|
||||||
|
</div>
|
||||||
|
<div class="therapist-info">With: Nathalie (Therapist)</div>
|
||||||
|
<div class="login-info">
|
||||||
|
On the day of your appointment, please log in to your account at https://attuneHeartTherapy.com 15 minutes early to join your Therapy Session.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-footer">
|
||||||
|
<div class="company-name">{{ settings.SITE_NAME }}</div>
|
||||||
|
<p class="support-info">
|
||||||
|
Need help? Contact our support team at
|
||||||
|
|
||||||
|
<a href="mailto:admin@attunehearttherapy.com"
|
||||||
|
style="color: #fff; text-decoration: none"
|
||||||
|
>admin@attunehearttherapy.com</a>
|
||||||
|
</p>
|
||||||
|
<p class="copyright">
|
||||||
|
© {% now "Y" %} {{ settings.SITE_NAME }}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user