Compare commits

...

10 Commits

Author SHA1 Message Date
35c7530ec7 Merge branch 'community'
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
# Conflicts:
#	frontend/src/views/main/Footer.vue
#	frontend/src/views/main/Landing.vue
2025-12-08 16:11:19 -05:00
a0c8571fca Merge branch 'backup' 2025-12-08 16:06:28 -05:00
6211339c0e pending 2025-12-08 15:57:32 -05:00
d4c278be48 feat(frontend): change some descriptions 2025-12-08 15:57:25 -05:00
3a84defec5 chore(debug): fix windows/linux case-sensitive issues with the database name 2025-12-08 15:57:00 -05:00
41da496870 feat(frontend): remove creators guide 2025-12-08 15:56:50 -05:00
1e46f5bbaa chore(codebase): remove dead code 2025-08-26 16:56:17 -04:00
9da862b629 refactor(ui): simplify layout by introducing an unified site-bar 2025-08-26 16:36:10 -04:00
262f21d157 feat(i18n): deprecate Spanish language support in the frontend 2025-08-26 15:58:06 -04:00
fbf7963842 feat(frontend): change some descriptions 2025-08-13 13:38:48 -04:00
49 changed files with 4245 additions and 4508 deletions

View File

@@ -1,57 +1,53 @@
<template> <template>
<v-app> <v-app>
<div class="shell-container"> <div class="shell-container">
<div class="shell-side">
<site-bar></site-bar>
</div>
<div class="shell-side"> <div class="shell-view">
<side-bar></side-bar> <router-view></router-view>
</div> </div>
</div>
<div class="shell-view"> </v-app>
<router-view></router-view>
</div>
</div>
</v-app>
</template> </template>
<script async setup> <script async setup>
import { mdiFileAccountOutline } from '@mdi/js'; import SiteBar from '@/views/main/SiteBar.vue';
import SideBar from "@/views/main/SideBar.vue"; import { useLanguageStore } from '@/stores/languageStore.js';
import { useLanguageStore } from "@/stores/languageStore.js"; import { watch } from 'vue';
import { watch } from "vue"; import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
// Watch for language changes and update i18n locale // Watch for language changes and update i18n locale
const languageStore = useLanguageStore(); const languageStore = useLanguageStore();
const { locale } = useI18n(); const { locale } = useI18n();
// Watch for changes to the language store
watch(() => languageStore.locale, (newLocale) => {
if (newLocale) {
locale.value = newLocale;
}
}, { immediate: true });
// Watch for changes to the language store
watch(
() => languageStore.locale,
newLocale => {
if (newLocale) {
locale.value = newLocale;
}
},
{ immediate: true }
);
</script> </script>
<style scoped> <style scoped>
.shell-container { .shell-container {
@apply flex flex-col lg:flex-row; @apply flex flex-col;
@apply font-sans; @apply w-full;
@apply bg-hBackground text-hOnBackground; @apply font-sans;
@apply min-h-screen h-full; @apply bg-hBackground text-hOnBackground;
} }
.shell-side { .shell-side {
@apply lg:fixed lg:max-h-screen; @apply flex-shrink-0;
@apply flex-shrink-0; }
}
.shell-view { .shell-view {
@apply flex-grow; @apply flex-grow;
@apply flex justify-center items-center; @apply flex justify-center items-center;
@apply w-full; }
@apply lg:ml-64;
}
</style> </style>

View File

@@ -1,49 +0,0 @@
.banner-image {
margin: 0 auto;
width: 100%; /* Augmenter la largeur de l'image */
max-height: 40vh; /* Réduire légèrement la hauteur de l'image */
object-fit: cover;
object-position: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); /* Ajouter une ombre à l'image */
}
.banner-images-row {
display: flex;
justify-content: space-between;
gap: 0; /* Supprime l'espacement entre les éléments */
}
.banner-image-item-wrapper {
width: 76%; /* Définit la largeur du conteneur à 20% de la largeur de l'écran */
height: auto; /* Garde la hauteur d'origine */
}
.banner-image-item {
width: 100%; /* Définit la largeur de l'image à 100% du conteneur */
height: 300x; /* Hauteur fixe pour l'exemple, à ajuster selon vos besoins */
object-fit: cover; /* Coupe l'image pour remplir le conteneur tout en conservant les proportions */
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background-color: white;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 5px;
display: none;
}
.banner-image-item:hover .dropdown-menu {
display: block;
}
.custom-container {
width: 40%;
}

View File

@@ -1,112 +0,0 @@
/* CSS pour ajuster la taille de l'image */
.img-small {
width: 70px;
height: 70px;
}
.img-Logo {
width: 200px;
height: 70px;
}
.text-custom {
color: #000000;
/* Couleur de texte spécifique */
}
/* CSS pour le texte du menu-left */
.menu-left a {
color: #000000;
/* Couleur du texte */
font-weight: bold;
/* Gras */
text-decoration: none;
/* Pas de soulignement */
font-size: 24px;
/* Taille de la police en pixels */
}
/* CSS pour le texte des liens du menu-center */
.menu-center {
display: flex;
justify-content: center;
/* Centrer les éléments horizontalement */
align-items: center;
/* Centrer les éléments verticalement */
flex: 1;
/* Utiliser tout l'espace disponible */
margin-right: 8%
}
/* CSS pour le texte du menu-right */
.menu-right a {
color: #e4e4e4;
/* Couleur du texte */
}
.bg-custom {
background-color: #ffffff;
/* Définissez la couleur de fond souhaitée */
}
.textLogo {
font-size: 35px;
/* Taille de la police en pixels */
}
.logo {
margin-right: 5px;
/* Réduire la marge entre le logo et le texte */
}
.profilePicture {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
/* Ajouter une ombre à la photo */
border: 2px solid #a30e79;
/* Ajouter une bordure de 2px solide de couleur rouge (#f00) */
}
.bg-customdarker {
background-color: #ffffffa4;
/* Définissez la couleur de fond souhaitée */
}
.top-aligned-column {
display: flex;
justify-content: space-between;
}
.column {
flex: 1;
}
.center-column {
flex: 3;
/* La colonne centrale occupe 3 fois plus d'espace que les autres */
}
.colum-aligncenter {
text-align: center;
}
.menu-center a:nth-child(1):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en rouge au survol */
}
/* Pour le deuxième bouton */
.menu-center a:nth-child(2):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en bleu au survol */
}
/* Pour le troisième bouton */
.menu-center a:nth-child(3):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en vert au survol */
}

View File

@@ -1,41 +0,0 @@
{
"save": "Guardar",
"cancel": "Cancelar",
"edit": "Editar",
"delete": "Eliminar",
"create": "Crear",
"apply": "Aplicar",
"preview": "Vista previa",
"label": "Etiqueta",
"confirm": "Confirmar",
"close": "Cerrar",
"accept": "Aceptar",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
"changesSaved": "Cambios guardados con éxito",
"errorOccurred": "Ha ocurrido un error",
"name": "Nombre",
"email": "Correo electrónico",
"password": "Contraseña",
"description": "Descripción",
"title": "Título",
"image": "Imagen",
"file": "Archivo",
"required": "Este campo es obligatorio",
"invalidEmail": "Correo electrónico inválido",
"invalidPassword": "Contraseña inválida",
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Sitio web",
"errors": {
"unexpected": "Ha ocurrido un error inesperado",
"imageLoad": "Error al cargar la imagen",
"imageUpload": "Error al subir la imagen"
}
}

View File

@@ -5,54 +5,71 @@ import { createPinia } from 'pinia';
import 'vuetify/styles'; import 'vuetify/styles';
import { createVuetify } from 'vuetify'; import { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'; import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
import { VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert } from 'vuetify/components'; import {
import { } from 'vuetify/directives'; VAlert,
VApp,
VBtn,
VDialog,
VForm,
VIcon,
VProgressCircular,
VProgressLinear,
VSnackbar,
VTextarea,
VTextField,
} from 'vuetify/components';
import vueGoogleOauth from 'vue3-google-login'; import vueGoogleOauth from 'vue3-google-login';
import { useAuthStore } from "@/stores/authStore.js"; import { useAuthStore } from '@/stores/authStore.js';
import { useUserProfileStore } from "@/stores/userProfileStore.js"; import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js"; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import Toast, { POSITION } from 'vue-toastification'; import Toast, { POSITION } from 'vue-toastification';
import 'vue-toastification/dist/index.css'; import 'vue-toastification/dist/index.css';
import './assets/main.css'; import './assets/main.css';
import { createI18n } from 'vue-i18n';
import en from '@/locales/en.json';
import fr from '@/locales/fr.json';
const vuetify = createVuetify({ const vuetify = createVuetify({
components: { components: {
VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert VDialog,
}, VApp,
directives: { VBtn,
}, VProgressLinear,
icons: { VProgressCircular,
defaultSet: 'mdi', VIcon,
aliases, VTextField,
sets: { mdi } VSnackbar,
} VForm,
VTextarea,
VAlert,
},
directives: {},
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi },
},
}); });
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import fr from '@/locales/fr.json'
import es from '@/locales/es.json'
const i18n = createI18n({ const i18n = createI18n({
legacy: false, legacy: false,
fallbackLocale: 'en', fallbackLocale: 'fr',
messages: { messages: {
en: en, en: en,
fr: fr, fr: fr,
es: es },
} });
})
const pinia = createPinia(); const pinia = createPinia();
const app = createApp(App) const app = createApp(App)
.use(pinia) .use(pinia)
.use(vuetify) .use(vuetify)
.use(router) .use(router)
.use(i18n) .use(i18n)
.use(vueGoogleOauth, { .use(vueGoogleOauth, {
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID, clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
}) })
.use(Toast, { .use(Toast, {
position: POSITION.TOP_CENTER, position: POSITION.TOP_CENTER,
}); });

View File

@@ -1,31 +1,38 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core' import { useSessionStorage } from '@vueuse/core';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n';
export const useLanguageStore = defineStore( const ALLOWED_LOCALES = ['en', 'fr'];
'language', const DEFAULT_LOCALE = 'fr';
() => {
// Initialize with the stored value or default to 'fr'
const storedLocale = useSessionStorage('user-locale', 'fr')
// Get i18n instance export const useLanguageStore = defineStore('language', () => {
const { locale } = useI18n() const storedLocale = useSessionStorage('user-locale', DEFAULT_LOCALE);
// Set the initial locale from storage // Get i18n instance (provided globally)
if (locale && storedLocale.value) { const { locale } = useI18n();
locale.value = storedLocale.value
}
function setLocale(newLocale) { function sanitizeLocale(value) {
if (locale) { return ALLOWED_LOCALES.includes(value) ? value : DEFAULT_LOCALE;
locale.value = newLocale
}
storedLocale.value = newLocale
}
return {
locale: storedLocale,
setLocale
}
} }
)
// Initialize locale with a sanitized value
const initial = sanitizeLocale(storedLocale.value);
storedLocale.value = initial;
if (locale) {
locale.value = initial;
}
function setLocale(newLocale) {
const next = sanitizeLocale(newLocale);
if (locale) {
locale.value = next;
}
storedLocale.value = next;
}
return {
locale: storedLocale,
setLocale,
allowedLocales: ALLOWED_LOCALES,
};
});

View File

@@ -1,209 +1,218 @@
<template> <template>
<div class="flex min-h-full justify-center items-center w-full p-4"> <div class="flex min-h-full justify-center items-center w-full p-4">
<div class="flex flex-col gap-10 w-full max-w-[512px]"> <div class="flex flex-col gap-10 w-full max-w-[512px]">
<h1 class="text-2xl font-bold text-center"> <h1 class="text-2xl font-bold text-center">
{{ t('title') }} {{ t('title') }}
</h1> </h1>
<p class="text-center text-hOnSurface"> <p class="text-center text-hOnSurface">
{{ t('description') }} {{ t('description') }}
</p> </p>
<div class="card"> <div class="card">
<form @submit.prevent="handleForgotPassword"> <form @submit.prevent="handleForgotPassword">
<div class="card-content"> <div class="card-content">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="form-field"> <div class="form-field">
<label for="email" class="form-label">{{ t('email') }}</label> <label
<input class="form-label"
id="email" for="email"
v-model="email" >
type="email" {{ t('email') }}
class="form-input" </label>
required <input
/> id="email"
</div> v-model="email"
class="form-input"
required
type="email"
/>
</div>
<button <button
type="submit" :disabled="isLoading"
class="primary w-full" class="primary w-full"
:disabled="isLoading" type="submit"
> >
<span v-if="isLoading" class="loading-spinner mr-2"></span> <span
{{ t('resetPassword') }} v-if="isLoading"
</button> class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }}
</button>
<div class="text-center mt-4"> <div class="text-center mt-4">
<router-link to="/login" class="text-sm text-blue-500"> <router-link
{{ t('backToLogin') }} class="text-sm text-blue-500"
</router-link> to="/login"
</div> >
{{ t('backToLogin') }}
</router-link>
</div>
</div>
</div>
</form>
</div> </div>
</div>
</form>
</div>
<!-- Success message --> <!-- Success message -->
<div v-if="showSuccessMessage" class="notification success"> <div
{{ t('resetEmailSent') }} v-if="showSuccessMessage"
</div> class="notification success"
>
{{ t('resetEmailSent') }}
</div>
<!-- Error message --> <!-- Error message -->
<div v-if="showErrorMessage" class="notification error"> <div
{{ errorMessage }} v-if="showErrorMessage"
</div> class="notification error"
>
{{ errorMessage }}
</div>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import {ref} from 'vue'; import { ref } from 'vue';
import {useI18n} from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import {useRouter} from 'vue-router'; import { useRouter } from 'vue-router';
import {useClient} from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
const {t} = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const clientApi = useClient(); const clientApi = useClient();
const email = ref(''); const email = ref('');
const isLoading = ref(false); const isLoading = ref(false);
const showSuccessMessage = ref(false); const showSuccessMessage = ref(false);
const showErrorMessage = ref(false); const showErrorMessage = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
async function handleForgotPassword() { async function handleForgotPassword() {
// Reset notification states // Reset notification states
showSuccessMessage.value = false; showSuccessMessage.value = false;
showErrorMessage.value = false; showErrorMessage.value = false;
if (!email.value) { if (!email.value) {
errorMessage.value = t('emailRequired'); errorMessage.value = t('emailRequired');
showErrorMessage.value = true; showErrorMessage.value = true;
return; return;
} }
isLoading.value = true; isLoading.value = true;
try { try {
// Call password reset API // Call password reset API
await clientApi.post('api/users/forgot-password', { await clientApi.post('api/users/forgot-password', {
email: email.value.trim() email: email.value.trim(),
}); });
// Show success message // Show success message
showSuccessMessage.value = true; showSuccessMessage.value = true;
// Clear the form // Clear the form
email.value = ''; email.value = '';
// Redirect to login after a short delay // Redirect to login after a short delay
setTimeout(() => { setTimeout(() => {
router.push('/login'); router.push('/login');
}, 3000); }, 3000);
} catch (error) { } catch (error) {
console.error('Password reset request failed:', error); console.error('Password reset request failed:', error);
errorMessage.value = error.response?.data?.message || t('resetRequestFailed'); errorMessage.value = error.response?.data?.message || t('resetRequestFailed');
showErrorMessage.value = true; showErrorMessage.value = true;
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
</script> </script>
<style scoped> <style scoped>
.card-content { .card-content {
@apply p-6; @apply p-6;
} }
.form-field { .form-field {
@apply flex flex-col mb-4; @apply flex flex-col mb-4;
} }
.form-label { .form-label {
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300; @apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
} }
.form-input { .form-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg @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 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; dark:bg-gray-700 dark:border-gray-600 dark:text-white;
} }
.primary { .primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5 @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; focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
} }
.notification { .notification {
@apply fixed bottom-4 right-4 p-4 mb-4 rounded-lg text-sm; @apply fixed bottom-4 right-4 p-4 mb-4 rounded-lg text-sm;
animation: fade-in 0.3s ease-in, fade-out 0.3s ease-out 5s forwards; animation:
} fade-in 0.3s ease-in,
fade-out 0.3s ease-out 5s forwards;
}
.success { .success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300; @apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
} }
.error { .error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300; @apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
} }
.loading-spinner { .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]; @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];
} }
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes fade-out { @keyframes fade-out {
from { from {
opacity: 1; opacity: 1;
} }
to { to {
opacity: 0; opacity: 0;
} }
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Forgot Password?", "title": "Forgot Password?",
"description": "Please enter your account email address. A password reset link will be sent to you.", "description": "Please enter your account email address. A password reset link will be sent to you.",
"email": "Email", "email": "Email",
"resetPassword": "Reset Password", "resetPassword": "Reset Password",
"backToLogin": "Back to Login", "backToLogin": "Back to Login",
"resetEmailSent": "Password reset email sent. Please check your inbox.", "resetEmailSent": "Password reset email sent. Please check your inbox.",
"resetRequestFailed": "Failed to request password reset. Please try again.", "resetRequestFailed": "Failed to request password reset. Please try again.",
"emailRequired": "Email is required." "emailRequired": "Email is required."
}, },
"fr": { "fr": {
"title": "Mot de passe oublié ?", "title": "Mot de passe oublié ?",
"description": "Veuillez saisir l'adresse e-mail de votre compte. Un lien de réinitialisation vous sera envoyé.", "description": "Veuillez saisir l'adresse e-mail de votre compte. Un lien de réinitialisation vous sera envoyé.",
"email": "Email", "email": "Email",
"resetPassword": "Réinitialiser le mot de passe", "resetPassword": "Réinitialiser le mot de passe",
"backToLogin": "Retour à la connexion", "backToLogin": "Retour à la connexion",
"resetEmailSent": "Email de réinitialisation du mot de passe envoyé. Veuillez vérifier votre boîte de réception.", "resetEmailSent": "Email de réinitialisation du mot de passe envoyé. Veuillez vérifier votre boîte de réception.",
"resetRequestFailed": "Échec de la demande de réinitialisation du mot de passe. Veuillez réessayer.", "resetRequestFailed": "Échec de la demande de réinitialisation du mot de passe. Veuillez réessayer.",
"emailRequired": "L'email est requis." "emailRequired": "L'email est requis."
}, }
"es": {
"title": "¿Olvidaste tu contraseña?",
"description": "Por favor, introduce la dirección de correo electrónico de tu cuenta. Te enviaremos un enlace para restablecer tu contraseña.",
"email": "Correo electrónico",
"resetPassword": "Restablecer contraseña",
"backToLogin": "Volver al inicio de sesión",
"resetEmailSent": "Correo electrónico de restablecimiento de contraseña enviado. Por favor revise su bandeja de entrada.",
"resetRequestFailed": "No se pudo solicitar el restablecimiento de contraseña. Por favor, inténtelo de nuevo.",
"emailRequired": "El correo electrónico es obligatorio."
}
} }
</i18n> </i18n>

View File

@@ -1,195 +1,214 @@
<template> <template>
<div class="flex min-h-full w-full items-center justify-center p-4"> <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="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="flex w-full max-w-[512px] flex-col gap-10"> <div class="flex flex-col gap-4">
<h1 class="login-text text-center text-2xl font-bold"> <google-login
{{ t('title') }} :callback="googleCallback"
</h1> popup-type="TOKEN"
>
<button class="secondary">
<v-icon
:icon="mdiGoogle"
class="mr-2"
/>
{{ t('continueWithGoogle') }}
</button>
</google-login>
</div>
<div class="my-4 flex items-center">
<div class="h-px grow bg-gray-200"></div>
<span class="px-3 text-sm font-semibold uppercase text-gray-300">{{ t('orContinueWith') }}</span>
<div class="h-px grow bg-gray-200"></div>
</div>
<div class="flex flex-col gap-4"> <!-- Add email/password form -->
<google-login :callback="googleCallback" popup-type="TOKEN"> <v-form @submit.prevent="handleLocalLogin">
<button class="secondary"> <div class="flex flex-col gap-4">
<v-icon class="mr-2" :icon="mdiGoogle" /> <v-text-field
{{ t('continueWithGoogle') }} v-model="email"
</button> :label="t('email')"
</google-login> required
</div> type="email"
></v-text-field>
<div class="my-4 flex items-center"> <v-text-field
<div class="h-px grow bg-gray-200"></div> v-model="password"
<span class="px-3 text-sm font-semibold uppercase text-gray-300">{{ t('orContinueWith') }}</span> :label="t('password')"
<div class="h-px grow bg-gray-200"></div> :type="showPassword ? 'text' : 'password'"
</div> required
>
<template v-slot:append-inner>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<!-- Add email/password form --> <v-btn
<v-form @submit.prevent="handleLocalLogin"> block
<div class="flex flex-col gap-4"> color="primary"
<v-text-field v-model="email" :label="t('email')" type="email" required></v-text-field> type="submit"
>
{{ t('signIn') }}
</v-btn>
<v-text-field v-model="password" :label="t('password')" :type="showPassword ? 'text' : 'password'" required> <div class="text-center">
<template v-slot:append-inner> <a
<v-icon @click="showPassword = !showPassword" class="visibility-toggle" size="small" class="cursor-pointer text-sm text-blue-500"
:icon="showPassword ? mdiEyeOff : mdiEye" /> @click="forgotPassword"
</template> >
</v-text-field> {{ t('forgotPassword') }}
</a>
</div>
<v-btn type="submit" color="primary" block> <div class="mt-2 text-center">
{{ t('signIn') }} <a
</v-btn> class="cursor-pointer text-sm text-blue-500"
@click="resendVerification"
>
{{ t('resendVerification') }}
</a>
</div>
<div class="text-center"> <div class="mt-4 text-center">
<a @click="forgotPassword" class="cursor-pointer text-sm text-blue-500"> {{ t('noAccount') }}
{{ t('forgotPassword') }} <router-link
</a> class="text-blue-500"
</div> to="/register"
>
<div class="mt-2 text-center"> {{ t('register') }}
<a @click="resendVerification" class="cursor-pointer text-sm text-blue-500"> </router-link>
{{ t('resendVerification') }} </div>
</a> </div>
</div> </v-form>
<div class="mt-4 text-center">
{{ t('noAccount') }}
<router-link to="/register" class="text-blue-500">
{{ t('register') }}
</router-link>
</div>
</div> </div>
</v-form>
<!-- Error notification -->
<v-snackbar
v-model="errorSnackBar"
color="error"
>
{{ t('loginFailed') }}
</v-snackbar>
</div> </div>
<!-- Error notification -->
<v-snackbar v-model="errorSnackBar" color="error">
{{ t('loginFailed') }}
</v-snackbar>
</div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { GoogleLogin } from "vue3-google-login"; import { GoogleLogin } from 'vue3-google-login';
import { useAuthStore } from '@/stores/authStore.js'; import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { mdiGoogle, mdiEye, mdiEyeOff } from '@mdi/js'; import { mdiEye, mdiEyeOff, mdiGoogle } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const errorSnackBar = ref(false); const errorSnackBar = ref(false);
const showPassword = ref(false); const showPassword = ref(false);
const props = defineProps({ const props = defineProps({
returnUrl: { returnUrl: {
type: String, type: String,
default: '/landing' default: '/landing',
} },
}); });
async function handleLocalLogin() { async function handleLocalLogin() {
try { try {
await authStore.login(email.value, password.value); await authStore.login(email.value, password.value);
await router.push(props.returnUrl); await router.push(props.returnUrl);
} catch (error) { } catch (error) {
console.error('Login failed:', error); console.error('Login failed:', error);
errorSnackBar.value = true; errorSnackBar.value = true;
} }
}
async function googleCallback(token) {
try {
const response = await authStore.loginWithGoogle(JSON.stringify(token));
if (response === true) {
await router.push(props.returnUrl);
} else {
errorSnackBar.value = true;
} }
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
function forgotPassword() { async function googleCallback(token) {
router.push('/forgot-password'); try {
} const response = await authStore.loginWithGoogle(JSON.stringify(token));
if (response === true) {
await router.push(props.returnUrl);
} else {
errorSnackBar.value = true;
}
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
function resendVerification() { function forgotPassword() {
router.push('/verify-email'); router.push('/forgot-password');
} }
function resendVerification() {
router.push('/verify-email');
}
</script> </script>
<style scoped> <style scoped>
.visibility-toggle { .visibility-toggle {
@apply cursor-pointer; @apply cursor-pointer;
@apply transition-opacity duration-300; @apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100; @apply opacity-60 hover:opacity-100;
@apply z-10; @apply z-10;
} }
/* Override Vuetify's default padding to accommodate our icon */ /* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) { :deep(.v-field__append-inner) {
padding-inline-start: 0; padding-inline-start: 0;
} }
/* Dark mode support if needed */ /* Dark mode support if needed */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.custom-divider { .custom-divider {
background-color: rgb(75, 85, 99); background-color: rgb(75, 85, 99);
/* Equivalent to gray-600 */ /* Equivalent to gray-600 */
} }
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Sign in", "title": "Sign in",
"alt": "Login", "alt": "Login",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"signIn": "Connect", "signIn": "Connect",
"forgotPassword": "Forgot password?", "forgotPassword": "Forgot password?",
"resendVerification": "Resend verification email", "resendVerification": "Resend verification email",
"orContinueWith": "Or", "orContinueWith": "Or",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"register": "Register", "register": "Register",
"loginFailed": "Login failed. Please check your credentials.", "loginFailed": "Login failed. Please check your credentials.",
"continueWithGoogle": "Continue with Google" "continueWithGoogle": "Continue with Google"
}, },
"fr": { "fr": {
"title": "Se connecter", "title": "Se connecter",
"alt": "Connexion", "alt": "Connexion",
"email": "Email", "email": "Email",
"password": "Mot de passe", "password": "Mot de passe",
"signIn": "Connexion", "signIn": "Connexion",
"forgotPassword": "Mot de passe oublié?", "forgotPassword": "Mot de passe oublié?",
"resendVerification": "Renvoyer l'email de vérification", "resendVerification": "Renvoyer l'email de vérification",
"orContinueWith": "Ou", "orContinueWith": "Ou",
"noAccount": "Vous n'avez pas de compte?", "noAccount": "Vous n'avez pas de compte?",
"register": "S'inscrire", "register": "S'inscrire",
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.", "loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
"continueWithGoogle": "Continuer avec Google" "continueWithGoogle": "Continuer avec Google"
}, }
"es": {
"title": "Iniciar sesión",
"alt": "Inicio de sesión",
"email": "Correo electrónico",
"password": "Contraseña",
"signIn": "Conéctate",
"forgotPassword": "¿Olvidó su contraseña?",
"resendVerification": "Reenviar correo de verificación",
"orContinueWith": "o",
"noAccount": "¿No tiene una cuenta?",
"register": "Registrarse",
"loginFailed": "Error de inicio de sesión. Por favor, compruebe sus credenciales.",
"continueWithGoogle": "Continuar con Google"
}
} }
</i18n> </i18n>

