Round 17: QR Codes, Gamification Badges, Auto-Award, Badge Management
This commit is contained in:
1 parent
6174f127f9
commit
cba8a8e724
23 files changed
+4028
-1
No files matched your search
+105
@@ -2889,3 +2889,108 @@ export function fetchDistrictSchools(districtId: string): Promise<Array<{
|
||||
export function assignSchoolToDistrict(districtId: string, schoolId: string): Promise<{ success: boolean }> {
|
||||
return apiPost<{ success: boolean }>(`/api/districts/${districtId}/schools`, { schoolId });
|
||||
}
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────── */
|
||||
|
||||
export interface BadgeData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string;
|
||||
requirementType: string;
|
||||
requirementValue: number | null;
|
||||
isAuto: boolean;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count?: { studentBadges: number };
|
||||
}
|
||||
|
||||
export interface StudentBadgeData {
|
||||
id: string;
|
||||
schoolId: string;
|
||||
studentId: string;
|
||||
badgeId: string;
|
||||
awardedAt: string;
|
||||
awardedBy: string | null;
|
||||
notes: string | null;
|
||||
badge: BadgeData;
|
||||
student: { id: string; firstName: string; lastName: string };
|
||||
awardedByUser?: { id: string; firstName: string; lastName: string } | null;
|
||||
}
|
||||
|
||||
export interface BadgeProgressData {
|
||||
badgeId: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string;
|
||||
earned: boolean;
|
||||
progress: number;
|
||||
current: number;
|
||||
target: number;
|
||||
}
|
||||
|
||||
export function fetchBadges(schoolId: string, category?: string): Promise<BadgeData[]> {
|
||||
const params = new URLSearchParams({ schoolId });
|
||||
if (category) params.set('category', category);
|
||||
return apiGet<BadgeData[]>(`/api/badges?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function createBadge(data: {
|
||||
schoolId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon: string;
|
||||
color?: string;
|
||||
category: string;
|
||||
requirementType: string;
|
||||
requirementValue?: number;
|
||||
isAuto?: boolean;
|
||||
}): Promise<BadgeData> {
|
||||
return apiPost<BadgeData>('/api/badges', data);
|
||||
}
|
||||
|
||||
export function updateBadge(id: string, data: Partial<BadgeData>): Promise<BadgeData> {
|
||||
return apiPut<BadgeData>(`/api/badges/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteBadge(id: string): Promise<{ success: boolean }> {
|
||||
return apiDelete<{ success: boolean }>(`/api/badges/${id}`);
|
||||
}
|
||||
|
||||
export function fetchStudentBadges(schoolId: string, studentId?: string, badgeId?: string): Promise<StudentBadgeData[]> {
|
||||
const params = new URLSearchParams({ schoolId });
|
||||
if (studentId) params.set('studentId', studentId);
|
||||
if (badgeId) params.set('badgeId', badgeId);
|
||||
return apiGet<StudentBadgeData[]>(`/api/student-badges?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function awardBadgeToStudent(data: {
|
||||
schoolId: string;
|
||||
studentId: string;
|
||||
badgeId: string;
|
||||
notes?: string;
|
||||
}): Promise<StudentBadgeData> {
|
||||
return apiPost<StudentBadgeData>('/api/student-badges', data);
|
||||
}
|
||||
|
||||
export function removeStudentBadge(id: string): Promise<{ success: boolean }> {
|
||||
return apiDelete<{ success: boolean }>(`/api/student-badges/${id}`);
|
||||
}
|
||||
|
||||
export function fetchBadgeProgress(schoolId: string, studentId: string): Promise<BadgeProgressData[]> {
|
||||
const params = new URLSearchParams({ schoolId, studentId });
|
||||
return apiGet<BadgeProgressData[]>(`/api/badge-progress?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function checkAndAwardBadges(schoolId: string, studentId: string): Promise<string[]> {
|
||||
return apiPost<string[]>('/api/badge-check', { schoolId, studentId });
|
||||
}
|
||||
|
||||
export function seedBadges(schoolId: string): Promise<{ success: boolean }> {
|
||||
return apiPost<{ success: boolean }>('/api/badge-seed', { schoolId });
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
// CompetenceTrack — Badge Auto-Award Check Utility
|
||||
// Checks if a student meets requirements for auto-awarded badges
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Default badge definitions that get seeded when a school is created
|
||||
export const DEFAULT_BADGES = [
|
||||
{
|
||||
name: 'Perfect Attendance',
|
||||
description: '100% attendance for 30 consecutive days',
|
||||
icon: 'CalendarCheck',
|
||||
color: '#10b981',
|
||||
category: 'attendance',
|
||||
requirementType: 'attendance_rate',
|
||||
requirementValue: 30,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Competency Master',
|
||||
description: 'Achieved mastery level 5+ in a competency',
|
||||
icon: 'Award',
|
||||
color: '#f59e0b',
|
||||
category: 'competency',
|
||||
requirementType: 'mastery_level',
|
||||
requirementValue: 5,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Progress Pioneer',
|
||||
description: '10+ progress entries in a month',
|
||||
icon: 'TrendingUp',
|
||||
color: '#3b82f6',
|
||||
category: 'achievement',
|
||||
requirementType: 'progress_entries',
|
||||
requirementValue: 10,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Behavior Star',
|
||||
description: '5+ positive behavior entries',
|
||||
icon: 'Star',
|
||||
color: '#eab308',
|
||||
category: 'behavior',
|
||||
requirementType: 'behavior_count',
|
||||
requirementValue: 5,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Notebook Champion',
|
||||
description: '10+ notebook pages created',
|
||||
icon: 'BookOpen',
|
||||
color: '#8b5cf6',
|
||||
category: 'achievement',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 10,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Drawing Artist',
|
||||
description: '5+ drawings created',
|
||||
icon: 'Pencil',
|
||||
color: '#ec4899',
|
||||
category: 'achievement',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 5,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Homework Hero',
|
||||
description: '10+ homework submissions on time',
|
||||
icon: 'ClipboardCheck',
|
||||
color: '#06b6d4',
|
||||
category: 'achievement',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 10,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Team Player',
|
||||
description: '3+ peer assessments given',
|
||||
icon: 'Users',
|
||||
color: '#14b8a6',
|
||||
category: 'competency',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 3,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Goal Achiever',
|
||||
description: '3+ learning goals completed',
|
||||
icon: 'Target',
|
||||
color: '#f97316',
|
||||
category: 'milestone',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 3,
|
||||
isAuto: true,
|
||||
},
|
||||
{
|
||||
name: 'Eco Warrior',
|
||||
description: 'Environmental badge for using digital notebooks',
|
||||
icon: 'Leaf',
|
||||
color: '#22c55e',
|
||||
category: 'milestone',
|
||||
requirementType: 'custom',
|
||||
requirementValue: 1,
|
||||
isAuto: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Seed default badges for a school if they don't exist yet
|
||||
*/
|
||||
export async function seedDefaultBadges(schoolId: string): Promise<void> {
|
||||
const existing = await db.badge.findMany({ where: { schoolId, deletedAt: null } });
|
||||
if (existing.length > 0) return; // Already seeded
|
||||
|
||||
for (const badge of DEFAULT_BADGES) {
|
||||
await db.badge.create({
|
||||
data: {
|
||||
schoolId,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
color: badge.color,
|
||||
category: badge.category,
|
||||
requirementType: badge.requirementType,
|
||||
requirementValue: badge.requirementValue,
|
||||
isAuto: badge.isAuto,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and auto-award badges for a student.
|
||||
* Returns the list of newly awarded badge IDs.
|
||||
*/
|
||||
export async function checkAndAwardBadges(
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<string[]> {
|
||||
const awardedBadgeIds: string[] = [];
|
||||
|
||||
// Get all auto-award badges for the school
|
||||
const autoBadges = await db.badge.findMany({
|
||||
where: { schoolId, isAuto: true, deletedAt: null },
|
||||
});
|
||||
|
||||
// Get already earned badges for this student
|
||||
const earnedBadges = await db.studentBadge.findMany({
|
||||
where: { studentId, schoolId },
|
||||
select: { badgeId: true },
|
||||
});
|
||||
const earnedBadgeIdSet = new Set(earnedBadges.map(eb => eb.badgeId));
|
||||
|
||||
for (const badge of autoBadges) {
|
||||
// Skip if already earned
|
||||
if (earnedBadgeIdSet.has(badge.id)) continue;
|
||||
|
||||
const met = await checkBadgeRequirement(badge, schoolId, studentId);
|
||||
if (met) {
|
||||
await db.studentBadge.create({
|
||||
data: {
|
||||
schoolId,
|
||||
studentId,
|
||||
badgeId: badge.id,
|
||||
awardedBy: null, // Auto-awarded
|
||||
},
|
||||
});
|
||||
awardedBadgeIds.push(badge.id);
|
||||
}
|
||||
}
|
||||
|
||||
return awardedBadgeIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific badge requirement is met for a student
|
||||
*/
|
||||
async function checkBadgeRequirement(
|
||||
badge: {
|
||||
id: string;
|
||||
requirementType: string;
|
||||
requirementValue: number | null;
|
||||
},
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<boolean> {
|
||||
const threshold = badge.requirementValue ?? 0;
|
||||
if (threshold === 0) return false;
|
||||
|
||||
switch (badge.requirementType) {
|
||||
case 'attendance_rate': {
|
||||
// Check if student has 30 consecutive days of attendance
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - threshold);
|
||||
const attendanceRecords = await db.attendanceRecord.findMany({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
status: 'PRESENT',
|
||||
session: { date: { gte: thirtyDaysAgo } },
|
||||
},
|
||||
include: { session: { select: { date: true } } },
|
||||
});
|
||||
return attendanceRecords.length >= threshold;
|
||||
}
|
||||
|
||||
case 'mastery_level': {
|
||||
// Check if student has any competency at mastery level >= threshold
|
||||
const progressEntries = await db.learningProgressEntry.findMany({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
masteryLevelValue: { gte: threshold },
|
||||
},
|
||||
});
|
||||
return progressEntries.length > 0;
|
||||
}
|
||||
|
||||
case 'progress_entries': {
|
||||
// Check if student has threshold+ progress entries in the last month
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
const count = await db.learningProgressEntry.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
date: { gte: oneMonthAgo },
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
|
||||
case 'behavior_count': {
|
||||
// Check if student has threshold+ positive behavior entries
|
||||
const count = await db.behaviorIncident.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
category: { valence: 'positive' },
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
// For custom badges, check based on badge name
|
||||
const badgeRecord = await db.badge.findUnique({ where: { id: badge.id } });
|
||||
if (!badgeRecord) return false;
|
||||
|
||||
switch (badgeRecord.name) {
|
||||
case 'Notebook Champion': {
|
||||
const count = await db.notebookPage.count({
|
||||
where: {
|
||||
notebook: { schoolId },
|
||||
notebook: { createdById: studentId },
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Drawing Artist': {
|
||||
const count = await db.drawing.count({
|
||||
where: { schoolId, createdById: studentId },
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Homework Hero': {
|
||||
const count = await db.homeworkSubmission.count({
|
||||
where: {
|
||||
studentId,
|
||||
homework: { schoolId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Team Player': {
|
||||
const count = await db.peerAssessment.count({
|
||||
where: {
|
||||
assessorId: studentId,
|
||||
schoolId,
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Goal Achiever': {
|
||||
const count = await db.learningGoal.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
case 'Eco Warrior': {
|
||||
// Awarded if student has at least 1 notebook
|
||||
const count = await db.notebook.count({
|
||||
where: { schoolId, createdById: studentId },
|
||||
});
|
||||
return count >= threshold;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge progress for a student (how close they are to earning each badge)
|
||||
*/
|
||||
export async function getBadgeProgress(
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<Array<{
|
||||
badgeId: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string;
|
||||
earned: boolean;
|
||||
progress: number; // 0-100
|
||||
current: number;
|
||||
target: number;
|
||||
}>> {
|
||||
const badges = await db.badge.findMany({
|
||||
where: { schoolId, deletedAt: null },
|
||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
const earnedBadges = await db.studentBadge.findMany({
|
||||
where: { studentId, schoolId },
|
||||
select: { badgeId: true, awardedAt: true },
|
||||
});
|
||||
const earnedBadgeMap = new Map(earnedBadges.map(eb => [eb.badgeId, eb.awardedAt]));
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const badge of badges) {
|
||||
const earned = earnedBadgeMap.has(badge.id);
|
||||
const target = badge.requirementValue ?? 1;
|
||||
let current = 0;
|
||||
|
||||
if (!earned) {
|
||||
current = await getCurrentProgress(badge, schoolId, studentId);
|
||||
} else {
|
||||
current = target;
|
||||
}
|
||||
|
||||
results.push({
|
||||
badgeId: badge.id,
|
||||
name: badge.name,
|
||||
icon: badge.icon,
|
||||
color: badge.color,
|
||||
category: badge.category,
|
||||
earned,
|
||||
progress: Math.min(100, Math.round((current / target) * 100)),
|
||||
current,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getCurrentProgress(
|
||||
badge: { id: string; requirementType: string; requirementValue: number | null; name: string },
|
||||
schoolId: string,
|
||||
studentId: string
|
||||
): Promise<number> {
|
||||
const threshold = badge.requirementValue ?? 1;
|
||||
|
||||
switch (badge.requirementType) {
|
||||
case 'attendance_rate': {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - threshold);
|
||||
return db.attendanceRecord.count({
|
||||
where: {
|
||||
studentId,
|
||||
schoolId,
|
||||
status: 'PRESENT',
|
||||
session: { date: { gte: thirtyDaysAgo } },
|
||||
},
|
||||
});
|
||||
}
|
||||
case 'mastery_level': {
|
||||
const entries = await db.learningProgressEntry.findMany({
|
||||
where: { studentId, schoolId },
|
||||
orderBy: { masteryLevelValue: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
return entries.length > 0 ? entries[0].masteryLevelValue : 0;
|
||||
}
|
||||
case 'progress_entries': {
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
return db.learningProgressEntry.count({
|
||||
where: { studentId, schoolId, date: { gte: oneMonthAgo } },
|
||||
});
|
||||
}
|
||||
case 'behavior_count': {
|
||||
return db.behaviorIncident.count({
|
||||
where: { studentId, schoolId, category: { valence: 'positive' } },
|
||||
});
|
||||
}
|
||||
case 'custom': {
|
||||
switch (badge.name) {
|
||||
case 'Notebook Champion':
|
||||
return db.notebookPage.count({ where: { notebook: { schoolId, createdById: studentId } } });
|
||||
case 'Drawing Artist':
|
||||
return db.drawing.count({ where: { schoolId, createdById: studentId } });
|
||||
case 'Homework Hero':
|
||||
return db.homeworkSubmission.count({ where: { studentId, homework: { schoolId }, status: 'SUBMITTED' } });
|
||||
case 'Team Player':
|
||||
return db.peerAssessment.count({ where: { assessorId: studentId, schoolId } });
|
||||
case 'Goal Achiever':
|
||||
return db.learningGoal.count({ where: { studentId, schoolId, status: 'COMPLETED' } });
|
||||
case 'Eco Warrior':
|
||||
return db.notebook.count({ where: { schoolId, createdById: studentId } });
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+290
@@ -3084,6 +3084,151 @@ const de: TranslationDict = {
|
||||
'onboarding.previous': 'Zurück',
|
||||
'onboarding.dont_show_again': 'Nicht mehr anzeigen',
|
||||
'onboarding.restart_tour': 'Tour erneut starten',
|
||||
|
||||
// Student Transportation
|
||||
'transport.title': 'Verkehrsmittel',
|
||||
'transport.type': 'Verkehrsmitteltyp',
|
||||
'transport.bus': 'Bus',
|
||||
'transport.tram': 'Straßenbahn',
|
||||
'transport.walk': 'Zu Fuß',
|
||||
'transport.car': 'Auto',
|
||||
'transport.bike': 'Fahrrad',
|
||||
'transport.other': 'Sonstiges',
|
||||
'transport.route': 'Linie',
|
||||
'transport.stop': 'Haltestelle',
|
||||
'transport.pickup': 'Abholung',
|
||||
'transport.dropoff': 'Rückfahrt',
|
||||
'transport.driver': 'Fahrer',
|
||||
'transport.distance': 'Entfernung',
|
||||
'transport.distance_km': '{count} km',
|
||||
'transport.notes': 'Hinweise',
|
||||
'transport.add': 'Verkehrsmittel hinzufügen',
|
||||
'transport.edit': 'Verkehrsmittel bearbeiten',
|
||||
'transport.no_transport': 'Keine Verkehrsmittel gespeichert',
|
||||
'transport.route_number': 'Liniennummer',
|
||||
'transport.stop_name': 'Haltestellenname',
|
||||
'transport.pickup_time': 'Abholzeit',
|
||||
'transport.dropoff_time': 'Rückfahrtzeit',
|
||||
'transport.driver_name': 'Fahrername',
|
||||
'transport.driver_phone': 'Fahrertelefon',
|
||||
'transport.distance_km_field': 'Entfernung (km)',
|
||||
'transport.class_overview': 'Klassenübersicht Verkehrsmittel',
|
||||
'transport.quick_call': 'Anrufen',
|
||||
|
||||
// Health Records
|
||||
'health.title': 'Gesundheitsdaten',
|
||||
'health.blood_type': 'Blutgruppe',
|
||||
'health.allergies': 'Allergien',
|
||||
'health.medications': 'Medikamente',
|
||||
'health.conditions': 'Erkrankungen',
|
||||
'health.doctor': 'Arzt',
|
||||
'health.insurance': 'Versicherung',
|
||||
'health.confidential': 'Vertraulich',
|
||||
'health.last_checkup': 'Letzte Untersuchung',
|
||||
'health.warning': 'Gesundheitshinweis',
|
||||
'health.add': 'Gesundheitsdaten hinzufügen',
|
||||
'health.edit': 'Gesundheitsdaten bearbeiten',
|
||||
'health.no_record': 'Keine Gesundheitsdaten vorhanden',
|
||||
'health.doctor_name': 'Arztname',
|
||||
'health.doctor_phone': 'Arzttelefon',
|
||||
'health.insurance_number': 'Versicherungsnummer',
|
||||
'health.insurance_provider': 'Versicherungsträger',
|
||||
'health.last_checkup_date': 'Datum letzte Untersuchung',
|
||||
'health.notes': 'Gesundheitshinweise',
|
||||
'health.confidential_toggle': 'Vertraulichkeitsstatus',
|
||||
'health.dosage': 'Dosierung',
|
||||
'health.frequency': 'Häufigkeit',
|
||||
'health.medication_name': 'Medikamentenname',
|
||||
'health.add_allergy': 'Allergie hinzufügen',
|
||||
'health.add_medication': 'Medikament hinzufügen',
|
||||
'health.add_condition': 'Erkrankung hinzufügen',
|
||||
|
||||
// Interactive Seating Chart
|
||||
'seating.title': 'Sitzplan',
|
||||
'seating.chart': 'Sitzordnung',
|
||||
'seating.row': 'Reihe',
|
||||
'seating.column': 'Spalte',
|
||||
'seating.save': 'Sitzplan speichern',
|
||||
'seating.randomize': 'Mischen',
|
||||
'seating.clear': 'Zurücksetzen',
|
||||
'seating.print': 'Sitzplan drucken',
|
||||
'seating.drag_to_rearrange': 'Ziehen zum Verschieben',
|
||||
'seating.empty_seat': 'Freier Platz',
|
||||
'seating.rows': 'Reihen',
|
||||
'seating.columns': 'Spalten',
|
||||
'seating.grid_size': 'Rastergröße',
|
||||
'seating.student_initials': 'Initialen',
|
||||
'seating.no_students': 'Keine Schüler für Sitzplan',
|
||||
'seating.visual': 'Visueller Sitzplan',
|
||||
'seating.list': 'Listenansicht',
|
||||
|
||||
// QR Code System
|
||||
'qr.title': 'QR-Code',
|
||||
'qr.student': 'Schüler-QR-Code',
|
||||
'qr.class': 'Klassen-QR-Code',
|
||||
'qr.attendance': 'Anwesenheits-QR-Code',
|
||||
'qr.event': 'Veranstaltungs-QR-Code',
|
||||
'qr.download': 'QR-Code herunterladen',
|
||||
'qr.scan': 'QR-Code scannen',
|
||||
'qr.refresh': 'QR-Code aktualisieren',
|
||||
'qr.generate': 'QR-Code erzeugen',
|
||||
'qr.scan_to_checkin': 'Scannen zum Einchecken',
|
||||
'qr.session_active': 'QR-Sitzung aktiv',
|
||||
'qr.refreshes_in': 'Aktualisierung in {seconds}s',
|
||||
'qr.checked_in': 'Eingecheckt',
|
||||
'qr.pending': 'Ausstehend',
|
||||
'qr.absent': 'Abwesend',
|
||||
'qr.show_qr': 'QR-Code anzeigen',
|
||||
'qr.print_report': 'QR im Bericht',
|
||||
'qr.student_card': 'Schülerausweis',
|
||||
|
||||
// Badge / Gamification System
|
||||
'badges.title': 'Abzeichen',
|
||||
'badges.earned': 'Verdient',
|
||||
'badges.unearned': 'Noch nicht verdient',
|
||||
'badges.progress': 'Fortschritt',
|
||||
'badges.collection': 'Abzeichen-Sammlung',
|
||||
'badges.award': 'Abzeichen verleihen',
|
||||
'badges.auto': 'Automatisch vergeben',
|
||||
'badges.manual': 'Manuell vergeben',
|
||||
'badges.leaderboard': 'Abzeichen-Rangliste',
|
||||
'badges.recent': 'Kürzlich verdient',
|
||||
'badges.details': 'Abzeichen-Details',
|
||||
'badges.earned_count': '{earned} von {total} Abzeichen verdient',
|
||||
'badges.category': 'Kategorie',
|
||||
'badges.competency': 'Kompetenz',
|
||||
'badges.attendance': 'Anwesenheit',
|
||||
'badges.behavior': 'Verhalten',
|
||||
'badges.achievement': 'Leistung',
|
||||
'badges.milestone': 'Meilenstein',
|
||||
'badges.requirement': 'Voraussetzung',
|
||||
'badges.create': 'Abzeichen erstellen',
|
||||
'badges.edit': 'Abzeichen bearbeiten',
|
||||
'badges.delete': 'Abzeichen löschen',
|
||||
'badges.manage': 'Abzeichen verwalten',
|
||||
'badges.statistics': 'Abzeichen-Statistik',
|
||||
'badges.award_manually': 'Manuell verleihen',
|
||||
'badges.awarded_by': 'Verliehen von',
|
||||
'badges.awarded_at': 'Verliehen am',
|
||||
'badges.no_badges': 'Keine Abzeichen vorhanden',
|
||||
'badges.celebration': 'Neues Abzeichen verdient!',
|
||||
'badges.icon': 'Icon',
|
||||
'badges.color': 'Farbe',
|
||||
'badges.threshold': 'Schwellenwert',
|
||||
'badges.type': 'Typ',
|
||||
'badges.is_auto': 'Automatisch vergeben',
|
||||
|
||||
// Badge Names
|
||||
'badges.perfect_attendance': 'Perfekte Anwesenheit',
|
||||
'badges.competency_master': 'Kompetenz-Meister',
|
||||
'badges.progress_pioneer': 'Fortschritts-Pionier',
|
||||
'badges.behavior_star': 'Verhaltensstern',
|
||||
'badges.notebook_champion': 'Heft-Champion',
|
||||
'badges.drawing_artist': 'Zeichen-Künstler',
|
||||
'badges.homework_hero': 'Hausaufgaben-Held',
|
||||
'badges.team_player': 'Teamplayer',
|
||||
'badges.goal_achiever': 'Zielerreicher',
|
||||
'badges.eco_warrior': 'Umweltkrieger',
|
||||
};
|
||||
|
||||
const en: TranslationDict = {
|
||||
@@ -6167,6 +6312,151 @@ const en: TranslationDict = {
|
||||
'onboarding.previous': 'Previous',
|
||||
'onboarding.dont_show_again': 'Don\'t show again',
|
||||
'onboarding.restart_tour': 'Restart tour',
|
||||
|
||||
// Student Transportation
|
||||
'transport.title': 'Transportation',
|
||||
'transport.type': 'Transport Type',
|
||||
'transport.bus': 'Bus',
|
||||
'transport.tram': 'Tram',
|
||||
'transport.walk': 'Walk',
|
||||
'transport.car': 'Car',
|
||||
'transport.bike': 'Bike',
|
||||
'transport.other': 'Other',
|
||||
'transport.route': 'Route',
|
||||
'transport.stop': 'Stop',
|
||||
'transport.pickup': 'Pickup',
|
||||
'transport.dropoff': 'Drop-off',
|
||||
'transport.driver': 'Driver',
|
||||
'transport.distance': 'Distance',
|
||||
'transport.distance_km': '{count} km',
|
||||
'transport.notes': 'Notes',
|
||||
'transport.add': 'Add Transportation',
|
||||
'transport.edit': 'Edit Transportation',
|
||||
'transport.no_transport': 'No transportation saved',
|
||||
'transport.route_number': 'Route Number',
|
||||
'transport.stop_name': 'Stop Name',
|
||||
'transport.pickup_time': 'Pickup Time',
|
||||
'transport.dropoff_time': 'Drop-off Time',
|
||||
'transport.driver_name': 'Driver Name',
|
||||
'transport.driver_phone': 'Driver Phone',
|
||||
'transport.distance_km_field': 'Distance (km)',
|
||||
'transport.class_overview': 'Class Transport Overview',
|
||||
'transport.quick_call': 'Quick Call',
|
||||
|
||||
// Health Records
|
||||
'health.title': 'Health Records',
|
||||
'health.blood_type': 'Blood Type',
|
||||
'health.allergies': 'Allergies',
|
||||
'health.medications': 'Medications',
|
||||
'health.conditions': 'Conditions',
|
||||
'health.doctor': 'Doctor',
|
||||
'health.insurance': 'Insurance',
|
||||
'health.confidential': 'Confidential',
|
||||
'health.last_checkup': 'Last Checkup',
|
||||
'health.warning': 'Health Warning',
|
||||
'health.add': 'Add Health Record',
|
||||
'health.edit': 'Edit Health Record',
|
||||
'health.no_record': 'No health records available',
|
||||
'health.doctor_name': 'Doctor Name',
|
||||
'health.doctor_phone': 'Doctor Phone',
|
||||
'health.insurance_number': 'Insurance Number',
|
||||
'health.insurance_provider': 'Insurance Provider',
|
||||
'health.last_checkup_date': 'Last Checkup Date',
|
||||
'health.notes': 'Health Notes',
|
||||
'health.confidential_toggle': 'Confidentiality Status',
|
||||
'health.dosage': 'Dosage',
|
||||
'health.frequency': 'Frequency',
|
||||
'health.medication_name': 'Medication Name',
|
||||
'health.add_allergy': 'Add Allergy',
|
||||
'health.add_medication': 'Add Medication',
|
||||
'health.add_condition': 'Add Condition',
|
||||
|
||||
// Interactive Seating Chart
|
||||
'seating.title': 'Seating Chart',
|
||||
'seating.chart': 'Seating Order',
|
||||
'seating.row': 'Row',
|
||||
'seating.column': 'Column',
|
||||
'seating.save': 'Save Seating',
|
||||
'seating.randomize': 'Randomize',
|
||||
'seating.clear': 'Clear',
|
||||
'seating.print': 'Print Seating',
|
||||
'seating.drag_to_rearrange': 'Drag to rearrange',
|
||||
'seating.empty_seat': 'Empty Seat',
|
||||
'seating.rows': 'Rows',
|
||||
'seating.columns': 'Columns',
|
||||
'seating.grid_size': 'Grid Size',
|
||||
'seating.student_initials': 'Initials',
|
||||
'seating.no_students': 'No students for seating chart',
|
||||
'seating.visual': 'Visual Seating',
|
||||
'seating.list': 'List View',
|
||||
|
||||
// QR Code System
|
||||
'qr.title': 'QR Code',
|
||||
'qr.student': 'Student QR Code',
|
||||
'qr.class': 'Class QR Code',
|
||||
'qr.attendance': 'Attendance QR Code',
|
||||
'qr.event': 'Event QR Code',
|
||||
'qr.download': 'Download QR Code',
|
||||
'qr.scan': 'Scan QR Code',
|
||||
'qr.refresh': 'Refresh QR Code',
|
||||
'qr.generate': 'Generate QR Code',
|
||||
'qr.scan_to_checkin': 'Scan to check in',
|
||||
'qr.session_active': 'QR session active',
|
||||
'qr.refreshes_in': 'Refreshes in {seconds}s',
|
||||
'qr.checked_in': 'Checked in',
|
||||
'qr.pending': 'Pending',
|
||||
'qr.absent': 'Absent',
|
||||
'qr.show_qr': 'Show QR Code',
|
||||
'qr.print_report': 'QR on Report',
|
||||
'qr.student_card': 'Student Card',
|
||||
|
||||
// Badge / Gamification System
|
||||
'badges.title': 'Badges',
|
||||
'badges.earned': 'Earned',
|
||||
'badges.unearned': 'Not yet earned',
|
||||
'badges.progress': 'Progress',
|
||||
'badges.collection': 'Badge Collection',
|
||||
'badges.award': 'Award Badge',
|
||||
'badges.auto': 'Auto-awarded',
|
||||
'badges.manual': 'Manually awarded',
|
||||
'badges.leaderboard': 'Badge Leaderboard',
|
||||
'badges.recent': 'Recently earned',
|
||||
'badges.details': 'Badge Details',
|
||||
'badges.earned_count': '{earned} of {total} badges earned',
|
||||
'badges.category': 'Category',
|
||||
'badges.competency': 'Competency',
|
||||
'badges.attendance': 'Attendance',
|
||||
'badges.behavior': 'Behavior',
|
||||
'badges.achievement': 'Achievement',
|
||||
'badges.milestone': 'Milestone',
|
||||
'badges.requirement': 'Requirement',
|
||||
'badges.create': 'Create Badge',
|
||||
'badges.edit': 'Edit Badge',
|
||||
'badges.delete': 'Delete Badge',
|
||||
'badges.manage': 'Manage Badges',
|
||||
'badges.statistics': 'Badge Statistics',
|
||||
'badges.award_manually': 'Award Manually',
|
||||
'badges.awarded_by': 'Awarded by',
|
||||
'badges.awarded_at': 'Awarded on',
|
||||
'badges.no_badges': 'No badges available',
|
||||
'badges.celebration': 'New badge earned!',
|
||||
'badges.icon': 'Icon',
|
||||
'badges.color': 'Color',
|
||||
'badges.threshold': 'Threshold',
|
||||
'badges.type': 'Type',
|
||||
'badges.is_auto': 'Auto-awarded',
|
||||
|
||||
// Badge Names
|
||||
'badges.perfect_attendance': 'Perfect Attendance',
|
||||
'badges.competency_master': 'Competency Master',
|
||||
'badges.progress_pioneer': 'Progress Pioneer',
|
||||
'badges.behavior_star': 'Behavior Star',
|
||||
'badges.notebook_champion': 'Notebook Champion',
|
||||
'badges.drawing_artist': 'Drawing Artist',
|
||||
'badges.homework_hero': 'Homework Hero',
|
||||
'badges.team_player': 'Team Player',
|
||||
'badges.goal_achiever': 'Goal Achiever',
|
||||
'badges.eco_warrior': 'Eco Warrior',
|
||||
};
|
||||
|
||||
const translations: Record<string, TranslationDict> = { de, en };
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// CompetenceTrack — QR Code Generation Utility
|
||||
// Generates QR codes as base64 PNG images using canvas
|
||||
|
||||
export type QRCodeType = 'student' | 'class' | 'attendance' | 'event';
|
||||
|
||||
export interface QRCodeData {
|
||||
type: QRCodeType;
|
||||
id: string;
|
||||
schoolId?: string;
|
||||
label?: string;
|
||||
// For attendance QR: session-specific data
|
||||
sessionId?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL string from QR code data that can be encoded into a QR code
|
||||
*/
|
||||
export function buildQRCodeUrl(data: QRCodeData): string {
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : 'https://competencetrack.app';
|
||||
switch (data.type) {
|
||||
case 'student':
|
||||
return `${base}/?student=${data.id}`;
|
||||
case 'class':
|
||||
return `${base}/?class=${data.id}`;
|
||||
case 'attendance':
|
||||
return `${base}/?attendance=${data.sessionId || data.id}&t=${data.timestamp || Date.now()}`;
|
||||
case 'event':
|
||||
return `${base}/?event=${data.id}`;
|
||||
default:
|
||||
return `${base}/?id=${data.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple QR code encoder using Canvas API
|
||||
// This implements a minimal QR code generator
|
||||
|
||||
// QR Code error correction levels
|
||||
const ERROR_CORRECTION_LEVEL = {
|
||||
L: 0, // 7% recovery
|
||||
M: 1, // 15% recovery
|
||||
Q: 2, // 25% recovery
|
||||
H: 3, // 30% recovery
|
||||
};
|
||||
|
||||
// QR Code version sizes (modules per side)
|
||||
const VERSION_SIZES = [
|
||||
0, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, // 1-10
|
||||
61, 65, 69, 73, 77, 81, 85, 89, 93, 97, // 11-20
|
||||
101, 105, 109, 113, 117, 121, 125, 129, 133, 137, // 21-30
|
||||
141, 145, 149, 153, 157, 161, 165, 169, 173, 177, // 31-40
|
||||
];
|
||||
|
||||
// Simplified QR code generation using canvas
|
||||
// We use a well-known approach: encode data to a binary matrix, then render to canvas
|
||||
|
||||
function getAlphanumericCode(char: string): number {
|
||||
const codes = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
|
||||
const idx = codes.indexOf(char);
|
||||
return idx >= 0 ? idx : -1;
|
||||
}
|
||||
|
||||
function isAlphanumeric(text: string): boolean {
|
||||
for (const c of text) {
|
||||
if (getAlphanumericCode(c) < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface QRMatrix {
|
||||
size: number;
|
||||
modules: boolean[][]; // true = dark
|
||||
reserved: boolean[][];
|
||||
}
|
||||
|
||||
function createMatrix(version: number): QRMatrix {
|
||||
const size = VERSION_SIZES[version];
|
||||
const modules: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
|
||||
const reserved: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
|
||||
return { size, modules, reserved };
|
||||
}
|
||||
|
||||
function setFunctionPatterns(matrix: QRMatrix): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
|
||||
// Finders: 7x7 at three corners
|
||||
const finderPositions = [
|
||||
[0, 0], [0, size - 7], [size - 7, 0],
|
||||
];
|
||||
|
||||
for (const [row, col] of finderPositions) {
|
||||
for (let r = -1; r <= 7; r++) {
|
||||
for (let c = -1; c <= 7; c++) {
|
||||
const rr = row + r;
|
||||
const cc = col + c;
|
||||
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||
const isDark = (r >= 0 && r <= 6 && (c === 0 || c === 6)) ||
|
||||
(c >= 0 && c <= 6 && (r === 0 || r === 6)) ||
|
||||
(r >= 2 && r <= 4 && c >= 2 && c <= 4);
|
||||
modules[rr][cc] = isDark;
|
||||
reserved[rr][cc] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timing patterns
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
if (!reserved[6][i]) {
|
||||
modules[6][i] = i % 2 === 0;
|
||||
reserved[6][i] = true;
|
||||
}
|
||||
if (!reserved[i][6]) {
|
||||
modules[i][6] = i % 2 === 0;
|
||||
reserved[i][6] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Alignment patterns (for version >= 2)
|
||||
if (size >= 25) {
|
||||
const alignPositions = getAlignmentPositions(size);
|
||||
for (const row of alignPositions) {
|
||||
for (const col of alignPositions) {
|
||||
if (reserved[row]?.[col]) continue;
|
||||
for (let r = -2; r <= 2; r++) {
|
||||
for (let c = -2; c <= 2; c++) {
|
||||
const rr = row + r;
|
||||
const cc = col + c;
|
||||
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||
if (reserved[rr][cc]) continue;
|
||||
const isDark = Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0);
|
||||
modules[rr][cc] = isDark;
|
||||
reserved[rr][cc] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve format info areas
|
||||
for (let i = 0; i < 8; i++) {
|
||||
reserved[8][i] = true;
|
||||
reserved[i][8] = true;
|
||||
reserved[8][size - 1 - i] = true;
|
||||
reserved[size - 1 - i][8] = true;
|
||||
}
|
||||
reserved[8][8] = true;
|
||||
reserved[size - 8][8] = true;
|
||||
reserved[8][size - 8] = true;
|
||||
}
|
||||
|
||||
function getAlignmentPositions(size: number): number[] {
|
||||
// Simplified: common alignment positions for various versions
|
||||
const positions: number[] = [6];
|
||||
if (size === 25) positions.push(18);
|
||||
else if (size === 29) positions.push(22);
|
||||
else if (size === 33) positions.push(26);
|
||||
else if (size === 37) positions.push(30);
|
||||
else if (size === 41) positions.push(34);
|
||||
else if (size === 45) positions.push(38);
|
||||
else if (size === 49) positions.push(42);
|
||||
else if (size === 53) positions.push(46);
|
||||
else positions.push(size - 7);
|
||||
return positions;
|
||||
}
|
||||
|
||||
function encodeData(text: string, version: number): number[] {
|
||||
// Simple byte encoding
|
||||
const data: number[] = [];
|
||||
|
||||
// Mode indicator: 0100 (byte mode)
|
||||
data.push(0, 1, 0, 0);
|
||||
|
||||
// Character count (8 bits for byte mode, version 1-9)
|
||||
const charCountBits = version <= 9 ? 8 : 16;
|
||||
const countBin = text.length.toString(2).padStart(charCountBits, '0');
|
||||
for (const bit of countBin) {
|
||||
data.push(parseInt(bit));
|
||||
}
|
||||
|
||||
// Data
|
||||
for (const char of text) {
|
||||
const byte = char.charCodeAt(0);
|
||||
const byteBin = byte.toString(2).padStart(8, '0');
|
||||
for (const bit of byteBin) {
|
||||
data.push(parseInt(bit));
|
||||
}
|
||||
}
|
||||
|
||||
// Terminator
|
||||
const maxDataBits = getDataCapacity(version);
|
||||
const terminatorLen = Math.min(4, maxDataBits - data.length);
|
||||
for (let i = 0; i < terminatorLen; i++) {
|
||||
data.push(0);
|
||||
}
|
||||
|
||||
// Pad to byte boundary
|
||||
while (data.length % 8 !== 0) {
|
||||
data.push(0);
|
||||
}
|
||||
|
||||
// Pad bytes
|
||||
const padBytes = [0xEC, 0x11];
|
||||
let padIdx = 0;
|
||||
while (data.length < maxDataBits) {
|
||||
const byte = padBytes[padIdx % 2];
|
||||
const byteBin = byte.toString(2).padStart(8, '0');
|
||||
for (const bit of byteBin) {
|
||||
data.push(parseInt(bit));
|
||||
}
|
||||
padIdx++;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function getDataCapacity(version: number): number {
|
||||
// Simplified: total data bits for byte mode with L error correction
|
||||
const capacities = [0, 152, 272, 400, 576, 752, 976, 1200, 1456, 1728, 2032];
|
||||
return capacities[Math.min(version, 10)] || 2032;
|
||||
}
|
||||
|
||||
function placeData(matrix: QRMatrix, data: number[]): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
let bitIdx = 0;
|
||||
let upward = true;
|
||||
|
||||
for (let col = size - 1; col >= 0; col -= 2) {
|
||||
if (col === 6) col = 5; // Skip timing column
|
||||
|
||||
for (let row = 0; row < size; row++) {
|
||||
const actualRow = upward ? (size - 1 - row) : row;
|
||||
|
||||
for (let c = 0; c < 2; c++) {
|
||||
const actualCol = col - c;
|
||||
if (actualCol < 0 || actualCol >= size) continue;
|
||||
if (reserved[actualRow][actualCol]) continue;
|
||||
|
||||
if (bitIdx < data.length) {
|
||||
modules[actualRow][actualCol] = data[bitIdx] === 1;
|
||||
bitIdx++;
|
||||
}
|
||||
// If no more data, leave as false (white)
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMask(matrix: QRMatrix): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
|
||||
// Apply mask pattern 0: (row + col) % 2 === 0
|
||||
for (let row = 0; row < size; row++) {
|
||||
for (let col = 0; col < size; col++) {
|
||||
if (reserved[row][col]) continue;
|
||||
if ((row + col) % 2 === 0) {
|
||||
modules[row][col] = !modules[row][col];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addFormatInfo(matrix: QRMatrix): void {
|
||||
const { size, modules, reserved } = matrix;
|
||||
|
||||
// Format info for L error correction + mask 0: 0x77C4
|
||||
const formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0];
|
||||
|
||||
// Horizontal format info
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (!reserved[8][i]) modules[8][i] = formatBits[i] === 1;
|
||||
if (!reserved[8][size - 1 - i]) modules[8][size - 1 - i] = formatBits[7 + i] === 1;
|
||||
}
|
||||
|
||||
// Vertical format info
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (!reserved[i][8]) modules[i][8] = formatBits[i] === 1;
|
||||
if (!reserved[size - 7 + i][8]) modules[size - 7 + i][8] = formatBits[i] === 1;
|
||||
}
|
||||
}
|
||||
|
||||
function selectVersion(text: string): number {
|
||||
const byteLen = new TextEncoder().encode(text).length;
|
||||
// Capacities for byte mode, L error correction
|
||||
const capacities = [0, 17, 32, 53, 78, 106, 134, 154, 192, 230, 271];
|
||||
for (let v = 1; v <= 10; v++) {
|
||||
if (byteLen <= capacities[v]) return v;
|
||||
}
|
||||
return 10; // Max version we support
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code matrix from text
|
||||
*/
|
||||
function generateQRMatrix(text: string): QRMatrix {
|
||||
const version = selectVersion(text);
|
||||
const matrix = createMatrix(version);
|
||||
|
||||
setFunctionPatterns(matrix);
|
||||
|
||||
const data = encodeData(text, version);
|
||||
placeData(matrix, data);
|
||||
applyMask(matrix);
|
||||
addFormatInfo(matrix);
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render QR matrix to canvas and return base64 PNG
|
||||
*/
|
||||
function renderToBase64(matrix: QRMatrix, size: number = 256, fgColor: string = '#000000', bgColor: string = '#ffffff'): string {
|
||||
// Only works in browser
|
||||
if (typeof document === 'undefined') return '';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
const quietZone = 4; // 4 module quiet zone
|
||||
const totalModules = matrix.size + quietZone * 2;
|
||||
const moduleSize = size / totalModules;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// Modules
|
||||
ctx.fillStyle = fgColor;
|
||||
for (let row = 0; row < matrix.size; row++) {
|
||||
for (let col = 0; col < matrix.size; col++) {
|
||||
if (matrix.modules[row][col]) {
|
||||
const x = (col + quietZone) * moduleSize;
|
||||
const y = (row + quietZone) * moduleSize;
|
||||
ctx.fillRect(x, y, moduleSize + 0.5, moduleSize + 0.5); // +0.5 to avoid gaps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a base64 PNG data URL
|
||||
*/
|
||||
export async function generateQRCode(
|
||||
data: QRCodeData,
|
||||
options?: { size?: number; fgColor?: string; bgColor?: string }
|
||||
): Promise<string> {
|
||||
const url = buildQRCodeUrl(data);
|
||||
const matrix = generateQRMatrix(url);
|
||||
const size = options?.size ?? 256;
|
||||
const fgColor = options?.fgColor ?? '#000000';
|
||||
const bgColor = options?.bgColor ?? '#ffffff';
|
||||
|
||||
return renderToBase64(matrix, size, fgColor, bgColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a base64 PNG data URL (sync version for browser)
|
||||
*/
|
||||
export function generateQRCodeSync(
|
||||
data: QRCodeData,
|
||||
options?: { size?: number; fgColor?: string; bgColor?: string }
|
||||
): string {
|
||||
const url = buildQRCodeUrl(data);
|
||||
const matrix = generateQRMatrix(url);
|
||||
const size = options?.size ?? 256;
|
||||
const fgColor = options?.fgColor ?? '#000000';
|
||||
const bgColor = options?.bgColor ?? '#ffffff';
|
||||
|
||||
return renderToBase64(matrix, size, fgColor, bgColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a QR code image
|
||||
*/
|
||||
export function downloadQRCode(dataUrl: string, filename: string = 'qrcode.png'): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
Reference in new issue
Block a user