Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions src/components/forms/AdvancedValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
'use client';

import React, { useState, useEffect } from 'react';
import { Loader2, XCircle, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { ValidationEngineImpl } from '@/form-management/validation/validation-engine';
import { ValidationResult, ValidationRule, FormState } from '@/form-management/types/core';

Expand Down Expand Up @@ -77,37 +78,37 @@ export const AdvancedValidation: React.FC<AdvancedValidationProps> = ({
return (
<div className={`advanced-validation ${className}`}>
{isValidating && (
<div className="validation-loading">
<span className="spinner">⏳</span>
<div className="validation-loading flex items-center gap-1.5 text-sm text-gray-500">
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
<span>Validating...</span>
</div>
)}

{showErrors && hasErrors && (
<div className="validation-errors">
<div className="validation-errors space-y-1">
{validationResult.errors.map((error, idx) => (
<div key={idx} className="validation-error">
<span className="error-icon">❌</span>
<div key={idx} className="validation-error flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
<XCircle size={14} aria-hidden="true" className="shrink-0" />
<span className="error-message">{error.message}</span>
</div>
))}
</div>
)}

{showErrors && hasWarnings && (
<div className="validation-warnings">
<div className="validation-warnings space-y-1">
{validationResult.warnings!.map((warning, idx) => (
<div key={idx} className="validation-warning">
<span className="warning-icon">⚠️</span>
<div key={idx} className="validation-warning flex items-center gap-1.5 text-sm text-amber-600 dark:text-amber-400">
<AlertTriangle size={14} aria-hidden="true" className="shrink-0" />
<span className="warning-message">{warning.message}</span>
</div>
))}
</div>
)}

{showErrors && validationResult && validationResult.isValid && !hasWarnings && (
<div className="validation-success">
<span className="success-icon">✅</span>
<div className="validation-success flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 size={14} aria-hidden="true" className="shrink-0" />
<span className="success-message">Valid</span>
</div>
)}
Expand Down
206 changes: 206 additions & 0 deletions src/components/instructor/BroadcastMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
'use client';

import { useState } from 'react';
import { Megaphone, Send, Users, CheckCircle2, AlertCircle, X } from 'lucide-react';

export interface BroadcastMessageProps {
lessonId: string;
lessonTitle?: string;
recipientCount?: number;
onSend?: (message: BroadcastPayload) => Promise<void>;
className?: string;
}

export interface BroadcastPayload {
lessonId: string;
subject: string;
body: string;
priority: 'normal' | 'urgent';
}

type Status = 'idle' | 'sending' | 'success' | 'error';

/**
* BroadcastMessage
*
* Allows instructors to broadcast a message to all enrolled students
* in a specific lesson. Closes automatically after a successful send.
*/
export function BroadcastMessage({
lessonId,
lessonTitle,
recipientCount,
onSend,
className = '',
}: BroadcastMessageProps) {
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [priority, setPriority] = useState<BroadcastPayload['priority']>('normal');
const [status, setStatus] = useState<Status>('idle');
const [errorMsg, setErrorMsg] = useState('');

const canSubmit = subject.trim().length > 0 && body.trim().length > 0 && status !== 'sending';

const handleSend = async () => {
if (!canSubmit) return;
setStatus('sending');
setErrorMsg('');

try {
const payload: BroadcastPayload = { lessonId, subject: subject.trim(), body: body.trim(), priority };
if (onSend) {
await onSend(payload);
} else {
// Default: POST to lessons broadcast API
const res = await fetch(`/api/lessons/${lessonId}/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Failed to send: ${res.status}`);
}
setStatus('success');
setSubject('');
setBody('');
setPriority('normal');
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Failed to send broadcast.');
setStatus('error');
}
};

return (
<section
aria-label="Broadcast message to lesson participants"
className={`rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900 ${className}`}
>
{/* Header */}
<div className="mb-4 flex items-center gap-2">
<Megaphone size={18} className="text-blue-500" aria-hidden="true" />
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-50">
Broadcast Message
</h2>
{lessonTitle && (
<span className="ml-1 truncate text-sm text-gray-500 dark:text-gray-400">
— {lessonTitle}
</span>
)}
</div>

{/* Recipient info */}
{recipientCount !== undefined && (
<div className="mb-4 flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
<Users size={14} aria-hidden="true" />
<span>
{recipientCount} enrolled student{recipientCount !== 1 ? 's' : ''} will receive this
message
</span>
</div>
)}

{/* Success state */}
{status === 'success' && (
<div
role="status"
className="mb-4 flex items-center gap-2 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300"
>
<CheckCircle2 size={16} aria-hidden="true" className="shrink-0" />
<span>Broadcast sent successfully.</span>
<button
onClick={() => setStatus('idle')}
aria-label="Dismiss"
className="ml-auto text-green-600 hover:text-green-800 dark:text-green-400"
>
<X size={14} />
</button>
</div>
)}

{/* Error state */}
{status === 'error' && (
<div
role="alert"
className="mb-4 flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
<AlertCircle size={16} aria-hidden="true" className="shrink-0" />
<span>{errorMsg}</span>
<button
onClick={() => setStatus('idle')}
aria-label="Dismiss"
className="ml-auto text-red-600 hover:text-red-800 dark:text-red-400"
>
<X size={14} />
</button>
</div>
)}

{/* Form */}
<div className="space-y-3">
{/* Priority */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority:</span>
{(['normal', 'urgent'] as const).map((p) => (
<label key={p} className="flex cursor-pointer items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
<input
type="radio"
name="priority"
value={p}
checked={priority === p}
onChange={() => setPriority(p)}
className="accent-blue-600"
/>
<span className="capitalize">{p}</span>
</label>
))}
</div>

{/* Subject */}
<div>
<label
htmlFor={`broadcast-subject-${lessonId}`}
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Subject
</label>
<input
id={`broadcast-subject-${lessonId}`}
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Message subject…"
maxLength={120}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>

{/* Body */}
<div>
<label
htmlFor={`broadcast-body-${lessonId}`}
className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Message
</label>
<textarea
id={`broadcast-body-${lessonId}`}
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
placeholder="Write your message to all participants…"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 resize-none"
/>
</div>

{/* Send button */}
<button
onClick={handleSend}
disabled={!canSubmit}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
<Send size={14} aria-hidden="true" />
{status === 'sending' ? 'Sending…' : 'Send Broadcast'}
</button>
</div>
</section>
);
}
82 changes: 79 additions & 3 deletions src/components/layout/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,80 @@
'use client';

import React from 'react';
import React, { useState } from 'react';
import Link from 'next/link';
import { useInternationalization } from '@/hooks/useInternationalization';
import { Award, Briefcase, ChevronRight, FileText, Globe, HelpCircle } from 'lucide-react';
import { Award, Briefcase, ChevronRight, FileText, Globe, HelpCircle, Tag, CheckCircle2, AlertCircle } from 'lucide-react';

type CouponStatus = 'idle' | 'loading' | 'success' | 'error';

function CouponCode() {
const [code, setCode] = useState('');
const [status, setStatus] = useState<CouponStatus>('idle');
const [message, setMessage] = useState('');

const handleApply = async () => {
if (!code.trim()) return;
setStatus('loading');
setMessage('');
try {
const res = await fetch('/api/v1/coupons/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code.trim().toUpperCase() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Invalid coupon code.');
setStatus('success');
setMessage(data.message || 'Coupon applied successfully!');
} catch (err) {
setStatus('error');
setMessage(err instanceof Error ? err.message : 'Could not apply coupon.');
}
};

return (
<div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Tag className="w-4 h-4 text-blue-600 dark:text-blue-400" aria-hidden="true" />
Have a Coupon?
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Enter your promo code to unlock a discount.
</p>
<div className="flex gap-2">
<input
type="text"
value={code}
onChange={(e) => { setCode(e.target.value); setStatus('idle'); setMessage(''); }}
onKeyDown={(e) => e.key === 'Enter' && handleApply()}
placeholder="e.g. TEACH20"
aria-label="Coupon code"
maxLength={32}
className="flex-1 min-w-0 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
<button
onClick={handleApply}
disabled={!code.trim() || status === 'loading'}
className="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{status === 'loading' ? 'Applying…' : 'Apply'}
</button>
</div>
{status === 'success' && (
<p role="status" className="mt-2 flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 size={14} aria-hidden="true" className="shrink-0" />
{message}
</p>
)}
{status === 'error' && (
<p role="alert" className="mt-2 flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle size={14} aria-hidden="true" className="shrink-0" />
{message}
</p>
)}
</div>
);
}

export const Footer: React.FC = () => {
const { t } = useInternationalization();
Expand All @@ -19,7 +90,7 @@ export const Footer: React.FC = () => {
return (
<footer className="no-print bg-white dark:bg-gray-950 border-t border-gray-200 dark:border-gray-800 transition-colors duration-200 mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="grid grid-cols-1 md:grid-cols-5 gap-8">
{/* Brand Section */}
<div className="col-span-1 md:col-span-1">
<Link
Expand Down Expand Up @@ -68,6 +139,11 @@ export const Footer: React.FC = () => {
</ul>
</div>

{/* Coupon Section */}
<div>
<CouponCode />
</div>

{/* Grant Management Section */}
<div className="col-span-1 md:col-span-2 bg-blue-50 dark:bg-gray-900 rounded-2xl p-6 border border-blue-100 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
Expand Down
Loading
Loading