View File

@@ -25,8 +25,8 @@
{{ t('success.backToLogin') }} {{ t('success.backToLogin') }}
</router-link> </router-link>
<router-link <router-link
class="text-blue-500 hover:underline"
:to="{ path: '/verify-email', query: { email: userEmail } }" :to="{ path: '/verify-email', query: { email: userEmail } }"
class="text-blue-500 hover:underline"
> >
{{ t('success.resendVerification') }} {{ t('success.resendVerification') }}
</router-link> </router-link>
@@ -232,26 +232,6 @@
"backToLogin": "Retour à la connexion", "backToLogin": "Retour à la connexion",
"resendVerification": "Vous n'avez pas reçu l'email? Renvoyer la vérification" "resendVerification": "Vous n'avez pas reçu l'email? Renvoyer la vérification"
} }
},
"es": {
"title": "Crea tu cuenta",
"alt": "Registro de Hutopy",
"name": "Nombre completo",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"register": "Registrarse",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"signIn": "Iniciar sesión",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"registrationFailed": "El registro falló. Por favor, inténtelo de nuevo.",
"success": {
"title": "¡Registro exitoso!",
"message": "Por favor revisa tu correo electrónico para verificar tu cuenta. Hemos enviado un enlace de verificación a:",
"backToLogin": "Volver al inicio de sesión",
"resendVerification": "¿No recibiste el correo? Reenviar verificación"
}
} }
} }
</i18n> </i18n>

View File

@@ -259,19 +259,6 @@
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères", "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.", "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." "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> </i18n>

View File

@@ -1,219 +1,237 @@
<template> <template>
<div class="flex min-h-full w-full items-center justify-center p-4"> <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 text-center"> <div class="flex w-full max-w-[512px] flex-col gap-10 text-center">
<!-- Loading state while verification is in progress --> <!-- Loading state while verification is in progress -->
<div v-if="isLoading" class="flex flex-col items-center gap-4"> <div
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular> v-if="isLoading"
<h2 class="text-xl font-medium">{{ t('verifying') }}</h2> class="flex flex-col items-center gap-4"
</div> >
<v-progress-circular
<!-- Success state --> color="primary"
<div v-else-if="verificationSuccess" class="flex flex-col items-center gap-6"> indeterminate
<v-icon icon="mdi-check-circle" color="green" size="64"></v-icon> size="64"
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1> ></v-progress-circular>
<p>{{ t('success.message') }}</p> <h2 class="text-xl font-medium">{{ t('verifying') }}</h2>
<v-btn color="primary" @click="goToLogin">{{ t('success.goToLogin') }}</v-btn> </div>
</div>
<!-- Success state -->
<!-- Error state --> <div
<div v-else class="flex flex-col items-center gap-6"> v-else-if="verificationSuccess"
<v-icon icon="mdi-alert-circle" color="error" size="64"></v-icon> class="flex flex-col items-center gap-6"
<h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1> >
<p>{{ errorMessage || t('error.defaultMessage') }}</p> <v-icon
color="green"
<div class="mt-4 flex flex-col gap-4 w-full"> icon="mdi-check-circle"
<v-btn color="primary" @click="goToLogin">{{ t('error.goToLogin') }}</v-btn> size="64"
<v-divider class="my-4"></v-divider> ></v-icon>
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1>
<!-- Resend verification email section --> <p>{{ t('success.message') }}</p>
<h2 class="text-xl font-medium">{{ t('resend.title') }}</h2> <v-btn
<v-form @submit.prevent="handleResendVerification" class="w-full"> color="primary"
<div class="flex flex-col gap-4"> @click="goToLogin"
<v-text-field >
v-model="resendEmail" {{ t('success.goToLogin') }}
:label="t('resend.emailLabel')" </v-btn>
type="email" </div>
required
:error-messages="resendEmailError" <!-- Error state -->
></v-text-field> <div
v-else
<v-btn class="flex flex-col items-center gap-6"
type="submit" >
color="secondary" <v-icon
block color="error"
:loading="resendLoading" icon="mdi-alert-circle"
> size="64"
{{ t('resend.button') }} ></v-icon>
</v-btn> <h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1>
<p>{{ errorMessage || t('error.defaultMessage') }}</p>
<!-- Resend success message -->
<div v-if="resendSuccess" class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm"> <div class="mt-4 flex flex-col gap-4 w-full">
{{ t('resend.success') }} <v-btn
</div> color="primary"
@click="goToLogin"
<!-- Resend error message --> >
<div v-if="resendError" class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"> {{ t('error.goToLogin') }}
{{ resendError }} </v-btn>
</div> <v-divider class="my-4"></v-divider>
<!-- Resend verification email section -->
<h2 class="text-xl font-medium">{{ t('resend.title') }}</h2>
<v-form
class="w-full"
@submit.prevent="handleResendVerification"
>
<div class="flex flex-col gap-4">
<v-text-field
v-model="resendEmail"
:error-messages="resendEmailError"
:label="t('resend.emailLabel')"
required
type="email"
></v-text-field>
<v-btn
:loading="resendLoading"
block
color="secondary"
type="submit"
>
{{ t('resend.button') }}
</v-btn>
<!-- Resend success message -->
<div
v-if="resendSuccess"
class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm"
>
{{ t('resend.success') }}
</div>
<!-- Resend error message -->
<div
v-if="resendError"
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
>
{{ resendError }}
</div>
</div>
</v-form>
</div>
</div> </div>
</v-form>
</div> </div>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { onMounted, ref } from 'vue';
import { useClient } from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const clientApi = useClient(); const clientApi = useClient();
// Verification state // Verification state
const isLoading = ref(true); const isLoading = ref(true);
const verificationSuccess = ref(false); const verificationSuccess = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
// Resend verification state // Resend verification state
const resendEmail = ref(''); const resendEmail = ref('');
const resendEmailError = ref(''); const resendEmailError = ref('');
const resendLoading = ref(false); const resendLoading = ref(false);
const resendSuccess = ref(false); const resendSuccess = ref(false);
const resendError = ref(''); const resendError = ref('');
onMounted(async () => { onMounted(async () => {
const userId = route.query.userId; const userId = route.query.userId;
const token = route.query.token; const token = route.query.token;
// Populate resend email field if it was in the URL // Populate resend email field if it was in the URL
if (route.query.email) { if (route.query.email) {
resendEmail.value = route.query.email; resendEmail.value = route.query.email;
} }
// Check if we have the required parameters // Check if we have the required parameters
if (!userId || !token) { if (!userId || !token) {
isLoading.value = false; isLoading.value = false;
errorMessage.value = t('error.missingParams'); errorMessage.value = t('error.missingParams');
return; return;
} }
try { try {
// Call the verification endpoint // Call the verification endpoint
await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`); await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`);
verificationSuccess.value = true; verificationSuccess.value = true;
} catch (error) { } catch (error) {
console.error('Email verification failed:', error); console.error('Email verification failed:', error);
errorMessage.value = error.response?.data?.message || t('error.defaultMessage'); errorMessage.value = error.response?.data?.message || t('error.defaultMessage');
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
});
async function handleResendVerification() {
// Reset states
resendEmailError.value = '';
resendSuccess.value = false;
resendError.value = '';
// Simple email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(resendEmail.value)) {
resendEmailError.value = t('resend.invalidEmail');
return;
}
resendLoading.value = true;
try {
await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim()
}); });
resendSuccess.value = true;
} catch (error) {
console.error('Resend verification failed:', error);
resendError.value = error.response?.data?.message || t('resend.error');
} finally {
resendLoading.value = false;
}
}
function goToLogin() { async function handleResendVerification() {
router.push('/login'); // Reset states
} resendEmailError.value = '';
resendSuccess.value = false;
resendError.value = '';
// Simple email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(resendEmail.value)) {
resendEmailError.value = t('resend.invalidEmail');
return;
}
resendLoading.value = true;
try {
await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim(),
});
resendSuccess.value = true;
} catch (error) {
console.error('Resend verification failed:', error);
resendError.value = error.response?.data?.message || t('resend.error');
} finally {
resendLoading.value = false;
}
}
function goToLogin() {
router.push('/login');
}
</script> </script>
<i18n> <i18n>
{ {
"en": { "en": {
"verifying": "Verifying your email...", "verifying": "Verifying your email...",
"success": { "success": {
"title": "Email Verified Successfully!", "title": "Email Verified Successfully!",
"message": "Your email has been verified. You can now log in to your account.", "message": "Your email has been verified. You can now log in to your account.",
"goToLogin": "Go to Login" "goToLogin": "Go to Login"
},
"error": {
"title": "Verification Failed",
"defaultMessage": "We couldn't verify your email. The link may be invalid or expired.",
"missingParams": "Missing required verification parameters.",
"goToLogin": "Go to Login"
},
"resend": {
"title": "Resend Verification Email",
"emailLabel": "Email",
"button": "Resend Verification Email",
"success": "Verification email sent successfully. Please check your inbox.",
"error": "Failed to send verification email. Please try again.",
"invalidEmail": "Please enter a valid email address."
}
}, },
"error": { "fr": {
"title": "Verification Failed", "verifying": "Vérification de votre email...",
"defaultMessage": "We couldn't verify your email. The link may be invalid or expired.", "success": {
"missingParams": "Missing required verification parameters.", "title": "Email vérifié avec succès !",
"goToLogin": "Go to Login" "message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.",
}, "goToLogin": "Aller à la connexion"
"resend": { },
"title": "Resend Verification Email", "error": {
"emailLabel": "Email", "title": "Échec de la vérification",
"button": "Resend Verification Email", "defaultMessage": "Nous n'avons pas pu vérifier votre email. Le lien peut être invalide ou expiré.",
"success": "Verification email sent successfully. Please check your inbox.", "missingParams": "Paramètres de vérification requis manquants.",
"error": "Failed to send verification email. Please try again.", "goToLogin": "Aller à la connexion"
"invalidEmail": "Please enter a valid email address." },
"resend": {
"title": "Renvoyer l'email de vérification",
"emailLabel": "Email",
"button": "Renvoyer l'email de vérification",
"success": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
"error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.",
"invalidEmail": "Veuillez entrer une adresse email valide."
}
} }
},
"fr": {
"verifying": "Vérification de votre email...",
"success": {
"title": "Email vérifié avec succès !",
"message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.",
"goToLogin": "Aller à la connexion"
},
"error": {
"title": "Échec de la vérification",
"defaultMessage": "Nous n'avons pas pu vérifier votre email. Le lien peut être invalide ou expiré.",
"missingParams": "Paramètres de vérification requis manquants.",
"goToLogin": "Aller à la connexion"
},
"resend": {
"title": "Renvoyer l'email de vérification",
"emailLabel": "Email",
"button": "Renvoyer l'email de vérification",
"success": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
"error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.",
"invalidEmail": "Veuillez entrer une adresse email valide."
}
},
"es": {
"verifying": "Verificando tu correo electrónico...",
"success": {
"title": "¡Correo electrónico verificado con éxito!",
"message": "Tu correo electrónico ha sido verificado. Ahora puedes iniciar sesión en tu cuenta.",
"goToLogin": "Ir al inicio de sesión"
},
"error": {
"title": "Falló la verificación",
"defaultMessage": "No pudimos verificar tu correo electrónico. El enlace puede ser inválido o estar caducado.",
"missingParams": "Faltan parámetros de verificación requeridos.",
"goToLogin": "Ir al inicio de sesión"
},
"resend": {
"title": "Reenviar correo de verificación",
"emailLabel": "Correo electrónico",
"button": "Reenviar correo de verificación",
"success": "Correo de verificación enviado con éxito. Por favor revisa tu bandeja de entrada.",
"error": "Error al enviar el correo de verificación. Por favor, inténtelo de nuevo.",
"invalidEmail": "Por favor, introduce una dirección de correo electrónico válida."
}
}
} }
</i18n> </i18n>

View File

@@ -681,36 +681,6 @@
"descriptionRequired": "La description est obligatoire" "descriptionRequired": "La description est obligatoire"
} }
} }
},
"es": {
"edit": "Editar",
"save": "Guardar",
"cancel": "Cancelar",
"creator": {
"sections": {
"about": {
"title": "Acerca de",
"description": "Descripción",
"contactInfo": "Información de contacto",
"characters": "caracteres",
"formattingHint": "Consejo: ¡Usa saltos de línea y emojis para hacer tu descripción más atractiva!"
},
"photos": {
"title": "Fotos",
"image": "Imagen"
}
},
"fields": {
"videoUrl": "URL del video",
"phoneNumber": "Número de teléfono",
"email": "Correo electrónico"
},
"validation": {
"invalidYoutubeUrl": "Por favor, introduce una URL de YouTube o un ID de video válido",
"descriptionTooLong": "La descripción no puede exceder los 2000 caracteres",
"descriptionRequired": "La descripción es obligatoria"
}
}
} }
} }
</i18n> </i18n>

View File

@@ -1,64 +1,81 @@
<template> <template>
<div class="relative"> <div class="relative">
<!-- Banner Container with mouse events --> <!-- Banner Container with mouse events -->
<div class="relative overflow-y-auto rounded-b-2xl" @mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false" @click="isCurrentCreator && openBannerEditor()">
<img class="banner aspect-[4/1] w-full object-cover"
:src="brandingStore.value?.bannerUrl ?? '/images/placeholders/banner.png'" :alt="t('alt')">
<!-- Tint Effect -->
<div v-if="showTint" class="absolute inset-0 cursor-pointer bg-black/25">
<!-- Top-right Icon -->
<div <div
class="absolute right-4 top-4 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"> class="relative overflow-y-auto rounded-b-2xl"
<v-icon large :icon="mdiPencil" /> @click="isCurrentCreator && openBannerEditor()"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
>
<img
:alt="t('alt')"
:src="brandingStore.value?.bannerUrl ?? '/images/placeholders/banner.png'"
class="banner aspect-[4/1] w-full object-cover"
/>
<!-- Tint Effect -->
<div
v-if="showTint"
class="absolute inset-0 cursor-pointer bg-black/25"
>
<!-- Top-right Icon -->
<div
class="absolute right-4 top-4 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
>
<v-icon
:icon="mdiPencil"
large
/>
</div>
</div>
</div> </div>
</div>
</div> </div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px"> <v-dialog
<BannerEditor :creator="brandingStore.value" @closeRequested="() => isDialogOpen = false" /> v-model="isDialogOpen"
</v-dialog> max-width="800px"
>
<BannerEditor
:creator="brandingStore.value"
@closeRequested="() => (isDialogOpen = false)"
/>
</v-dialog>
</template> </template>
<script setup> <script setup>
import BannerEditor from "@/views/creators/BannerEditor.vue"; import BannerEditor from '@/views/creators/BannerEditor.vue';
import { computed, ref } from "vue"; import { computed, ref } from 'vue';
import { useBrandingStore } from "@/stores/brandingStore.js"; import { useBrandingStore } from '@/stores/brandingStore.js';
import { useAuthStore } from "@/stores/authStore.js"; import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { mdiPencil } from '@mdi/js'; import { mdiPencil } from '@mdi/js';
const authStore = useAuthStore(); const authStore = useAuthStore();
const brandingStore = useBrandingStore(); const brandingStore = useBrandingStore();
const { t } = useI18n(); const { t } = useI18n();
// State // State
const showTint = ref(false); const showTint = ref(false);
const isDialogOpen = ref(false); const isDialogOpen = ref(false);
// Methods // Methods
const openBannerEditor = () => { const openBannerEditor = () => {
isDialogOpen.value = true; isDialogOpen.value = true;
}; };
const isCurrentCreator = computed(() => { const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id; return authStore.userId === brandingStore.value.id;
}); });
</script> </script>
<style scoped></style> <style scoped></style>
<i18n> <i18n>
{ {
"en": { "en": {
"alt": "Creator banner" "alt": "Creator banner"
}, },
"fr": { "fr": {
"alt": "Bannière du créateur" "alt": "Bannière du créateur"
}, }
"es": {
"alt": "Banner del creador"
}
} }
</i18n> </i18n>

View File

@@ -1,330 +1,367 @@
<template> <template>
<div class="album-editor"> <div class="album-editor">
<h2 class="mb-4 text-xl font-semibold">
{{ t('title') }}
</h2>
<h2 class="mb-4 text-xl font-semibold"> <!-- Drop zone with photos -->
{{ t('title') }} <div
</h2> class="drop-zone"
@click="triggerFileInput"
<!-- Drop zone with photos --> @dragover.prevent
<div class="drop-zone" @dragover.prevent @drop.prevent="handleDrop" @click="triggerFileInput"> @drop.prevent="handleDrop"
>
<!-- Upload prompt --> <!-- Upload prompt -->
<div class="drop-zone-content"> <div class="drop-zone-content">
<v-icon size="large" :icon="mdiPlus" /> <v-icon
<span class="mt-2 text-sm">{{ t('dropzoneText') }}</span> :icon="mdiPlus"
</div> size="large"
/>
<!-- Hidden file input --> <span class="mt-2 text-sm">{{ t('dropzoneText') }}</span>
<input type="file" ref="fileInput" @change="handleFileUpload" accept="image/*" multiple class="hidden" />
<!-- Photos grid -->
<draggable v-model="localImages" class="photos-grid" item-key="id" @end="handleReorder" :filter="'.action-btn'"
:prevent-on-filter="false">
<template #item="{ element, index }">
<div class="photo-wrapper">
<div class="index-bubble">{{ index + 1 }}</div>
<img :src="element.image.originalUrl" :alt="'Image ' + (index + 1)" />
<!-- Processing spinner overlay -->
<div v-if="element.isProcessing" class="loading-overlay">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('processing') }}</span>
</div> </div>
<!-- Upload spinner overlay -->
<div v-if="element.isUploading" class="loading-overlay uploading"> <!-- Hidden file input -->
<v-progress-circular indeterminate color="secondary"></v-progress-circular> <input
<span class="mt-2 text-sm text-white">{{ t('uploading') }}</span> ref="fileInput"
</div> accept="image/*"
<!-- Left arrow --> class="hidden"
<button @click.stop="moveImage(index, 'up')" @touchstart.stop="moveImage(index, 'up')" multiple
class="action-btn left-btn" :disabled="index === 0" :title="t('moveLeft')"> type="file"
<v-icon :icon="mdiArrowLeft" /> @change="handleFileUpload"
</button> />
<!-- Right arrow -->
<button @click.stop="moveImage(index, 'down')" @touchstart.stop="moveImage(index, 'down')" <!-- Photos grid -->
class="action-btn right-btn" :disabled="index === localImages.length - 1" :title="t('moveRight')"> <draggable
<v-icon :icon="mdiArrowRight" /> v-model="localImages"
</button> :filter="'.action-btn'"
<!-- Delete button --> :prevent-on-filter="false"
<button @click.stop="deleteImage(index)" touchstart.stop="deleteImage(index)" class="action-btn delete-btn" class="photos-grid"
:title="t('delete')"> item-key="id"
<v-icon :icon="mdiDelete" /> @end="handleReorder"
</button> >
</div> <template #item="{ element, index }">
</template> <div class="photo-wrapper">
</draggable> <div class="index-bubble">{{ index + 1 }}</div>
<img
:alt="'Image ' + (index + 1)"
:src="element.image.originalUrl"
/>
<!-- Processing spinner overlay -->
<div
v-if="element.isProcessing"
class="loading-overlay"
>
<v-progress-circular
color="primary"
indeterminate
></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('processing') }}</span>
</div>
<!-- Upload spinner overlay -->
<div
v-if="element.isUploading"
class="loading-overlay uploading"
>
<v-progress-circular
color="secondary"
indeterminate
></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('uploading') }}</span>
</div>
<!-- Left arrow -->
<button
:disabled="index === 0"
:title="t('moveLeft')"
class="action-btn left-btn"
@click.stop="moveImage(index, 'up')"
@touchstart.stop="moveImage(index, 'up')"
>
<v-icon :icon="mdiArrowLeft" />
</button>
<!-- Right arrow -->
<button
:disabled="index === localImages.length - 1"
:title="t('moveRight')"
class="action-btn right-btn"
@click.stop="moveImage(index, 'down')"
@touchstart.stop="moveImage(index, 'down')"
>
<v-icon :icon="mdiArrowRight" />
</button>
<!-- Delete button -->
<button
:title="t('delete')"
class="action-btn delete-btn"
touchstart.stop="deleteImage(index)"
@click.stop="deleteImage(index)"
>
<v-icon :icon="mdiDelete" />
</button>
</div>
</template>
</draggable>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { v7 } from 'uuid'; import { v7 } from 'uuid';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import { mdiArrowLeft, mdiArrowRight, mdiDelete, mdiPlus } from '@mdi/js'; import { mdiArrowLeft, mdiArrowRight, mdiDelete, mdiPlus } from '@mdi/js';
const props = defineProps({ const props = defineProps({
images: { images: {
type: Array, type: Array,
required: true required: true,
} },
}); });
const emit = defineEmits(['update:images']); const emit = defineEmits(['update:images']);
const { t } = useI18n(); const { t } = useI18n();
const fileInput = ref(null); const fileInput = ref(null);
const localImages = ref([]); const localImages = ref([]);
onMounted(() => { onMounted(() => {
// Initialize local images with IDs and states // Initialize local images with IDs and states
localImages.value = props.images; localImages.value = props.images;
}); });
function handleFiles(files) { function handleFiles(files) {
console.log('handleFiles:', files) console.log('handleFiles:', files);
for (const file of files) { for (const file of files) {
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
try { try {
const reader = new FileReader(); const reader = new FileReader();
// Create a temporary image object with processing state // Create a temporary image object with processing state
const tempImage = { const tempImage = {
image: { image: {
id: v7(), id: v7(),
originalUrl: '', originalUrl: '',
}, },
file: file, file: file,
isProcessing: true, isProcessing: true,
isUploading: false, isUploading: false,
}; };
localImages.value.push(tempImage); localImages.value.push(tempImage);
console.log('Processing image:', tempImage); console.log('Processing image:', tempImage);
reader.onload = (e) => { reader.onload = e => {
console.log('Image loaded:', e); console.log('Image loaded:', e);
const index = localImages.value.findIndex(local => local.image.id === tempImage.image.id); const index = localImages.value.findIndex(local => local.image.id === tempImage.image.id);
if (index !== -1) { if (index !== -1) {
localImages.value[index].image.originalUrl = e.target.result; localImages.value[index].image.originalUrl = e.target.result;
localImages.value[index].isProcessing = false; localImages.value[index].isProcessing = false;
emit('update:images', localImages.value); emit('update:images', localImages.value);
} }
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} catch (error) { } catch (error) {
console.error('Error processing image:', error); console.error('Error processing image:', error);
} }
}
}
} }
}
}
function handleDrop(event) {
console.log('Drop triggered');
const files = Array.from(event.dataTransfer.files);
handleFiles(files);
}
function triggerFileInput() { function handleDrop(event) {
console.log('Input triggered'); console.log('Drop triggered');
fileInput.value.click(); const files = Array.from(event.dataTransfer.files);
} handleFiles(files);
}
function handleFileUpload(event) { function triggerFileInput() {
console.log('File input triggered'); console.log('Input triggered');
const files = Array.from(event.target.files); fileInput.value.click();
handleFiles(files); }
event.target.value = '';
}
function handleReorder() { function handleFileUpload(event) {
emit('update:images', localImages.value); console.log('File input triggered');
} const files = Array.from(event.target.files);
handleFiles(files);
event.target.value = '';
}
function handleReorder() {
emit('update:images', localImages.value);
}
function moveImage(index, direction) { function moveImage(index, direction) {
const newIndex = direction === 'up' ? index - 1 : index + 1; const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex >= 0 && newIndex < localImages.value.length) { if (newIndex >= 0 && newIndex < localImages.value.length) {
const temp = localImages.value[index]; const temp = localImages.value[index];
localImages.value[index] = localImages.value[newIndex]; localImages.value[index] = localImages.value[newIndex];
localImages.value[newIndex] = temp; localImages.value[newIndex] = temp;
emit('update:images', localImages.value); emit('update:images', localImages.value);
} }
} }
function deleteImage(index) {
localImages.value.splice(index, 1);
emit('update:images', localImages.value);
}
function deleteImage(index) {
localImages.value.splice(index, 1);
emit('update:images', localImages.value);
}
</script> </script>
<style scoped> <style scoped>
.album-editor { .album-editor {
@apply w-full; @apply w-full;
} }
.drop-zone { .drop-zone {
@apply w-full; @apply w-full;
@apply min-h-[200px]; @apply min-h-[200px];
@apply border-2; @apply border-2;
@apply border-dashed; @apply border-dashed;
@apply border-gray-300 hover:border-gray-500; @apply border-gray-300 hover:border-gray-500;
@apply rounded-lg; @apply rounded-lg;
@apply p-4; @apply p-4;
@apply relative; @apply relative;
@apply transition-colors; @apply transition-colors;
@apply duration-200; @apply duration-200;
@apply overflow-visible; @apply overflow-visible;
@apply bg-hSurface; @apply bg-hSurface;
} }
.drop-zone-content { .drop-zone-content {
@apply flex; @apply flex;
@apply flex-col; @apply flex-col;
@apply items-center; @apply items-center;
@apply text-gray-500; @apply text-gray-500;
@apply mb-8; @apply mb-8;
@apply relative; @apply relative;
@apply z-10; @apply z-10;
} }
.photos-grid { .photos-grid {
@apply grid; @apply grid;
@apply grid-cols-2; @apply grid-cols-2;
@apply sm:grid-cols-3; @apply sm:grid-cols-3;
@apply md:grid-cols-4; @apply md:grid-cols-4;
@apply lg:grid-cols-5; @apply lg:grid-cols-5;
@apply gap-4; @apply gap-4;
@apply w-full; @apply w-full;
@apply pb-1; @apply pb-1;
} }
.photo-wrapper { .photo-wrapper {
@apply relative; @apply relative;
@apply aspect-square; @apply aspect-square;
@apply rounded-lg; @apply rounded-lg;
@apply overflow-hidden; @apply overflow-hidden;
@apply bg-gray-100; @apply bg-gray-100;
@apply cursor-pointer; @apply cursor-pointer;
} }
.photo-wrapper img { .photo-wrapper img {
@apply w-full; @apply w-full;
@apply h-full; @apply h-full;
@apply object-cover; @apply object-cover;
@apply pointer-events-none; @apply pointer-events-none;
} }
.action-btn { .action-btn {
@apply absolute; @apply absolute;
@apply bg-black; @apply bg-black;
@apply bg-opacity-50; @apply bg-opacity-50;
@apply text-white; @apply text-white;
@apply rounded-full; @apply rounded-full;
@apply p-1; @apply p-1;
@apply flex; @apply flex;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply transition-all; @apply transition-all;
@apply duration-200; @apply duration-200;
@apply opacity-0; @apply opacity-0;
@apply z-10; @apply z-10;
} }
/* Show buttons on hover for desktop */ /* Show buttons on hover for desktop */
.action-btn { .action-btn {
@apply opacity-100; @apply opacity-100;
} }
.action-btn:disabled { .action-btn:disabled {
@apply opacity-30; @apply opacity-30;
@apply cursor-not-allowed; @apply cursor-not-allowed;
@apply bg-gray-500; @apply bg-gray-500;
@apply scale-90; @apply scale-90;
} }
.left-btn { .left-btn {
@apply top-1/2; @apply top-1/2;
@apply -translate-y-1/2; @apply -translate-y-1/2;
@apply left-2; @apply left-2;
} }
.right-btn { .right-btn {
@apply top-1/2; @apply top-1/2;
@apply -translate-y-1/2; @apply -translate-y-1/2;
@apply right-2; @apply right-2;
} }
.delete-btn { .delete-btn {
@apply top-2; @apply top-2;
@apply right-2; @apply right-2;
@apply bg-red-500; @apply bg-red-500;
@apply bg-opacity-50; @apply bg-opacity-50;
} }
.index-bubble { .index-bubble {
@apply absolute; @apply absolute;
@apply top-2; @apply top-2;
@apply left-2; @apply left-2;
@apply bg-black; @apply bg-black;
@apply bg-opacity-50; @apply bg-opacity-50;
@apply text-white; @apply text-white;
@apply text-xs; @apply text-xs;
@apply font-medium; @apply font-medium;
@apply rounded-full; @apply rounded-full;
@apply w-6; @apply w-6;
@apply h-6; @apply h-6;
@apply flex; @apply flex;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply z-10; @apply z-10;
} }
.loading-overlay { .loading-overlay {
@apply absolute; @apply absolute;
@apply inset-0; @apply inset-0;
@apply flex; @apply flex;
@apply flex-col; @apply flex-col;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply bg-black; @apply bg-black;
@apply bg-opacity-50; @apply bg-opacity-50;
@apply z-20; @apply z-20;
} }
.loading-overlay.uploading { .loading-overlay.uploading {
@apply bg-opacity-75; @apply bg-opacity-75;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Album", "title": "Album",
"dropzoneText": "Drop a photo here to add it to your album", "dropzoneText": "Drop a photo here to add it to your album",
"processing": "Processing...", "processing": "Processing...",
"uploading": "Uploading...", "uploading": "Uploading...",
"moveLeft": "Move Left", "moveLeft": "Move Left",
"moveRight": "Move Right", "moveRight": "Move Right",
"delete": "Delete" "delete": "Delete"
}, },
"fr": { "fr": {
"title": "Album", "title": "Album",
"dropzoneText": "Déposez une photo ici pour l'ajouter à l'album", "dropzoneText": "Déposez une photo ici pour l'ajouter à l'album",
"processing": "Traitement en cours...", "processing": "Traitement en cours...",
"uploading": "Téléchargement...", "uploading": "Téléchargement...",
"moveLeft": "Déplacer à gauche", "moveLeft": "Déplacer à gauche",
"moveRight": "Déplacer à droite", "moveRight": "Déplacer à droite",
"delete": "Supprimer" "delete": "Supprimer"
}, }
"es": {
"title": "Album",
"dropzoneText": "Suelta una foto aquí para añadirla al álbum",
"processing": "Procesando...",
"uploading": "Subiendo...",
"moveLeft": "Mover a la izquierda",
"moveRight": "Mover a la derecha",
"delete": "Eliminar"
}
} }
</i18n> </i18n>

