45 lines
1.5 KiB
TypeScript
Executable File
45 lines
1.5 KiB
TypeScript
Executable File
export function toCents(price: string | number): number {
|
|
const n = typeof price === 'string' ? parseFloat(price) : price;
|
|
if (Number.isNaN(n) || n < 0) {
|
|
throw new Error('invalid price');
|
|
}
|
|
return Math.round(n * 100);
|
|
}
|
|
|
|
export function formatPrice(cents: number): string {
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
// RFC 5321 caps an address at 254 characters; reject anything longer up front so
|
|
// validation cost stays bounded regardless of what a client posts.
|
|
const MAX_EMAIL_LENGTH = 254;
|
|
|
|
// Both patterns are anchored single character classes with no overlapping
|
|
// alternatives, so they match in linear time. Splitting on '@' and '.' in code
|
|
// rather than in one combined pattern avoids the ambiguous (and backtracking)
|
|
// `[^\s@]+\.[^\s@]+` domain match.
|
|
const LOCAL_PART_RE = /^[^\s@]+$/;
|
|
const DOMAIN_LABEL_RE = /^[^\s@.]+$/;
|
|
|
|
export function isValidEmail(email: string): boolean {
|
|
const trimmed = email.trim();
|
|
if (trimmed.length === 0 || trimmed.length > MAX_EMAIL_LENGTH) {
|
|
return false;
|
|
}
|
|
|
|
const at = trimmed.indexOf('@');
|
|
if (at === -1 || at !== trimmed.lastIndexOf('@')) {
|
|
return false;
|
|
}
|
|
|
|
if (!LOCAL_PART_RE.test(trimmed.slice(0, at))) {
|
|
return false;
|
|
}
|
|
|
|
const labels = trimmed.slice(at + 1).split('.');
|
|
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
|
|
}
|
|
|
|
export const MARKETING_CONSENT_TEXT =
|
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|