many fixes and improvements - rework for modules/ and common/

feat(emailer): add Postmark and Resend providers
This commit is contained in:
2025-06-06 12:21:43 -04:00
parent 31ba18fa8d
commit 25b94d3e02
313 changed files with 6586 additions and 18260 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -5,17 +5,19 @@ import { createPinia } from 'pinia';
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
import { VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea } from 'vuetify/components';
import { VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert } from 'vuetify/components';
import { } from 'vuetify/directives';
import vueGoogleOauth from 'vue3-google-login';
import { useAuthStore } from "@/stores/authStore.js";
import { useUserProfileStore } from "@/stores/userProfileStore.js";
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
import Toast, { POSITION } from 'vue-toastification';
import 'vue-toastification/dist/index.css';
import './assets/main.css';
const vuetify = createVuetify({
components: {
VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea
VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert
},
directives: {
},
@@ -50,7 +52,10 @@ const app = createApp(App)
.use(i18n)
.use(vueGoogleOauth, {
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
});
})
.use(Toast, {
position: POSITION.TOP_CENTER,
});
useAuthStore();
useUserProfileStore();

View File

@@ -1,147 +1,155 @@
<template>
<div class="container">
<div class="card">
<!-- Navigation Link at the top -->
<div class="navigation-link">
<button class="link-button" @click="goBack()">
<v-icon :icon="mdiArrowLeft" />
{{ t('returnToCreator') }}
</button>
</div>
<div class="container">
<div class="card">
<!-- Navigation Link at the top -->
<div class="navigation-link">
<button
class="link-button"
@click="goBack()"
>
<v-icon :icon="mdiArrowLeft" />
{{ t('returnToCreator') }}
</button>
</div>
<h1>
{{ t('title') }}
</h1>
<h1 v-html="titleWithCreatorName"></h1>
<p>
<v-icon size="120" color="success" :icon="mdiCheckCircle" />
</p>
<p>
<v-icon
:icon="mdiCheckCircle"
color="success"
size="120"
/>
</p>
<p>
{{ t('message') }}
<p>
{{ t('message') }}
</p>
<span v-if="brandingStore.value?.name">
{{ brandingStore.value.name }}
</span>
<span v-else>
{{ t('usernameDefault') }}
</span>
</p>
<p>
{{ t('receipt') }}
</p>
<p>
{{ t('receipt') }}
</p>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useBrandingStore } from '@/stores/brandingStore.js';
import { mdiArrowLeft, mdiCheckCircle } from '@mdi/js';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useBrandingStore } from '@/stores/brandingStore.js';
import { mdiArrowLeft, mdiCheckCircle } from '@mdi/js';
import { computed } from 'vue';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const brandingStore = useBrandingStore();
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const brandingStore = useBrandingStore();
function goBack() {
// Navigate back to the creator's page
const creatorName = route.params.creator?.split('/')[0] || '';
router.push(`/@${creatorName}`);
}
const creatorName = computed(() => {
return route.params.creator?.split('/')[0] || t('usernameDefault');
});
const titleWithCreatorName = computed(() => {
return t('title', { creatorName: creatorName.value });
});
function goBack() {
// Navigate back to the creator's page
const creatorNameParam = route.params.creator?.split('/')[0] || '';
router.push(`/@${creatorNameParam}`);
}
</script>
<i18n>
{
"en": {
"title": "Payment Successful!",
"message": "Your payment has been processed successfully.",
"usernameDefault": "the creator",
"receipt": "A receipt has been sent to your email.",
"continue": "Continue to",
"returnToCreator": "Return to creator page"
},
"fr": {
"title": "Paiement réussi !",
"message": "Votre paiement a été traité avec succès.",
"usernameDefault": "le créateur",
"receipt": "Un reçu a été envoyé à votre email.",
"continue": "Continuer vers",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "¡Pago exitoso!",
"message": "Su pago ha sido procesado con éxito.",
"usernameDefault": "el creador",
"receipt": "Se ha enviado un recibo a su correo electrónico.",
"continue": "Continuar a",
"returnToCreator": "Volver a la página del creador"
}
"en": {
"title": "{creatorName} thanks you!",
"message": "Your payment has been processed successfully.",
"usernameDefault": "The creator",
"receipt": "A receipt has been sent to your email.",
"returnToCreator": "Return to creator page"
},
"fr": {
"title": "{creatorName} vous remercie !",
"message": "Votre paiement a été traité avec succès.",
"usernameDefault": "Le créateur",
"receipt": "Un reçu a été envoyé à votre email.",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "¡{creatorName} te agradece!",
"message": "Su pago ha sido procesado con éxito.",
"usernameDefault": "El creador",
"receipt": "Se ha enviado un recibo a su correo electrónico.",
"returnToCreator": "Volver a la página del creador"
}
}
</i18n>
<style scoped>
.container {
@apply flex items-center justify-center;
@apply p-5;
@apply w-full;
@apply mx-auto;
@apply max-w-[1024px];
}
.container {
@apply flex items-center justify-center;
@apply p-5;
@apply w-full;
@apply mx-auto;
@apply max-w-[1024px];
}
.card {
@apply bg-hSurface text-hOnSurface;
@apply p-8;
@apply font-sans;
@apply rounded-2xl;
@apply shadow-2xl;
@apply relative;
@apply w-full;
@apply max-w-2xl;
@apply mx-auto;
}
.card {
@apply bg-hSurface text-hOnSurface;
@apply p-8;
@apply font-sans;
@apply rounded-2xl;
@apply shadow-2xl;
@apply relative;
@apply w-full;
@apply max-w-2xl;
@apply mx-auto;
}
.card::before {
@apply absolute inset-0;
@apply rounded-2xl;
@apply p-[1px];
content: '';
background: linear-gradient(135deg, rgba(64, 64, 64, 1) 0%, rgba(64, 64, 64, 0) 20%, rgba(64, 64, 64, 0.5) 100%);
mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
.card::before {
@apply absolute inset-0;
@apply rounded-2xl;
@apply p-[1px];
content: '';
background: linear-gradient(
135deg,
rgba(64, 64, 64, 1) 0%,
rgba(64, 64, 64, 0) 20%,
rgba(64, 64, 64, 0.5) 100%
);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
.navigation-link {
@apply flex items-center;
@apply mb-6;
}
.navigation-link {
@apply flex items-center text-hutopyPrimary;
@apply mb-6;
}
.link-button {
@apply flex items-center gap-2;
@apply text-hutopyPrimary hover:text-hutopySecondary;
@apply transition-colors;
@apply duration-300;
@apply font-medium;
}
.link-button {
@apply flex items-center gap-2;
@apply text-hutopyPrimary hover:text-hutopySecondary;
@apply transition-colors;
@apply duration-300;
@apply text-xl;
@apply font-semibold;
}
h1 {
@apply text-6xl;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
h1 {
@apply text-6xl;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
p {
@apply text-lg;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
p {
@apply text-lg;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
</style>

View File

@@ -1,115 +1,125 @@
<template>
<div class="container">
<div class="card">
<!-- Navigation Link at the top -->
<div class="navigation-link">
<button class="link-button" @click="goBack()">
<v-icon :icon="mdiArrowLeft" />
{{ t('returnToCreator') }}
</button>
</div>
<div class="container">
<div class="card">
<!-- Navigation Link at the top -->
<div class="navigation-link">
<button
class="link-button"
@click="goBack()"
>
<v-icon :icon="mdiArrowLeft" />
{{ t('returnToCreator') }}
</button>
</div>
<h1>{{ t('title') }}</h1>
<p>{{ t('message') }}</p>
<h1>{{ t('title') }}</h1>
<p>{{ t('message') }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { mdiArrowLeft } from '@mdi/js';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { mdiArrowLeft } from '@mdi/js';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
function goBack() {
const creatorName = route.params.creator?.split('/')[0] || '';
router.push(`/@${creatorName}`);
}
function goBack() {
const creatorName = route.params.creator?.split('/')[0] || '';
router.push(`/@${creatorName}`);
}
</script>
<i18n>
{
"en": {
"title": "Payment Failed",
"message": "We couldn't process your payment.",
"retry": "Try Again",
"returnToCreator": "Return to creator page"
},
"fr": {
"title": "Échec du paiement",
"message": "Nous n'avons pas pu traiter votre paiement.",
"retry": "Réessayer",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "Pago fallido",
"message": "No pudimos procesar su pago.",
"retry": "Intentar de nuevo",
"returnToCreator": "Volver a la página del creador"
}
"en": {
"title": "Payment Failed",
"message": "We couldn't process your payment.",
"retry": "Try Again",
"returnToCreator": "Return to creator page"
},
"fr": {
"title": "Échec du paiement",
"message": "Nous n'avons pas pu traiter votre paiement.",
"retry": "Réessayer",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "Pago fallido",
"message": "No pudimos procesar su pago.",
"retry": "Intentar de nuevo",
"returnToCreator": "Volver a la página del creador"
}
}
</i18n>
<style scoped>
.container {
@apply flex items-center justify-center;
@apply p-5;
@apply w-full;
@apply mx-auto;
@apply max-w-[1024px];
}
.container {
@apply flex items-center justify-center;
@apply p-5;
@apply w-full;
@apply mx-auto;
@apply max-w-[1024px];
}
.card {
@apply bg-hSurface text-hOnSurface;
@apply p-8;
@apply font-sans;
@apply rounded-2xl;
@apply shadow-2xl;
@apply relative;
@apply w-full;
@apply max-w-2xl;
@apply mx-auto;
}
.card {
@apply bg-hSurface text-hOnSurface;
@apply p-8;
@apply font-sans;
@apply rounded-2xl;
@apply shadow-2xl;
@apply relative;
@apply w-full;
@apply max-w-2xl;
@apply mx-auto;
}
.card::before {
content: '';
@apply absolute inset-0;
@apply rounded-2xl;
@apply p-[1px];
background: linear-gradient(135deg, rgba(64, 64, 64, 1) 0%, rgba(64, 64, 64, 0) 20%, rgba(64, 64, 64, 0.5) 100%);
mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
.card::before {
content: '';
@apply absolute inset-0;
@apply rounded-2xl;
@apply p-[1px];
background: linear-gradient(
135deg,
rgba(64, 64, 64, 1) 0%,
rgba(64, 64, 64, 0) 20%,
rgba(64, 64, 64, 0.5) 100%
);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
.navigation-link {
@apply flex items-center;
@apply mb-6;
}
.navigation-link {
@apply flex items-center;
@apply mb-6;
}
.link-button {
@apply flex items-center gap-2;
@apply text-hutopyPrimary hover:text-hutopySecondary;
@apply transition-colors;
@apply duration-300;
@apply font-medium;
}
.link-button {
@apply flex items-center gap-2;
@apply text-hutopyPrimary hover:text-hutopySecondary;
@apply transition-colors;
@apply duration-300;
@apply text-xl;
@apply font-semibold;
}
h1 {
@apply text-6xl;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
h1 {
@apply text-6xl;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
p {
@apply text-lg;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
p {
@apply text-lg;
@apply font-medium;
@apply mb-8;
@apply text-center;
}
</style>

View File

@@ -1,229 +1,277 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<form @submit.prevent="handleResetPassword" class="card">
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label for="password" class="form-label">{{ t('newPassword') }}</label>
<div class="relative">
<input id="password" v-model="password" :type="showPassword ? 'text' : 'password'" class="form-input"
required />
<button type="button" @click="showPassword = !showPassword" class="password-toggle">
<v-icon size="small" :icon="showPassword ? mdiEyeOff : mdiEye" />
</button>
</div>
<p class="mt-1 text-sm text-gray-500">{{ t('passwordRequirements') }}</p>
<form
class="card"
@submit.prevent="handleResetPassword"
>
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label
class="form-label"
for="password"
>
{{ t('newPassword') }}
</label>
<div class="relative">
<input
id="password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="form-input"
required
/>
<button
class="password-toggle"
type="button"
@click="showPassword = !showPassword"
>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
size="small"
/>
</button>
</div>
<p class="mt-1 text-sm text-gray-500">{{ t('passwordRequirements') }}</p>
</div>
<div class="form-field">
<label
class="form-label"
for="confirmPassword"
>
{{ t('confirmPassword') }}
</label>
<div class="relative">
<input
id="confirmPassword"
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
class="form-input"
required
/>
<button
class="password-toggle"
type="button"
@click="showConfirmPassword = !showConfirmPassword"
>
<v-icon
:icon="showConfirmPassword ? mdiEyeOff : mdiEye"
size="small"
/>
</button>
</div>
</div>
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<button
:disabled="isLoading"
class="primary w-full"
type="submit"
>
<span
v-if="isLoading"
class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }}
</button>
</div>
</div>
</form>
<!-- Success message -->
<div
v-if="success"
class="success-message"
>
{{ t('passwordResetSuccess') }}
<div class="mt-4">
<router-link
class="text-blue-500"
to="/login"
>
{{ t('proceedToLogin') }}
</router-link>
</div>
</div>
<div class="form-field">
<label for="confirmPassword" class="form-label">{{ t('confirmPassword') }}</label>
<div class="relative">
<input id="confirmPassword" v-model="confirmPassword" :type="showConfirmPassword ? 'text' : 'password'"
class="form-input" required />
<button type="button" @click="showConfirmPassword = !showConfirmPassword" class="password-toggle">
<v-icon size="small" :icon="showConfirmPassword ? mdiEyeOff : mdiEye" />
</button>
</div>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<button type="submit" class="primary w-full" :disabled="isLoading">
<span v-if="isLoading" class="loading-spinner mr-2"></span>
{{ t('resetPassword') }}
</button>
</div>
</div>
</form>
<!-- Success message -->
<div v-if="success" class="success-message">
{{ t('passwordResetSuccess') }}
<div class="mt-4">
<router-link to="/login" class="text-blue-500">
{{ t('proceedToLogin') }}
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { useClient } from '@/plugins/api.js';
import { mdiEye, mdiEyeOff } from '@mdi/js';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useClient } from '@/plugins/api.js';
import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const clientApi = useClient();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const clientApi = useClient();
const email = ref('');
const token = ref('');
const password = ref('');
const confirmPassword = ref('');
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const isLoading = ref(false);
const errorMessage = ref('');
const success = ref(false);
const email = ref('');
const token = ref('');
const password = ref('');
const confirmPassword = ref('');
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const isLoading = ref(false);
const errorMessage = ref('');
const success = ref(false);
onMounted(() => {
// Get email and token from URL query parameters
email.value = route.query.email || '';
token.value = route.query.token || '';
onMounted(() => {
// Get email and token from URL query parameters
email.value = route.query.email || '';
token.value = route.query.token || '';
// Validate that we have both email and token
if (!email.value || !token.value) {
errorMessage.value = t('invalidResetLink');
}
});
async function handleResetPassword() {
// Reset error message
errorMessage.value = '';
// Validate passwords match
if (password.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
// Validate password length
if (password.value.length < 8) {
errorMessage.value = t('passwordTooShort');
return;
}
// Validate that we have email and token
if (!email.value || !token.value) {
errorMessage.value = t('invalidResetLink');
return;
}
isLoading.value = true;
try {
// Call password reset API
await clientApi.post('api/users/reset-password', {
email: email.value,
token: token.value,
newPassword: password.value
// Validate that we have both email and token
if (!email.value || !token.value) {
errorMessage.value = t('invalidResetLink');
}
});
// Show success message
success.value = true;
async function handleResetPassword() {
// Reset error message
errorMessage.value = '';
// Clear form fields
password.value = '';
confirmPassword.value = '';
// Validate passwords match
if (password.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
// Redirect to login after a delay
setTimeout(() => {
router.push('/login');
}, 5000);
} catch (error) {
console.error('Password reset failed:', error);
errorMessage.value = error.response?.data || t('resetFailed');
} finally {
isLoading.value = false;
}
}
// Validate password length
if (password.value.length < 8) {
errorMessage.value = t('passwordTooShort');
return;
}
// Validate that we have email and token
if (!email.value || !token.value) {
errorMessage.value = t('invalidResetLink');
return;
}
isLoading.value = true;
try {
// Call password reset API
await clientApi.post('api/users/reset-password', {
email: email.value,
token: token.value,
newPassword: password.value,
});
// Show success message
success.value = true;
// Clear form fields
password.value = '';
confirmPassword.value = '';
// Redirect to login after a delay
setTimeout(() => {
router.push('/login');
}, 5000);
} catch (error) {
console.error('Password reset failed:', error);
errorMessage.value = error.response?.data || t('resetFailed');
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden;
}
form {
@apply bg-hSurface rounded-xl p-4;
}
.card-content {
@apply p-6;
}
.form-field {
@apply flex flex-col mb-4;
}
.form-field {
@apply flex flex-col mb-4;
}
.form-label {
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
}
.form-label {
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
}
.form-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.form-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
}
.primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
}
.error-message {
@apply p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-100 dark:bg-red-900 dark:text-red-300;
}
.error-message {
@apply p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-100 dark:bg-red-900 dark:text-red-300;
}
.success-message {
@apply p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-100 dark:bg-green-900 dark:text-green-300 text-center;
}
.success-message {
@apply p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-100 dark:bg-green-900 dark:text-green-300 text-center;
}
.password-toggle {
@apply absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300;
}
.password-toggle {
@apply absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300;
}
.loading-spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
}
.loading-spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
}
</style>
<i18n>
{
"en": {
"title": "Reset Your Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"passwordRequirements": "Password must be at least 8 characters",
"resetPassword": "Reset Password",
"passwordResetSuccess": "Your password has been reset successfully!",
"proceedToLogin": "Proceed to Login",
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long",
"resetFailed": "Password reset failed. Please try again or request a new reset link.",
"invalidResetLink": "Invalid or expired reset link. Please request a new password reset."
},
"fr": {
"title": "Réinitialiser Votre Mot de Passe",
"newPassword": "Nouveau Mot de Passe",
"confirmPassword": "Confirmer le Mot de Passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"resetPassword": "Réinitialiser le Mot de Passe",
"passwordResetSuccess": "Votre mot de passe a été réinitialisé avec succès!",
"proceedToLogin": "Procéder à la Connexion",
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"resetFailed": "Échec de la réinitialisation du mot de passe. Veuillez réessayer ou demander un nouveau lien de réinitialisation.",
"invalidResetLink": "Lien de réinitialisation invalide ou expiré. Veuillez demander une nouvelle réinitialisation de mot de passe."
},
"es": {
"title": "Restablecer su Contraseña",
"newPassword": "Nueva Contraseña",
"confirmPassword": "Confirmar Contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"resetPassword": "Restablecer Contraseña",
"passwordResetSuccess": "¡Su contraseña ha sido restablecida con éxito!",
"proceedToLogin": "Proceder al Inicio de Sesión",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"resetFailed": "Error al restablecer la contraseña. Inténtelo de nuevo o solicite un nuevo enlace de restablecimiento.",
"invalidResetLink": "Enlace de restablecimiento inválido o caducado. Solicite un nuevo restablecimiento de contraseña."
}
"en": {
"title": "Reset Your Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"passwordRequirements": "Password must be at least 8 characters",
"resetPassword": "Reset Password",
"passwordResetSuccess": "Your password has been reset successfully!",
"proceedToLogin": "Proceed to Login",
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long",
"resetFailed": "Password reset failed. Please try again or request a new reset link.",
"invalidResetLink": "Invalid or expired reset link. Please request a new password reset."
},
"fr": {
"title": "Réinitialiser Votre Mot de Passe",
"newPassword": "Nouveau Mot de Passe",
"confirmPassword": "Confirmer le Mot de Passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"resetPassword": "Réinitialiser le Mot de Passe",
"passwordResetSuccess": "Votre mot de passe a été réinitialisé avec succès!",
"proceedToLogin": "Procéder à la Connexion",
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"resetFailed": "Échec de la réinitialisation du mot de passe. Veuillez réessayer ou demander un nouveau lien de réinitialisation.",
"invalidResetLink": "Lien de réinitialisation invalide ou expiré. Veuillez demander une nouvelle réinitialisation de mot de passe."
},
"es": {
"title": "Restablecer su Contraseña",
"newPassword": "Nueva Contraseña",
"confirmPassword": "Confirmar Contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"resetPassword": "Restablecer Contraseña",
"passwordResetSuccess": "¡Su contraseña ha sido restablecida con éxito!",
"proceedToLogin": "Proceder al Inicio de Sesión",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"resetFailed": "Error al restablecer la contraseña. Inténtelo de nuevo o solicite un nuevo enlace de restablecimiento.",
"invalidResetLink": "Enlace de restablecimiento inválido o caducado. Solicite un nuevo restablecimiento de contraseña."
}
}
</i18n>

View File

@@ -87,16 +87,21 @@
<!-- Contact Information Section -->
<div v-if="phoneNumber || email" class="contact-info mt-6">
<!-- Phone Number -->
<div v-if="phoneNumber" class="contact-item">
{{ phoneNumber }}
<div v-if="phoneNumber" class="contact-capsule" @click="callPhone">
<v-icon :icon="mdiPhone" class="contact-icon" />
<span class="contact-text">{{ phoneNumber }}</span>
</div>
<!-- Email -->
<div v-if="email" class="contact-item">
{{ email }}
<div v-if="email" class="contact-capsule" @click="sendEmail">
<v-icon :icon="mdiEmail" class="contact-icon" />
<span class="contact-text">{{ email }}</span>
</div>
</div>
</div>
</div>
</div>
@@ -112,12 +117,14 @@ import { buildEmbedUrl, isValidYouTubeUrlOrId, extractVideoId } from '@/utils/yo
import AlbumEditor from "@/views/creators/AlbumEditor.vue";
import AlbumView from "@/views/creators/AlbumView.vue";
import AlbumViewer from './AlbumViewer.vue';
import { mdiPencil, mdiCheck, mdiClose } from '@mdi/js';
import { useToast } from 'vue-toastification';
import { mdiPencil, mdiCheck, mdiClose, mdiPhone, mdiEmail } from '@mdi/js';
const { t } = useI18n();
const creatorProfileStore = useCreatorProfileStore();
const brandingStore = useBrandingStore();
const client = useClient();
const toast = useToast();
const isLoading = ref(true);
const isSaving = ref(false);
@@ -143,6 +150,21 @@ const editableVideoUrl = ref("");
const videoUrlError = ref("");
const descriptionError = ref("");
function callPhone() {
if (phoneNumber.value) {
toast.info('Calling your contact');
// Remove formatting and create tel: link
const cleanPhone = phoneNumber.value.replace(/\D/g, '');
window.location.href = `tel:+1${cleanPhone}`;
}
}
function sendEmail() {
if (email.value) {
window.location.href = `mailto:${email.value}`;
}
}
// Computed property to check if we can save
const canSave = computed(() => {
if (isSaving.value == true) { return false; }
@@ -400,11 +422,13 @@ function cancelEdit() {
// Désactiver le mode édition
isEditMode.value = false;
}
// Add this function to handle photo clicks
function handlePhotoClick(index) {
selectedPhotoIndex.value = index;
showAlbumViewer.value = true;
}
</script>
<style scoped>
@@ -446,12 +470,27 @@ function handlePhotoClick(index) {
}
.contact-info {
@apply text-lg text-justify whitespace-pre-line;
@apply flex flex-col items-center;
@apply flex flex-col items-center gap-3;
}
.contact-item {
@apply text-lg text-center mb-2;
.contact-capsule {
@apply flex items-center gap-2 px-2 py-1 bg-hSurface ;
@apply rounded-xl cursor-pointer transition-all duration-200;
@apply hover:shadow-md min-w-fit;
@apply border border-hutopyPrimary;
}
.contact-capsule:hover {
@apply transform scale-105;
}
.contact-icon {
@apply text-hutopyPrimary;
@apply text-xl
}
.contact-text {
@apply text-hOnSurface font-medium text-base;
}
/* Formatting styles for description */

View File

@@ -119,7 +119,7 @@ async function createAccount() {
"en": {
"title": "Create your Hutopy",
"cancel": "Cancel",
"create": "Create Creator Page",
"create": "Create my page",
"errors": {
"unexpected": "An unexpected error occurred"
}
@@ -127,7 +127,7 @@ async function createAccount() {
"fr": {
"title": "Créez votre Hutopy",
"cancel": "Annuler",
"create": "Créer la page créateur",
"create": "Créer ma page",
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
@@ -135,10 +135,10 @@ async function createAccount() {
"es": {
"title": "Crea tu Hutopy",
"cancel": "Cancelar",
"create": "Crear página de creador",
"create": "Crear mi página",
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>
</i18n>

View File

@@ -1,45 +1,45 @@
<template>
<v-dialog v-model="donationModal">
<DonationForm
:show-cancel-button="showCancelButton"
:creator-id="creatorId"
:creator-name="creatorName"
:on-success-url="onSuccessUrl"
:on-cancelled-url="onCancelledUrl"
@cancel="closeDonationDialog"
/>
</v-dialog>
<v-dialog v-model="donationModal">
<DonationForm
:show-cancel-button="showCancelButton"
:creator-id="creatorId"
:creator-name="creatorName"
:on-success-url="onSuccessUrl"
:on-cancelled-url="onCancelledUrl"
@cancel="closeDonationDialog"
/>
</v-dialog>
</template>
<script setup>
import {ref} from 'vue';
import DonationForm from './DonationForm.vue';
import { ref } from 'vue';
import DonationForm from './DonationForm.vue';
const props = defineProps({
creatorId: {default: 'missing-creator-id', required: true},
creatorName: {default: 'missing-creator-name', required: true},
onSuccessUrl: {default: 'missing-on-success-u', required: true},
onCancelledUrl: {default: 'missing-on-cancelled-url', required: true},
showCancelButton: {
type: Boolean,
default: true
}
});
const props = defineProps({
creatorId: { default: 'missing-creator-id', required: true },
creatorName: { default: 'missing-creator-name', required: true },
onSuccessUrl: { default: 'missing-on-success-u', required: true },
onCancelledUrl: { default: 'missing-on-cancelled-url', required: true },
showCancelButton: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['close']);
const emit = defineEmits(['close']);
const donationModal = ref(false);
const donationModal = ref(false);
function openDonationDialog() {
donationModal.value = true;
}
function openDonationDialog() {
donationModal.value = true;
}
function closeDonationDialog() {
donationModal.value = false;
emit('close');
}
function closeDonationDialog() {
donationModal.value = false;
emit('close');
}
defineExpose({
openDonationDialog
});
</script>
defineExpose({
openDonationDialog,
});
</script>

View File

@@ -1,207 +1,206 @@
<template>
<div class="card bg-hSurface text-hOnSurface">
<div class="card-title">
{{ t('creator.donation.isupport') }}
<div class="card dialog bg-hSurface text-hOnSurface">
<div class="card-title">
{{ t('creator.donation.isupport') }}
</div>
<div class="card-content">
<v-text-field
v-model="tipAmountInDollars"
:label="t('creator.donation.amount')"
:min="0"
autofocus
class="p-2"
clearable
density="comfortable"
hide-details
inputmode="numeric"
placeholder="0"
prepend-inner-icon="mdi-currency-usd"
type="number"
variant="outlined"
@keydown="preventNonNumeric"
></v-text-field>
<v-textarea
v-model="tipMessage"
:label="t('creator.donation.message')"
auto-grow
class="p-2"
clearable
density="compact"
hide-details
rows="2"
variant="outlined"
></v-textarea>
</div>
<div class="card-actions">
<button
v-if="showCancelButton"
class="secondary"
@click="$emit('cancel')"
>
{{ t('common.cancel') }}
</button>
<button
:disabled="isProcessing"
class="primary"
@click="handleSubmit"
>
<span
v-if="isProcessing"
class="spinner mr-2"
></span>
{{ isProcessing ? t('creator.donation.processing') : t('creator.donation.send') }}
</button>
</div>
</div>
<div class="card-content">
<v-text-field
v-model="tipAmountInDollars"
type="number"
autofocus
placeholder="0"
:min="0"
class="p-2"
:label="t('creator.donation.amount')"
density="comfortable"
variant="outlined"
hide-details
clearable
inputmode="numeric"
@keydown="preventNonNumeric"
prepend-inner-icon="mdi-currency-usd"
></v-text-field>
<v-textarea
v-model="tipMessage"
:label="t('creator.donation.message')"
class="p-2"
rows="2"
density="compact"
variant="outlined"
hide-details
clearable
auto-grow
></v-textarea>
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
</div>
<div class="card-actions">
<button v-if="showCancelButton"
class="secondary"
@click="$emit('cancel')">
{{ t('common.cancel') }}
</button>
<button class="primary"
@click="handleSubmit"
:disabled="isProcessing">
<span v-if="isProcessing" class="spinner mr-2"></span>
{{ isProcessing ? t('creator.donation.processing') : t('creator.donation.send') }}
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useToast } from 'vue-toastification';
const { t } = useI18n();
const client = useClient();
const { t } = useI18n();
const client = useClient();
const toast = useToast();
const props = defineProps({
showCancelButton: {
type: Boolean,
default: true
},
creatorId: {
type: String,
required: true
},
onSuccessUrl: {
type: String,
required: true
},
onCancelledUrl: {
type: String,
required: true
}
});
const emit = defineEmits(['cancel', 'submit']);
const tipAmountInDollars = ref('');
const tipMessage = ref('');
const errorMessage = ref('');
const isProcessing = ref(false);
function preventNonNumeric(event) {
const key = event.key;
const allowedKeys = ['Backspace', 'ArrowLeft', 'ArrowRight', 'Delete'];
if (!/^\d$/.test(key) && !allowedKeys.includes(key)) {
event.preventDefault();
}
}
async function handleSubmit() {
if (!tipAmountInDollars.value || tipAmountInDollars.value <= 0) {
errorMessage.value = t('creator.donation.errors.invalidAmount');
return;
}
isProcessing.value = true;
errorMessage.value = '';
try {
const response = await client.post(`/api/tips`, {
creatorId: props.creatorId,
amount: tipAmountInDollars.value * 100,
currency: 'CAD',
message: tipMessage.value,
checkoutSuccessUrl: props.onSuccessUrl,
checkoutCancelledUrl: props.onCancelledUrl,
const props = defineProps({
showCancelButton: {
type: Boolean,
default: true,
},
creatorId: {
type: String,
required: true,
},
onSuccessUrl: {
type: String,
required: true,
},
onCancelledUrl: {
type: String,
required: true,
},
});
if (response.data?.stripeCheckoutUrl) {
window.location.href = response.data.stripeCheckoutUrl;
} else {
throw new Error('No checkout URL received');
const emit = defineEmits(['cancel', 'submit']);
const tipAmountInDollars = ref('');
const tipMessage = ref('');
const isProcessing = ref(false);
function preventNonNumeric(event) {
const key = event.key;
const allowedKeys = ['Backspace', 'ArrowLeft', 'ArrowRight', 'Delete'];
if (!/^\d$/.test(key) && !allowedKeys.includes(key)) {
event.preventDefault();
}
}
async function handleSubmit() {
if (!tipAmountInDollars.value || tipAmountInDollars.value <= 0) {
toast.warning(t('creator.donation.errors.invalidAmount'));
return;
}
isProcessing.value = true;
try {
const response = await client.post(`/api/tips`, {
creatorId: props.creatorId,
amount: tipAmountInDollars.value * 100,
currency: 'CAD',
message: tipMessage.value,
checkoutSuccessUrl: props.onSuccessUrl,
checkoutCancelledUrl: props.onCancelledUrl,
});
if (response.data?.url) {
window.location.href = response.data.url;
} else {
throw new Error('No checkout URL received');
}
} catch (error) {
console.error(error);
toast.error(t('creator.donation.errors.payment'));
isProcessing.value = false;
}
}
} catch (error) {
console.error(error);
errorMessage.value = t('creator.donation.errors.payment');
isProcessing.value = false;
}
}
</script>
<style scoped>
.error-message {
@apply text-white bg-red-500;
@apply rounded-md text-center w-full p-2;
@apply mt-2;
}
.spinner {
@apply inline-block;
@apply w-4 h-4;
@apply border-2;
@apply border-current;
@apply border-t-transparent;
@apply rounded-full;
@apply animate-spin;
}
.spinner {
@apply inline-block;
@apply w-4 h-4;
@apply border-2;
@apply border-current;
@apply border-t-transparent;
@apply rounded-full;
@apply animate-spin;
}
</style>
<i18n>
{
"en": {
"common": {
"cancel": "Cancel"
},
"creator": {
"donation": {
"isupport": "I Support",
"amount": "Amount ($)",
"message": "Message (optional)",
"send": "Send",
"processing": "Processing...",
"errors": {
"payment": "An error occurred during payment processing",
"invalidAmount": "Please enter a valid amount"
"en": {
"common": {
"cancel": "Cancel"
},
"creator": {
"donation": {
"isupport": "I Support",
"amount": "Amount ($)",
"message": "Message (optional)",
"send": "Send",
"processing": "Processing...",
"errors": {
"payment": "An error occurred during payment processing",
"invalidAmount": "Please enter a valid amount"
}
}
}
}
}
},
"fr": {
"common": {
"cancel": "Annuler"
},
"creator": {
"donation": {
"isupport": "Je Soutiens",
"amount": "Montant ($)",
"message": "Message (optionnel)",
"send": "Envoyer",
"processing": "Traitement en cours...",
"errors": {
"payment": "Une erreur s'est produite lors du traitement du paiement",
"invalidAmount": "Veuillez entrer un montant valide"
"fr": {
"common": {
"cancel": "Annuler"
},
"creator": {
"donation": {
"isupport": "Je Soutiens",
"amount": "Montant ($)",
"message": "Message (optionnel)",
"send": "Envoyer",
"processing": "Traitement en cours...",
"errors": {
"payment": "Une erreur s'est produite lors du traitement du paiement",
"invalidAmount": "Veuillez entrer un montant valide"
}
}
}
}
}
},
"es": {
"common": {
"cancel": "Cancelar"
},
"creator": {
"donation": {
"isupport": "Apoyo",
"amount": "Cantidad ($)",
"message": "Mensaje (opcional)",
"send": "Enviar",
"processing": "Procesando...",
"errors": {
"payment": "Ocurrió un error durante el procesamiento del pago",
"invalidAmount": "Por favor ingrese un monto válido"
"es": {
"common": {
"cancel": "Cancelar"
},
"creator": {
"donation": {
"isupport": "Apoyo",
"amount": "Cantidad ($)",
"message": "Mensaje (opcional)",
"send": "Enviar",
"processing": "Procesando...",
"errors": {
"payment": "Ocurrió un error durante el procesamiento del pago",
"invalidAmount": "Por favor ingrese un monto válido"
}
}
}
}
}
}
}
</i18n>
</i18n>

View File

@@ -66,7 +66,7 @@ onMounted(() => {
emits('update:creatorNameReservationId', reservationId.value);
}
// If the name is the same as the original slug, set reservation state to "reserved"
// If the name is the same as the original slug, set the reservation state to "reserved"
if (isCurrentSlug.value) {
reservationState.value = "reserved";
}
@@ -166,7 +166,7 @@ onUnmounted(() => {
<v-text-field variant="outlined" :label="t('creator.name.label')" v-model="name" @input="handleInput"
:error-messages="validationError">
<template #prepend-inner>
<span class="font-sans text-gray-400">{{ baseUrl }}</span>
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span>
</template>
<template #append-inner>

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@
import { ref } from 'vue';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n();
const authStore = useAuthStore();

View File

@@ -8,12 +8,24 @@
:label="t('email')"
type="email"
variant="outlined"
:error-messages="emailErrors"
:rules="emailRules"
validate-on="blur"
/>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
class="mt-4">
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
{{ t('cancel') }}
</button>
<button class="primary" @click="saveEmail" :disabled="isLoading">
<button class="primary" @click="saveEmail" :disabled="!canSave || isLoading">
{{ t('save') }}
</button>
</div>
@@ -22,7 +34,7 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
@@ -40,6 +52,34 @@ const props = defineProps({
const email = ref(props.creator.presentation?.email || '');
const isLoading = ref(false);
const errorMessage = ref('');
// Email validation
const isValidEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const emailRules = [
v => !!v || t('validation.emailRequired'),
v => isValidEmail(v) || t('validation.emailInvalid'),
];
const emailErrors = computed(() => {
if (!email.value) {
return [t('validation.emailRequired')];
}
if (!isValidEmail(email.value)) {
return [t('validation.emailInvalid')];
}
return [];
});
const canSave = computed(() => {
return email.value &&
isValidEmail(email.value) &&
email.value !== (props.creator.presentation?.email || '');
});
async function saveEmail() {
if (!props.creator.id) {
@@ -47,24 +87,34 @@ async function saveEmail() {
return;
}
if (!canSave.value) {
return;
}
try {
isLoading.value = true;
errorMessage.value = '';
// Save email
await client.post(
`/api/creators/${props.creator.id}/email`,
{
email: email.value || ""
email: email.value.trim()
}
);
// Refresh creator profile
await creatorProfileStore.fetchCreatorProfile();
// Close dialog
emit('closeRequested');
} catch (error) {
console.error("Error saving email:", error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
}
} finally {
isLoading.value = false;
}
@@ -85,19 +135,40 @@ const emit = defineEmits(['closeRequested']);
"changeEmail": "Change Email",
"email": "Email",
"save": "Save",
"cancel": "Cancel"
"cancel": "Cancel",
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Please enter a valid email address"
},
"errors": {
"unexpected": "An unexpected error occurred"
}
},
"fr": {
"changeEmail": "Modifier l'email",
"email": "Email",
"save": "Enregistrer",
"cancel": "Annuler"
"cancel": "Annuler",
"validation": {
"emailRequired": "L'email est requis",
"emailInvalid": "Veuillez entrer une adresse email valide"
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"changeEmail": "Cambiar correo electrónico",
"email": "Correo electrónico",
"save": "Guardar",
"cancel": "Cancelar"
"cancel": "Cancelar",
"validation": {
"emailRequired": "El correo electrónico es obligatorio",
"emailInvalid": "Por favor ingrese una dirección de correo electrónico válida"
},
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>
</i18n>

View File

@@ -3,17 +3,33 @@
<div class="card-title">{{ t('changePhoneNumber') }}</div>
<div class="card-content">
<v-text-field
v-model="phoneNumber"
v-model="displayPhoneNumber"
class="w-full p-2"
:label="t('phoneNumber')"
type="tel"
variant="outlined"
:error-messages="phoneErrors"
:rules="phoneRules"
validate-on="blur"
:placeholder="t('phonePlaceholder')"
@input="handlePhoneInput"
@keydown="handleKeydown"
maxlength="14"
/>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
class="mt-4">
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
{{ t('cancel') }}
</button>
<button class="primary" @click="savePhoneNumber" :disabled="isLoading">
<button class="primary" @click="savePhoneNumber" :disabled="!canSave || isLoading">
{{ t('save') }}
</button>
</div>
@@ -22,7 +38,7 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
@@ -38,8 +54,99 @@ const props = defineProps({
}
});
const phoneNumber = ref(props.creator.presentation?.phoneNumber || '');
// Format existing phone number to display format
const formatPhoneForDisplay = (phone) => {
if (!phone) return '';
const digits = phone.replace(/\D/g, '');
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
return phone;
};
// Extract just the digits from formatted phone
const extractDigits = (formattedPhone) => {
return formattedPhone.replace(/\D/g, '');
};
const displayPhoneNumber = ref(formatPhoneForDisplay(props.creator.presentation?.phoneNumber || ''));
const phoneDigits = ref(extractDigits(displayPhoneNumber.value));
const isLoading = ref(false);
const errorMessage = ref('');
// Phone number formatting and validation
const formatPhoneNumber = (digits) => {
// Remove all non-digits
const cleaned = digits.replace(/\D/g, '');
// Apply formatting based on length
if (cleaned.length === 0) return '';
if (cleaned.length <= 3) return `(${cleaned}`;
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
};
const handlePhoneInput = (event) => {
const input = event.target.value;
const digits = extractDigits(input);
// Limit to 10 digits
if (digits.length > 10) return;
phoneDigits.value = digits;
displayPhoneNumber.value = formatPhoneNumber(digits);
};
const handleKeydown = (event) => {
// Allow backspace, delete, tab, escape, enter
if ([8, 9, 27, 13, 46].includes(event.keyCode)) return;
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
if ((event.ctrlKey || event.metaKey) && [65, 67, 86, 88].includes(event.keyCode)) return;
// Allow arrow keys
if (event.keyCode >= 35 && event.keyCode <= 40) return;
// Only allow numbers (0-9)
if (event.keyCode < 48 || event.keyCode > 57) {
event.preventDefault();
}
};
// Watch for changes to phoneDigits to update display
watch(phoneDigits, (newDigits) => {
displayPhoneNumber.value = formatPhoneNumber(newDigits);
});
const isValidPhoneNumber = (digits) => {
return digits.length === 10;
};
const phoneRules = [
v => {
const digits = extractDigits(v);
return digits.length > 0 || t('validation.phoneRequired');
},
v => {
const digits = extractDigits(v);
return isValidPhoneNumber(digits) || t('validation.phoneInvalid');
},
];
const phoneErrors = computed(() => {
if (phoneDigits.value.length === 0) {
return [t('validation.phoneRequired')];
}
if (!isValidPhoneNumber(phoneDigits.value)) {
return [t('validation.phoneInvalid')];
}
return [];
});
const canSave = computed(() => {
return phoneDigits.value.length === 10 &&
phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || '');
});
async function savePhoneNumber() {
if (!props.creator.id) {
@@ -47,14 +154,21 @@ async function savePhoneNumber() {
return;
}
if (!canSave.value) {
return;
}
try {
isLoading.value = true;
errorMessage.value = '';
// Save phone number
// Save the formatted phone number
const formattedPhone = formatPhoneNumber(phoneDigits.value);
await client.post(
`/api/creators/${props.creator.id}/phone`,
{
phoneNumber: phoneNumber.value || ""
phoneNumber: formattedPhone
}
);
@@ -65,6 +179,11 @@ async function savePhoneNumber() {
emit('closeRequested');
} catch (error) {
console.error("Error saving phone number:", error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
}
} finally {
isLoading.value = false;
}
@@ -84,20 +203,44 @@ const emit = defineEmits(['closeRequested']);
"en": {
"changePhoneNumber": "Change Phone Number",
"phoneNumber": "Phone Number",
"phonePlaceholder": "(555) 123-4567",
"save": "Save",
"cancel": "Cancel"
"cancel": "Cancel",
"validation": {
"phoneRequired": "Phone number is required",
"phoneInvalid": "Please enter a complete 10-digit phone number"
},
"errors": {
"unexpected": "An unexpected error occurred"
}
},
"fr": {
"changePhoneNumber": "Modifier le numéro de téléphone",
"phoneNumber": "Numéro de téléphone",
"phonePlaceholder": "(555) 123-4567",
"save": "Enregistrer",
"cancel": "Annuler"
"cancel": "Annuler",
"validation": {
"phoneRequired": "Le numéro de téléphone est requis",
"phoneInvalid": "Veuillez entrer un numéro de téléphone complet à 10 chiffres"
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"changePhoneNumber": "Cambiar número de teléfono",
"phoneNumber": "Número de teléfono",
"phonePlaceholder": "(555) 123-4567",
"save": "Guardar",
"cancel": "Cancelar"
"cancel": "Cancelar",
"validation": {
"phoneRequired": "El número de teléfono es obligatorio",
"phoneInvalid": "Por favor ingrese un número de teléfono completo de 10 dígitos"
},
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>
</i18n>