View File

@@ -1,138 +1,134 @@
<template> <template>
<div v-if="hasImages" class="album-view"> <div
<!-- Album Display --> v-if="hasImages"
<div class="image-grid"> class="album-view"
<div v-for="(url, index) in displayedImages" >
:key="index" <!-- Album Display -->
class="image-wrapper" <div class="image-grid">
@click="$emit('photo-click', index)"> <div
<img :src="url" v-for="(url, index) in displayedImages"
:alt="t('creator.sections.album.image')" :key="index"
class="image"/> class="image-wrapper"
</div> @click="$emit('photo-click', index)"
>
<img
:alt="t('creator.sections.album.image')"
:src="url"
class="image"
/>
</div>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
// Add 'photo-click' to emits // Add 'photo-click' to emits
const emit = defineEmits(['photo-click']); const emit = defineEmits(['photo-click']);
import { computed, ref, onMounted, onUnmounted } from "vue"; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
images: { images: {
type: Array, type: Array,
required: true, required: true,
default: () => [] default: () => [],
} },
}); });
const { t } = useI18n(); const { t } = useI18n();
// Add a reactive window width // Add a reactive window width
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
// Update window width on resize // Update window width on resize
const handleResize = () => { const handleResize = () => {
windowWidth.value = window.innerWidth; windowWidth.value = window.innerWidth;
}; };
// Add and remove event listener // Add and remove event listener
onMounted(() => { onMounted(() => {
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
}); });
const hasImages = computed(() => { const hasImages = computed(() => {
return props.images.some(url => url); return props.images.some(url => url);
}); });
const nonEmptyImages = computed(() => { const nonEmptyImages = computed(() => {
return props.images.filter(url => url); return props.images.filter(url => url);
}); });
// Show different number of images based on reactive window width // Show different number of images based on reactive window width
const displayedImages = computed(() => { const displayedImages = computed(() => {
const images = nonEmptyImages.value; const images = nonEmptyImages.value;
if (windowWidth.value >= 1024) { if (windowWidth.value >= 1024) {
return images.slice(0, 5); // 5 images on large screens return images.slice(0, 5); // 5 images on large screens
} else if (windowWidth.value >= 768) { } else if (windowWidth.value >= 768) {
return images.slice(0, 4); // 4 images on medium screens return images.slice(0, 4); // 4 images on medium screens
} }
return images.slice(0, 3); // 3 images on smaller screens return images.slice(0, 3); // 3 images on smaller screens
}); });
// Add computed property for grid columns based on number of images // Add computed property for grid columns based on number of images
const gridColumns = computed(() => { const gridColumns = computed(() => {
const count = displayedImages.value.length; const count = displayedImages.value.length;
if (count === 1) return '1fr'; if (count === 1) return '1fr';
if (count === 2) return 'repeat(2, 1fr)'; if (count === 2) return 'repeat(2, 1fr)';
if (count === 3) return 'repeat(3, 1fr)'; if (count === 3) return 'repeat(3, 1fr)';
if (count === 4) return 'repeat(4, 1fr)'; if (count === 4) return 'repeat(4, 1fr)';
return 'repeat(5, 1fr)'; return 'repeat(5, 1fr)';
}); });
</script> </script>
<style scoped> <style scoped>
.album-view { .album-view {
@apply w-full; @apply w-full;
} }
.image-grid { .image-grid {
@apply w-full grid; @apply w-full grid;
grid-template-columns: v-bind(gridColumns); grid-template-columns: v-bind(gridColumns);
} }
.image-wrapper { .image-wrapper {
@apply relative w-full aspect-square; @apply relative w-full aspect-square;
} }
.image { .image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
/* Responsive adjustments */
/* Responsive adjustments */
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"creator": { "creator": {
"sections": { "sections": {
"album": { "album": {
"title": "Photo Album", "title": "Photo Album",
"image": "Album image" "image": "Album image"
}
}
} }
} },
} "fr": {
}, "creator": {
"fr": { "sections": {
"creator": { "album": {
"sections": { "title": "Album photo",
"album": { "image": "Image de l'album"
"title": "Album photo", }
"image": "Image de l'album" }
} }
}
} }
},
"es": {
"creator": {
"sections": {
"album": {
"title": "Álbum de fotos",
"image": "Imagen del álbum"
}
}
}
}
} }
</i18n> </i18n>

View File

@@ -1,175 +1,207 @@
<template> <template>
<v-dialog v-model="dialog" fullscreen :scrim="true" transition="dialog-bottom-transition" <v-dialog
@click:outside="closeViewer"> v-model="dialog"
<div class="album-viewer" @click.self="closeViewer"> :scrim="true"
<!-- Main image container --> fullscreen
<div class="image-container"> transition="dialog-bottom-transition"
<img :src="currentImage" :alt="t('viewer.imageAlt', { index: currentIndex + 1 })" class="main-image" /> @click:outside="closeViewer"
>
<div
class="album-viewer"
@click.self="closeViewer"
>
<!-- Main image container -->
<div class="image-container">
<img
:alt="t('viewer.imageAlt', { index: currentIndex + 1 })"
:src="currentImage"
class="main-image"
/>
<!-- Navigation buttons --> <!-- Navigation buttons -->
<button class="nav-btn left-btn" @click.stop="previousImage" :disabled="currentIndex === 0" <button
:title="t('viewer.previous')"> :disabled="currentIndex === 0"
<v-icon size="large" color="white" :icon="mdiChevronLeft" /> :title="t('viewer.previous')"
</button> class="nav-btn left-btn"
@click.stop="previousImage"
>
<v-icon
:icon="mdiChevronLeft"
color="white"
size="large"
/>
</button>
<button class="nav-btn right-btn" @click.stop="nextImage" :disabled="currentIndex === images.length - 1" <button
:title="t('viewer.next')"> :disabled="currentIndex === images.length - 1"
<v-icon size="large" color="white" :icon="mdiChevronRight" /> :title="t('viewer.next')"
</button> class="nav-btn right-btn"
@click.stop="nextImage"
>
<v-icon
:icon="mdiChevronRight"
color="white"
size="large"
/>
</button>
<!-- Close button --> <!-- Close button -->
<button class="close-btn" @click.stop="closeViewer" :title="t('viewer.close')"> <button
<v-icon size="large" color="white" :icon="mdiClose" /> :title="t('viewer.close')"
</button> class="close-btn"
@click.stop="closeViewer"
>
<v-icon
:icon="mdiClose"
color="white"
size="large"
/>
</button>
<!-- Image counter --> <!-- Image counter -->
<div class="image-counter"> <div class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</div>
{{ currentIndex + 1 }} / {{ images.length }} </div>
</div> </div>
</div> </v-dialog>
</div>
</v-dialog>
</template> </template>
<script setup> <script setup>
import { ref, watch, computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { mdiChevronLeft, mdiChevronRight, mdiClose } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight, mdiClose } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Boolean, type: Boolean,
required: true required: true,
}, },
images: { images: {
type: Array, type: Array,
required: true required: true,
}, },
startIndex: { startIndex: {
type: Number, type: Number,
default: 0 default: 0,
} },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const dialog = ref(false); const dialog = ref(false);
const currentIndex = ref(0); const currentIndex = ref(0);
const currentImage = computed(() => props.images[currentIndex.value]); const currentImage = computed(() => props.images[currentIndex.value]);
watch(() => props.modelValue, (newVal) => { watch(
dialog.value = newVal; () => props.modelValue,
if (newVal) { newVal => {
currentIndex.value = props.startIndex; dialog.value = newVal;
} if (newVal) {
}); currentIndex.value = props.startIndex;
}
}
);
watch(() => dialog.value, (newVal) => { watch(
emit('update:modelValue', newVal); () => dialog.value,
}); newVal => {
emit('update:modelValue', newVal);
}
);
function nextImage() { function nextImage() {
if (currentIndex.value < props.images.length - 1) { if (currentIndex.value < props.images.length - 1) {
currentIndex.value++; currentIndex.value++;
} }
} }
function previousImage() { function previousImage() {
if (currentIndex.value > 0) { if (currentIndex.value > 0) {
currentIndex.value--; currentIndex.value--;
} }
} }
function closeViewer() { function closeViewer() {
dialog.value = false; dialog.value = false;
} }
</script> </script>
<style scoped> <style scoped>
.album-viewer { .album-viewer {
@apply fixed inset-0; @apply fixed inset-0;
@apply flex items-center justify-center; @apply flex items-center justify-center;
@apply bg-black bg-opacity-90; @apply bg-black bg-opacity-90;
@apply z-50; @apply z-50;
} }
.image-container { .image-container {
@apply relative; @apply relative;
@apply max-w-[90vw]; @apply max-w-[90vw];
@apply max-h-[90vh]; @apply max-h-[90vh];
} }
.main-image { .main-image {
@apply max-w-full; @apply max-w-full;
@apply max-h-[90vh]; @apply max-h-[90vh];
@apply object-contain; @apply object-contain;
} }
.nav-btn { .nav-btn {
@apply absolute top-1/2 -translate-y-1/2; @apply absolute top-1/2 -translate-y-1/2;
@apply p-4; @apply p-4;
@apply rounded-full; @apply rounded-full;
@apply bg-black bg-opacity-50; @apply bg-black bg-opacity-50;
@apply transition-all duration-200; @apply transition-all duration-200;
@apply hover:bg-opacity-75; @apply hover:bg-opacity-75;
@apply disabled:opacity-30 disabled:cursor-not-allowed; @apply disabled:opacity-30 disabled:cursor-not-allowed;
} }
.left-btn { .left-btn {
@apply left-4; @apply left-4;
} }
.right-btn { .right-btn {
@apply right-4; @apply right-4;
} }
.close-btn { .close-btn {
@apply absolute top-4 right-4; @apply absolute top-4 right-4;
@apply p-2; @apply p-2;
@apply rounded-full; @apply rounded-full;
@apply bg-black bg-opacity-50; @apply bg-black bg-opacity-50;
@apply transition-all duration-200; @apply transition-all duration-200;
@apply hover:bg-opacity-75; @apply hover:bg-opacity-75;
} }
.image-counter { .image-counter {
@apply absolute bottom-4 left-1/2 -translate-x-1/2; @apply absolute bottom-4 left-1/2 -translate-x-1/2;
@apply px-4 py-2; @apply px-4 py-2;
@apply bg-black bg-opacity-50; @apply bg-black bg-opacity-50;
@apply text-white; @apply text-white;
@apply rounded-full; @apply rounded-full;
@apply text-sm; @apply text-sm;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"viewer": { "viewer": {
"previous": "Previous image", "previous": "Previous image",
"next": "Next image", "next": "Next image",
"close": "Close viewer", "close": "Close viewer",
"imageAlt": "Image {index}" "imageAlt": "Image {index}"
}
},
"fr": {
"viewer": {
"previous": "Image précédente",
"next": "Image suivante",
"close": "Fermer",
"imageAlt": "Image {index}"
}
} }
},
"fr": {
"viewer": {
"previous": "Image précédente",
"next": "Image suivante",
"close": "Fermer",
"imageAlt": "Image {index}"
}
},
"es": {
"viewer": {
"previous": "Imagen anterior",
"next": "Imagen siguiente",
"close": "Cerrar",
"imageAlt": "Imagen {index}"
}
}
} }
</i18n> </i18n>

View File

@@ -1,172 +1,170 @@
<script setup> <script setup>
import {useBrandingStore} from '@/stores/brandingStore.js'; import { useBrandingStore } from '@/stores/brandingStore.js';
import DonationButton from '@/views/creators/DonationButton.vue'; import DonationButton from '@/views/creators/DonationButton.vue';
import CreatorLogo from "@/views/creators/CreatorLogo.vue"; import CreatorLogo from '@/views/creators/CreatorLogo.vue';
import NameTitle from "@/views/creators/NameTitle.vue"; import NameTitle from '@/views/creators/NameTitle.vue';
import Linkedin from "@/views/svg/Linkedin.vue"; import Linkedin from '@/views/svg/Linkedin.vue';
import X from "@/views/svg/X.vue"; import X from '@/views/svg/X.vue';
import Facebook from "@/views/svg/Facebook.vue"; import Facebook from '@/views/svg/Facebook.vue';
import Instagram from "@/views/svg/Instagram.vue"; import Instagram from '@/views/svg/Instagram.vue';
import Tiktok from "@/views/svg/Tiktok.vue"; import Tiktok from '@/views/svg/Tiktok.vue';
import Reddit from "@/views/svg/Reddit.vue"; import Reddit from '@/views/svg/Reddit.vue';
import Youtube from "@/views/svg/Youtube.vue"; import Youtube from '@/views/svg/Youtube.vue';
import Web from "@/views/svg/Web.vue"; import Web from '@/views/svg/Web.vue';
import {useI18n} from 'vue-i18n' import { useI18n } from 'vue-i18n';
const brandingStore = useBrandingStore(); const brandingStore = useBrandingStore();
const baseURL = window.location.origin; const baseURL = window.location.origin;
const {t} = useI18n(); const { t } = useI18n();
</script> </script>
<template> <template>
<div class="flex flex-column w-full"> <div class="flex flex-column w-full">
<!-- Container principal avec le profil --> <!-- Container principal avec le profil -->
<div class="relative w-full"> <div class="relative w-full">
<div class="bg-hPrimary text-hOnPrimary relative"> <div class="bg-hPrimary text-hOnPrimary relative">
<!-- Portrait that overlaps both sections -->
<div class="absolute left-4 -bottom-2 z-10">
<creator-logo />
</div>
<!-- Portrait that overlaps both sections --> <!-- Desktop version (visible only on écrans moyens et grands) -->
<div class="absolute left-4 -bottom-2 z-10"> <div class="social-info">
<creator-logo/> <div class="w-36"></div>
<div class="flex-grow flex flex-row">
<div class="flex-grow">
<name-title></name-title>
</div>
<div class="hidden sm:flex pr-6">
<DonationButton
v-if="brandingStore.value?.acceptDonation"
:creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/@' + brandingStore.value.slug + '/tip-cancelled'"
:on-success-url="baseURL + '/@' + brandingStore.value.slug + '/tip-completed'"
/>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Desktop version (visible only on écrans moyens et grands) --> <!-- Section pour les icônes de réseaux sociaux -->
<div class="social-info"> <div class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative">
<div class="w-36"> <div class="flex flex-row gap-10">
</div> <a
<div class="flex-grow flex flex-row"> v-if="brandingStore.value?.socials?.facebookUrl"
<div class="flex-grow"> :href="brandingStore.value?.socials?.facebookUrl"
<name-title></name-title> :title="t('facebook')"
target="_blank"
>
<facebook class="social-icon"></facebook>
</a>
<a
v-if="brandingStore.value?.socials?.instagramUrl"
:href="brandingStore.value?.socials?.instagramUrl"
:title="t('instagram')"
target="_blank"
>
<instagram class="social-icon"></instagram>
</a>
<a
v-if="brandingStore.value?.socials?.linkedInUrl"
:href="brandingStore.value?.socials?.linkedInUrl"
:title="t('linkedin')"
target="_blank"
>
<linkedin class="social-icon"></linkedin>
</a>
<a
v-if="brandingStore.value?.socials?.redditUrl"
:href="brandingStore.value?.socials?.redditUrl"
:title="t('reddit')"
target="_blank"
>
<reddit class="social-icon"></reddit>
</a>
<a
v-if="brandingStore.value?.socials?.tikTokUrl"
:href="brandingStore.value?.socials?.tikTokUrl"
:title="t('tiktok')"
target="_blank"
>
<tiktok class="social-icon"></tiktok>
</a>
<a
v-if="brandingStore.value?.socials?.xUrl"
:href="brandingStore.value?.socials?.xUrl"
:title="t('x')"
target="_blank"
>
<x class="social-icon"></x>
</a>
<a
v-if="brandingStore.value?.socials?.youtubeUrl"
:href="brandingStore.value?.socials?.youtubeUrl"
:title="t('youtube')"
target="_blank"
>
<youtube class="social-icon"></youtube>
</a>
<a
v-if="brandingStore.value?.socials?.websiteUrl"
:href="brandingStore.value?.socials?.websiteUrl"
:title="t('website')"
target="_blank"
>
<web class="social-icon"></web>
</a>
</div> </div>
<div class="hidden sm:flex pr-6">
<DonationButton
v-if="brandingStore.value?.acceptDonation"
:creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/@' + brandingStore.value.slug + '/tip-cancelled'"
:on-success-url="baseURL + '/@' + brandingStore.value.slug + '/tip-completed'"
/>
</div>
</div>
</div> </div>
</div>
</div> </div>
<!-- Section pour les icônes de réseaux sociaux -->
<div
class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative"
>
<div class="flex flex-row gap-10">
<a v-if="brandingStore.value?.socials?.facebookUrl"
:href="brandingStore.value?.socials?.facebookUrl"
target="_blank"
:title="t('facebook')">
<facebook class="social-icon"></facebook>
</a>
<a v-if="brandingStore.value?.socials?.instagramUrl"
:href="brandingStore.value?.socials?.instagramUrl"
target="_blank"
:title="t('instagram')">
<instagram class="social-icon"></instagram>
</a>
<a v-if="brandingStore.value?.socials?.linkedInUrl"
:href="brandingStore.value?.socials?.linkedInUrl"
target="_blank"
:title="t('linkedin')">
<linkedin class="social-icon"></linkedin>
</a>
<a v-if="brandingStore.value?.socials?.redditUrl"
:href="brandingStore.value?.socials?.redditUrl"
target="_blank"
:title="t('reddit')">
<reddit class="social-icon"></reddit>
</a>
<a v-if="brandingStore.value?.socials?.tikTokUrl"
:href="brandingStore.value?.socials?.tikTokUrl"
target="_blank"
:title="t('tiktok')">
<tiktok class="social-icon"></tiktok>
</a>
<a v-if="brandingStore.value?.socials?.xUrl"
:href="brandingStore.value?.socials?.xUrl"
target="_blank"
:title="t('x')">
<x class="social-icon"></x>
</a>
<a v-if="brandingStore.value?.socials?.youtubeUrl"
:href="brandingStore.value?.socials?.youtubeUrl"
target="_blank"
:title="t('youtube')">
<youtube class="social-icon"></youtube>
</a>
<a v-if="brandingStore.value?.socials?.websiteUrl"
:href="brandingStore.value?.socials?.websiteUrl"
target="_blank"
:title="t('website')">
<web class="social-icon"></web>
</a>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.social-icon { .social-icon {
@apply w-5 h-5; @apply w-5 h-5;
@apply text-base; @apply text-base;
@apply transform transition-transform duration-200; @apply transform transition-transform duration-200;
@apply hover:scale-125 hover:text-fuchsia-900; @apply hover:scale-125 hover:text-fuchsia-900;
} }
.social-info { .social-info {
@apply flex flex-row; @apply flex flex-row;
@apply py-4 w-full; @apply py-4 w-full;
@apply justify-center ; @apply justify-center;
@apply max-h-52; @apply max-h-52;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"facebook": "Facebook", "facebook": "Facebook",
"instagram": "Instagram", "instagram": "Instagram",
"linkedin": "LinkedIn", "linkedin": "LinkedIn",
"reddit": "Reddit", "reddit": "Reddit",
"tiktok": "TikTok", "tiktok": "TikTok",
"x": "X (Twitter)", "x": "X (Twitter)",
"youtube": "YouTube", "youtube": "YouTube",
"website": "Website" "website": "Website"
}, },
"fr": { "fr": {
"facebook": "Facebook", "facebook": "Facebook",
"instagram": "Instagram", "instagram": "Instagram",
"linkedin": "LinkedIn", "linkedin": "LinkedIn",
"reddit": "Reddit", "reddit": "Reddit",
"tiktok": "TikTok", "tiktok": "TikTok",
"x": "X (Twitter)", "x": "X (Twitter)",
"youtube": "YouTube", "youtube": "YouTube",
"website": "Site web" "website": "Site web"
}, }
"es": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Sitio web"
}
} }
</i18n> </i18n>

View File

