
- Introduced a new `notes.md` file to document critical tasks and progress for stabilizing the core functionality of the MitList application. - Documented the status and findings for key tasks, including backend financial logic fixes, frontend expense split settlement implementation, and core authentication flow reviews. - Outlined remaining work for production deployment, including secret management, CI/CD pipeline setup, and performance optimizations. - Updated the logging configuration to change the log level to WARNING for production readiness. - Enhanced the database connection settings to disable SQL query logging in production. - Added a new endpoint to list all chores for improved user experience and optimized database queries. - Implemented various CRUD operations for chore assignments, including creation, retrieval, updating, and deletion. - Updated frontend components and services to support new chore assignment features and improved error handling. - Enhanced the expense management system with new fields and improved API interactions for better user experience.
678 lines
20 KiB
Vue
678 lines
20 KiB
Vue
<template>
|
|
<div class="modal-backdrop open" @click.self="closeForm">
|
|
<div class="modal-container" ref="formModalRef" style="min-width: 550px;">
|
|
<div class="modal-header">
|
|
<h3>Create New Expense</h3>
|
|
<button class="close-button" @click="closeForm" aria-label="Close">
|
|
<svg class="icon">
|
|
<use xlink:href="#icon-close" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form @submit.prevent="handleSubmit" class="expense-form">
|
|
<div class="form-group">
|
|
<label for="description" class="form-label">Description</label>
|
|
<input type="text" id="description" v-model="formData.description" class="form-input" required
|
|
placeholder="What was this expense for?" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="totalAmount" class="form-label">Total Amount</label>
|
|
<div class="amount-input-group">
|
|
<span class="currency-symbol">$</span>
|
|
<input type="number" id="totalAmount" v-model.number="formData.total_amount" class="form-input" required
|
|
min="0.01" step="0.01" placeholder="0.00" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="paidBy" class="form-label">Paid By</label>
|
|
<select id="paidBy" v-model="formData.paid_by_user_id" class="form-input" required>
|
|
<option value="" disabled>Select who paid</option>
|
|
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
|
{{ user.name || user.email }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="splitType" class="form-label">Split Type</label>
|
|
<select id="splitType" v-model="formData.split_type" class="form-input" required
|
|
@change="onSplitTypeChange">
|
|
<option value="EQUAL">Equal Split</option>
|
|
<option value="EXACT_AMOUNTS">Exact Amounts</option>
|
|
<option value="PERCENTAGE">Percentage</option>
|
|
<option value="SHARES">Shares</option>
|
|
<option value="ITEM_BASED">Item Based</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="expenseDate" class="form-label">Date</label>
|
|
<input type="date" id="expenseDate" v-model="formData.expense_date" class="form-input" :max="today" />
|
|
</div>
|
|
|
|
<!-- Split Details Section -->
|
|
<div v-if="showSplitConfiguration" class="form-group">
|
|
<label class="form-label">{{ splitConfigurationLabel }}</label>
|
|
|
|
<!-- EXACT_AMOUNTS -->
|
|
<div v-if="formData.split_type === 'EXACT_AMOUNTS'" class="splits-container">
|
|
<div v-for="(split, index) in formData.splits_in" :key="index" class="split-item">
|
|
<select v-model="split.user_id" class="form-input split-user-select" required>
|
|
<option value="" disabled>Select user</option>
|
|
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
|
{{ user.name || user.email }}
|
|
</option>
|
|
</select>
|
|
<input type="number" v-model.number="split.owed_amount" class="form-input" min="0.01" step="0.01"
|
|
placeholder="Amount" required />
|
|
<button type="button" class="btn btn-danger btn-sm" @click="removeSplit(index)"
|
|
:disabled="formData.splits_in!.length <= 1">
|
|
<svg class="icon icon-sm">
|
|
<use xlink:href="#icon-trash" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="split-summary">
|
|
<span>Total: ${{ exactAmountTotal.toFixed(2) }}</span>
|
|
<span v-if="exactAmountTotal !== formData.total_amount" class="validation-error">
|
|
(Should equal ${{ formData.total_amount.toFixed(2) }})
|
|
</span>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="addSplit">
|
|
Add Split
|
|
</button>
|
|
</div>
|
|
|
|
<!-- PERCENTAGE -->
|
|
<div v-else-if="formData.split_type === 'PERCENTAGE'" class="splits-container">
|
|
<div v-for="(split, index) in formData.splits_in" :key="index" class="split-item">
|
|
<select v-model="split.user_id" class="form-input split-user-select" required>
|
|
<option value="" disabled>Select user</option>
|
|
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
|
{{ user.name || user.email }}
|
|
</option>
|
|
</select>
|
|
<div class="percentage-input-group">
|
|
<input type="number" v-model.number="split.share_percentage" class="form-input" min="0.01" max="100"
|
|
step="0.01" placeholder="Percentage" required />
|
|
<span class="percentage-symbol">%</span>
|
|
</div>
|
|
<span class="split-amount-preview">
|
|
${{ calculatePercentageAmount(split.share_percentage).toFixed(2) }}
|
|
</span>
|
|
<button type="button" class="btn btn-danger btn-sm" @click="removeSplit(index)"
|
|
:disabled="formData.splits_in!.length <= 1">
|
|
<svg class="icon icon-sm">
|
|
<use xlink:href="#icon-trash" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="split-summary">
|
|
<span>Total: {{ percentageTotal.toFixed(2) }}%</span>
|
|
<span v-if="Math.abs(percentageTotal - 100) > 0.01" class="validation-error">
|
|
(Should equal 100%)
|
|
</span>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="addSplit">
|
|
Add Split
|
|
</button>
|
|
</div>
|
|
|
|
<!-- SHARES -->
|
|
<div v-else-if="formData.split_type === 'SHARES'" class="splits-container">
|
|
<div v-for="(split, index) in formData.splits_in" :key="index" class="split-item">
|
|
<select v-model="split.user_id" class="form-input split-user-select" required>
|
|
<option value="" disabled>Select user</option>
|
|
<option v-for="user in availableUsers" :key="user.id" :value="user.id">
|
|
{{ user.name || user.email }}
|
|
</option>
|
|
</select>
|
|
<input type="number" v-model.number="split.share_units" class="form-input" min="1" step="1"
|
|
placeholder="Shares" required />
|
|
<span class="split-amount-preview">
|
|
${{ calculateShareAmount(split.share_units).toFixed(2) }}
|
|
</span>
|
|
<button type="button" class="btn btn-danger btn-sm" @click="removeSplit(index)"
|
|
:disabled="formData.splits_in!.length <= 1">
|
|
<svg class="icon icon-sm">
|
|
<use xlink:href="#icon-trash" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="split-summary">
|
|
<span>Total shares: {{ sharesTotal }}</span>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="addSplit">
|
|
Add Split
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ITEM_BASED -->
|
|
<div v-else-if="formData.split_type === 'ITEM_BASED'" class="item-based-info">
|
|
<div class="info-box">
|
|
<svg class="icon">
|
|
<use xlink:href="#icon-info" />
|
|
</svg>
|
|
<div>
|
|
<p><strong>Item-Based Split</strong></p>
|
|
<p>The expense will be automatically split based on item prices and who added each item to the list.
|
|
</p>
|
|
<p v-if="props.itemId">Split will be based on the selected item only.</p>
|
|
<p v-else>Split will be based on all priced items in the list.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Validation Messages -->
|
|
<div v-if="validationErrors.length > 0" class="validation-messages">
|
|
<div v-for="error in validationErrors" :key="error" class="validation-error">
|
|
{{ error }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-neutral" @click="closeForm">Cancel</button>
|
|
<button type="submit" class="btn btn-primary ml-2" :disabled="isSubmitting || !isFormValid">
|
|
<span v-if="isSubmitting" class="spinner-dots-sm"><span /><span /><span /></span>
|
|
<span v-else>Create Expense</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from 'vue';
|
|
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
|
import { useNotificationStore } from '@/stores/notifications';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';
|
|
import type { UserPublic } from '@/types/user';
|
|
import { onClickOutside } from '@vueuse/core';
|
|
|
|
const props = defineProps<{
|
|
listId?: number;
|
|
groupId?: number;
|
|
itemId?: number;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'close'): void;
|
|
(e: 'created', expense: any): void;
|
|
}>();
|
|
|
|
const notificationStore = useNotificationStore();
|
|
const authStore = useAuthStore();
|
|
const formModalRef = ref<HTMLElement | null>(null);
|
|
const isSubmitting = ref(false);
|
|
const availableUsers = ref<UserPublic[]>([]);
|
|
|
|
const today = computed(() => {
|
|
const date = new Date();
|
|
return date.toISOString().split('T')[0];
|
|
});
|
|
|
|
const formData = ref<ExpenseCreate>({
|
|
description: '',
|
|
total_amount: 0,
|
|
currency: 'USD',
|
|
expense_date: today.value,
|
|
split_type: 'EQUAL',
|
|
list_id: props.listId,
|
|
group_id: props.groupId,
|
|
item_id: props.itemId,
|
|
paid_by_user_id: authStore.user?.id || 0,
|
|
splits_in: []
|
|
});
|
|
|
|
// Computed properties for split configuration
|
|
const showSplitConfiguration = computed(() => {
|
|
return ['EXACT_AMOUNTS', 'PERCENTAGE', 'SHARES', 'ITEM_BASED'].includes(formData.value.split_type);
|
|
});
|
|
|
|
const splitConfigurationLabel = computed(() => {
|
|
switch (formData.value.split_type) {
|
|
case 'EXACT_AMOUNTS': return 'Specify exact amounts for each person';
|
|
case 'PERCENTAGE': return 'Specify percentage for each person';
|
|
case 'SHARES': return 'Specify number of shares for each person';
|
|
case 'ITEM_BASED': return 'Item-based split configuration';
|
|
default: return 'Split configuration';
|
|
}
|
|
});
|
|
|
|
// Validation computed properties
|
|
const exactAmountTotal = computed(() => {
|
|
if (formData.value.split_type !== 'EXACT_AMOUNTS' || !formData.value.splits_in) return 0;
|
|
return formData.value.splits_in.reduce((sum: number, split: ExpenseSplitCreate) => sum + (split.owed_amount || 0), 0);
|
|
});
|
|
|
|
const percentageTotal = computed(() => {
|
|
if (formData.value.split_type !== 'PERCENTAGE' || !formData.value.splits_in) return 0;
|
|
return formData.value.splits_in.reduce((sum: number, split: ExpenseSplitCreate) => sum + (split.share_percentage || 0), 0);
|
|
});
|
|
|
|
const sharesTotal = computed(() => {
|
|
if (formData.value.split_type !== 'SHARES' || !formData.value.splits_in) return 0;
|
|
return formData.value.splits_in.reduce((sum: number, split: ExpenseSplitCreate) => sum + (split.share_units || 0), 0);
|
|
});
|
|
|
|
const validationErrors = computed(() => {
|
|
const errors: string[] = [];
|
|
|
|
if (!formData.value.description) errors.push('Description is required');
|
|
if (!formData.value.total_amount || formData.value.total_amount <= 0) errors.push('Total amount must be greater than 0');
|
|
if (!formData.value.paid_by_user_id) errors.push('Please select who paid for this expense');
|
|
|
|
if (formData.value.split_type === 'EXACT_AMOUNTS' && formData.value.splits_in) {
|
|
if (Math.abs(exactAmountTotal.value - formData.value.total_amount) > 0.01) {
|
|
errors.push('Split amounts must equal the total expense amount');
|
|
}
|
|
if (formData.value.splits_in.some((s: ExpenseSplitCreate) => !s.user_id)) {
|
|
errors.push('Please select a user for each split');
|
|
}
|
|
}
|
|
|
|
if (formData.value.split_type === 'PERCENTAGE' && formData.value.splits_in) {
|
|
if (Math.abs(percentageTotal.value - 100) > 0.01) {
|
|
errors.push('Percentages must total 100%');
|
|
}
|
|
if (formData.value.splits_in.some((s: ExpenseSplitCreate) => !s.user_id)) {
|
|
errors.push('Please select a user for each split');
|
|
}
|
|
}
|
|
|
|
if (formData.value.split_type === 'SHARES' && formData.value.splits_in) {
|
|
if (sharesTotal.value === 0) {
|
|
errors.push('Total shares must be greater than 0');
|
|
}
|
|
if (formData.value.splits_in.some((s: ExpenseSplitCreate) => !s.user_id)) {
|
|
errors.push('Please select a user for each split');
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
});
|
|
|
|
const isFormValid = computed(() => {
|
|
return validationErrors.value.length === 0;
|
|
});
|
|
|
|
// Helper functions
|
|
const calculatePercentageAmount = (percentage: number | undefined) => {
|
|
if (!percentage) return 0;
|
|
return (formData.value.total_amount * percentage) / 100;
|
|
};
|
|
|
|
const calculateShareAmount = (shares: number | undefined) => {
|
|
if (!shares || sharesTotal.value === 0) return 0;
|
|
return (formData.value.total_amount * shares) / sharesTotal.value;
|
|
};
|
|
|
|
const initializeSplits = () => {
|
|
if (formData.value.split_type === 'EQUAL' || formData.value.split_type === 'ITEM_BASED') {
|
|
formData.value.splits_in = undefined;
|
|
} else {
|
|
formData.value.splits_in = [createEmptySplit()];
|
|
}
|
|
};
|
|
|
|
const createEmptySplit = (): ExpenseSplitCreate => {
|
|
const base = { user_id: 0 };
|
|
|
|
switch (formData.value.split_type) {
|
|
case 'EXACT_AMOUNTS':
|
|
return { ...base, owed_amount: 0 };
|
|
case 'PERCENTAGE':
|
|
return { ...base, share_percentage: 0 };
|
|
case 'SHARES':
|
|
return { ...base, share_units: 0 };
|
|
default:
|
|
return { ...base, owed_amount: 0 };
|
|
}
|
|
};
|
|
|
|
const addSplit = () => {
|
|
if (!formData.value.splits_in) {
|
|
formData.value.splits_in = [];
|
|
}
|
|
formData.value.splits_in.push(createEmptySplit());
|
|
};
|
|
|
|
const removeSplit = (index: number) => {
|
|
if (formData.value.splits_in && formData.value.splits_in.length > 1) {
|
|
formData.value.splits_in.splice(index, 1);
|
|
}
|
|
};
|
|
|
|
const onSplitTypeChange = () => {
|
|
initializeSplits();
|
|
};
|
|
|
|
const fetchAvailableUsers = async () => {
|
|
try {
|
|
let endpoint = '';
|
|
|
|
if (props.listId) {
|
|
// Get users from list's group or just current user for personal lists
|
|
const listResponse = await apiClient.get(`${API_ENDPOINTS.LISTS.BASE}/${props.listId}`);
|
|
if (listResponse.data.group_id) {
|
|
endpoint = `${API_ENDPOINTS.GROUPS.BASE}/${listResponse.data.group_id}/members`;
|
|
} else {
|
|
// Personal list - only current user
|
|
availableUsers.value = authStore.user ? [authStore.user] : [];
|
|
return;
|
|
}
|
|
} else if (props.groupId) {
|
|
endpoint = `${API_ENDPOINTS.GROUPS.BASE}/${props.groupId}/members`;
|
|
} else {
|
|
// Fallback to current user only
|
|
availableUsers.value = authStore.user ? [authStore.user] : [];
|
|
return;
|
|
}
|
|
|
|
const response = await apiClient.get(endpoint);
|
|
availableUsers.value = response.data;
|
|
} catch (error) {
|
|
console.error('Failed to fetch available users:', error);
|
|
availableUsers.value = authStore.user ? [authStore.user] : [];
|
|
}
|
|
};
|
|
|
|
const closeForm = () => {
|
|
emit('close');
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!isFormValid.value) {
|
|
notificationStore.addNotification({
|
|
message: 'Please fix the validation errors before submitting',
|
|
type: 'warning'
|
|
});
|
|
return;
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
try {
|
|
const response = await apiClient.post(API_ENDPOINTS.FINANCIALS.EXPENSES, formData.value);
|
|
emit('created', response.data);
|
|
closeForm();
|
|
notificationStore.addNotification({
|
|
message: 'Expense created successfully',
|
|
type: 'success'
|
|
});
|
|
} catch (err) {
|
|
notificationStore.addNotification({
|
|
message: (err instanceof Error ? err.message : String(err)) || 'Failed to create expense',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
};
|
|
|
|
// Initialize
|
|
onMounted(() => {
|
|
fetchAvailableUsers();
|
|
initializeSplits();
|
|
});
|
|
|
|
// Watch for changes to ensure current user is selected as payer by default
|
|
watch(() => authStore.user, (user) => {
|
|
if (user && !formData.value.paid_by_user_id) {
|
|
formData.value.paid_by_user_id = user.id;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
onClickOutside(formModalRef, closeForm);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.expense-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.form-label {
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
.form-input {
|
|
padding: 0.75rem;
|
|
border: 2px solid #111;
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.amount-input-group {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.currency-symbol {
|
|
position: absolute;
|
|
left: 1rem;
|
|
color: #666;
|
|
z-index: 1;
|
|
}
|
|
|
|
.amount-input-group .form-input {
|
|
padding-left: 2rem;
|
|
}
|
|
|
|
.percentage-input-group {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.percentage-symbol {
|
|
position: absolute;
|
|
right: 1rem;
|
|
color: #666;
|
|
z-index: 1;
|
|
}
|
|
|
|
.percentage-input-group .form-input {
|
|
padding-right: 2rem;
|
|
}
|
|
|
|
.splits-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.split-item {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.split-user-select {
|
|
flex: 2;
|
|
}
|
|
|
|
.split-amount-preview {
|
|
min-width: 80px;
|
|
font-size: 0.875rem;
|
|
color: #666;
|
|
text-align: right;
|
|
}
|
|
|
|
.split-summary {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem;
|
|
background: #f9f9f9;
|
|
border-radius: 4px;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.validation-error {
|
|
color: #dc2626;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.validation-messages {
|
|
background: #fee2e2;
|
|
border: 1px solid #f87171;
|
|
border-radius: 8px;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.item-based-info {
|
|
background: #eff6ff;
|
|
border: 1px solid #bfdbfe;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.info-box {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.info-box .icon {
|
|
color: #3b82f6;
|
|
width: 20px;
|
|
height: 20px;
|
|
flex-shrink: 0;
|
|
margin-top: 0.125rem;
|
|
}
|
|
|
|
.info-box p {
|
|
margin: 0 0 0.5rem 0;
|
|
}
|
|
|
|
.info-box p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #111;
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: #333;
|
|
}
|
|
|
|
.btn-neutral {
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
border: 2px solid #ddd;
|
|
}
|
|
|
|
.btn-neutral:hover {
|
|
background: #e5e5e5;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #fee2e2;
|
|
color: #dc2626;
|
|
border: none;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #fecaca;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #e5e7eb;
|
|
color: #374151;
|
|
border: none;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #d1d5db;
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 0.5rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.ml-2 {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.spinner-dots-sm {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
}
|
|
|
|
.spinner-dots-sm span {
|
|
width: 4px;
|
|
height: 4px;
|
|
background-color: white;
|
|
border-radius: 50%;
|
|
animation: dot-pulse 1.4s infinite ease-in-out both;
|
|
}
|
|
|
|
.spinner-dots-sm span:nth-child(1) {
|
|
animation-delay: -0.32s;
|
|
}
|
|
|
|
.spinner-dots-sm span:nth-child(2) {
|
|
animation-delay: -0.16s;
|
|
}
|
|
|
|
@keyframes dot-pulse {
|
|
|
|
0%,
|
|
80%,
|
|
100% {
|
|
transform: scale(0);
|
|
}
|
|
|
|
40% {
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
</style>
|