Skip to content

Commit 426f291

Browse files
authored
Merge pull request #300 from Jokay1997/feature/voice-payment-commands
feat(voice): add voice-activated payment commands for accessibility
2 parents 521ccc1 + d6c74d3 commit 426f291

9 files changed

Lines changed: 849 additions & 8 deletions

File tree

contract/src/lib.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,12 @@ impl NepaBillingContract {
187187
if meter_id_len < METER_ID_MIN_LENGTH || meter_id_len > METER_ID_MAX_LENGTH {
188188
panic!("Meter ID must be between 3 and 50 characters");
189189
}
190-
let meter_id_bytes: Vec<u8> = meter_id.clone().into();
191-
for i in 0..meter_id_len {
192-
let b = meter_id_bytes.get(i as u32).unwrap_or(0);
193-
let is_valid = (b >= 65 && b <= 90)
194-
|| (b >= 97 && b <= 122)
195-
|| (b >= 48 && b <= 57)
196-
|| b == 45
197-
|| b == 95;
190+
for byte in meter_id.iter() {
191+
let is_valid = (byte >= 65 && byte <= 90)
192+
|| (byte >= 97 && byte <= 122)
193+
|| (byte >= 48 && byte <= 57)
194+
|| byte == 45
195+
|| byte == 95;
198196
if !is_valid {
199197
panic!("Meter ID contains invalid characters (alphanumeric, hyphens, and underscores only)");
200198
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React, { useEffect, useRef, useCallback } from 'react';
2+
import type { ParsedPaymentCommand } from '../hooks/useVoiceCommands';
3+
import { announceToScreenReader, trapFocus } from '../utils/accessibility';
4+
5+
interface VoiceCommandModalProps {
6+
command: ParsedPaymentCommand;
7+
isOpen: boolean;
8+
onConfirm: (command: ParsedPaymentCommand) => void;
9+
onCancel: () => void;
10+
}
11+
12+
export const VoiceCommandModal: React.FC<VoiceCommandModalProps> = ({
13+
command,
14+
isOpen,
15+
onConfirm,
16+
onCancel,
17+
}) => {
18+
const containerRef = useRef<HTMLDivElement>(null);
19+
20+
useEffect(() => {
21+
if (isOpen) {
22+
announceToScreenReader(
23+
`Voice command detected: Pay ${command.amount} XLM${command.meterId ? ` to meter ${command.meterId}` : ''}. Confirm or cancel.`
24+
);
25+
}
26+
}, [isOpen, command]);
27+
28+
useEffect(() => {
29+
if (!isOpen || !containerRef.current) return;
30+
const cleanup = trapFocus(containerRef.current);
31+
return cleanup;
32+
}, [isOpen]);
33+
34+
const handleKeyDown = useCallback(
35+
(e: React.KeyboardEvent) => {
36+
if (e.key === 'Escape') {
37+
onCancel();
38+
} else if (e.key === 'Enter' && !e.shiftKey) {
39+
e.preventDefault();
40+
onConfirm(command);
41+
}
42+
},
43+
[onCancel, onConfirm, command]
44+
);
45+
46+
if (!isOpen) return null;
47+
48+
return (
49+
<div
50+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
51+
onClick={onCancel}
52+
onKeyDown={handleKeyDown}
53+
role="dialog"
54+
aria-modal="true"
55+
aria-label="Confirm voice payment"
56+
>
57+
<div
58+
ref={containerRef}
59+
className="bg-slate-900 border border-slate-700 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
60+
onClick={e => e.stopPropagation()}
61+
>
62+
<div className="flex items-center gap-3 mb-4">
63+
<div className="w-10 h-10 rounded-full bg-sky-500/20 flex items-center justify-center">
64+
<svg className="w-5 h-5 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
65+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
66+
</svg>
67+
</div>
68+
<div>
69+
<h3 className="text-lg font-semibold text-slate-100">Voice Payment Confirmation</h3>
70+
<p className="text-sm text-slate-400">Confirm the payment details from your voice command</p>
71+
</div>
72+
</div>
73+
74+
<div className="bg-slate-800 rounded-lg p-4 mb-4 space-y-3">
75+
<div>
76+
<p className="text-xs text-slate-500 uppercase tracking-wide font-medium">Amount</p>
77+
<p className="text-2xl font-bold text-slate-100">{command.amount} XLM</p>
78+
</div>
79+
{command.meterId && (
80+
<div>
81+
<p className="text-xs text-slate-500 uppercase tracking-wide font-medium">Meter ID</p>
82+
<p className="text-base font-mono text-slate-200">{command.meterId}</p>
83+
</div>
84+
)}
85+
<div>
86+
<p className="text-xs text-slate-500 uppercase tracking-wide font-medium">Heard</p>
87+
<p className="text-sm text-slate-400 italic">"{command.raw}"</p>
88+
</div>
89+
</div>
90+
91+
<div className="flex gap-3">
92+
<button
93+
onClick={() => onConfirm(command)}
94+
className="flex-1 px-4 py-2.5 bg-sky-600 text-white rounded-lg font-medium hover:bg-sky-500 transition-colors focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-slate-900"
95+
aria-label={`Confirm payment of ${command.amount} XLM${command.meterId ? ` to meter ${command.meterId}` : ''}`}
96+
>
97+
Confirm Payment
98+
</button>
99+
<button
100+
onClick={onCancel}
101+
className="flex-1 px-4 py-2.5 bg-slate-700 text-slate-200 rounded-lg font-medium hover:bg-slate-600 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 focus:ring-offset-slate-900"
102+
>
103+
Cancel
104+
</button>
105+
</div>
106+
</div>
107+
</div>
108+
);
109+
};

0 commit comments

Comments
 (0)