@@ -1,375 +1,377 @@
<template> <template>
<div class="card"> <div class="card">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div>
<div class="card-content">
<p class="card-text">
{{ t('description') }}
</p>
<div class="file-input-container">
<input
type="file"
ref="fileInput"
accept="image/*"
class="hidden"
@change="onFileSelected"
/>
<button
class="choose-file-button"
@click="triggerFileInput"
>
{{ t('chooseImage') }}
</button>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div v-if="showCropper" class="cropper-wrapper">
<Cropper
ref="cropper"
:src="fileUrl"
:aspect-ratio="4"
:stencil-props="{
aspectRatio: 4,
class: 'banner-stencil'
}"
/>
</div>
<div v-else class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop">
<img
:src="fileUrl || fallbackUrl"
:alt="t('preview')"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
</div> </div>
</div>
</div>
<div class="card-actions"> <div class="card-content">
<button class="secondary" <p class="card-text">
@click="cancel" {{ t('description') }}
:disabled="isUploading"> </p>
{{ t('cancel') }}
</button> <div class="file-input-container">
<button class="primary" <input
@click="showCropper ? applyCrop() : publish()" ref="fileInput"
:disabled="!selectedFile || isUploading"> accept="image/*"
<template v-if="isUploading"> class="hidden"
<span class="loading-spinner"></span> type="file"
{{ t('uploading') }} ({{ uploadProgress }}%) @change="onFileSelected"
</template> />
<template v-else> <button
{{ showCropper ? t('apply') : t('save') }} class="choose-file-button"
</template> @click="triggerFileInput"
</button> >
{{ t('chooseImage') }}
</button>
</div>
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper
ref="cropper"
:aspect-ratio="4"
:src="fileUrl"
:stencil-props="{
aspectRatio: 4,
class: 'banner-stencil',
}"
/>
</div>
<div
v-else
class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop"
>
<img
:alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
</div>
</div>
</div>
<div class="card-actions">
<button
:disabled="isUploading"
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()"
>
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
</template>
<template v-else>
{{ showCropper ? t('apply') : t('save') }}
</template>
</button>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import {ref} from 'vue' import { ref } from 'vue';
import {useClient} from '@/plugins/api.js' import { useClient } from '@/plugins/api.js';
import { Cropper } from 'vue-advanced-cropper' import { Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css' import 'vue-advanced-cropper/dist/style.css';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
creator: { creator: {
required: true required: true,
} },
}) });
const emits = defineEmits(['closeRequested']) const emits = defineEmits(['closeRequested']);
const fileInput = ref(null) const fileInput = ref(null);
const selectedFile = ref(null) const selectedFile = ref(null);
const fileUrl = ref(props.creator?.bannerUrl) const fileUrl = ref(props.creator?.bannerUrl);
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png' const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png';
const errorMessage = ref('') const errorMessage = ref('');
const showCropper = ref(false) const showCropper = ref(false);
const cropper = ref(null) const cropper = ref(null);
const isUploading = ref(false) const isUploading = ref(false);
const uploadProgress = ref(0) const uploadProgress = ref(0);
// Get translations for this component // Get translations for this component
const { t } = useI18n() const { t } = useI18n();
const triggerFileInput = () => { const triggerFileInput = () => {
if (fileInput.value) { if (fileInput.value) {
fileInput.value.value = '' // Reset the input value to ensure the change event fires fileInput.value.value = ''; // Reset the input value to ensure the change event fires
fileInput.value.click() fileInput.value.click();
}
}
const onFileSelected = (event) => {
const file = event.target.files[0]
if (file) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
} else {
selectedFile.value = null
fileUrl.value = null
showCropper.value = false
}
}
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value)
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput()
}
}
// Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => {
const arr = dataURL.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
}
const applyCrop = () => {
if (!cropper.value) return
const canvas = cropper.value.getResult().canvas
canvas.toBlob((blob) => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type
})
selectedFile.value = croppedFile
fileUrl.value = canvas.toDataURL()
showCropper.value = false
}, selectedFile.value.type)
}
const client = useClient()
const publish = async () => {
if (!selectedFile.value || isUploading.value) return
try {
isUploading.value = true
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await client.post(
`/api/creators/${props.creator.id}/banner`,
formData,
{
onUploadProgress: (progressEvent) => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
} }
) };
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}` const onFileSelected = event => {
fileUrl.value = props.creator.bannerUrl const file = event.target.files[0];
emits('closeRequested') if (file) {
} catch (error) { selectedFile.value = file;
console.error(error) const reader = new FileReader();
errorMessage.value = t('errors.imageUpload') reader.onload = e => {
} finally { fileUrl.value = e.target.result;
isUploading.value = false showCropper.value = true;
uploadProgress.value = 0 };
} reader.readAsDataURL(file);
} } else {
selectedFile.value = null;
fileUrl.value = null;
showCropper.value = false;
}
};
const cancel = () => { const startEditing = () => {
showCropper.value = false if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Reset to original state if we were editing // Only try to load the image if it's a data URL (newly selected image)
if (props.creator?.bannerUrl) { const blob = dataURLtoBlob(fileUrl.value);
fileUrl.value = props.creator.bannerUrl selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
selectedFile.value = null showCropper.value = true;
} else { } else {
fileUrl.value = fallbackUrl // If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
selectedFile.value = null triggerFileInput();
} }
emits('closeRequested') };
}
// Add drop handler // Helper function to convert data URL to blob
const handleDrop = (event) => { const dataURLtoBlob = dataURL => {
const file = event.dataTransfer.files[0] const arr = dataURL.split(',');
if (file && file.type.startsWith('image/')) { const mime = arr[0].match(/:(.*?);/)[1];
selectedFile.value = file const bstr = atob(arr[1]);
const reader = new FileReader() let n = bstr.length;
reader.onload = (e) => { const u8arr = new Uint8Array(n);
fileUrl.value = e.target.result while (n--) {
showCropper.value = true u8arr[n] = bstr.charCodeAt(n);
} }
reader.readAsDataURL(file) return new Blob([u8arr], { type: mime });
} };
}
const applyCrop = () => {
if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas;
canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type,
});
selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL();
showCropper.value = false;
}, selectedFile.value.type);
};
const client = useClient();
const publish = async () => {
if (!selectedFile.value || isUploading.value) return;
try {
isUploading.value = true;
uploadProgress.value = 0;
const formData = new FormData();
formData.append('file', selectedFile.value);
const response = await client.post(`/api/creators/${props.creator.id}/banner`, formData, {
onUploadProgress: progressEvent => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
},
});
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`;
fileUrl.value = props.creator.bannerUrl;
emits('closeRequested');
} catch (error) {
console.error(error);
errorMessage.value = t('errors.imageUpload');
} finally {
isUploading.value = false;
uploadProgress.value = 0;
}
};
const cancel = () => {
showCropper.value = false;
// Reset to original state if we were editing
if (props.creator?.bannerUrl) {
fileUrl.value = props.creator.bannerUrl;
selectedFile.value = null;
} else {
fileUrl.value = fallbackUrl;
selectedFile.value = null;
}
emits('closeRequested');
};
// Add drop handler
const handleDrop = event => {
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
}
};
</script> </script>
<style scoped> <style scoped>
.card-text { .card-text {
@apply font-sans text-lg; @apply font-sans text-lg;
} }
.image-preview-container { .image-preview-container {
@apply mb-5; @apply mb-5;
@apply w-full; @apply w-full;
@apply flex; @apply flex;
@apply justify-center; @apply justify-center;
@apply items-center; @apply items-center;
@apply overflow-hidden; @apply overflow-hidden;
@apply rounded-lg; @apply rounded-lg;
@apply relative; @apply relative;
@apply cursor-pointer; @apply cursor-pointer;
@apply border-2; @apply border-2;
@apply border-dashed; @apply border-dashed;
@apply border-gray-300; @apply border-gray-300;
@apply hover:border-gray-500; @apply hover:border-gray-500;
@apply transition-colors; @apply transition-colors;
@apply duration-200; @apply duration-200;
} }
.preview-image { .preview-image {
@apply w-full; @apply w-full;
@apply aspect-[4/1]; @apply aspect-[4/1];
@apply object-cover; @apply object-cover;
} }
.edit-overlay { .edit-overlay {
@apply absolute; @apply absolute;
@apply inset-0; @apply inset-0;
@apply flex; @apply flex;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply bg-black; @apply bg-black;
@apply bg-opacity-0; @apply bg-opacity-0;
@apply transition-opacity; @apply transition-opacity;
@apply duration-200; @apply duration-200;
} }
.image-preview-container:hover .edit-overlay { .image-preview-container:hover .edit-overlay {
@apply bg-opacity-30; @apply bg-opacity-30;
} }
.edit-text { .edit-text {
@apply text-white; @apply text-white;
@apply font-medium; @apply font-medium;
@apply opacity-0; @apply opacity-0;
@apply transition-opacity; @apply transition-opacity;
@apply duration-200; @apply duration-200;
} }
.image-preview-container:hover .edit-text { .image-preview-container:hover .edit-text {
@apply opacity-100; @apply opacity-100;
} }
.cropper-wrapper { .cropper-wrapper {
@apply mb-5; @apply mb-5;
@apply w-full; @apply w-full;
@apply h-[400px]; @apply h-[400px];
@apply flex; @apply flex;
@apply justify-center; @apply justify-center;
@apply items-center; @apply items-center;
@apply overflow-hidden; @apply overflow-hidden;
} }
.file-input-container { .file-input-container {
@apply flex; @apply flex;
@apply justify-center; @apply justify-center;
@apply items-center; @apply items-center;
@apply w-full; @apply w-full;
} }
.choose-file-button { .choose-file-button {
@apply px-4; @apply px-4;
@apply py-2; @apply py-2;
@apply primary; @apply primary;
@apply rounded-lg; @apply rounded-lg;
@apply cursor-pointer; @apply cursor-pointer;
} }
.error-message { .error-message {
@apply text-red-500; @apply text-red-500;
@apply mt-2; @apply mt-2;
@apply text-center; @apply text-center;
@apply font-medium; @apply font-medium;
} }
:deep(.banner-stencil) { :deep(.banner-stencil) {
@apply border-2; @apply border-2;
@apply border-white; @apply border-white;
} }
:deep(.cropper) { :deep(.cropper) {
@apply max-h-full; @apply max-h-full;
} }
.loading-spinner { .loading-spinner {
@apply inline-block; @apply inline-block;
@apply w-4; @apply w-4;
@apply h-4; @apply h-4;
@apply mr-2; @apply mr-2;
@apply border-2; @apply border-2;
@apply border-white; @apply border-white;
@apply border-t-transparent; @apply border-t-transparent;
@apply rounded-full; @apply rounded-full;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Banner Editor", "title": "Banner Editor",
"description": "Upload or edit your profile banner image. The recommended size is 1024x256 pixels (4:1 ratio).", "description": "Upload or edit your profile banner image. The recommended size is 1024x256 pixels (4:1 ratio).",
"chooseImage": "Choose an image", "chooseImage": "Choose an image",
"clickToEdit": "Click to edit", "clickToEdit": "Click to edit",
"uploading": "Uploading" "uploading": "Uploading"
}, },
"fr": { "fr": {
"title": "Éditeur de bannière", "title": "Éditeur de bannière",
"description": "Téléchargez ou modifiez votre image de bannière de profil. La taille recommandée est de 1024x256 pixels (ratio 4:1).", "description": "Téléchargez ou modifiez votre image de bannière de profil. La taille recommandée est de 1024x256 pixels (ratio 4:1).",
"chooseImage": "Choisir une image", "chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier", "clickToEdit": "Cliquez pour modifier",
"uploading": "Téléchargement" "uploading": "Téléchargement"
}, }
"es": {
"title": "Editor de banner",
"description": "Sube o edita tu imagen de banner de perfil. El tamaño recomendado es de 1024x256 píxeles (ratio 4:1).",
"chooseImage": "Elegir una imagen",
"clickToEdit": "Haga clic para editar",
"uploading": "Subiendo"
}
} }
</i18n> </i18n>

View File

@@ -1,144 +1,131 @@
<script setup> <script setup>
import {computed, ref} from 'vue' import { computed, ref } from 'vue';
import {useUserProfileStore} from "@/stores/userProfileStore.js"; import { useUserProfileStore } from '@/stores/userProfileStore.js';
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js"; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import {useClient} from "@/plugins/api.js"; import { useClient } from '@/plugins/api.js';
import {useRouter, useRoute} from "vue-router"; import { useRoute, useRouter } from 'vue-router';
import NameEditor from "@/views/creators/NameEditor.vue"; import NameEditor from '@/views/creators/NameEditor.vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n';
const creatorName = ref(''); const creatorName = ref('');
const creatorNameReservationId = ref(undefined); const creatorNameReservationId = ref(undefined);
const canSave = computed(() => creatorNameReservationId.value !== undefined) const canSave = computed(() => creatorNameReservationId.value !== undefined);
const isOperationPending = ref(false); const isOperationPending = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
const userProfileStore = useUserProfileStore(); const userProfileStore = useUserProfileStore();
const { t } = useI18n(); const { t } = useI18n();
function handleCreatorNameReservationIdChanged($event) { function handleCreatorNameReservationIdChanged($event) {
creatorNameReservationId.value = $event creatorNameReservationId.value = $event;
}
function cancel () {
// if a returnUrl querystring was supplied, prefer it
const returnUrl = route.query.returnUrl
if (typeof returnUrl === 'string' && returnUrl.length) {
router.push(returnUrl)
return
}
// otherwise just go back one step in history
router.back()
}
// TODO: The `fetchCreatorProfile` function should be private (push-up to the store)!
async function createAccount() {
try {
isOperationPending.value = true;
const client = useClient();
errorMessage.value = '';
await client.post('/api/creators', {
creatorId: userProfileStore.user.id,
slugReservationId: creatorNameReservationId.value,
});
await creatorProfileStore.fetchCreatorProfile();
await router.push(`/@${creatorProfileStore.creator.slug}`);
} catch (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 {
isOperationPending.value = false;
}
}
function cancel() {
// if a returnUrl querystring was supplied, prefer it
const returnUrl = route.query.returnUrl;
if (typeof returnUrl === 'string' && returnUrl.length) {
router.push(returnUrl);
return;
}
// otherwise just go back one step in history
router.back();
}
// TODO: The `fetchCreatorProfile` function should be private (push-up to the store)!
async function createAccount() {
try {
isOperationPending.value = true;
const client = useClient();
errorMessage.value = '';
await client.post('/api/creators', {
creatorId: userProfileStore.user.id,
slugReservationId: creatorNameReservationId.value,
});
await creatorProfileStore.fetchCreatorProfile();
await router.push(`/@${creatorProfileStore.creator.slug}`);
} catch (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 {
isOperationPending.value = false;
}
}
</script> </script>
<template> <template>
<div class="container"> <div class="container">
<div class="card"> <div class="card">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-title"> <div class="card-content">
{{ t('title') }} <name-editor
</div> v-model:name="creatorName"
:creator-name-reservation-id="creatorNameReservationId"
<div class="card-content"> @update:creator-name-reservation-id="handleCreatorNameReservationIdChanged"
<name-editor ></name-editor>
v-model:name="creatorName" </div>
:creator-name-reservation-id="creatorNameReservationId"
@update:creator-name-reservation-id="handleCreatorNameReservationIdChanged"
></name-editor>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button
class="primary"
:disabled="!canSave || isOperationPending"
@click="createAccount">
{{ t('create') }}
</button>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!canSave || isOperationPending"
class="primary"
@click="createAccount"
>
{{ t('create') }}
</button>
</div>
</div>
</div> </div>
</div>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
</template> </template>
<style scoped> <style scoped>
.container {
.container { @apply min-h-screen w-full;
@apply min-h-screen w-full; @apply flex items-center justify-center;
@apply flex items-center justify-center; }
}
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Create your Hutopy", "title": "Create your Hutopy",
"cancel": "Cancel", "cancel": "Cancel",
"create": "Create my page", "create": "Create my page",
"errors": { "errors": {
"unexpected": "An unexpected error occurred" "unexpected": "An unexpected error occurred"
}
},
"fr": {
"title": "Créez votre Hutopy",
"cancel": "Annuler",
"create": "Créer ma page",
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
} }
},
"fr": {
"title": "Créez votre Hutopy",
"cancel": "Annuler",
"create": "Créer ma page",
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"title": "Crea tu Hutopy",
"cancel": "Cancelar",
"create": "Crear mi página",
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
} }
</i18n> </i18n>

View File

@@ -1,64 +1,69 @@
<template> <template>
<div class="creator-home"> <div class="creator-home">
<!-- Content sections container -->
<!-- Content sections container --> <div class="content-sections">
<div class="content-sections"> <!-- Donation Section -->
<div
<!-- Donation Section --> v-if="brandingStore.value?.acceptDonation"
<div v-if="brandingStore.value?.acceptDonation" class="section sm:hidden"> class="section sm:hidden"
<DonationButton :creator-id="brandingStore.value?.id" :creator-name="brandingStore.value?.name" >
:on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id" <DonationButton
:on-success-url="baseURL + '/paymentcompleted/' + brandingStore.value?.id" /> :creator-id="brandingStore.value?.id"
</div> :creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id"
<!-- About Creator Section --> :on-success-url="baseURL + '/paymentcompleted/' + brandingStore.value?.id"
<div class="section"> />
<AboutCreator /> </div>
</div>
<!-- About Creator Section -->
<div class="section">
<AboutCreator />
</div>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import AboutCreator from './AboutCreator.vue'; import AboutCreator from './AboutCreator.vue';
import DonationButton from "@/views/creators/DonationButton.vue"; import DonationButton from '@/views/creators/DonationButton.vue';
import { useBrandingStore } from "@/stores/brandingStore.js"; import { useBrandingStore } from '@/stores/brandingStore.js';
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
</script> </script>
<style scoped> <style scoped>
.creator-home { .creator-home {
@apply w-full; @apply w-full;
@apply p-5; @apply p-5;
} }
.content-sections { .content-sections {
@apply flex flex-col; @apply flex flex-col;
@apply gap-5; @apply gap-5;
} }
.section { .section {
@apply rounded-2xl; @apply rounded-2xl;
@apply shadow-2xl; @apply shadow-2xl;
@apply relative; @apply relative;
} }
.section::before { .section::before {
content: ''; content: '';
@apply absolute inset-0; @apply absolute inset-0;
@apply rounded-2xl; @apply rounded-2xl;
@apply p-[1px]; @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%); background: linear-gradient(
mask: linear-gradient(#fff 0 0) content-box, 135deg,
linear-gradient(#fff 0 0); rgba(64, 64, 64, 1) 0%,
mask-composite: exclude; rgba(64, 64, 64, 0) 20%,
pointer-events: none; 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;
}
</style> </style>
<i18n>
</i18n>

View File

@@ -141,26 +141,6 @@
"goHome": "Aller à l'accueil" "goHome": "Aller à l'accueil"
} }
} }
},
"es": {
"creator": {
"layout": {
"deletion": {
"pending": "Esta página de creador está pendiente de eliminación"
}
},
"notFound": {
"title": "Creador No Encontrado",
"message": "El creador '{creator}' no existe o puede haber sido eliminado.",
"goHome": "Ir al Inicio",
"goBack": "Volver"
},
"error": {
"title": "Algo Salió Mal",
"message": "Tenemos problemas para cargar esta página de creador. Por favor, inténtalo de nuevo más tarde.",
"goHome": "Ir al Inicio"
}
}
} }
} }
</i18n> </i18n>

View File

@@ -1,78 +1,92 @@
<template> <template>
<div class="relative" @mouseenter="showTint = isCurrentCreator" @mouseleave="showTint = false" <div
@click="isCurrentCreator && openBannerEditor()"> class="relative"
@click="isCurrentCreator && openBannerEditor()"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
>
<div class="size-[110px] rounded-full border-4 border-hPrimary">
<img
:alt="t('logoAlt')"
:src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'"
class="rounded-full"
height="110px"
width="110px"
/>
</div>
<div class="size-[110px] rounded-full border-4 border-hPrimary"> <!-- Tint Effect -->
<img :src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'" :alt="t('logoAlt')" <div
width="110px" height="110px" class="rounded-full" /> v-if="showTint"
:title="t('editLogo')"
class="absolute inset-0 cursor-pointer rounded-full bg-black/25"
>
<!-- Top-right Icon -->
<div
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
>
<v-icon
:icon="mdiPencil"
large
/>
</div>
</div>
</div> </div>
<!-- Tint Effect --> <v-dialog
<div v-if="showTint" class="absolute inset-0 cursor-pointer rounded-full bg-black/25" :title="t('editLogo')"> v-model="isDialogOpen"
<!-- Top-right Icon --> max-width="800px"
<div >
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"> <template #default="{ close }">
<v-icon large :icon="mdiPencil" /> <creator-logo-editor
</div> :creator="brandingStore?.value"
</div> @closeRequested="() => (isDialogOpen = false)"
></creator-logo-editor>
</div> </template>
</v-dialog>
<v-dialog v-model="isDialogOpen" max-width="800px">
<template #default="{ close }">
<creator-logo-editor :creator="brandingStore?.value"
@closeRequested="() => isDialogOpen = false"></creator-logo-editor>
</template>
</v-dialog>
</template> </template>
<script setup> <script setup>
import { useAuthStore } from "@/stores/authStore.js"; import { useAuthStore } from '@/stores/authStore.js';
import { useBrandingStore } from "@/stores/brandingStore.js"; import { useBrandingStore } from '@/stores/brandingStore.js';
import CreatorLogoEditor from "@/views/creators/CreatorLogoEditor.vue"; import CreatorLogoEditor from '@/views/creators/CreatorLogoEditor.vue';
import { computed, ref } from "vue"; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n';
import { mdiPencil } from '@mdi/js'; import { mdiPencil } from '@mdi/js';
const authStore = useAuthStore(); const authStore = useAuthStore();
const brandingStore = useBrandingStore(); const brandingStore = useBrandingStore();
const { t } = useI18n(); const { t } = useI18n();
// State // State
const showTint = ref(false); const showTint = ref(false);
const isDialogOpen = ref(false); const isDialogOpen = ref(false);
// Methods // Methods
const openBannerEditor = () => { const openBannerEditor = () => {
isDialogOpen.value = true; isDialogOpen.value = true;
}; };
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script> </script>
<style scoped> <style scoped>
.logo-image { .logo-image {
@apply border-4 border-solid border-hTertiary; @apply border-4 border-solid border-hTertiary;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"logoAlt": "Creator logo", "logoAlt": "Creator logo",
"editLogo": "Edit logo" "editLogo": "Edit logo"
}, },
"fr": { "fr": {
"logoAlt": "Logo du créateur", "logoAlt": "Logo du créateur",
"editLogo": "Modifier le logo" "editLogo": "Modifier le logo"
}, }
"es": {
"logoAlt": "Logo del creador",
"editLogo": "Editar logo"
}
} }
</i18n> </i18n>

View File

