first commit
This commit is contained in:
882
frontend/src/lib/api.ts
Normal file
882
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,882 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Request failed' }));
|
||||
const errorMessage = typeof errorData.error === 'string'
|
||||
? errorData.error
|
||||
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Events API
|
||||
export const eventsApi = {
|
||||
getAll: (params?: { status?: string; upcoming?: boolean }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.upcoming) query.set('upcoming', 'true');
|
||||
return fetchApi<{ events: Event[] }>(`/api/events?${query}`);
|
||||
},
|
||||
|
||||
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
|
||||
|
||||
getNextUpcoming: () => fetchApi<{ event: Event | null }>('/api/events/next/upcoming'),
|
||||
|
||||
create: (data: Partial<Event>) =>
|
||||
fetchApi<{ event: Event }>('/api/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
update: (id: string, data: Partial<Event>) =>
|
||||
fetchApi<{ event: Event }>(`/api/events/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/events/${id}`, { method: 'DELETE' }),
|
||||
|
||||
duplicate: (id: string) =>
|
||||
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Tickets API
|
||||
export const ticketsApi = {
|
||||
book: (data: BookingData) =>
|
||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getById: (id: string) => fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`),
|
||||
|
||||
getAll: (params?: { eventId?: string; status?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
|
||||
},
|
||||
|
||||
checkin: (id: string) =>
|
||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
removeCheckin: (id: string) =>
|
||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/remove-checkin`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
cancel: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/tickets/${id}/cancel`, { method: 'POST' }),
|
||||
|
||||
updateStatus: (id: string, status: string) =>
|
||||
fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
|
||||
updateNote: (id: string, note: string) =>
|
||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/note`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note }),
|
||||
}),
|
||||
|
||||
markPaid: (id: string) =>
|
||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// For manual payment methods (bank_transfer, tpago) - user marks payment as sent
|
||||
markPaymentSent: (id: string) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
adminCreate: (data: {
|
||||
eventId: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
preferredLanguage?: 'en' | 'es';
|
||||
autoCheckin?: boolean;
|
||||
adminNote?: string;
|
||||
}) =>
|
||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
checkPaymentStatus: (ticketId: string) =>
|
||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||
`/api/lnbits/status/${ticketId}`
|
||||
),
|
||||
};
|
||||
|
||||
// Contacts API
|
||||
export const contactsApi = {
|
||||
submit: (data: { name: string; email: string; message: string }) =>
|
||||
fetchApi<{ message: string }>('/api/contacts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
subscribe: (email: string, name?: string) =>
|
||||
fetchApi<{ message: string }>('/api/contacts/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, name }),
|
||||
}),
|
||||
|
||||
getAll: (status?: string) => {
|
||||
const query = status ? `?status=${status}` : '';
|
||||
return fetchApi<{ contacts: Contact[] }>(`/api/contacts${query}`);
|
||||
},
|
||||
|
||||
updateStatus: (id: string, status: string) =>
|
||||
fetchApi<{ contact: Contact }>(`/api/contacts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Users API
|
||||
export const usersApi = {
|
||||
getAll: (role?: string) => {
|
||||
const query = role ? `?role=${role}` : '';
|
||||
return fetchApi<{ users: User[] }>(`/api/users${query}`);
|
||||
},
|
||||
|
||||
getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),
|
||||
|
||||
update: (id: string, data: Partial<User>) =>
|
||||
fetchApi<{ user: User }>(`/api/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/users/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Payments API
|
||||
export const paymentsApi = {
|
||||
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.provider) query.set('provider', params.provider);
|
||||
if (params?.pendingApproval) query.set('pendingApproval', 'true');
|
||||
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
|
||||
},
|
||||
|
||||
getPendingApproval: () =>
|
||||
fetchApi<{ payments: PaymentWithDetails[] }>('/api/payments/pending-approval'),
|
||||
|
||||
update: (id: string, data: { status: string; reference?: string; adminNote?: string }) =>
|
||||
fetchApi<{ payment: Payment }>(`/api/payments/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
approve: (id: string, adminNote?: string) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminNote }),
|
||||
}),
|
||||
|
||||
reject: (id: string, adminNote?: string) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminNote }),
|
||||
}),
|
||||
|
||||
updateNote: (id: string, adminNote: string) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/note`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminNote }),
|
||||
}),
|
||||
|
||||
refund: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Payment Options API
|
||||
export const paymentOptionsApi = {
|
||||
// Global payment options
|
||||
getGlobal: () =>
|
||||
fetchApi<{ paymentOptions: PaymentOptionsConfig }>('/api/payment-options'),
|
||||
|
||||
updateGlobal: (data: Partial<PaymentOptionsConfig>) =>
|
||||
fetchApi<{ paymentOptions: PaymentOptionsConfig; message: string }>('/api/payment-options', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Event-specific options (merged with global)
|
||||
getForEvent: (eventId: string) =>
|
||||
fetchApi<{ paymentOptions: PaymentOptionsConfig; hasOverrides: boolean }>(
|
||||
`/api/payment-options/event/${eventId}`
|
||||
),
|
||||
|
||||
// Event overrides (admin only)
|
||||
getEventOverrides: (eventId: string) =>
|
||||
fetchApi<{ overrides: Partial<PaymentOptionsConfig> | null }>(
|
||||
`/api/payment-options/event/${eventId}/overrides`
|
||||
),
|
||||
|
||||
updateEventOverrides: (eventId: string, data: Partial<PaymentOptionsConfig>) =>
|
||||
fetchApi<{ overrides: Partial<PaymentOptionsConfig>; message: string }>(
|
||||
`/api/payment-options/event/${eventId}/overrides`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
),
|
||||
|
||||
deleteEventOverrides: (eventId: string) =>
|
||||
fetchApi<{ message: string }>(`/api/payment-options/event/${eventId}/overrides`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Media API
|
||||
export const mediaApi = {
|
||||
upload: async (file: File, relatedId?: string, relatedType?: string) => {
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
: null;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (relatedId) formData.append('relatedId', relatedId);
|
||||
if (relatedType) formData.append('relatedType', relatedType);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/media/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
|
||||
return res.json() as Promise<{ media: Media; url: string }>;
|
||||
},
|
||||
|
||||
delete: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/media/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Admin API
|
||||
export const adminApi = {
|
||||
getDashboard: () => fetchApi<{ dashboard: DashboardData }>('/api/admin/dashboard'),
|
||||
getAnalytics: () => fetchApi<{ analytics: AnalyticsData }>('/api/admin/analytics'),
|
||||
exportTickets: (eventId?: string) => {
|
||||
const query = eventId ? `?eventId=${eventId}` : '';
|
||||
return fetchApi<{ tickets: ExportedTicket[] }>(`/api/admin/export/tickets${query}`);
|
||||
},
|
||||
exportFinancial: (params?: { startDate?: string; endDate?: string; eventId?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.startDate) query.set('startDate', params.startDate);
|
||||
if (params?.endDate) query.set('endDate', params.endDate);
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Emails API
|
||||
export const emailsApi = {
|
||||
// Templates
|
||||
getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
|
||||
|
||||
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
|
||||
|
||||
createTemplate: (data: Partial<EmailTemplate>) =>
|
||||
fetchApi<{ template: EmailTemplate; message: string }>('/api/emails/templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
updateTemplate: (id: string, data: Partial<EmailTemplate>) =>
|
||||
fetchApi<{ template: EmailTemplate; message: string }>(`/api/emails/templates/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteTemplate: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/emails/templates/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getTemplateVariables: (slug: string) =>
|
||||
fetchApi<{ variables: EmailVariable[] }>(`/api/emails/templates/${slug}/variables`),
|
||||
|
||||
// Sending
|
||||
sendToEvent: (eventId: string, data: {
|
||||
templateSlug: string;
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
}) =>
|
||||
fetchApi<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }>(
|
||||
`/api/emails/send/event/${eventId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
),
|
||||
|
||||
sendCustom: (data: {
|
||||
to: string;
|
||||
toName?: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
eventId?: string;
|
||||
}) =>
|
||||
fetchApi<{ success: boolean; logId?: string; error?: string }>('/api/emails/send/custom', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
preview: (data: {
|
||||
templateSlug: string;
|
||||
variables?: Record<string, any>;
|
||||
locale?: string;
|
||||
}) =>
|
||||
fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Logs
|
||||
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.limit) query.set('limit', params.limit.toString());
|
||||
if (params?.offset) query.set('offset', params.offset.toString());
|
||||
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
|
||||
},
|
||||
|
||||
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
|
||||
|
||||
getStats: (eventId?: string) => {
|
||||
const query = eventId ? `?eventId=${eventId}` : '';
|
||||
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
||||
},
|
||||
|
||||
seedTemplates: () =>
|
||||
fetchApi<{ message: string }>('/api/emails/seed-templates', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Types
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description: string;
|
||||
descriptionEs?: string;
|
||||
startDatetime: string;
|
||||
endDatetime?: string;
|
||||
location: string;
|
||||
locationUrl?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl?: string;
|
||||
bookedCount?: number;
|
||||
availableSeats?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
userId: string;
|
||||
eventId: string;
|
||||
attendeeFirstName: string;
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
preferredLanguage?: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
|
||||
checkinAt?: string;
|
||||
qrCode: string;
|
||||
adminNote?: string;
|
||||
createdAt: string;
|
||||
event?: Event;
|
||||
payment?: Payment;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
|
||||
reference?: string;
|
||||
userMarkedPaidAt?: string;
|
||||
paidAt?: string;
|
||||
paidByAdminId?: string;
|
||||
adminNote?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PaymentWithDetails extends Payment {
|
||||
ticket: {
|
||||
id: string;
|
||||
attendeeFirstName: string;
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
status: string;
|
||||
} | null;
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PaymentOptionsConfig {
|
||||
tpagoEnabled: boolean;
|
||||
tpagoLink?: string | null;
|
||||
tpagoInstructions?: string | null;
|
||||
tpagoInstructionsEs?: string | null;
|
||||
bankTransferEnabled: boolean;
|
||||
bankName?: string | null;
|
||||
bankAccountHolder?: string | null;
|
||||
bankAccountNumber?: string | null;
|
||||
bankAlias?: string | null;
|
||||
bankPhone?: string | null;
|
||||
bankNotes?: string | null;
|
||||
bankNotesEs?: string | null;
|
||||
lightningEnabled: boolean;
|
||||
cashEnabled: boolean;
|
||||
cashInstructions?: string | null;
|
||||
cashInstructionsEs?: string | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: 'admin' | 'organizer' | 'staff' | 'marketing' | 'user';
|
||||
languagePreference?: string;
|
||||
isClaimed?: boolean;
|
||||
rucNumber?: string;
|
||||
accountStatus?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
status: 'new' | 'read' | 'replied';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BookingData {
|
||||
eventId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
preferredLanguage?: 'en' | 'es';
|
||||
paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||
ruc?: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
stats: {
|
||||
totalUsers: number;
|
||||
totalEvents: number;
|
||||
totalTickets: number;
|
||||
confirmedTickets: number;
|
||||
pendingPayments: number;
|
||||
totalRevenue: number;
|
||||
newContacts: number;
|
||||
totalSubscribers: number;
|
||||
};
|
||||
upcomingEvents: Event[];
|
||||
recentTickets: Ticket[];
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
events: {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
capacity: number;
|
||||
totalBookings: number;
|
||||
confirmedBookings: number;
|
||||
checkedIn: number;
|
||||
revenue: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
id: string;
|
||||
fileUrl: string;
|
||||
type: 'image' | 'video' | 'document';
|
||||
relatedId?: string;
|
||||
relatedType?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ExportedTicket {
|
||||
ticketId: string;
|
||||
ticketStatus: string;
|
||||
qrCode: string;
|
||||
checkinAt?: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
userPhone?: string;
|
||||
eventTitle: string;
|
||||
eventDate: string;
|
||||
paymentStatus: string;
|
||||
paymentAmount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
subject: string;
|
||||
subjectEs?: string;
|
||||
bodyHtml: string;
|
||||
bodyHtmlEs?: string;
|
||||
bodyText?: string;
|
||||
bodyTextEs?: string;
|
||||
description?: string;
|
||||
variables: EmailVariable[];
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EmailVariable {
|
||||
name: string;
|
||||
description: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface EmailLog {
|
||||
id: string;
|
||||
templateId?: string;
|
||||
eventId?: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
bodyHtml?: string;
|
||||
status: 'pending' | 'sent' | 'failed' | 'bounced';
|
||||
errorMessage?: string;
|
||||
sentAt?: string;
|
||||
sentBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EmailStats {
|
||||
total: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ExportedPayment {
|
||||
paymentId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
reference?: string;
|
||||
paidAt?: string;
|
||||
createdAt: string;
|
||||
ticketId: string;
|
||||
attendeeFirstName: string;
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
eventDate: string;
|
||||
}
|
||||
|
||||
export interface FinancialSummary {
|
||||
totalPayments: number;
|
||||
totalPaid: number;
|
||||
totalPending: number;
|
||||
totalRefunded: number;
|
||||
byProvider: {
|
||||
bancard: number;
|
||||
lightning: number;
|
||||
cash: number;
|
||||
bank_transfer: number;
|
||||
tpago: number;
|
||||
};
|
||||
paidCount: number;
|
||||
pendingCount: number;
|
||||
pendingApprovalCount: number;
|
||||
refundedCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
// ==================== User Dashboard Types ====================
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
languagePreference?: string;
|
||||
rucNumber?: string;
|
||||
isClaimed: boolean;
|
||||
accountStatus: string;
|
||||
hasPassword: boolean;
|
||||
hasGoogleLinked: boolean;
|
||||
memberSince: string;
|
||||
membershipDays: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserTicket extends Ticket {
|
||||
invoice?: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
pdfUrl?: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UserPayment extends Payment {
|
||||
ticket: {
|
||||
id: string;
|
||||
attendeeFirstName: string;
|
||||
attendeeLastName?: string;
|
||||
status: string;
|
||||
} | null;
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
startDatetime: string;
|
||||
} | null;
|
||||
invoice?: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
pdfUrl?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UserInvoice {
|
||||
id: string;
|
||||
paymentId: string;
|
||||
invoiceNumber: string;
|
||||
rucNumber?: string;
|
||||
legalName?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
pdfUrl?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
event?: {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
startDatetime: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
lastActiveAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
accountStatus: string;
|
||||
memberSince: string;
|
||||
membershipDays: number;
|
||||
};
|
||||
stats: {
|
||||
totalTickets: number;
|
||||
confirmedTickets: number;
|
||||
upcomingEvents: number;
|
||||
pendingPayments: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NextEventInfo {
|
||||
event: Event;
|
||||
ticket: Ticket;
|
||||
payment: Payment | null;
|
||||
}
|
||||
|
||||
// ==================== Auth API (new methods) ====================
|
||||
|
||||
export const authApi = {
|
||||
// Magic link
|
||||
requestMagicLink: (email: string) =>
|
||||
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
|
||||
verifyMagicLink: (token: string) =>
|
||||
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
|
||||
// Password reset
|
||||
requestPasswordReset: (email: string) =>
|
||||
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
|
||||
confirmPasswordReset: (token: string, password: string) =>
|
||||
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, password }),
|
||||
}),
|
||||
|
||||
// Account claiming
|
||||
requestClaimAccount: (email: string) =>
|
||||
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
|
||||
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
|
||||
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
|
||||
'/api/auth/claim-account/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, ...data }),
|
||||
}
|
||||
),
|
||||
|
||||
// Google OAuth
|
||||
googleAuth: (credential: string) =>
|
||||
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ credential }),
|
||||
}),
|
||||
|
||||
// Change password
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
fetchApi<{ message: string }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
|
||||
// Get current user
|
||||
me: () => fetchApi<{ user: User }>('/api/auth/me'),
|
||||
};
|
||||
|
||||
// ==================== User Dashboard API ====================
|
||||
|
||||
export const dashboardApi = {
|
||||
// Summary
|
||||
getSummary: () =>
|
||||
fetchApi<{ summary: DashboardSummary }>('/api/dashboard/summary'),
|
||||
|
||||
// Profile
|
||||
getProfile: () =>
|
||||
fetchApi<{ profile: UserProfile }>('/api/dashboard/profile'),
|
||||
|
||||
updateProfile: (data: { name?: string; phone?: string; languagePreference?: string; rucNumber?: string }) =>
|
||||
fetchApi<{ profile: UserProfile; message: string }>('/api/dashboard/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Tickets
|
||||
getTickets: () =>
|
||||
fetchApi<{ tickets: UserTicket[] }>('/api/dashboard/tickets'),
|
||||
|
||||
getTicket: (id: string) =>
|
||||
fetchApi<{ ticket: UserTicket }>(`/api/dashboard/tickets/${id}`),
|
||||
|
||||
// Next event
|
||||
getNextEvent: () =>
|
||||
fetchApi<{ nextEvent: NextEventInfo | null }>('/api/dashboard/next-event'),
|
||||
|
||||
// Payments
|
||||
getPayments: () =>
|
||||
fetchApi<{ payments: UserPayment[] }>('/api/dashboard/payments'),
|
||||
|
||||
// Invoices
|
||||
getInvoices: () =>
|
||||
fetchApi<{ invoices: UserInvoice[] }>('/api/dashboard/invoices'),
|
||||
|
||||
// Sessions
|
||||
getSessions: () =>
|
||||
fetchApi<{ sessions: UserSession[] }>('/api/dashboard/sessions'),
|
||||
|
||||
revokeSession: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/dashboard/sessions/${id}`, { method: 'DELETE' }),
|
||||
|
||||
revokeAllSessions: () =>
|
||||
fetchApi<{ message: string }>('/api/dashboard/sessions/revoke-all', { method: 'POST' }),
|
||||
|
||||
// Security
|
||||
setPassword: (password: string) =>
|
||||
fetchApi<{ message: string }>('/api/dashboard/set-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
|
||||
unlinkGoogle: () =>
|
||||
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
|
||||
};
|
||||
98
frontend/src/lib/legal.ts
Normal file
98
frontend/src/lib/legal.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface LegalPage {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface LegalPageMeta {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Map file names to display titles
|
||||
const titleMap: Record<string, { en: string; es: string }> = {
|
||||
'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||
'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||
'refund_cancelation_policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
|
||||
};
|
||||
|
||||
// Convert file name to URL-friendly slug
|
||||
export function fileNameToSlug(fileName: string): string {
|
||||
return fileName.replace('.md', '').replace(/_/g, '-');
|
||||
}
|
||||
|
||||
// Convert slug back to file name
|
||||
export function slugToFileName(slug: string): string {
|
||||
return slug.replace(/-/g, '_') + '.md';
|
||||
}
|
||||
|
||||
// Get the legal directory path
|
||||
function getLegalDir(): string {
|
||||
return path.join(process.cwd(), 'legal');
|
||||
}
|
||||
|
||||
// Get all legal page slugs for static generation
|
||||
export function getAllLegalSlugs(): string[] {
|
||||
const legalDir = getLegalDir();
|
||||
|
||||
if (!fs.existsSync(legalDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(legalDir);
|
||||
return files
|
||||
.filter(file => file.endsWith('.md'))
|
||||
.map(file => fileNameToSlug(file));
|
||||
}
|
||||
|
||||
// Get metadata for all legal pages (for navigation/footer)
|
||||
export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] {
|
||||
const legalDir = getLegalDir();
|
||||
|
||||
if (!fs.existsSync(legalDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(legalDir);
|
||||
return files
|
||||
.filter(file => file.endsWith('.md'))
|
||||
.map(file => {
|
||||
const slug = fileNameToSlug(file);
|
||||
const baseFileName = file.replace('.md', '');
|
||||
const titles = titleMap[baseFileName];
|
||||
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
|
||||
|
||||
return { slug, title };
|
||||
});
|
||||
}
|
||||
|
||||
// Get a specific legal page content
|
||||
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
const legalDir = getLegalDir();
|
||||
const fileName = slugToFileName(slug);
|
||||
const filePath = path.join(legalDir, fileName);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const baseFileName = fileName.replace('.md', '');
|
||||
const titles = titleMap[baseFileName];
|
||||
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
|
||||
|
||||
// Try to extract last updated date from content
|
||||
const lastUpdatedMatch = content.match(/Last updated:\s*(.+)/i);
|
||||
const lastUpdated = lastUpdatedMatch ? lastUpdatedMatch[1].trim() : undefined;
|
||||
|
||||
return {
|
||||
slug,
|
||||
title,
|
||||
content,
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
163
frontend/src/lib/socialLinks.tsx
Normal file
163
frontend/src/lib/socialLinks.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// Social links configuration - reads from environment variables
|
||||
// All links are optional - if not set, they won't be displayed
|
||||
|
||||
export interface SocialLinks {
|
||||
whatsapp?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
telegram?: string;
|
||||
}
|
||||
|
||||
export interface SocialLink {
|
||||
type: 'whatsapp' | 'instagram' | 'email' | 'telegram';
|
||||
url: string;
|
||||
label: string;
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
// Get raw values from env
|
||||
export const socialConfig: SocialLinks = {
|
||||
whatsapp: process.env.NEXT_PUBLIC_WHATSAPP || undefined,
|
||||
instagram: process.env.NEXT_PUBLIC_INSTAGRAM || undefined,
|
||||
email: process.env.NEXT_PUBLIC_EMAIL || undefined,
|
||||
telegram: process.env.NEXT_PUBLIC_TELEGRAM || undefined,
|
||||
};
|
||||
|
||||
// Generate URLs from handles/values
|
||||
export function getWhatsAppUrl(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
// If it's already a full URL (group invite link or wa.me link), return as-is
|
||||
if (value.startsWith('https://') || value.startsWith('http://')) {
|
||||
return value;
|
||||
}
|
||||
// Otherwise, treat as phone number - remove any non-digit characters except +
|
||||
const clean = value.replace(/[^\d+]/g, '');
|
||||
return `https://wa.me/${clean.replace('+', '')}`;
|
||||
}
|
||||
|
||||
export function getInstagramUrl(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
// If it's already a full URL, return as-is
|
||||
if (value.startsWith('https://') || value.startsWith('http://')) {
|
||||
return value;
|
||||
}
|
||||
// Otherwise, treat as handle - remove @ if present
|
||||
const clean = value.replace('@', '');
|
||||
return `https://instagram.com/${clean}`;
|
||||
}
|
||||
|
||||
export function getEmailUrl(email?: string): string | null {
|
||||
if (!email) return null;
|
||||
return `mailto:${email}`;
|
||||
}
|
||||
|
||||
export function getTelegramUrl(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
// If it's already a full URL, return as-is
|
||||
if (value.startsWith('https://') || value.startsWith('http://')) {
|
||||
return value;
|
||||
}
|
||||
// Otherwise, treat as handle - remove @ if present
|
||||
const clean = value.replace('@', '');
|
||||
return `https://t.me/${clean}`;
|
||||
}
|
||||
|
||||
// Extract display handle from URL or value
|
||||
function extractInstagramHandle(value: string): string {
|
||||
// If it's a URL, extract the username from the path
|
||||
if (value.startsWith('http')) {
|
||||
const match = value.match(/instagram\.com\/([^/?]+)/);
|
||||
return match ? `@${match[1]}` : '@instagram';
|
||||
}
|
||||
// Otherwise it's already a handle
|
||||
return `@${value.replace('@', '')}`;
|
||||
}
|
||||
|
||||
function extractTelegramHandle(value: string): string {
|
||||
// If it's a URL, extract the channel/username from the path
|
||||
if (value.startsWith('http')) {
|
||||
const match = value.match(/t\.me\/([^/?]+)/);
|
||||
return match ? `@${match[1]}` : '@telegram';
|
||||
}
|
||||
// Otherwise it's already a handle
|
||||
return `@${value.replace('@', '')}`;
|
||||
}
|
||||
|
||||
// Get all active social links as an array
|
||||
export function getSocialLinks(): SocialLink[] {
|
||||
const links: SocialLink[] = [];
|
||||
|
||||
if (socialConfig.whatsapp) {
|
||||
const url = getWhatsAppUrl(socialConfig.whatsapp);
|
||||
if (url) {
|
||||
links.push({
|
||||
type: 'whatsapp',
|
||||
url,
|
||||
label: 'WhatsApp',
|
||||
handle: '@WhatsApp community',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (socialConfig.instagram) {
|
||||
const url = getInstagramUrl(socialConfig.instagram);
|
||||
if (url) {
|
||||
links.push({
|
||||
type: 'instagram',
|
||||
url,
|
||||
label: 'Instagram',
|
||||
handle: extractInstagramHandle(socialConfig.instagram),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (socialConfig.email) {
|
||||
const url = getEmailUrl(socialConfig.email);
|
||||
if (url) {
|
||||
links.push({
|
||||
type: 'email',
|
||||
url,
|
||||
label: 'Email',
|
||||
handle: socialConfig.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (socialConfig.telegram) {
|
||||
const url = getTelegramUrl(socialConfig.telegram);
|
||||
if (url) {
|
||||
links.push({
|
||||
type: 'telegram',
|
||||
url,
|
||||
label: 'Telegram',
|
||||
handle: extractTelegramHandle(socialConfig.telegram),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
// SVG icons for each platform
|
||||
export const socialIcons = {
|
||||
whatsapp: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
),
|
||||
instagram: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||
</svg>
|
||||
),
|
||||
email: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
),
|
||||
telegram: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user