@@ -1,397 +1,398 @@
<template> <template>
<div class="card"> <div class="card">
<div class="card-title"> <div class="card-title">
{{ t('logoTitle') }} {{ t('logoTitle') }}
</div>
<div class="card-content">
<p class="card-text">
{{ t('logoDescription') }}
</p>
<div class="file-input-container">
<input
type="file"
ref="fileInput"
accept="image/*"
class="hidden"
@change="onFileSelected"
/>
<button
class="choose-file-button"
@click="triggerFileInput"
>
{{ t('chooseImage') }}
</button>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div v-if="showCropper" class="cropper-wrapper">
<Cropper
ref="cropper"
:src="fileUrl"
:aspect-ratio="1"
:stencil-component="CircleStencil"
:stencil-props="{
aspectRatio: 1,
class: 'circle-stencil'
}"
/>
</div>
<div v-else class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop">
<div class="circular-preview">
<img
:src="fileUrl || fallbackUrl"
:alt="t('preview')"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
</div>
</div> </div>
</div>
</div>
<div class="card-actions"> <div class="card-content">
<button class="secondary" <p class="card-text">
@click="cancel" {{ t('logoDescription') }}
:disabled="isUploading"> </p>
{{ t('cancel') }}
</button> <div class="file-input-container">
<button class="primary" <input
@click="showCropper ? applyCrop() : publish()" ref="fileInput"
:disabled="!selectedFile || isUploading"> accept="image/*"
<template v-if="isUploading"> class="hidden"
<span class="loading-spinner"></span> type="file"
{{ t('uploading') }} ({{ uploadProgress }}%) @change="onFileSelected"
</template> />
<template v-else> <button
{{ showCropper ? t('apply') : t('save') }} class="choose-file-button"
</template> @click="triggerFileInput"
</button> >
{{ t('chooseImage') }}
</button>
</div>
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper
ref="cropper"
:aspect-ratio="1"
:src="fileUrl"
:stencil-component="CircleStencil"
:stencil-props="{
aspectRatio: 1,
class: 'circle-stencil',
}"
/>
</div>
<div
v-else
class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop"
>
<div class="circular-preview">
<img
:alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
</div>
</div>
</div>
</div>
<div class="card-actions">
<button
:disabled="isUploading"
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()"
>
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
</template>
<template v-else>
{{ showCropper ? t('apply') : t('save') }}
</template>
</button>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import {ref} from 'vue' import { ref } from 'vue';
import {useClient} from '@/plugins/api.js' import { useClient } from '@/plugins/api.js';
import { Cropper, CircleStencil } from 'vue-advanced-cropper' import { CircleStencil, Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css' import 'vue-advanced-cropper/dist/style.css';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
creator: { creator: {
required: true required: true,
} },
}) });
const emits = defineEmits(['closeRequested']) const emits = defineEmits(['closeRequested']);
const fileInput = ref(null) const fileInput = ref(null);
const selectedFile = ref(null) const selectedFile = ref(null);
const fileUrl = ref(props.creator.portraitUrl) const fileUrl = ref(props.creator.portraitUrl);
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png' const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png';
const errorMessage = ref('') const errorMessage = ref('');
const showCropper = ref(false) const showCropper = ref(false);
const cropper = ref(null) const cropper = ref(null);
const isUploading = ref(false) const isUploading = ref(false);
const uploadProgress = ref(0) const uploadProgress = ref(0);
const TARGET_WIDTH = 200 const TARGET_WIDTH = 200;
const TARGET_HEIGHT = 200 const TARGET_HEIGHT = 200;
const { t } = useI18n(); const { t } = useI18n();
const triggerFileInput = () => { const triggerFileInput = () => {
if (fileInput.value) { if (fileInput.value) {
fileInput.value.value = '' // Reset the input value to ensure the change event fires fileInput.value.value = ''; // Reset the input value to ensure the change event fires
fileInput.value.click() fileInput.value.click();
}
}
const onFileSelected = (event) => {
const file = event.target.files[0]
if (file) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
} else {
selectedFile.value = null
fileUrl.value = null
showCropper.value = false
}
}
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value)
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput()
}
}
// Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => {
const arr = dataURL.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
}
const applyCrop = () => {
if (!cropper.value) return
const canvas = cropper.value.getResult().canvas
canvas.toBlob((blob) => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type
})
selectedFile.value = croppedFile
fileUrl.value = canvas.toDataURL()
showCropper.value = false
}, selectedFile.value.type)
}
const client = useClient()
const publish = async () => {
if (!selectedFile.value || isUploading.value) return
try {
isUploading.value = true
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await client.post(
`/api/creators/${props.creator.id}/logo`,
formData,
{
onUploadProgress: (progressEvent) => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
} }
) };
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}` const onFileSelected = event => {
if (props.creator.portraitUrl) { const file = event.target.files[0];
fileUrl.value = props.creator.portraitUrl if (file) {
} selectedFile.value = file;
emits('closeRequested') const reader = new FileReader();
} catch (error) { reader.onload = e => {
console.error(error) fileUrl.value = e.target.result;
errorMessage.value = t('errors.imageUpload') showCropper.value = true;
} finally { };
isUploading.value = false reader.readAsDataURL(file);
uploadProgress.value = 0 } else {
} selectedFile.value = null;
} fileUrl.value = null;
showCropper.value = false;
}
};
const cancel = () => { const startEditing = () => {
showCropper.value = false if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Reset to original state if we were editing // Only try to load the image if it's a data URL (newly selected image)
if (props.creator.portraitUrl) { const blob = dataURLtoBlob(fileUrl.value);
fileUrl.value = props.creator.portraitUrl selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
selectedFile.value = null showCropper.value = true;
} else { } else {
fileUrl.value = fallbackUrl // If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
selectedFile.value = null triggerFileInput();
} }
emits('closeRequested') };
}
// Add drop handler // Helper function to convert data URL to blob
const handleDrop = (event) => { const dataURLtoBlob = dataURL => {
const file = event.dataTransfer.files[0] const arr = dataURL.split(',');
if (file && file.type.startsWith('image/')) { const mime = arr[0].match(/:(.*?);/)[1];
selectedFile.value = file const bstr = atob(arr[1]);
const reader = new FileReader() let n = bstr.length;
reader.onload = (e) => { const u8arr = new Uint8Array(n);
fileUrl.value = e.target.result while (n--) {
showCropper.value = true u8arr[n] = bstr.charCodeAt(n);
} }
reader.readAsDataURL(file) return new Blob([u8arr], { type: mime });
} };
}
const applyCrop = () => {
if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas;
canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type,
});
selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL();
showCropper.value = false;
}, selectedFile.value.type);
};
const client = useClient();
const publish = async () => {
if (!selectedFile.value || isUploading.value) return;
try {
isUploading.value = true;
uploadProgress.value = 0;
const formData = new FormData();
formData.append('file', selectedFile.value);
const response = await client.post(`/api/creators/${props.creator.id}/logo`, formData, {
onUploadProgress: progressEvent => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
},
});
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`;
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl;
}
emits('closeRequested');
} catch (error) {
console.error(error);
errorMessage.value = t('errors.imageUpload');
} finally {
isUploading.value = false;
uploadProgress.value = 0;
}
};
const cancel = () => {
showCropper.value = false;
// Reset to original state if we were editing
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl;
selectedFile.value = null;
} else {
fileUrl.value = fallbackUrl;
selectedFile.value = null;
}
emits('closeRequested');
};
// Add drop handler
const handleDrop = event => {
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
}
};
</script> </script>
<style scoped> <style scoped>
.card-text { .card-text {
@apply font-sans text-lg; @apply font-sans text-lg;
} }
.image-preview-container { .image-preview-container {
@apply mb-5; @apply mb-5;
@apply w-full; @apply w-full;
@apply flex; @apply flex;
@apply justify-center; @apply justify-center;
@apply items-center; @apply items-center;
@apply border-2; @apply border-2;
@apply border-dashed; @apply border-dashed;
@apply border-gray-300; @apply border-gray-300;
@apply hover:border-gray-500; @apply hover:border-gray-500;
@apply transition-colors; @apply transition-colors;
@apply duration-200; @apply duration-200;
@apply rounded-lg; @apply rounded-lg;
@apply p-4; @apply p-4;
} }
.circular-preview { .circular-preview {
@apply w-[200px]; @apply w-[200px];
@apply h-[200px]; @apply h-[200px];
@apply rounded-full; @apply rounded-full;
@apply overflow-hidden; @apply overflow-hidden;
@apply border-2; @apply border-2;
@apply border-gray-200; @apply border-gray-200;
@apply relative; @apply relative;
@apply cursor-pointer; @apply cursor-pointer;
} }
.preview-image { .preview-image {
@apply w-full; @apply w-full;
@apply h-full; @apply h-full;
@apply object-cover; @apply object-cover;
} }
.edit-overlay { .edit-overlay {
@apply absolute; @apply absolute;
@apply inset-0; @apply inset-0;
@apply flex; @apply flex;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply bg-black; @apply bg-black;
@apply bg-opacity-0; @apply bg-opacity-0;
@apply transition-opacity; @apply transition-opacity;
@apply duration-200; @apply duration-200;
} }
.circular-preview:hover .edit-overlay { .circular-preview:hover .edit-overlay {
@apply bg-opacity-30; @apply bg-opacity-30;
} }
.edit-text { .edit-text {
@apply text-white; @apply text-white;
@apply font-medium; @apply font-medium;
@apply opacity-0; @apply opacity-0;
@apply transition-opacity; @apply transition-opacity;
@apply duration-200; @apply duration-200;
} }
.circular-preview:hover .edit-text { .circular-preview:hover .edit-text {
@apply opacity-100; @apply opacity-100;
} }
.cropper-wrapper { .cropper-wrapper {
@apply mb-5; @apply mb-5;
@apply w-full; @apply w-full;
@apply h-[400px]; @apply h-[400px];
@apply flex; @apply flex;
@apply justify-center; @apply justify-center;
@apply items-center; @apply items-center;
@apply overflow-hidden; @apply overflow-hidden;
} }
.file-input-container { .file-input-container {
@apply flex; @apply flex;
@apply justify-center; @apply justify-center;
@apply items-center; @apply items-center;
@apply w-full; @apply w-full;
} }
.choose-file-button { .choose-file-button {
@apply px-4; @apply px-4;
@apply py-2; @apply py-2;
@apply primary; @apply primary;
@apply rounded-lg; @apply rounded-lg;
@apply cursor-pointer; @apply cursor-pointer;
} }
.error-message { .error-message {
@apply text-red-500; @apply text-red-500;
@apply mt-2; @apply mt-2;
@apply text-center; @apply text-center;
@apply font-medium; @apply font-medium;
} }
:deep(.circle-stencil) { :deep(.circle-stencil) {
@apply border-2; @apply border-2;
@apply border-white; @apply border-white;
@apply rounded-full; @apply rounded-full;
} }
:deep(.cropper) { :deep(.cropper) {
@apply max-h-full; @apply max-h-full;
} }
:deep(.cropper__stencil) { :deep(.cropper__stencil) {
@apply rounded-full; @apply rounded-full;
} }
.loading-spinner { .loading-spinner {
@apply inline-block; @apply inline-block;
@apply w-4; @apply w-4;
@apply h-4; @apply h-4;
@apply mr-2; @apply mr-2;
@apply border-2; @apply border-2;
@apply border-white; @apply border-white;
@apply border-t-transparent; @apply border-t-transparent;
@apply rounded-full; @apply rounded-full;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"logoTitle": "Edit Logo", "logoTitle": "Edit Logo",
"logoDescription": "Choose a logo image for your creator page. The image will be cropped to a circle.", "logoDescription": "Choose a logo image for your creator page. The image will be cropped to a circle.",
"chooseImage": "Choose Image", "chooseImage": "Choose Image",
"clickToEdit": "Click to edit", "clickToEdit": "Click to edit",
"uploading": "Uploading" "uploading": "Uploading"
}, },
"fr": { "fr": {
"logoTitle": "Modifier le logo", "logoTitle": "Modifier le logo",
"logoDescription": "Choisissez une image de logo pour votre page de créateur. L'image sera recadrée en cercle.", "logoDescription": "Choisissez une image de logo pour votre page de créateur. L'image sera recadrée en cercle.",
"chooseImage": "Choisir une image", "chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier", "clickToEdit": "Cliquez pour modifier",
"uploading": "Téléchargement" "uploading": "Téléchargement"
}, }
"es": {
"logoTitle": "Editar logo",
"logoDescription": "Elige una imagen de logo para tu página de creador. La imagen se recortará en círculo.",
"chooseImage": "Elegir imagen",
"clickToEdit": "Haz clic para editar",
"uploading": "Subiendo"
}
} }
</i18n> </i18n>

View File

@@ -1,83 +1,74 @@
<template> <template>
<button
class="secondary donation-action"
@click="openDonationDialog()"
>
{{ t('creator.donation.isupport') }}
</button>
<button <DonationDialog
class="secondary donation-action" ref="donationDialogRef"
@click="openDonationDialog()" :creator-id="creatorId"
> :creator-name="creatorName"
{{ t('creator.donation.isupport') }} :icon-color-class="iconColorClass"
</button> :on-cancelled-url="onCancelledUrl"
:on-success-url="onSuccessUrl"
<DonationDialog @close="handleDialogClose"
ref="donationDialogRef" />
:creator-id="creatorId"
:creator-name="creatorName"
:on-success-url="onSuccessUrl"
:on-cancelled-url="onCancelledUrl"
:icon-color-class="iconColorClass"
@close="handleDialogClose"
/>
</template> </template>
<script setup> <script setup>
import {ref} from 'vue'; import { ref } from 'vue';
import {useI18n} from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import DonationDialog from './DonationDialog.vue'; import DonationDialog from './DonationDialog.vue';
const {t} = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
creatorId: {default: 'missing-creator-id', required: true}, creatorId: { default: 'missing-creator-id', required: true },
creatorName: {default: 'missing-creator-name', required: true}, creatorName: { default: 'missing-creator-name', required: true },
onSuccessUrl: {default: 'missing-on-success-u', required: true}, onSuccessUrl: { default: 'missing-on-success-u', required: true },
onCancelledUrl: {default: 'missing-on-cancelled-url', required: true}, onCancelledUrl: { default: 'missing-on-cancelled-url', required: true },
iconColorClass: {default: 'text-black'}, iconColorClass: { default: 'text-black' },
}); });
const donationDialogRef = ref(null); const donationDialogRef = ref(null);
function openDonationDialog() { function openDonationDialog() {
donationDialogRef.value.openDonationDialog(); donationDialogRef.value.openDonationDialog();
} }
function handleDialogClose() { function handleDialogClose() {
// Handle any cleanup or additional logic when dialog closes // Handle any cleanup or additional logic when dialog closes
} }
</script> </script>
<style scoped> <style scoped>
.donation-action { .donation-action {
@apply bg-hutopyPrimary text-hOnPrimary; @apply bg-hutopyPrimary text-hOnPrimary;
@apply hover:bg-hutopySecondary; @apply hover:bg-hutopySecondary;
@apply w-fit place-self-center; @apply w-fit place-self-center;
@apply h-12; @apply h-12;
@apply rounded-2xl w-full; @apply rounded-2xl w-full;
@apply font-sans font-semibold text-lg; @apply font-sans font-semibold text-lg;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"creator": { "creator": {
"donation": { "donation": {
"isupport": "I Support" "isupport": "I Support"
} }
}
},
"fr": {
"creator": {
"donation": {
"isupport": "Je Soutiens"
}
}
} }
},
"fr": {
"creator": {
"donation": {
"isupport": "Je Soutiens"
}
}
},
"es": {
"creator": {
"donation": {
"isupport": "Apoyo"
}
}
}
} }
</i18n> </i18n>

View File

@@ -183,24 +183,6 @@
} }
} }
} }
},
"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

@@ -1,220 +1,223 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, computed } from "vue"; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { v7 } from "uuid"; import { v7 } from 'uuid';
import { useClient } from "@/plugins/api.js"; import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import config from '@/config'; import config from '@/config';
import { mdiCheckCircle, mdiCloseCircle } from '@mdi/js'; import { mdiCheckCircle, mdiCloseCircle } from '@mdi/js';
const props = defineProps({ const props = defineProps({
name: { name: {
required: true required: true,
}, },
creatorNameReservationId: { creatorNameReservationId: {
required: true required: true,
}, },
originalSlug: { originalSlug: {
type: String, type: String,
default: null default: null,
} },
}); });
const emits = defineEmits([ const emits = defineEmits(['update:name', 'update:creatorNameReservationId']);
'update:name',
'update:creatorNameReservationId'
]);
const name = ref(props.name); const name = ref(props.name);
const { t } = useI18n(); const { t } = useI18n();
const isOperationPending = ref(false); const isOperationPending = ref(false);
const reservationState = ref(null); const reservationState = ref(null);
const validationError = ref(''); const validationError = ref('');
// Use the reservationId from props if provided, otherwise generate a new one // Use the reservationId from props if provided, otherwise generate a new one
const reservationId = ref(props.creatorNameReservationId || v7()); const reservationId = ref(props.creatorNameReservationId || v7());
// Check if the current name is the same as the original slug // Check if the current name is the same as the original slug
const isCurrentSlug = computed(() => { const isCurrentSlug = computed(() => {
return props.originalSlug && name.value === props.originalSlug; return props.originalSlug && name.value === props.originalSlug;
}); });
// Base URL for display // Base URL for display
const baseUrl = computed(() => `${config.baseUrl}/@`); const baseUrl = computed(() => `${config.baseUrl}/@`);
// Validation function for the slug // Validation function for the slug
const validateSlug = (slug) => { const validateSlug = slug => {
if (!slug) { if (!slug) {
validationError.value = t('creator.name.errors.required'); validationError.value = t('creator.name.errors.required');
return false; return false;
} }
// Only allow letters, numbers, and hyphens // Only allow letters, numbers, and hyphens
const validSlugRegex = /^[a-zA-Z0-9-]+$/; const validSlugRegex = /^[a-zA-Z0-9-]+$/;
if (!validSlugRegex.test(slug)) { if (!validSlugRegex.test(slug)) {
validationError.value = t('creator.name.errors.invalid'); validationError.value = t('creator.name.errors.invalid');
return false; return false;
} }
validationError.value = ''; validationError.value = '';
return true; return true;
}; };
// Ensure we emit the reservationId on mount if we generated a new one // Ensure we emit the reservationId on mount if we generated a new one
onMounted(() => { onMounted(() => {
if (!props.creatorNameReservationId) { if (!props.creatorNameReservationId) {
emits('update:creatorNameReservationId', reservationId.value); emits('update:creatorNameReservationId', reservationId.value);
} }
// If the name is the same as the original slug, set the reservation state to "reserved" // If the name is the same as the original slug, set the reservation state to "reserved"
if (isCurrentSlug.value) { if (isCurrentSlug.value) {
reservationState.value = "reserved"; reservationState.value = 'reserved';
} }
}); });
// Request handling // Request handling
let currentController = null; let currentController = null;
let timeout = null; let timeout = null;
let lastProcessedName = ''; let lastProcessedName = '';
const cancelCurrentRequest = () => { const cancelCurrentRequest = () => {
if (currentController) { if (currentController) {
currentController.abort(); currentController.abort();
currentController = null; currentController = null;
} }
}; };
const handleInput = () => { const handleInput = () => {
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => { timeout = setTimeout(() => {
const currentName = name.value; const currentName = name.value;
if (currentName === lastProcessedName) { if (currentName === lastProcessedName) {
return; // Skip if we've already processed this exact name return; // Skip if we've already processed this exact name
} }
// Validate the slug // Validate the slug
if (!validateSlug(currentName)) { if (!validateSlug(currentName)) {
reservationState.value = "unavailable"; reservationState.value = 'unavailable';
return; return;
} }
// 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 reservation state to "reserved"
if (props.originalSlug && currentName === props.originalSlug) { if (props.originalSlug && currentName === props.originalSlug) {
reservationState.value = "reserved"; reservationState.value = 'reserved';
lastProcessedName = currentName; lastProcessedName = currentName;
emits('update:name', currentName); emits('update:name', currentName);
return; return;
} }
checkNameAvailability(currentName); checkNameAvailability(currentName);
}, 200); }, 200);
}; };
const client = useClient(); const client = useClient();
const checkNameAvailability = async (nameToCheck) => { const checkNameAvailability = async nameToCheck => {
if (!nameToCheck || nameToCheck.trim() === "") { if (!nameToCheck || nameToCheck.trim() === '') {
reservationState.value = null; reservationState.value = null;
lastProcessedName = nameToCheck; lastProcessedName = nameToCheck;
return; return;
} }
// Cancel any ongoing request // Cancel any ongoing request
cancelCurrentRequest(); cancelCurrentRequest();
try { try {
isOperationPending.value = true; isOperationPending.value = true;
reservationState.value = "loading"; reservationState.value = 'loading';
// Create a new request with cancellation token // Create a new request with cancellation token
const controller = new AbortController(); const controller = new AbortController();
currentController = controller; currentController = controller;
await client.post( await client.post(
`/api/creators/@${encodeURIComponent(nameToCheck)}/reserve`, `/api/creators/@${encodeURIComponent(nameToCheck)}/reserve`,
{ reservationId: reservationId.value }, { reservationId: reservationId.value },
{ signal: controller.signal } { signal: controller.signal }
); );
// Only process the response if this is still the current request // Only process the response if this is still the current request
if (currentController === controller) { if (currentController === controller) {
reservationState.value = "reserved"; reservationState.value = 'reserved';
lastProcessedName = nameToCheck; lastProcessedName = nameToCheck;
emits('update:name', nameToCheck); emits('update:name', nameToCheck);
} }
} catch (error) { } catch (error) {
// Only process the error if this is still the current request and it's not an abort error // Only process the error if this is still the current request and it's not an abort error
if (currentController && error.name !== 'AbortError') { if (currentController && error.name !== 'AbortError') {
reservationState.value = "unavailable"; reservationState.value = 'unavailable';
lastProcessedName = nameToCheck; lastProcessedName = nameToCheck;
} }
} finally { } finally {
if (currentController) { if (currentController) {
isOperationPending.value = false; isOperationPending.value = false;
} }
} }
}; };
// Cleanup on component unmount
onUnmounted(() => {
cancelCurrentRequest();
clearTimeout(timeout);
});
// Cleanup on component unmount
onUnmounted(() => {
cancelCurrentRequest();
clearTimeout(timeout);
});
</script> </script>
<template> <template>
<v-text-field variant="outlined" :label="t('creator.name.label')" v-model="name" @input="handleInput" <v-text-field
:error-messages="validationError"> v-model="name"
<template #prepend-inner> :error-messages="validationError"
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span> :label="t('creator.name.label')"
</template> variant="outlined"
@input="handleInput"
>
<template #prepend-inner>
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span>
</template>
<template #append-inner> <template #append-inner>
<v-progress-circular v-if="reservationState === 'loading'" indeterminate size="24" width="3" <v-progress-circular
color="grey"></v-progress-circular> v-if="reservationState === 'loading'"
color="grey"
indeterminate
size="24"
width="3"
></v-progress-circular>
<v-icon v-else-if="reservationState === 'reserved'" color="green" :icon="mdiCheckCircle" /> <v-icon
<v-icon v-else-if="reservationState === 'unavailable'" color="red" :icon="mdiCloseCircle" /> v-else-if="reservationState === 'reserved'"
</template> :icon="mdiCheckCircle"
</v-text-field> color="green"
/>
<v-icon
v-else-if="reservationState === 'unavailable'"
:icon="mdiCloseCircle"
color="red"
/>
</template>
</v-text-field>
</template> </template>
<style scoped></style> <style scoped></style>
<i18n> <i18n>
{ {
"en": { "en": {
"creator": { "creator": {
"name": { "name": {
"label": "Your creator handle", "label": "Your creator handle",
"errors": { "errors": {
"required": "Creator handle is required", "required": "Creator handle is required",
"invalid": "Only letters, numbers, and hyphens are allowed" "invalid": "Only letters, numbers, and hyphens are allowed"
}
}
} }
} },
} "fr": {
}, "creator": {
"fr": { "name": {
"creator": { "label": "Votre identifiant de créateur",
"name": { "errors": {
"label": "Votre identifiant de créateur", "required": "L'identifiant est requis",
"errors": { "invalid": "Seules les lettres, chiffres et tirets sont autorisés"
"required": "L'identifiant est requis", }
"invalid": "Seules les lettres, chiffres et tirets sont autorisés" }
} }
}
} }
},
"es": {
"creator": {
"name": {
"label": "Tu identificador de creador",
"errors": {
"required": "El identificador es obligatorio",
"invalid": "Solo se permiten letras, números y guiones"
}
}
}
}
} }
</i18n> </i18n>

View File

@@ -4,9 +4,11 @@
<span class="capitalize text-3xl"> <span class="capitalize text-3xl">
{{ brandingStore.value.name }} {{ brandingStore.value.name }}
</span> </span>
<div v-show="brandingStore.value.verified" <div
class="text-blue mt-1" v-show="brandingStore.value.verified"
:title="t('verified')"> :title="t('verified')"
class="text-blue mt-1"
>
<icon-account-verified></icon-account-verified> <icon-account-verified></icon-account-verified>
</div> </div>
</div> </div>
@@ -17,24 +19,21 @@
</template> </template>
<script setup> <script setup>
import IconAccountVerified from "@/components/icons/IconAccountVerified.vue"; import IconAccountVerified from '@/components/icons/IconAccountVerified.vue';
import {useBrandingStore} from "@/stores/brandingStore.js"; import { useBrandingStore } from '@/stores/brandingStore.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const brandingStore = useBrandingStore(); const brandingStore = useBrandingStore();
const { t } = useI18n(); const { t } = useI18n();
</script> </script>
<i18n> <i18n>
{ {
"en": { "en": {
"verified": "Verified Account" "verified": "Verified Account"
}, },
"fr": { "fr": {
"verified": "Compte vérifié" "verified": "Compte vérifié"
}, }
"es": {
"verified": "Cuenta verificada"
}
} }
</i18n> </i18n>

View File

@@ -75,13 +75,6 @@
"usernameDefault": "Le créateur", "usernameDefault": "Le créateur",
"receipt": "Un reçu a été envoyé à votre email.", "receipt": "Un reçu a été envoyé à votre email.",
"returnToCreator": "Retourner à la page du créateur" "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> </i18n>

View File

@@ -46,12 +46,6 @@
"message": "Nous n'avons pas pu traiter votre paiement.", "message": "Nous n'avons pas pu traiter votre paiement.",
"retry": "Réessayer", "retry": "Réessayer",
"returnToCreator": "Retourner à la page du créateur" "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> </i18n>

View File

@@ -3,10 +3,10 @@
<h1>À propos</h1> <h1>À propos</h1>
<p> <p>
Bienvenue sur la page "À Propos" dHutopy, nous partageons notre histoire, notre mission, Notre mission chez Hutopy est de développer des outils permettant à chaque utilisateur de se démarquer, autant dans
notre vision, et vous psentons l'équipe passionnée qui rend tout cela possible. Hutopy le monde réel que dans le monde numérique, en cant un véritable pont entre les deux. Que vous soyez artiste de
n'est pas seulement une plateforme ; c'est une communauté, un mouvement, un lieu où la rue, organisme à but non lucratif ou toute autre personne ayant besoin doutils pour être soutenue, Hutopy est
créativité rencontre la technologie pour créer des expériences inoubliables. pour vous.
</p> </p>
<h2>Notre Histoire</h2> <h2>Notre Histoire</h2>
@@ -51,26 +51,6 @@
<div class="members"> <div class="members">
<div class="card">
<div class="card-header">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileMarco.png"
alt="Marc-Olivier Hébert">
</div>
<div class="card-body">
<div class="member-name">Marc-Olivier Hébert</div>
<div class="member-title">CEO / Fondateur</div>
<div class="member-description">
<p>
Avec une vision claire et un engagement sans faille, il a lancé Hutopy pour changer la manière dont le
contenu est créé et partagé.
</p>
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -81,12 +61,31 @@
<div class="card-body"> <div class="card-body">
<div class="member-name">Pascal Marchesseault</div> <div class="member-name">Pascal Marchesseault</div>
<div class="member-title">Gestionnaire de projet / UI</div> <div class="member-title">Président-directeur général</div>
<div class="member-description"> <div class="member-description">
<p> <p>
A pour mission d'assurer le développement du projet tout en cant une interface Avec une vision claire et un engagement sans faille, il a toujours été psent pour veiller à la bonne
qui permettra au projet d'avoir une interaction positive et enrichissante avec Hutopy pour les réalisation du projet.
utilisateurs. </p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileMarco.png"
alt="Marc-Olivier Hébert">
</div>
<div class="card-body">
<div class="member-name">Marc-Olivier Hébert</div>
<div class="member-title">Directeur de linnovation et de la vision</div>
<div class="member-description">
<p>
Avec une vision avant-gardiste, il permet à léquipe dexplorer de nouvelles idées ou de les réinventer.
</p> </p>
</div> </div>
</div> </div>
@@ -103,7 +102,7 @@
<div class="card-body"> <div class="card-body">
<div class="member-name">Chloé Beaugrand</div> <div class="member-name">Chloé Beaugrand</div>
<div class="member-title">Responsable Marketing</div> <div class="member-title">Directrice marketing</div>
<div class="member-description"> <div class="member-description">
<p> <p>
Elle façonne l'image dHutopy et engage notre communauté à travers des campagnes Elle façonne l'image dHutopy et engage notre communauté à travers des campagnes
@@ -124,7 +123,7 @@
<div class="card-body"> <div class="card-body">
<div class="member-name">Jonathan Bourdon</div> <div class="member-name">Jonathan Bourdon</div>
<div class="member-title">Directeur Technique</div> <div class="member-title">Directeur des technologies</div>
<div class="member-description"> <div class="member-description">
<p> <p>
Son expérience d'architecte senior nous permet de développer un logiciel avec Son expérience d'architecte senior nous permet de développer un logiciel avec
@@ -191,3 +190,5 @@
} }
</style> </style>
<script setup lang="ts">
</script>

View File

@@ -1,8 +1,6 @@
<template> <template>
<h1>FAQ</h1> <h1>Foire Aux Questions</h1>
<h2>Foire Aux Questions</h2>
<p> <p>
La section FAQ de Hutopy est votre ressource essentielle pour trouver des réponses rapides aux questions les plus La section FAQ de Hutopy est votre ressource essentielle pour trouver des réponses rapides aux questions les plus
@@ -11,7 +9,7 @@
dernières fonctionnalités. dernières fonctionnalités.
</p> </p>
<h2>Comment puis-je créer un compte sur Hutopy ?</h2> <h2>Comment puis-je créer un compte sur Hutopy?</h2>
<p> <p>
Créer un compte est simple! Visitez notre page d'inscription, remplissez les informations requises, et suivez les Créer un compte est simple! Visitez notre page d'inscription, remplissez les informations requises, et suivez les
@@ -19,43 +17,35 @@
commencer à explorer et à interagir avec la communauté Hutopy immédiatement après. commencer à explorer et à interagir avec la communauté Hutopy immédiatement après.
</p> </p>
<h2>Quels types de contenu puis-je publier sur Hutopy ?</h2>
<p>
Hutopy accueille une large variété de contenus créatifs, incluant mais non limité à des vidéos, articles,
podcasts, et illustrations. Nous encourageons la diversité et l'originalité, tant que le contenu respecte nos
valeurs.
</p>
<h2>Comment Hutopy rémunère-t-il les créateurs de contenu ?</h2> <h2>Comment Hutopy rémunère-t-il les créateurs de contenu ?</h2>
<p> <p>
Les créateurs peuvent monétiser leur contenu de plusieurs façons, notamment via des abonnements payants et des Les créateurs peuvent monétiser leur contenu de plusieurs façons des
dons de la part des utilisateurs. dons de la part des utilisateurs.
</p> </p>
<h2>Comment puis-je modifier mon profil ?</h2> <h2>Comment puis-je modifier mon profil?</h2>
<p> <p>
Connectez-vous à votre compte, accédez à votre profil, puis cliquez sur "Éditer le profil" pour modifier vos Connectez-vous à votre compte, accédez à votre profil, puis cliquez sur "Éditer le profil" pour modifier vos
informations, ajouter une bio, changer votre photo de profil, et plus encore. informations, ajouter une bio, changer votre photo de profil, et plus encore.
</p> </p>
<h2>Est-il possible de supprimer mon compte ?</h2> <h2>Est-il possible de supprimer mon compte?</h2>
<p> <p>
Oui, vous pouvez faire la suppression de votre compte sur votre profil dans la section plus. Notez que cette Oui, vous pouvez faire la suppression de votre compte sur votre profil dans la section plus. Notez que cette
action est irréversible. action est irréversible.
</p> </p>
<h2>Que faire si j'oublie mon mot de passe ?</h2> <h2>Que faire si j'oublie mon mot de passe?</h2>
<p> <p>
Sur la page de connexion, cliquez sur "Mot de passe oublié ?" et suivez les instructions pour réinitialiser votre Sur la page de connexion, cliquez sur "Mot de passe oublié ?" et suivez les instructions pour réinitialiser votre
mot de passe via votre adresse courriel. mot de passe via votre adresse courriel.
</p> </p>
<h2>Comment signaler un contenu inapproprié ?</h2> <h2>Comment signaler un contenu inapproprié?</h2>
<p> <p>
Si vous rencontrez du contenu qui viole nos directives, cliquer sur les trois petits points en haut de la Si vous rencontrez du contenu qui viole nos directives, cliquer sur les trois petits points en haut de la
@@ -63,7 +53,7 @@
modération. modération.
</p> </p>
<h2>Comment puis-je contacter le support Hutopy ?</h2> <h2>Comment puis-je contacter le support Hutopy?</h2>
<p> <p>
Pour toute assistance, vous pouvez nous contacter via notre formulaire en ligne ou par e-mail à Pour toute assistance, vous pouvez nous contacter via notre formulaire en ligne ou par e-mail à
@@ -71,7 +61,7 @@
demandes. demandes.
</p> </p>
<h2>Quels sont les frais pour les créateurs sur Hutopy ?</h2> <h2>Quels sont les frais pour les créateurs sur Hutopy?</h2>
<p> <p>
Hutopy prélève une commission de 12% + 0,30$ sur chaque transaction réalisée sur la plateforme, que ce soit pour Hutopy prélève une commission de 12% + 0,30$ sur chaque transaction réalisée sur la plateforme, que ce soit pour
@@ -80,36 +70,13 @@
Stripe et le développement continu pour améliorer votre expérience sur Hutopy. Stripe et le développement continu pour améliorer votre expérience sur Hutopy.
</p> </p>
<h2>Y a-t-il des frais pour s'inscrire ou pour maintenir mon compte sur Hutopy ?</h2> <h2>Y a-t-il des frais pour s'inscrire ou pour maintenir mon compte sur Hutopy?</h2>
<p> <p>
Non, l'inscription sur Hutopy est gratuite, et il n'y a pas de frais mensuels ou annuels pour maintenir votre Non, l'inscription sur Hutopy est gratuite, et il n'y a pas de frais mensuels ou annuels pour maintenir votre
compte. Vous pouvez commencer à utiliser Hutopy et à partager votre contenu sans aucun coût initial. compte. Vous pouvez commencer à utiliser Hutopy et à partager votre contenu sans aucun coût initial.
</p> </p>
<h2>Les utilisateurs doivent-ils payer pour accéder au contenu sur Hutopy ?</h2>
<p>
Hutopy offre à la fois du contenu gratuit et du contenu premium. Les utilisateurs peuvent accéder gratuitement à
une partie du contenu sur la plateforme. Cependant, certains créateurs peuvent choisir de rendre leur contenu
accessible uniquement via un abonnement payant ou un achat unique pour soutenir leur travail.
</p>
<h2>Existe-t-il des frais pour retirer mes gains de la plateforme ?</h2>
<p>
Les créateurs peuvent retirer leurs gains sans frais supplémentaires de la part dHutopy. Cependant, les
transactions bancaires ou les transferts vers des portefeuilles électroniques peuvent être soumis aux frais
standards imposés par ces services ou institutions financières, mais pas par Hutopy.
</p>
<h2>Les frais Hutopy sont-ils les mêmes pour tous les types de contenu ?</h2>
<p>
Oui, les frais de commission dHutopy sont uniformément appliqués à tous les types de contenu et de transactions
sur la plateforme pour maintenir la simplicité et la transparence et ce peu importe le montant.
</p>
</template> </template>
<style scoped> <style scoped>

View File

@@ -9,11 +9,11 @@
aussi enrichissante et agréable que possible. aussi enrichissante et agréable que possible.
</p> </p>
<h2>FAQ (Foire Aux Questions)</h2> <h2>Foire Aux Questions</h2>
<p> <p>
Retrouvez les réponses aux questions les plus fréquemment posées concernant l'utilisation dHutopy, les Retrouvez les réponses aux questions les plus fréquemment posées concernant l'utilisation dHutopy, les
fonctionnalités de la plateforme, les options de monétisation, et plus encore. Consulter la FAQ fonctionnalités de la plateforme, les options de monétisation, et plus encore. Consulter la <a href="FAQ" style="color: #a30e79;">FAQ</a>
</p> </p>
<h2>Contactez-Nous</h2> <h2>Contactez-Nous</h2>

View File

@@ -2,11 +2,11 @@
<h1>Frais</h1> <h1>Frais</h1>
<p> <p>
Découvrez Hutopy, l'endroit où la valorisation de votre travail atteint son apogée. Avec une commission réduite à Hutopy ne prend que 9,1 % de commission sur vos transactions notre unique façon de soutenir la plateforme.
seulement 9 %, notre engagement envers votre succès est palpable. Chaque pourcentage prélevé est réinvesti avec soin Chaque dollar prélevé est intégralement réinvesti pour développer des fonctionnalités innovantes, maintenir une
pour catalyser votre croissance afin de développer des fonctionnalités innovantes, maintenir une infrastructure infrastructure technologique de pointe et offrir un support utilisateur irréprochable. Ce modèle nous permet
technologique de pointe, et un support utilisateur de premier ordre. Notre objectif ? Amplifier votre expansion et dapporter, dans un avenir très proche, des outils uniques qui vous aideront à vous démarquer encore davantage
garantir une expérience utilisateur sans précédent. et à vivre pleinement de votre passion.
</p> </p>
<p> <p>

View File

@@ -14,7 +14,7 @@ h2 {
p { p {
@apply text-hOnBackground; @apply text-hOnBackground;
@apply font-sans font-normal text-base; @apply font-sans font-normal text-lg;
@apply tracking-normal; @apply tracking-normal;
@apply mb-6; @apply mb-6;
@apply text-justify; @apply text-justify;

View File

@@ -1,130 +1,139 @@
<script setup> <script setup>
import Instagram from "@/views/svg/Instagram.vue"; import Instagram from '@/views/svg/Instagram.vue';
import Facebook from "@/views/svg/Facebook.vue"; import Facebook from '@/views/svg/Facebook.vue';
import X from "@/views/svg/X.vue"; import X from '@/views/svg/X.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
</script> </script>
<template> <template>
<footer class="flex flex-col gap-10 pt-7 pb-10">
<div class="footer-socials">
<a
href="https://www.facebook.com/profile.php?id=61556819217561"
target="_blank"
>
<facebook class="social-icon"></facebook>
</a>
<a
href="https://www.instagram.com/hutopy.inc/"
target="_blank"
>
<instagram class="social-icon"></instagram>
</a>
<a
href="https://x.com/Hutopyinc/"
target="_blank"
>
<x class="social-icon"></x>
</a>
</div>
<footer class="flex flex-col gap-10 pt-7 pb-10"> <div class="footer-links">
<router-link
<div class="footer-socials"> class="link"
<a href="https://www.facebook.com/profile.php?id=61556819217561" target="_blank"> to="/documents/helpandcontact"
<facebook class="social-icon"></facebook> >
</a> {{ t('footer.helpandcontact') }}
<a href="https://www.instagram.com/hutopy.inc/" target="_blank"> </router-link>
<instagram class="social-icon"></instagram> <router-link
</a> class="link"
<a href="https://x.com/Hutopyinc/" target="_blank"> to="/documents/faq"
<x class="social-icon"></x> >
</a> {{ t('footer.faq') }}
</div> </router-link>
<router-link
<div class="footer-links"> class="link"
<router-link to="/documents/helpandcontact" to="/documents/guideforcreators"
class="link"> >
{{ t('footer.helpandcontact') }} {{ t('footer.creatorguide') }}
</router-link> </router-link>
<router-link to="/documents/faq" <router-link
class="link"> class="link"
{{ t('footer.faq') }} to="/documents/termsandconditions"
</router-link> >
<router-link to="/documents/termsandconditions" {{ t('footer.termsandconditions') }}
class="link"> </router-link>
{{ t('footer.termsandconditions') }} <router-link
</router-link> class="link"
<router-link to="/documents/contentpolicy" to="/documents/contentpolicy"
class="link"> >
{{ t('footer.contentpolicy') }} {{ t('footer.contentpolicy') }}
</router-link> </router-link>
<router-link to="/documents/about" <router-link
class="link"> class="link"
{{ t('footer.about') }} to="/documents/about"
</router-link> >
<router-link to="/documents/pricing" {{ t('footer.about') }}
class="link"> </router-link>
{{ t('footer.pricing') }} <router-link
</router-link> class="link"
</div> to="/documents/pricing"
>
<div class="footer-copyright"> {{ t('footer.pricing') }}
Hutopy &copy;{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }} </router-link>
</div> </div>
</footer>
<div class="footer-copyright">
Hutopy &copy;{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }}
</div>
</footer>
</template> </template>
<style scoped> <style scoped>
.footer-socials {
@apply flex flex-row justify-center;
@apply gap-10;
}
.footer-socials { .footer-links {
@apply flex flex-row justify-center; @apply flex flex-row flex-wrap justify-center;
@apply gap-10; @apply gap-4 px-4;
} }
.footer-links { .footer-copyright {
@apply flex flex-row flex-wrap justify-center; @apply flex justify-center;
@apply gap-4 px-4; @apply text-hOnBackground tracking-widest font-sans text-sm;
} }
.footer-copyright { .social-icon {
@apply flex justify-center; @apply fill-current w-6 h-6;
@apply text-hOnBackground tracking-widest font-sans text-sm; @apply text-hOnBackground;
} }
.social-icon {
@apply fill-current w-6 h-6;
@apply text-hOnBackground;
}
.link {
@apply text-hOnBackground;
@apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400;
}
.link {
@apply text-hOnBackground;
@apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400;
}
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"footer": { "footer": {
"helpandcontact": "Help & Contact", "helpandcontact": "Help & Contact",
"faq": "FAQ", "faq": "FAQ",
"creatorguide": "Creator Guide", "creatorguide": "Creator Guide",
"termsandconditions": "Terms & Conditions", "termsandconditions": "Terms & Conditions",
"contentpolicy": "Content Policy", "contentpolicy": "Content Policy",
"about": "About", "about": "About",
"pricing": "Pricing", "pricing": "Pricing",
"allRightsReserved": "All Rights Reserved" "allRightsReserved": "All Rights Reserved"
}
},
"fr": {
"footer": {
"helpandcontact": "Aide & Contact",
"faq": "FAQ",
"creatorguide": "Guide du Créateur",
"termsandconditions": "Conditions Générales",
"contentpolicy": "Politique de Contenu",
"about": "À Propos",
"pricing": "Tarifs",
"allRightsReserved": "Tous Droits Réservés"
}
} }
},
"fr": {
"footer": {
"helpandcontact": "Aide & Contact",
"faq": "FAQ",
"creatorguide": "Guide du Créateur",
"termsandconditions": "Conditions Générales",
"contentpolicy": "Politique de Contenu",
"about": "À Propos",
"pricing": "Tarifs",
"allRightsReserved": "Tous Droits Réservés"
}
},
"es": {
"footer": {
"helpandcontact": "Ayuda y Contacto",
"faq": "Preguntas Frecuentes",
"creatorguide": "Guía del Creador",
"termsandconditions": "Términos y Condiciones",
"contentpolicy": "Política de Contenido",
"about": "Acerca de",
"pricing": "Precios",
"allRightsReserved": "Todos los Derechos Reservados"
}
}
} }
</i18n> </i18n>

View File

@@ -1,297 +1,309 @@
<script setup> <script setup>
import Footer from "@/views/main/Footer.vue"; import Footer from '@/views/main/Footer.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
</script> </script>
<template> <template>
<div>
<div> <div>
<div class="pa-4 flex flex-col justify-center md:flex-row"> <div>
<div class="py-6"> <div class="pa-4 flex flex-col justify-center md:flex-row">
<div> <div class="py-6">
<img alt="Hutopy Logo" class="md:h-44 logo-image sm:h-28 sm:mx-auto" <div>
src="/images/hutopymedia/banners/hutopy.png"> <img
</div> alt="Hutopy Logo"
</div> class="md:h-44 logo-image sm:h-28 sm:mx-auto"
<div class="flex flex-col space-y-3 header-btn"> src="/images/hutopymedia/banners/hutopy.png"
<v-btn />
class="text-white w-full sm:w-auto inscription-btn-header" </div>
to="/login"> </div>
{{ t('inscription') }} <div class="flex flex-col space-y-3 header-btn">
</v-btn> <v-btn
<v-btn class="text-white w-full sm:w-auto inscription-btn-header"
class="w-full sm:w-auto inscription-btn-header-outlined" to="/login"
to="/create-creator" >
variant="outlined"> {{ t('inscription') }}
{{ t('createPage') }} </v-btn>
</v-btn> <v-btn
</div> class="w-full sm:w-auto inscription-btn-header-outlined"
to="/create-creator"
</div> variant="outlined"
</div> >
{{ t('createPage') }}
<div class="support-container flex flex-col items-center space-y-4 md:flex-row md:space-y-0 md:space-x-6"> </v-btn>
<div class="support-text text-justify md:text-left"> </div>
<span class="text-white"> {{ t('support') }} </span><br>
<span class="text-white"> {{ t('creators') }} </span><br>
<span class="text-white"> {{ t('projects') }} </span><br>
<span class="text-white"> {{ t('love') }} </span>
</div>
<img alt="YourHutopy" class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png">
</div>
<div class="relative mt-10">
<div class="flex flex-col lg:flex-row justify-center items-center lg:space-x-14 space-y-6 lg:space-y-0 pa-1">
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/hands.png"
>
<div class="text-md text-justify px-6 ">
{{ t('supportDescription') }}
</div>
<!-- <v-btn>Soutenir</v-btn> -->
</div>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('create') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/brain.png"
>
<div class="text-md text-justify px-6">
{{ t('creatorDescription') }}
</div>
<v-btn
class="inscription-btn"
to="/login"
>
{{ t('signup') }}
</v-btn>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-6 py-8">
<div class="gap-8 items-start flex flex-col md:flex-row">
<!-- Section de texte -->
<div class="space-y-6">
<img alt="YourHutopy" class="w-full mb-6" src="/images/hutopymedia/homepage/votrehutopy.png">
<div class="space-y-4">
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">{{ t('whatIsHutopy') }}</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyDescription') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyValues') }}
</p>
<div class="flex justify-center">
<v-btn
class="text-white mt-12 flex items-center justify-center round create-btn"
to="/create-creator"
>
{{ t('createPage') }}
</v-btn>
</div> </div>
</div>
</div> </div>
<!-- Section droite : 4 images --> <div class="support-container flex flex-col items-center space-y-4 md:flex-row md:space-y-0 md:space-x-6">
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15"> <div class="support-text text-justify md:text-left">
<div><img alt="Grinding" class="w-full h-auto object-cover rounded-2xl" <span class="text-white">{{ t('support') }}</span>
src="/images/hutopymedia/homepage/grinding.png"></div> <br />
<div><img alt="Microphone" class="w-full h-auto object-cover rounded-2xl" <span class="text-white">{{ t('creators') }}</span>
src="/images/hutopymedia/homepage/sign.png"></div> <br />
<div><img alt="Girl VR" class="w-full h-auto object-cover rounded-2xl" <span class="text-white">{{ t('projects') }}</span>
src="/images/hutopymedia/homepage/girlvr.png"></div> <br />
<div><img alt="Girl Army" class="w-full h-auto object-cover rounded-2xl" <span class="text-white">{{ t('love') }}</span>
src="/images/hutopymedia/homepage/girlarmy.png"></div> </div>
<img
alt="YourHutopy"
class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png"
/>
</div> </div>
</div>
<div class="relative mt-10">
<div
class="flex flex-col lg:flex-row justify-center items-center lg:space-x-14 space-y-6 lg:space-y-0 pa-1"
>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/hands.png"
/>
<div class="text-md text-justify px-6">
{{ t('supportDescription') }}
</div>
<!-- <v-btn>Soutenir</v-btn> -->
</div>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('create') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/brain.png"
/>
<div class="text-md text-justify px-6">
{{ t('creatorDescription') }}
</div>
<v-btn
class="inscription-btn"
to="/login"
>
{{ t('signup') }}
</v-btn>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-6 py-8">
<div class="gap-8 items-start flex flex-col md:flex-row">
<!-- Section de texte -->
<div class="space-y-6">
<img
alt="YourHutopy"
class="w-full mb-6"
src="/images/hutopymedia/homepage/votrehutopy.png"
/>
<div class="space-y-4">
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('whatIsHutopy') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyDescription') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyValues') }}
</p>
<div class="flex justify-center">
<v-btn
class="text-white mt-12 flex items-center justify-center round create-btn"
to="/create-creator"
>
{{ t('createPage') }}
</v-btn>
</div>
</div>
</div>
<!-- Section droite : 4 images -->
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15">
<div>
<img
alt="Grinding"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/grinding.png"
/>
</div>
<div>
<img
alt="Microphone"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/sign.png"
/>
</div>
<div>
<img
alt="Girl VR"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlvr.png"
/>
</div>
<div>
<img
alt="Girl Army"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlarmy.png"
/>
</div>
</div>
</div>
</div>
<Footer class="mt-10"></Footer>
</div> </div>
<Footer class="mt-10"></Footer>
</div>
</template> </template>
<style scoped> <style scoped>
.box-text { .box-text {
color: #6A0164; color: #6a0164;
font-size: 30px; font-size: 30px;
font-weight: bold; font-weight: bold;
} }
.inscription-btn-header { .inscription-btn-header {
color: white; color: white;
background-color: #6A0164; background-color: #6a0164;
font-size: 18px; font-size: 18px;
height: 40px; height: 40px;
width: auto; width: auto;
padding: 0 32px; padding: 0 32px;
font-weight: bold; font-weight: bold;
}
} .inscription-btn-header-outlined {
color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
}
.inscription-btn-header-outlined { .inscription-btn {
color: #6A0164; color: white;
font-size: 18px; background-color: #6a0164;
height: 40px; font-size: 18px;
width: auto; height: 40px;
padding: 0 32px; width: auto;
font-weight: bold; padding: 0 32px;
font-weight: bold;
border-radius: 10px;
}
} .create-btn {
background-color: #6a0164;
font-size: 18px;
height: 48px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px;
}
.inscription-btn { .overlay p {
color: white; color: white;
background-color: #6A0164; font-size: 1.5rem;
font-size: 18px; text-align: center;
height: 40px; }
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px;
}
.create-btn { body {
background-color: #6A0164; background-color: #f4f4f4;
font-size: 18px; }
height: 48px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px
}
.overlay p { .support-container {
color: white; display: flex;
font-size: 1.5rem; justify-content: center; /* Centre le bloc horizontalement */
text-align: center; align-items: center; /* Centre le bloc verticalement (optionnel) */
} }
body { .support-text {
background-color: #F4F4F4; font-size: 2.2rem; /* Ajustez la taille du texte */
} line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
.support-container { .support-text .highlight {
display: flex; color: #6a0164; /* Remplacez par la couleur souhaitée */
justify-content: center; /* Centre le bloc horizontalement */ font-weight: bold; /* Mettre en gras */
align-items: center; /* Centre le bloc verticalement (optionnel) */ }
} .highlight2 {
color: #b81286; /* Remplacez par la couleur souhaitée */
}
.support-text { .logo-image {
font-size: 2.2rem; /* Ajustez la taille du texte */ margin-left: auto;
line-height: 1.1; /* Ajustez l'espacement entre les lignes */ }
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
.support-text .highlight { @media (min-width: 640px) {
color: #6A0164; /* Remplacez par la couleur souhaitée */ .header-btn {
font-weight: bold; /* Mettre en gras */ margin-top: 25px;
} margin-bottom: 25px;
}
.highlight2 { .support-text {
color: #B81286; /* Remplacez par la couleur souhaitée */ font-size: 3rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
}
} @media (min-width: 768px) {
.header-btn {
margin-top: 60px;
}
.logo-image { .logo-image {
margin-left: auto; margin-right: 20px;
} margin-left: 0;
}
@media (min-width: 640px) { }
.header-btn {
margin-top: 25px;
margin-bottom: 25px;
}
.support-text {
font-size: 3.0rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
}
@media (min-width: 768px) {
.header-btn {
margin-top: 60px;
}
.logo-image {
margin-right: 20px;
margin-left: 0;
}
}
.homepagetext {
color: white;
font-family: "Roboto", sans-serif;
}
.homepagetext {
color: white;
font-family: 'Roboto', sans-serif;
}
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"inscription": "Sign Up", "inscription": "Sign Up",
"createPage": "Create Page", "createPage": "Create Page",
"support": "Support", "support": "Support",
"creators": "Creators", "creators": "Creators",
"projects": "Projects", "projects": "Projects",
"love": "Love", "love": "Love",
"supportText": "Support", "supportText": "Support",
"supportDescription": "Support your favorite creators and help them grow. Your contributions make a real difference in their creative journey.", "supportDescription": "Support your favorite creators and help them grow. Your contributions make a real difference in their creative journey.",
"create": "Create", "create": "Create",
"creatorDescription": "Create your own page and start your creative journey. Share your passion with the world and build your community.", "creatorDescription": "Create your own page and start your creative journey. Share your passion with the world and build your community.",
"signup": "Sign Up", "signup": "Sign Up",
"whatIsHutopy": "What is Hutopy?", "whatIsHutopy": "What is Hutopy?",
"hutopyDescription": "Hutopy is a platform that connects creators with their audience. We provide tools and features to help creators monetize their content and build their community.", "hutopyDescription": "Hutopy is a platform that connects creators with their audience. We provide tools and features to help creators monetize their content and build their community.",
"hutopyValues": "Our values are centered around creativity, community, and support. We believe in empowering creators to pursue their passions and build sustainable careers." "hutopyValues": "Our values are centered around creativity, community, and support. We believe in empowering creators to pursue their passions and build sustainable careers."
}, },
"fr": { "fr": {
"inscription": "S'inscrire", "inscription": "S'inscrire",
"createPage": "Créer une Page", "createPage": "Créer une Page",
"support": "Soutenir", "support": "Soutenir",
"creators": "Créateurs", "creators": "Créateurs",
"projects": "Projets", "projects": "Projets",
"love": "Passion", "love": "Passion",
"supportText": "Soutenir", "supportText": "Soutenir",
"supportDescription": "Soutenez vos créateurs préférés et aidez-les à grandir. Vos contributions font une réelle différence dans leur parcours créatif.", "supportDescription": "Soutenez vos créateurs préférés et aidez-les à grandir. Vos contributions font une réelle différence dans leur parcours créatif.",
"create": "Créer", "create": "Créer",
"creatorDescription": "Créez votre propre page et commencez votre parcours créatif. Partagez votre passion avec le monde et construisez votre communauté.", "creatorDescription": "Créez votre propre page et commencez votre parcours créatif. Partagez votre passion avec le monde et construisez votre communauté.",
"signup": "S'inscrire", "signup": "S'inscrire",
"whatIsHutopy": "Qu'est-ce que Hutopy ?", "whatIsHutopy": "Qu'est-ce que Hutopy ?",
"hutopyDescription": "Hutopy est une plateforme qui connecte les créateurs avec leur audience. Nous fournissons des outils et des fonctionnalités pour aider les créateurs à monétiser leur contenu et à construire leur communauté.", "hutopyDescription": "Hutopy est une plateforme qui connecte les créateurs avec leur audience. Nous fournissons des outils et des fonctionnalités pour aider les créateurs à monétiser leur contenu et à construire leur communauté.",
"hutopyValues": "Nos valeurs sont centrées sur la créativité, la communauté et le soutien. Nous croyons en l'autonomisation des créateurs pour poursuivre leurs passions et construire des carrières durables." "hutopyValues": "Nos valeurs sont centrées sur la créativité, la communauté et le soutien. Nous croyons en l'autonomisation des créateurs pour poursuivre leurs passions et construire des carrières durables."
}, }
"es": {
"inscription": "Registrarse",
"createPage": "Crear Página",
"support": "Apoyar",
"creators": "Creadores",
"projects": "Proyectos",
"love": "Pasión",
"supportText": "Apoyar",
"supportDescription": "Apoya a tus creadores favoritos y ayúdales a crecer. Tus contribuciones hacen una diferencia real en su viaje creativo.",
"create": "Crear",
"creatorDescription": "Crea tu propia página y comienza tu viaje creativo. Comparte tu pasión con el mundo y construye tu comunidad.",
"signup": "Registrarse",
"whatIsHutopy": "¿Qué es Hutopy?",
"hutopyDescription": "Hutopy es una plataforma que conecta a los creadores con su audiencia. Proporcionamos herramientas y funciones para ayudar a los creadores a monetizar su contenido y construir su comunidad.",
"hutopyValues": "Nuestros valores se centran en la creatividad, la comunidad y el apoyo. Creemos en empoderar a los creadores para perseguir sus pasiones y construir carreras sostenibles."
}
} }
</i18n> </i18n>

View File

@@ -4,19 +4,17 @@
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js'; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js'; import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useLanguageStore } from '@/stores/languageStore.js'; import { useLanguageStore } from '@/stores/languageStore.js';
import { useRoute } from 'vue-router';
import { mdiAccount, mdiFileAccountOutline, mdiLogin, mdiLogout, mdiTranslateVariant } from '@mdi/js'; import { mdiAccount, mdiFileAccountOutline, mdiLogin, mdiLogout, mdiTranslateVariant } from '@mdi/js';
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const languageStore = useLanguageStore(); const languageStore = useLanguageStore();
const route = useRoute();
const userProfileStore = useUserProfileStore(); const userProfileStore = useUserProfileStore();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
function toggleLanguage() { function toggleLanguage() {
const languages = ['fr', 'en', 'es']; const languages = ['fr', 'en'];
const currentIndex = languages.indexOf(locale.value); const currentIndex = languages.indexOf(locale.value);
const nextIndex = (currentIndex + 1) % languages.length; const nextIndex = (currentIndex + 1) % languages.length;
languageStore.setLocale(languages[nextIndex]); languageStore.setLocale(languages[nextIndex]);
@@ -117,23 +115,20 @@
<style scoped> <style scoped>
.side-container { .side-container {
@apply bg-hSurface text-hOnSurface; @apply bg-hSurface text-hOnSurface;
@apply lg:fixed lg:max-h-screen;
@apply flex; @apply flex;
@apply lg:flex-col lg:w-64 lg:max-w-64; @apply max-h-screen;
@apply h-16 lg:h-screen; @apply h-16;
@apply lg:border-r-2 lg:border-[#2d282d];
} }
.side-logo { .side-logo {
@apply flex flex-grow; @apply flex flex-grow;
@apply items-center justify-start p-4; @apply items-center justify-start p-4;
@apply lg:items-start lg:justify-center lg:pt-4;
} }
.side-menu { .side-menu {
@apply flex gap-4 p-6; @apply flex gap-4 p-6;
@apply items-center lg:items-stretch; @apply items-center;
@apply flex-row-reverse lg:flex-col; @apply flex-row-reverse;
} }
.side-menu-portrait { .side-menu-portrait {
@@ -145,21 +140,20 @@
.side-menu-items { .side-menu-items {
@apply flex gap-2; @apply flex gap-2;
@apply flex-row; @apply flex-row;
@apply lg:w-full lg:flex-col;
} }
.profile-label { .profile-label {
@apply ml-5; @apply ml-5;
@apply text-lg font-sans capitalize; @apply text-lg font-sans capitalize;
@apply font-semibold; @apply font-semibold;
@apply hidden lg:inline; @apply hidden;
@apply min-w-40 truncate; @apply min-w-40 truncate;
} }
.label { .label {
@apply text-nowrap; @apply text-nowrap;
@apply ml-4; @apply ml-4;
@apply hidden lg:inline; @apply hidden;
} }
.menu-item-action { .menu-item-action {
@@ -193,14 +187,6 @@
"signIn": "Se Connecter", "signIn": "Se Connecter",
"signOut": "Se Déconnecter" "signOut": "Se Déconnecter"
} }
},
"es": {
"sidebar": {
"myPage": "Mi Página",
"myProfile": "Mi Perfil",
"signIn": "Iniciar Sesión",
"signOut": "Cerrar Sesión"
}
} }
} }
</i18n> </i18n>

View File

@@ -948,39 +948,6 @@
"phoneNumber": "Numéro de téléphone", "phoneNumber": "Numéro de téléphone",
"title": "Titre", "title": "Titre",
"removeStripe": "Retirer Stripe" "removeStripe": "Retirer Stripe"
},
"es": {
"personalInfo": "Información Personal",
"fullName": "Nombre Completo",
"alias": "Alias",
"email": "Correo Electrónico",
"changePassword": "Actualizar contraseña",
"creatorInfo": "Información del Creador",
"dangerZone": "Zona de Peligro",
"dangerZoneWarning": "Las acciones a continuación pueden tener impactos significativos en tu página de creador. Por favor, procede con precaución.",
"deleteWarning": "¿Estás seguro de que quieres eliminar tu página de creador? Esta acción no se puede deshacer.",
"restoreWarning": "¿Estás seguro de que quieres restaurar tu página de creador? Esto hará que tu página sea visible nuevamente.",
"deleteCreatorPage": "Eliminar Página de Creador",
"restoreCreatorPage": "Restaurar Página de Creador",
"stripeAccountId": "ID de Cuenta Stripe",
"socialNetworks": "Redes Sociales",
"handle": "Identificador del creador",
"qrCode": "Código QR",
"qrCodeDescription": "¡Imprime este código QR para compartir tu Hutopy con el mundo! Perfecto para tarjetas de presentación, redes sociales y materiales promocionales.",
"downloadQRCode": "Descargar Código QR",
"payment-information": "Información de Pago",
"stripeStatus": "Estado de Stripe",
"configured": "Configurado",
"notConfigured": "No Configurado",
"needsMoreInfo": "Requiere Más Información",
"pendingVerification": "Verificación Pendiente",
"continueStripeSetup": "Continuar Configuración de Stripe",
"reviewStripe": "Revisar Stripe",
"notSet": "No Establecido",
"configureStripe": "Connectar Stripe",
"phoneNumber": "Número de teléfono",
"title": "Título",
"removeStripe": "Eliminar Stripe"
} }
} }
</i18n> </i18n>

View File

@@ -1,66 +1,58 @@
<template> <template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog"> <div class="card-content">
<v-text-field
v-model="alias"
:label="t('label')"
variant="outlined"
></v-text-field>
</div>
<div class="card-title"> <div class="card-actions">
{{ t('title') }} <button
class="secondary"
@click="requestClose"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="requestSave"
>
{{ t('save') }}
</button>
</div>
</div> </div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="alias"
:label="t('label')"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary"
@click="requestClose">
{{ t('cancel') }}
</button>
<button class="primary"
@click="requestSave">
{{ t('save') }}
</button>
</div>
</div>
</template> </template>
<script setup> <script setup>
import {ref} from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps(['alias']) const props = defineProps(['alias']);
const emit = defineEmits(['close', 'save']) const emit = defineEmits(['close', 'save']);
const alias = ref(props.alias) const alias = ref(props.alias);
const requestClose = () => emit('close') const requestClose = () => emit('close');
const requestSave = () => emit('save', alias.value) const requestSave = () => emit('save', alias.value);
</script> </script>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Alias", "title": "Alias",
"label": "Your alias" "label": "Your alias"
}, },
"fr": { "fr": {
"title": "Alias", "title": "Alias",
"label": "Votre alias" "label": "Votre alias"
}, }
"es": {
"title": "Alias",
"label": "Tu alias"
}
} }
</i18n> </i18n>

View File

@@ -1,162 +1,177 @@
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title">
{{ t('changePassword') }}
</div>
<div class="card-title"> <div class="card-content">
{{ t('changePassword') }} <p class="description mb-4">{{ t('passwordDescription') }}</p>
<v-text-field
v-model="newPassword"
:hint="t('passwordRequirements')"
:label="t('newPassword')"
:type="showNewPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner>
<v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showNewPassword = !showNewPassword"
/>
</template>
</v-text-field>
<v-text-field
v-model="confirmPassword"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner>
<v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showConfirmPassword = !showConfirmPassword"
/>
</template>
</v-text-field>
<div
v-if="errorMessage"
class="error-message mb-4"
>
{{ errorMessage }}
</div>
<div class="card-actions">
<button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }}
</button>
<button
:disabled="isLoading"
class="primary"
@click="handleChangePassword"
>
{{ t('save') }}
</button>
</div>
</div>
</div> </div>
<div class="card-content">
<p class="description mb-4">{{ t('passwordDescription') }}</p>
<v-text-field v-model="newPassword" :label="t('newPassword')" :type="showNewPassword ? 'text' : 'password'"
variant="outlined" required :hint="t('passwordRequirements')">
<template v-slot:append-inner>
<v-icon @click="showNewPassword = !showNewPassword" class="visibility-toggle" size="small"
:icon="showNewPassword ? mdiEyeOff : mdiEye" />
</template>
</v-text-field>
<v-text-field v-model="confirmPassword" :label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'" variant="outlined" required>
<template v-slot:append-inner>
<v-icon @click="showConfirmPassword = !showConfirmPassword" class="visibility-toggle" size="small"
:icon="showNewPassword ? mdiEyeOff : mdiEye" />
</template>
</v-text-field>
<div v-if="errorMessage" class="error-message mb-4">
{{ errorMessage }}
</div>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
{{ t('cancel') }}
</button>
<button class="primary" @click="handleChangePassword" :disabled="isLoading">
{{ t('save') }}
</button>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { useAuthStore } from '@/stores/authStore.js'; import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { mdiEye, mdiEyeOff } from '@mdi/js'; import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
const authStore = useAuthStore(); const authStore = useAuthStore();
const emit = defineEmits(['closeRequested']); const emit = defineEmits(['closeRequested']);
const newPassword = ref(''); const newPassword = ref('');
const confirmPassword = ref(''); const confirmPassword = ref('');
const isLoading = ref(false); const isLoading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const showNewPassword = ref(false); const showNewPassword = ref(false);
const showConfirmPassword = ref(false); const showConfirmPassword = ref(false);
async function handleChangePassword() { async function handleChangePassword() {
// Clear previous error // Clear previous error
errorMessage.value = ''; errorMessage.value = '';
// Validate passwords match // Validate passwords match
if (newPassword.value !== confirmPassword.value) { if (newPassword.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch'); errorMessage.value = t('passwordsDoNotMatch');
return; return;
} }
// Validate password length // Validate password length
if (newPassword.value.length < 8) { if (newPassword.value.length < 8) {
errorMessage.value = t('passwordTooShort'); errorMessage.value = t('passwordTooShort');
return; return;
} }
isLoading.value = true; isLoading.value = true;
try { try {
// Pass empty string for current password since we're already authenticated // Pass empty string for current password since we're already authenticated
// This will use the set-password endpoint for OAuth users // This will use the set-password endpoint for OAuth users
await authStore.changePassword(newPassword.value); await authStore.changePassword(newPassword.value);
// Success - close dialog // Success - close dialog
emit('closeRequested'); emit('closeRequested');
// You could also emit a success event if needed // You could also emit a success event if needed
// emit('success'); // emit('success');
} catch (error) { } catch (error) {
console.error('Failed to change password:', error); console.error('Failed to change password:', error);
// Use error message from response if available, or the error message itself, or fallback // Use error message from response if available, or the error message itself, or fallback
errorMessage.value = error.response?.data || error.message || t('passwordUpdateFailed'); errorMessage.value = error.response?.data || error.message || t('passwordUpdateFailed');
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
</script> </script>
<style scoped> <style scoped>
.dialog { .dialog {
@apply max-w-md mx-auto; @apply max-w-md mx-auto;
} }
.error-message { .error-message {
@apply text-red-500 text-sm mt-2; @apply text-red-500 text-sm mt-2;
} }
.visibility-toggle { .visibility-toggle {
@apply cursor-pointer; @apply cursor-pointer;
@apply transition-opacity duration-300; @apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100; @apply opacity-60 hover:opacity-100;
@apply absolute right-2 top-1/2 transform -translate-y-1/2; @apply absolute right-2 top-1/2 transform -translate-y-1/2;
@apply z-10; @apply z-10;
} }
/* Override Vuetify's default padding to accommodate our icon */ /* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) { :deep(.v-field__append-inner) {
padding-inline-start: 0; padding-inline-start: 0;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"changePassword": "Update Password", "changePassword": "Update Password",
"newPassword": "New Password", "newPassword": "New Password",
"confirmPassword": "Confirm New Password", "confirmPassword": "Confirm New Password",
"passwordRequirements": "Password must be at least 8 characters", "passwordRequirements": "Password must be at least 8 characters",
"passwordDescription": "Updating your password allows you to log in directly with your email and password.", "passwordDescription": "Updating your password allows you to log in directly with your email and password.",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"passwordsDoNotMatch": "New passwords do not match", "passwordsDoNotMatch": "New passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long", "passwordTooShort": "Password must be at least 8 characters long",
"passwordUpdateFailed": "Failed to update password. Please try again." "passwordUpdateFailed": "Failed to update password. Please try again."
}, },
"fr": { "fr": {
"changePassword": "Modifier le mot de passe", "changePassword": "Modifier le mot de passe",
"newPassword": "Nouveau mot de passe", "newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le nouveau mot de passe", "confirmPassword": "Confirmer le nouveau mot de passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères", "passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"passwordDescription": "La modification de votre mot de passe vous permet de vous connecter directement avec votre email et mot de passe.", "passwordDescription": "La modification de votre mot de passe vous permet de vous connecter directement avec votre email et mot de passe.",
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",
"passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas", "passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères", "passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer." "passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer."
}, }
"es": {
"changePassword": "Actualizar contraseña",
"newPassword": "Nueva contraseña",
"confirmPassword": "Confirmar nueva contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"passwordDescription": "La actualización de su contraseña le permite iniciar sesión directamente con su correo electrónico y contraseña.",
"save": "Guardar",
"cancel": "Cancelar",
"passwordsDoNotMatch": "Las nuevas contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"passwordUpdateFailed": "Error al actualizar la contraseña. Por favor, inténtelo de nuevo."
}
} }
</i18n> </i18n>

View File

@@ -1,82 +1,78 @@
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div> </div>
<div class="card-content"> <div class="card-content">
<v-text-field <v-text-field
v-model="email" v-model="email"
:label="t('label')" :label="t('label')"
variant="outlined" variant="outlined"
></v-text-field> ></v-text-field>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
{{ t('cancel') }} @click="cancel"
</button> >
<button class="primary" {{ t('cancel') }}
@click="save"> </button>
{{ t('save') }} <button
</button> class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import {ref} from 'vue'; import { ref } from 'vue';
import {useClient} from "@/plugins/api.js"; import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
email: { email: {
required: true, required: true,
type: String type: String,
} },
}); });
const emits = defineEmits(['closeRequested']); const emits = defineEmits(['closeRequested']);
const email = ref(props.email); const email = ref(props.email);
const client = useClient(); const client = useClient();
const save = async () => { const save = async () => {
try { try {
await client.post( await client.post(`/api/users/email`, {
`/api/users/email`, email: email.value,
{ });
email: email.value
});
emits('closeRequested'); emits('closeRequested');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}; };
const cancel = () => { const cancel = () => {
emits('closeRequested'); emits('closeRequested');
}; };
</script> </script>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Change your Email", "title": "Change your Email",
"label": "Your email" "label": "Your email"
}, },
"fr": { "fr": {
"title": "Changez votre Courriel", "title": "Changez votre Courriel",
"label": "Votre email" "label": "Votre email"
}, }
"es": {
"title": "Cambia tu correo electrónico",
"label": "Tu correo electrónico"
}
} }
</i18n> </i18n>

View File

@@ -1,70 +1,69 @@
<script setup> <script setup>
import {ref} from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps(['firstname', 'lastname']) const props = defineProps(['firstname', 'lastname']);
const emit = defineEmits(['close', 'save']) const emit = defineEmits(['close', 'save']);
const firstname = ref(props.firstname) const firstname = ref(props.firstname);
const lastname = ref(props.lastname) const lastname = ref(props.lastname);
const requestClose = () => emit('close') const requestClose = () => emit('close');
const requestSave = () => emit('save', firstname.value, lastname.value) const requestSave = () => emit('save', firstname.value, lastname.value);
</script> </script>
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div> </div>
<div class="card-content"> <div class="card-content">
<v-text-field <v-text-field
variant="outlined" v-model="firstname"
v-model="firstname" :label="t('firstname')"
:label="t('firstname')" variant="outlined"
></v-text-field> ></v-text-field>
</div> </div>
<div class="card-content"> <div class="card-content">
<v-text-field <v-text-field
variant="outlined" v-model="lastname"
v-model="lastname" :label="t('lastname')"
:label="t('lastname')" variant="outlined"
></v-text-field> ></v-text-field>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="requestClose"> class="secondary"
{{ t('cancel') }} @click="requestClose"
</button> >
{{ t('cancel') }}
</button>
<button class="primary" <button
@click="requestSave"> class="primary"
{{ t('save') }} @click="requestSave"
</button> >
{{ t('save') }}
</button>
</div>
</div> </div>
</div>
</template> </template>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Full Name", "title": "Full Name",
"firstname": "First Name", "firstname": "First Name",
"lastname": "Last Name" "lastname": "Last Name"
}, },
"fr": { "fr": {
"title": "Nom complet", "title": "Nom complet",
"firstname": "Prénom", "firstname": "Prénom",
"lastname": "Nom" "lastname": "Nom"
}, }
"es": {
"title": "Nombre completo",
"firstname": "Nombre",
"lastname": "Apellido"
}
} }
</i18n> </i18n>

View File

@@ -1,174 +1,164 @@
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title">{{ t('changeEmail') }}</div> <div class="card-title">{{ t('changeEmail') }}</div>
<div class="card-content"> <div class="card-content">
<v-text-field <v-text-field
v-model="email" v-model="email"
class="w-full p-2" :error-messages="emailErrors"
:label="t('email')" :label="t('email')"
type="email" :rules="emailRules"
variant="outlined" class="w-full p-2"
:error-messages="emailErrors" type="email"
:rules="emailRules" validate-on="blur"
validate-on="blur" variant="outlined"
/> />
<v-alert <v-alert
v-if="!!errorMessage" v-if="!!errorMessage"
outlined class="mt-4"
type="error" outlined
class="mt-4"> type="error"
{{ errorMessage }} >
</v-alert> {{ errorMessage }}
</v-alert>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')"> <button
{{ t('cancel') }} class="secondary"
</button> @click="$emit('closeRequested')"
<button class="primary" @click="saveEmail" :disabled="!canSave || isLoading"> >
{{ t('save') }} {{ t('cancel') }}
</button> </button>
</div> <button
:disabled="!canSave || isLoading"
class="primary"
@click="saveEmail"
>
{{ t('save') }}
</button>
</div>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js'; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
const { t } = useI18n(); const { t } = useI18n();
const client = useClient(); const client = useClient();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
const props = defineProps({ const props = defineProps({
creator: { creator: {
type: Object, type: Object,
required: true required: true,
} },
}); });
const email = ref(props.creator.presentation?.email || ''); const email = ref(props.creator.presentation?.email || '');
const isLoading = ref(false); const isLoading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
// Email validation // Email validation
const isValidEmail = (email) => { const isValidEmail = email => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email); return emailRegex.test(email);
}; };
const emailRules = [ const emailRules = [
v => !!v || t('validation.emailRequired'), v => !!v || t('validation.emailRequired'),
v => isValidEmail(v) || t('validation.emailInvalid'), v => isValidEmail(v) || t('validation.emailInvalid'),
]; ];
const emailErrors = computed(() => { const emailErrors = computed(() => {
if (!email.value) { if (!email.value) {
return [t('validation.emailRequired')]; return [t('validation.emailRequired')];
} }
if (!isValidEmail(email.value)) { if (!isValidEmail(email.value)) {
return [t('validation.emailInvalid')]; return [t('validation.emailInvalid')];
} }
return []; return [];
}); });
const canSave = computed(() => { const canSave = computed(() => {
return email.value && return email.value && isValidEmail(email.value) && email.value !== (props.creator.presentation?.email || '');
isValidEmail(email.value) && });
email.value !== (props.creator.presentation?.email || '');
});
async function saveEmail() { async function saveEmail() {
if (!props.creator.id) { if (!props.creator.id) {
console.error("Creator ID is missing!"); console.error('Creator ID is missing!');
return; return;
} }
if (!canSave.value) { if (!canSave.value) {
return; return;
} }
try { try {
isLoading.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
// Save email // Save email
await client.post( await client.post(`/api/creators/${props.creator.id}/email`, {
`/api/creators/${props.creator.id}/email`, email: email.value.trim(),
{ });
email: email.value.trim()
}
);
// Refresh creator profile // Refresh creator profile
await creatorProfileStore.fetchCreatorProfile(); await creatorProfileStore.fetchCreatorProfile();
// Close dialog // Close dialog
emit('closeRequested'); emit('closeRequested');
} catch (error) { } catch (error) {
console.error("Error saving email:", error); console.error('Error saving email:', error);
if (error?.response?.data?.errors) { if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected'); errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else { } else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected'); errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
}
} finally {
isLoading.value = false;
}
} }
} finally {
isLoading.value = false;
}
}
const emit = defineEmits(['closeRequested']); const emit = defineEmits(['closeRequested']);
</script> </script>
<style scoped> <style scoped>
.dialog { .dialog {
@apply max-w-md mx-auto; @apply max-w-md mx-auto;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"changeEmail": "Change Email", "changeEmail": "Change Email",
"email": "Email", "email": "Email",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
"emailInvalid": "Please enter a valid email address" "emailInvalid": "Please enter a valid email address"
},
"errors": {
"unexpected": "An unexpected error occurred"
}
}, },
"errors": { "fr": {
"unexpected": "An unexpected error occurred" "changeEmail": "Modifier l'email",
"email": "Email",
"save": "Enregistrer",
"cancel": "Annuler",
"validation": {
"emailRequired": "L'email est requis",
"emailInvalid": "Veuillez entrer une adresse email valide"
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
} }
},
"fr": {
"changeEmail": "Modifier l'email",
"email": "Email",
"save": "Enregistrer",
"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",
"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

@@ -1,87 +1,82 @@
<script setup> <script setup>
import {ref} from 'vue'; import { ref } from 'vue';
import {useClient} from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
creator: { creator: {
required: true required: true,
} },
}); });
const emits = defineEmits(['closeRequested']); const emits = defineEmits(['closeRequested']);
const name = ref(props.creator.name); const name = ref(props.creator.name);
const client = useClient(); const client = useClient();
async function save() { async function save() {
try { try {
await client.post( await client.post(`/api/creators/${props.creator.id}/name`, {
`/api/creators/${props.creator.id}/name`, name: name.value,
{ });
name: name.value
props.creator.name = name.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving title:', error);
} }
); }
props.creator.name = name.value; const cancel = () => {
emits('closeRequested'); emits('closeRequested');
} catch (error) { };
console.error('Error saving title:', error);
}
}
const cancel = () => {
emits('closeRequested');
};
</script> </script>
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div> </div>
<div class="card-content"> <div class="card-content">
<v-text-field <v-text-field
v-model="name" v-model="name"
:label="t('label')" :label="t('label')"
outlined outlined
variant="outlined" variant="outlined"
></v-text-field> ></v-text-field>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
{{ t('cancel') }} @click="cancel"
</button> >
<button class="primary" {{ t('cancel') }}
@click="save"> </button>
{{ t('save') }} <button
</button> class="primary"
</div> @click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Change Name", "title": "Change Name",
"label": "Your name" "label": "Your name"
}, },
"fr": { "fr": {
"title": "Modifier le nom", "title": "Modifier le nom",
"label": "Votre nom" "label": "Votre nom"
}, }
"es": {
"title": "Cambiar nombre",
"label": "Tu nombre"
}
} }
</i18n> </i18n>

View File

@@ -1,246 +1,239 @@
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title">{{ t('changePhoneNumber') }}</div> <div class="card-title">{{ t('changePhoneNumber') }}</div>
<div class="card-content"> <div class="card-content">
<v-text-field <v-text-field
v-model="displayPhoneNumber" v-model="displayPhoneNumber"
class="w-full p-2" :error-messages="phoneErrors"
:label="t('phoneNumber')" :label="t('phoneNumber')"
type="tel" :placeholder="t('phonePlaceholder')"
variant="outlined" :rules="phoneRules"
:error-messages="phoneErrors" class="w-full p-2"
:rules="phoneRules" maxlength="14"
validate-on="blur" type="tel"
:placeholder="t('phonePlaceholder')" validate-on="blur"
@input="handlePhoneInput" variant="outlined"
@keydown="handleKeydown" @input="handlePhoneInput"
maxlength="14" @keydown="handleKeydown"
/> />
<v-alert <v-alert
v-if="!!errorMessage" v-if="!!errorMessage"
outlined class="mt-4"
type="error" outlined
class="mt-4"> type="error"
{{ errorMessage }} >
</v-alert> {{ errorMessage }}
</v-alert>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')"> <button
{{ t('cancel') }} class="secondary"
</button> @click="$emit('closeRequested')"
<button class="primary" @click="savePhoneNumber" :disabled="!canSave || isLoading"> >
{{ t('save') }} {{ t('cancel') }}
</button> </button>
</div> <button
:disabled="!canSave || isLoading"
class="primary"
@click="savePhoneNumber"
>
{{ t('save') }}
</button>
</div>
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js'; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
const { t } = useI18n(); const { t } = useI18n();
const client = useClient(); const client = useClient();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
const props = defineProps({ const props = defineProps({
creator: { creator: {
type: Object, type: Object,
required: true required: true,
} },
}); });
// Format existing phone number to display format // Format existing phone number to display format
const formatPhoneForDisplay = (phone) => { const formatPhoneForDisplay = phone => {
if (!phone) return ''; if (!phone) return '';
const digits = phone.replace(/\D/g, ''); const digits = phone.replace(/\D/g, '');
if (digits.length === 10) { if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
} }
return phone; return phone;
}; };
// Extract just the digits from formatted phone // Extract just the digits from formatted phone
const extractDigits = (formattedPhone) => { const extractDigits = formattedPhone => {
return formattedPhone.replace(/\D/g, ''); return formattedPhone.replace(/\D/g, '');
}; };
const displayPhoneNumber = ref(formatPhoneForDisplay(props.creator.presentation?.phoneNumber || '')); const displayPhoneNumber = ref(formatPhoneForDisplay(props.creator.presentation?.phoneNumber || ''));
const phoneDigits = ref(extractDigits(displayPhoneNumber.value)); const phoneDigits = ref(extractDigits(displayPhoneNumber.value));
const isLoading = ref(false); const isLoading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
// Phone number formatting and validation // Phone number formatting and validation
const formatPhoneNumber = (digits) => { const formatPhoneNumber = digits => {
// Remove all non-digits // Remove all non-digits
const cleaned = digits.replace(/\D/g, ''); const cleaned = digits.replace(/\D/g, '');
// Apply formatting based on length // Apply formatting based on length
if (cleaned.length === 0) return ''; if (cleaned.length === 0) return '';
if (cleaned.length <= 3) return `(${cleaned}`; if (cleaned.length <= 3) return `(${cleaned}`;
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`; 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)}`; return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
}; };
const handlePhoneInput = (event) => { const handlePhoneInput = event => {
const input = event.target.value; const input = event.target.value;
const digits = extractDigits(input); const digits = extractDigits(input);
// Limit to 10 digits // Limit to 10 digits
if (digits.length > 10) return; if (digits.length > 10) return;
phoneDigits.value = digits; phoneDigits.value = digits;
displayPhoneNumber.value = formatPhoneNumber(digits); displayPhoneNumber.value = formatPhoneNumber(digits);
}; };
const handleKeydown = (event) => { const handleKeydown = event => {
// Allow backspace, delete, tab, escape, enter // Allow backspace, delete, tab, escape, enter
if ([8, 9, 27, 13, 46].includes(event.keyCode)) return; if ([8, 9, 27, 13, 46].includes(event.keyCode)) return;
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X // Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
if ((event.ctrlKey || event.metaKey) && [65, 67, 86, 88].includes(event.keyCode)) return; if ((event.ctrlKey || event.metaKey) && [65, 67, 86, 88].includes(event.keyCode)) return;
// Allow arrow keys // Allow arrow keys
if (event.keyCode >= 35 && event.keyCode <= 40) return; if (event.keyCode >= 35 && event.keyCode <= 40) return;
// Only allow numbers (0-9) // Only allow numbers (0-9)
if (event.keyCode < 48 || event.keyCode > 57) { if (event.keyCode < 48 || event.keyCode > 57) {
event.preventDefault(); event.preventDefault();
} }
}; };
// Watch for changes to phoneDigits to update display // Watch for changes to phoneDigits to update display
watch(phoneDigits, (newDigits) => { watch(phoneDigits, newDigits => {
displayPhoneNumber.value = formatPhoneNumber(newDigits); displayPhoneNumber.value = formatPhoneNumber(newDigits);
}); });
const isValidPhoneNumber = (digits) => { const isValidPhoneNumber = digits => {
return digits.length === 10; return digits.length === 10;
}; };
const phoneRules = [ const phoneRules = [
v => { v => {
const digits = extractDigits(v); const digits = extractDigits(v);
return digits.length > 0 || t('validation.phoneRequired'); return digits.length > 0 || t('validation.phoneRequired');
}, },
v => { v => {
const digits = extractDigits(v); const digits = extractDigits(v);
return isValidPhoneNumber(digits) || t('validation.phoneInvalid'); return isValidPhoneNumber(digits) || t('validation.phoneInvalid');
}, },
]; ];
const phoneErrors = computed(() => { const phoneErrors = computed(() => {
if (phoneDigits.value.length === 0) { if (phoneDigits.value.length === 0) {
return [t('validation.phoneRequired')]; return [t('validation.phoneRequired')];
} }
if (!isValidPhoneNumber(phoneDigits.value)) { if (!isValidPhoneNumber(phoneDigits.value)) {
return [t('validation.phoneInvalid')]; return [t('validation.phoneInvalid')];
} }
return []; return [];
}); });
const canSave = computed(() => { const canSave = computed(() => {
return phoneDigits.value.length === 10 && return (
phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || ''); phoneDigits.value.length === 10 &&
}); phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || '')
);
});
async function savePhoneNumber() { async function savePhoneNumber() {
if (!props.creator.id) { if (!props.creator.id) {
console.error("Creator ID is missing!"); console.error('Creator ID is missing!');
return; return;
} }
if (!canSave.value) { if (!canSave.value) {
return; return;
} }
try { try {
isLoading.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
// Save the formatted phone number // Save the formatted phone number
const formattedPhone = formatPhoneNumber(phoneDigits.value); const formattedPhone = formatPhoneNumber(phoneDigits.value);
await client.post( await client.post(`/api/creators/${props.creator.id}/phone`, {
`/api/creators/${props.creator.id}/phone`, phoneNumber: formattedPhone,
{ });
phoneNumber: formattedPhone
}
);
// Refresh creator profile // Refresh creator profile
await creatorProfileStore.fetchCreatorProfile(); await creatorProfileStore.fetchCreatorProfile();
// Close dialog // Close dialog
emit('closeRequested'); emit('closeRequested');
} catch (error) { } catch (error) {
console.error("Error saving phone number:", error); console.error('Error saving phone number:', error);
if (error?.response?.data?.errors) { if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected'); errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else { } else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected'); errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
}
} finally {
isLoading.value = false;
}
} }
} finally {
isLoading.value = false;
}
}
const emit = defineEmits(['closeRequested']); const emit = defineEmits(['closeRequested']);
</script> </script>
<style scoped> <style scoped>
.dialog { .dialog {
@apply max-w-md mx-auto; @apply max-w-md mx-auto;
} }
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"changePhoneNumber": "Change Phone Number", "changePhoneNumber": "Change Phone Number",
"phoneNumber": "Phone Number", "phoneNumber": "Phone Number",
"phonePlaceholder": "(555) 123-4567", "phonePlaceholder": "(555) 123-4567",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"validation": { "validation": {
"phoneRequired": "Phone number is required", "phoneRequired": "Phone number is required",
"phoneInvalid": "Please enter a complete 10-digit phone number" "phoneInvalid": "Please enter a complete 10-digit phone number"
},
"errors": {
"unexpected": "An unexpected error occurred"
}
}, },
"errors": { "fr": {
"unexpected": "An unexpected error occurred" "changePhoneNumber": "Modifier le numéro de téléphone",
"phoneNumber": "Numéro de téléphone",
"phonePlaceholder": "(555) 123-4567",
"save": "Enregistrer",
"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"
}
} }
},
"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",
"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",
"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>

View File

@@ -1,119 +1,120 @@
<script setup> <script setup>
import {computed, ref, watch} from 'vue'; import { computed, ref, watch } from 'vue';
import {useCreatorProfileStore} from '@/stores/creatorProfileStore.js'; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import {useClient} from "@/plugins/api.js"; import { useClient } from '@/plugins/api.js';
import NameEditor from "@/views/creators/NameEditor.vue"; import NameEditor from '@/views/creators/NameEditor.vue';
import {useI18n} from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
creator: { creator: {
required: true required: true,
} },
});
const emit = defineEmits(['closeRequested']);
const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const {t} = useI18n();
const newSlug = ref(props.creator.slug);
const slugReservationId = ref(undefined);
const isOperationPending = ref(false);
const errorMessage = ref('');
const isCurrentHandle = ref(false);
// Watch for changes to the new slug to check if it's the same as the current one
watch(newSlug, (newValue) => {
isCurrentHandle.value = newValue === props.creator.slug;
if (isCurrentHandle.value) {
slugReservationId.value = undefined;
}
});
const canSave = computed(() => slugReservationId.value !== undefined && !isCurrentHandle.value);
function handleSlugReservationIdChanged($event) {
slugReservationId.value = $event;
}
async function save() {
try {
isOperationPending.value = true;
errorMessage.value = '';
await client.put(`/api/creators/${props.creator.id}/slug`, {
slugReservationId: slugReservationId.value
}); });
await creatorProfileStore.fetchCreatorProfile(); const emit = defineEmits(['closeRequested']);
emit('closeRequested');
} catch (error) {
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || 'An unexpected error occurred.';
} else {
errorMessage.value = error?.response?.data?.message || error.message || 'An unexpected error occurred.';
}
} finally {
isOperationPending.value = false;
}
}
const cancel = () => { const creatorProfileStore = useCreatorProfileStore();
emit('closeRequested'); const client = useClient();
}; const { t } = useI18n();
const newSlug = ref(props.creator.slug);
const slugReservationId = ref(undefined);
const isOperationPending = ref(false);
const errorMessage = ref('');
const isCurrentHandle = ref(false);
// Watch for changes to the new slug to check if it's the same as the current one
watch(newSlug, newValue => {
isCurrentHandle.value = newValue === props.creator.slug;
if (isCurrentHandle.value) {
slugReservationId.value = undefined;
}
});
const canSave = computed(() => slugReservationId.value !== undefined && !isCurrentHandle.value);
function handleSlugReservationIdChanged($event) {
slugReservationId.value = $event;
}
async function save() {
try {
isOperationPending.value = true;
errorMessage.value = '';
await client.put(`/api/creators/${props.creator.id}/slug`, {
slugReservationId: slugReservationId.value,
});
await creatorProfileStore.fetchCreatorProfile();
emit('closeRequested');
} catch (error) {
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || 'An unexpected error occurred.';
} else {
errorMessage.value = error?.response?.data?.message || error.message || 'An unexpected error occurred.';
}
} finally {
isOperationPending.value = false;
}
}
const cancel = () => {
emit('closeRequested');
};
</script> </script>
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div>
<div class="card-content">
<name-editor
v-model:name="newSlug"
:creator-name-reservation-id="slugReservationId"
:original-slug="creator.slug"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
></name-editor>
<v-alert
v-if="!!errorMessage"
class="mt-4"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!canSave || isOperationPending"
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div> </div>
<div class="card-content">
<name-editor
v-model:name="newSlug"
:creator-name-reservation-id="slugReservationId"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
:original-slug="creator.slug"
></name-editor>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
class="mt-4">
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save"
:disabled="!canSave || isOperationPending">
{{ t('save') }}
</button>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Change Creator Handle" "title": "Change Creator Handle"
}, },
"fr": { "fr": {
"title": "Modifier l'identifiant du créateur" "title": "Modifier l'identifiant du créateur"
}, }
"es": {
"title": "Cambiar identificador del creador"
}
} }
</i18n> </i18n>

View File

@@ -1,86 +1,84 @@
<script setup> <script setup>
import {useClient} from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
import {ref} from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import {useCreatorProfileStore} from '@/stores/creatorProfileStore.js'; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
const props = defineProps({ const props = defineProps({
creator: { creator: {
required: true, required: true,
}, },
});
const emits = defineEmits(['closeRequested']);
const stripeId = ref('');
const { t } = useI18n();
const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const save = async () => {
try {
await client.post(`/api/membership/stripe-account`, {
stripeAccountId: stripeId.value,
}); });
await creatorProfileStore.fetchCreatorProfile(); const emits = defineEmits(['closeRequested']);
emits('closeRequested');
} catch (error) {
console.error('Error saving stripe id:', error);
}
};
const cancel = () => { const stripeId = ref('');
emits('closeRequested'); const { t } = useI18n();
}; const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const save = async () => {
try {
await client.post(`/api/membership/stripe-account`, {
stripeAccountId: stripeId.value,
});
await creatorProfileStore.fetchCreatorProfile();
emits('closeRequested');
} catch (error) {
console.error('Error saving stripe id:', error);
}
};
const cancel = () => {
emits('closeRequested');
};
</script> </script>
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div> </div>
<div class="card-content"> <div class="card-content">
<v-text-field <v-text-field
v-model="stripeId" v-model="stripeId"
:label="t('label')" :label="t('label')"
outlined outlined
variant="outlined" variant="outlined"
></v-text-field> ></v-text-field>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
{{ t('cancel') }} @click="cancel"
</button> >
<button class="primary" {{ t('cancel') }}
@click="save"> </button>
{{ t('save') }} <button
</button> class="primary"
</div> @click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Change Stripe ID", "title": "Change Stripe ID",
"label": "Your Stripe ID" "label": "Your Stripe ID"
}, },
"fr": { "fr": {
"title": "Modifier l'ID Stripe", "title": "Modifier l'ID Stripe",
"label": "Votre ID Stripe" "label": "Votre ID Stripe"
}, }
"es": {
"title": "Cambiar ID de Stripe",
"label": "Tu ID de Stripe"
}
} }
</i18n> </i18n>

View File

@@ -1,88 +1,82 @@
<script setup> <script setup>
import {ref} from 'vue'; import { ref } from 'vue';
import {useClient} from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
creator: { creator: {
required: true required: true,
} },
}); });
const emits = defineEmits(['closeRequested']); const emits = defineEmits(['closeRequested']);
const title = ref(props.creator.title); const title = ref(props.creator.title);
const { t } = useI18n(); const { t } = useI18n();
const client = useClient(); const client = useClient();
async function save() { async function save() {
try { try {
await client.post( await client.post(`/api/creators/${props.creator.id}/title`, {
`/api/creators/${props.creator.id}/title`, title: title.value,
{ });
title: title.value
props.creator.title = title.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving title:', error);
} }
); }
props.creator.title = title.value; const cancel = () => {
emits('closeRequested'); emits('closeRequested');
} catch (error) { };
console.error('Error saving title:', error);
}
}
const cancel = () => {
emits('closeRequested');
};
</script> </script>
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-title"> <div class="card-content">
{{ t('title') }} <v-text-field
v-model="title"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div> </div>
<div class="card-content">
<v-text-field
v-model="title"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
{{ t('save') }}
</button>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Change Title", "title": "Change Title",
"label": "Your title" "label": "Your title"
}, },
"fr": { "fr": {
"title": "Modifier le titre", "title": "Modifier le titre",
"label": "Votre titre" "label": "Votre titre"
}, }
"es": {
"title": "Cambiar título",
"label": "Tu título"
}
} }
</i18n> </i18n>

View File

@@ -1,210 +1,200 @@
<script setup> <script setup>
import {ref} from 'vue' import { ref } from 'vue';
import {useClient} from "@/plugins/api.js"; import { useClient } from '@/plugins/api.js';
import X from "@/views/svg/X.vue"; import X from '@/views/svg/X.vue';
import Tiktok from "@/views/svg/Tiktok.vue"; import Tiktok from '@/views/svg/Tiktok.vue';
import Reddit from "@/views/svg/Reddit.vue"; import Reddit from '@/views/svg/Reddit.vue';
import Web from "@/views/svg/Web.vue"; import Web from '@/views/svg/Web.vue';
import Youtube from "@/views/svg/Youtube.vue"; import Youtube from '@/views/svg/Youtube.vue';
import Linkedin from "@/views/svg/Linkedin.vue"; import Linkedin from '@/views/svg/Linkedin.vue';
import Instagram from "@/views/svg/Instagram.vue"; import Instagram from '@/views/svg/Instagram.vue';
import Facebook from "@/views/svg/Facebook.vue"; import Facebook from '@/views/svg/Facebook.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
creator: { creator: {
required: true required: true,
} },
}) });
const emits = defineEmits(['closeRequested']) const emits = defineEmits(['closeRequested']);
const facebookUrl = ref(props.creator.socials.facebookUrl) const facebookUrl = ref(props.creator.socials.facebookUrl);
const instagramUrl = ref(props.creator.socials.instagramUrl) const instagramUrl = ref(props.creator.socials.instagramUrl);
const linkedInUrl = ref(props.creator.socials.linkedInUrl) const linkedInUrl = ref(props.creator.socials.linkedInUrl);
const redditUrl = ref(props.creator.socials.redditUrl) const redditUrl = ref(props.creator.socials.redditUrl);
const tikTokUrl = ref(props.creator.socials.tikTokUrl) const tikTokUrl = ref(props.creator.socials.tikTokUrl);
const websiteUrl = ref(props.creator.socials.websiteUrl) const websiteUrl = ref(props.creator.socials.websiteUrl);
const xUrl = ref(props.creator.socials.xUrl) const xUrl = ref(props.creator.socials.xUrl);
const youtubeUrl = ref(props.creator.socials.youtubeUrl) const youtubeUrl = ref(props.creator.socials.youtubeUrl);
const client = useClient() const client = useClient();
const save = async () => { const save = async () => {
try { try {
await client.post( await client.post(`/api/creators/${props.creator.id}/socials`, {
`/api/creators/${props.creator.id}/socials`, facebookUrl: facebookUrl.value || null,
{ instagramUrl: instagramUrl.value || null,
"facebookUrl": facebookUrl.value || null, linkedInUrl: linkedInUrl.value || null,
"instagramUrl": instagramUrl.value || null, redditUrl: redditUrl.value || null,
"linkedInUrl": linkedInUrl.value || null, tikTokUrl: tikTokUrl.value || null,
"redditUrl": redditUrl.value || null, websiteUrl: websiteUrl.value || null,
"tikTokUrl": tikTokUrl.value || null, xUrl: xUrl.value || null,
"websiteUrl": websiteUrl.value || null, youtubeUrl: youtubeUrl.value || null,
"xUrl": xUrl.value || null, });
"youtubeUrl": youtubeUrl.value || null,
})
props.creator.socials.facebookUrl = facebookUrl props.creator.socials.facebookUrl = facebookUrl;
props.creator.socials.instagramUrl = instagramUrl props.creator.socials.instagramUrl = instagramUrl;
props.creator.socials.linkedInUrl = linkedInUrl props.creator.socials.linkedInUrl = linkedInUrl;
props.creator.socials.redditUrl = redditUrl props.creator.socials.redditUrl = redditUrl;
props.creator.socials.tikTokUrl = tikTokUrl props.creator.socials.tikTokUrl = tikTokUrl;
props.creator.socials.websiteUrl = websiteUrl props.creator.socials.websiteUrl = websiteUrl;
props.creator.socials.xUrl = xUrl props.creator.socials.xUrl = xUrl;
props.creator.socials.youtubeUrl = youtubeUrl props.creator.socials.youtubeUrl = youtubeUrl;
emits('closeRequested') emits('closeRequested');
} catch (error) { } catch (error) {
console.error(error) console.error(error);
} }
} };
const cancel = () => {
emits('closeRequested')
}
const cancel = () => {
emits('closeRequested');
};
</script> </script>
<template> <template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog"> <div class="card-content">
<div class="editor-line">
<facebook class="social-icon"></facebook>
<input
v-model="facebookUrl"
:placeholder="t('facebook')"
class="input-field"
type="text"
/>
</div>
<div class="card-title"> <div class="editor-line">
{{ t('title') }} <instagram class="social-icon"></instagram>
<input
v-model="instagramUrl"
:placeholder="t('instagram')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<linkedin class="social-icon"></linkedin>
<input
v-model="linkedInUrl"
:placeholder="t('linkedin')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<reddit class="social-icon"></reddit>
<input
v-model="redditUrl"
:placeholder="t('reddit')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<tiktok class="social-icon"></tiktok>
<input
v-model="tikTokUrl"
:placeholder="t('tiktok')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<web class="social-icon"></web>
<input
v-model="websiteUrl"
:placeholder="t('website')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<x class="social-icon"></x>
<input
v-model="xUrl"
:placeholder="t('x')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<youtube class="social-icon"></youtube>
<input
v-model="youtubeUrl"
:placeholder="t('youtube')"
class="input-field"
type="text"
/>
</div>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div> </div>
<div class="card-content">
<div class="editor-line">
<facebook class="social-icon"></facebook>
<input
v-model="facebookUrl"
class="input-field"
:placeholder="t('facebook')"
type="text"
/>
</div>
<div class="editor-line">
<instagram class="social-icon"></instagram>
<input
v-model="instagramUrl"
class="input-field"
:placeholder="t('instagram')"
type="text"
/>
</div>
<div class="editor-line">
<linkedin class="social-icon"></linkedin>
<input
v-model="linkedInUrl"
class="input-field"
:placeholder="t('linkedin')"
type="text"
/>
</div>
<div class="editor-line">
<reddit class="social-icon"></reddit>
<input
v-model="redditUrl"
class="input-field"
:placeholder="t('reddit')"
type="text"
/>
</div>
<div class="editor-line">
<tiktok class="social-icon"></tiktok>
<input
v-model="tikTokUrl"
class="input-field"
:placeholder="t('tiktok')"
type="text"
/>
</div>
<div class="editor-line">
<web class="social-icon"></web>
<input
v-model="websiteUrl"
class="input-field"
:placeholder="t('website')"
type="text"
/>
</div>
<div class="editor-line">
<x class="social-icon"></x>
<input
v-model="xUrl"
class="input-field"
:placeholder="t('x')"
type="text"
/>
</div>
<div class="editor-line">
<youtube class="social-icon"></youtube>
<input
v-model="youtubeUrl"
class="input-field"
:placeholder="t('youtube')"
type="text"
/>
</div>
</div>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
{{ t('save') }}
</button>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.editor-line {
@apply flex flex-row gap-4;
@apply items-center;
}
.editor-line { .social-icon {
@apply flex flex-row gap-4; @apply w-8 h-8;
@apply items-center; }
}
.social-icon {
@apply w-8 h-8;
}
.input-field {
@apply w-full p-[10px];
@apply rounded-sm;
@apply transition duration-200;
@apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary;
@apply hover:ring-hutopyPrimary;
@apply placeholder:text-[#6D6C70]
}
.input-field {
@apply w-full p-[10px];
@apply rounded-sm;
@apply transition duration-200;
@apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary;
@apply hover:ring-hutopyPrimary;
@apply placeholder:text-[#6D6C70];
}
</style> </style>
<i18n> <i18n>
{ {
"en": { "en": {
"title": "Social Media Links" "title": "Social Media Links"
}, },
"fr": { "fr": {
"title": "Liens des réseaux sociaux" "title": "Liens des réseaux sociaux"
}, }
"es": {
"title": "Enlaces de redes sociales"
}
} }
</i18n> </i18n>