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"> <div class="shell-side">
<side-bar></side-bar> <site-bar></site-bar>
</div> </div>
<div class="shell-view"> <div class="shell-view">
<router-view></router-view> <router-view></router-view>
</div> </div>
</div> </div>
</v-app> </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 for changes to the language store
watch(() => languageStore.locale, (newLocale) => { watch(
() => languageStore.locale,
newLocale => {
if (newLocale) { if (newLocale) {
locale.value = newLocale; locale.value = newLocale;
} }
}, { immediate: true }); },
{ immediate: true }
);
</script> </script>
<style scoped> <style scoped>
.shell-container { .shell-container {
@apply flex flex-col lg:flex-row; @apply flex flex-col;
@apply w-full;
@apply font-sans; @apply font-sans;
@apply bg-hBackground text-hOnBackground; @apply bg-hBackground text-hOnBackground;
@apply min-h-screen h-full;
} }
.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,43 +5,60 @@ 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,
VProgressCircular,
VIcon,
VTextField,
VSnackbar,
VForm,
VTextarea,
VAlert,
}, },
directives: {},
icons: { icons: {
defaultSet: 'mdi', defaultSet: 'mdi',
aliases, aliases,
sets: { mdi } 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();

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 sanitizeLocale(value) {
return ALLOWED_LOCALES.includes(value) ? value : DEFAULT_LOCALE;
}
// Initialize locale with a sanitized value
const initial = sanitizeLocale(storedLocale.value);
storedLocale.value = initial;
if (locale) {
locale.value = initial;
} }
function setLocale(newLocale) { function setLocale(newLocale) {
const next = sanitizeLocale(newLocale);
if (locale) { if (locale) {
locale.value = newLocale locale.value = next;
} }
storedLocale.value = newLocale storedLocale.value = next;
} }
return { return {
locale: storedLocale, locale: storedLocale,
setLocale setLocale,
} allowedLocales: ALLOWED_LOCALES,
} };
) });

View File

@@ -14,27 +14,38 @@
<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
class="form-label"
for="email"
>
{{ t('email') }}
</label>
<input <input
id="email" id="email"
v-model="email" v-model="email"
type="email"
class="form-input" class="form-input"
required required
type="email"
/> />
</div> </div>
<button <button
type="submit"
class="primary w-full"
:disabled="isLoading" :disabled="isLoading"
class="primary w-full"
type="submit"
> >
<span v-if="isLoading" class="loading-spinner mr-2"></span> <span
v-if="isLoading"
class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }} {{ t('resetPassword') }}
</button> </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
class="text-sm text-blue-500"
to="/login"
>
{{ t('backToLogin') }} {{ t('backToLogin') }}
</router-link> </router-link>
</div> </div>
@@ -44,12 +55,18 @@
</div> </div>
<!-- Success message --> <!-- Success message -->
<div v-if="showSuccessMessage" class="notification success"> <div
v-if="showSuccessMessage"
class="notification success"
>
{{ t('resetEmailSent') }} {{ t('resetEmailSent') }}
</div> </div>
<!-- Error message --> <!-- Error message -->
<div v-if="showErrorMessage" class="notification error"> <div
v-if="showErrorMessage"
class="notification error"
>
{{ errorMessage }} {{ errorMessage }}
</div> </div>
</div> </div>
@@ -88,7 +105,7 @@ async function handleForgotPassword() {
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
@@ -137,7 +154,9 @@ async function handleForgotPassword() {
.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 {
@@ -194,16 +213,6 @@ async function handleForgotPassword() {
"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,16 +1,20 @@
<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"> <div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold"> <h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }} {{ t('title') }}
</h1> </h1>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<google-login :callback="googleCallback" popup-type="TOKEN"> <google-login
:callback="googleCallback"
popup-type="TOKEN"
>
<button class="secondary"> <button class="secondary">
<v-icon class="mr-2" :icon="mdiGoogle" /> <v-icon
:icon="mdiGoogle"
class="mr-2"
/>
{{ t('continueWithGoogle') }} {{ t('continueWithGoogle') }}
</button> </button>
</google-login> </google-login>
@@ -25,44 +29,73 @@
<!-- Add email/password form --> <!-- Add email/password form -->
<v-form @submit.prevent="handleLocalLogin"> <v-form @submit.prevent="handleLocalLogin">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<v-text-field v-model="email" :label="t('email')" type="email" required></v-text-field> <v-text-field
v-model="email"
:label="t('email')"
required
type="email"
></v-text-field>
<v-text-field v-model="password" :label="t('password')" :type="showPassword ? 'text' : 'password'" required> <v-text-field
v-model="password"
:label="t('password')"
:type="showPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner> <template v-slot:append-inner>
<v-icon @click="showPassword = !showPassword" class="visibility-toggle" size="small" <v-icon
:icon="showPassword ? mdiEyeOff : mdiEye" /> :icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template> </template>
</v-text-field> </v-text-field>
<v-btn type="submit" color="primary" block> <v-btn
block
color="primary"
type="submit"
>
{{ t('signIn') }} {{ t('signIn') }}
</v-btn> </v-btn>
<div class="text-center"> <div class="text-center">
<a @click="forgotPassword" class="cursor-pointer text-sm text-blue-500"> <a
class="cursor-pointer text-sm text-blue-500"
@click="forgotPassword"
>
{{ t('forgotPassword') }} {{ t('forgotPassword') }}
</a> </a>
</div> </div>
<div class="mt-2 text-center"> <div class="mt-2 text-center">
<a @click="resendVerification" class="cursor-pointer text-sm text-blue-500"> <a
class="cursor-pointer text-sm text-blue-500"
@click="resendVerification"
>
{{ t('resendVerification') }} {{ t('resendVerification') }}
</a> </a>
</div> </div>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
{{ t('noAccount') }} {{ t('noAccount') }}
<router-link to="/register" class="text-blue-500"> <router-link
class="text-blue-500"
to="/register"
>
{{ t('register') }} {{ t('register') }}
</router-link> </router-link>
</div> </div>
</div> </div>
</v-form> </v-form>
</div> </div>
<!-- Error notification --> <!-- Error notification -->
<v-snackbar v-model="errorSnackBar" color="error"> <v-snackbar
v-model="errorSnackBar"
color="error"
>
{{ t('loginFailed') }} {{ t('loginFailed') }}
</v-snackbar> </v-snackbar>
</div> </div>
@@ -70,11 +103,11 @@
<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();
@@ -88,8 +121,8 @@ 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() {
@@ -176,20 +209,6 @@ function resendVerification() {
"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

@@ -2,57 +2,97 @@
<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"
class="flex flex-col items-center gap-4"
>
<v-progress-circular
color="primary"
indeterminate
size="64"
></v-progress-circular>
<h2 class="text-xl font-medium">{{ t('verifying') }}</h2> <h2 class="text-xl font-medium">{{ t('verifying') }}</h2>
</div> </div>
<!-- Success state --> <!-- Success state -->
<div v-else-if="verificationSuccess" class="flex flex-col items-center gap-6"> <div
<v-icon icon="mdi-check-circle" color="green" size="64"></v-icon> v-else-if="verificationSuccess"
class="flex flex-col items-center gap-6"
>
<v-icon
color="green"
icon="mdi-check-circle"
size="64"
></v-icon>
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1> <h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1>
<p>{{ t('success.message') }}</p> <p>{{ t('success.message') }}</p>
<v-btn color="primary" @click="goToLogin">{{ t('success.goToLogin') }}</v-btn> <v-btn
color="primary"
@click="goToLogin"
>
{{ t('success.goToLogin') }}
</v-btn>
</div> </div>
<!-- Error state --> <!-- Error state -->
<div v-else class="flex flex-col items-center gap-6"> <div
<v-icon icon="mdi-alert-circle" color="error" size="64"></v-icon> v-else
class="flex flex-col items-center gap-6"
>
<v-icon
color="error"
icon="mdi-alert-circle"
size="64"
></v-icon>
<h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1> <h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1>
<p>{{ errorMessage || t('error.defaultMessage') }}</p> <p>{{ errorMessage || t('error.defaultMessage') }}</p>
<div class="mt-4 flex flex-col gap-4 w-full"> <div class="mt-4 flex flex-col gap-4 w-full">
<v-btn color="primary" @click="goToLogin">{{ t('error.goToLogin') }}</v-btn> <v-btn
color="primary"
@click="goToLogin"
>
{{ t('error.goToLogin') }}
</v-btn>
<v-divider class="my-4"></v-divider> <v-divider class="my-4"></v-divider>
<!-- Resend verification email section --> <!-- Resend verification email section -->
<h2 class="text-xl font-medium">{{ t('resend.title') }}</h2> <h2 class="text-xl font-medium">{{ t('resend.title') }}</h2>
<v-form @submit.prevent="handleResendVerification" class="w-full"> <v-form
class="w-full"
@submit.prevent="handleResendVerification"
>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<v-text-field <v-text-field
v-model="resendEmail" v-model="resendEmail"
:label="t('resend.emailLabel')"
type="email"
required
:error-messages="resendEmailError" :error-messages="resendEmailError"
:label="t('resend.emailLabel')"
required
type="email"
></v-text-field> ></v-text-field>
<v-btn <v-btn
type="submit"
color="secondary"
block
:loading="resendLoading" :loading="resendLoading"
block
color="secondary"
type="submit"
> >
{{ t('resend.button') }} {{ t('resend.button') }}
</v-btn> </v-btn>
<!-- Resend success message --> <!-- 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
v-if="resendSuccess"
class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm"
>
{{ t('resend.success') }} {{ t('resend.success') }}
</div> </div>
<!-- Resend error message --> <!-- 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"> <div
v-if="resendError"
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
>
{{ resendError }} {{ resendError }}
</div> </div>
</div> </div>
@@ -64,10 +104,10 @@
</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();
@@ -131,7 +171,7 @@ async function handleResendVerification() {
try { try {
await clientApi.post('/api/users/resend-verification', { await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim() email: resendEmail.value.trim(),
}); });
resendSuccess.value = true; resendSuccess.value = true;
} catch (error) { } catch (error) {
@@ -192,28 +232,6 @@ function goToLogin() {
"error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.", "error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.",
"invalidEmail": "Veuillez entrer une adresse email valide." "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,31 +1,51 @@
<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" <div
@mouseleave="showTint = false" @click="isCurrentCreator && openBannerEditor()"> class="relative overflow-y-auto rounded-b-2xl"
<img class="banner aspect-[4/1] w-full object-cover" @click="isCurrentCreator && openBannerEditor()"
:src="brandingStore.value?.bannerUrl ?? '/images/placeholders/banner.png'" :alt="t('alt')"> @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 --> <!-- Tint Effect -->
<div v-if="showTint" class="absolute inset-0 cursor-pointer bg-black/25"> <div
v-if="showTint"
class="absolute inset-0 cursor-pointer bg-black/25"
>
<!-- Top-right Icon --> <!-- 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="absolute right-4 top-4 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
<v-icon large :icon="mdiPencil" /> >
<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"
max-width="800px"
>
<BannerEditor
:creator="brandingStore.value"
@closeRequested="() => (isDialogOpen = false)"
/>
</v-dialog> </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';
@@ -56,9 +76,6 @@ const isCurrentCreator = computed(() => {
}, },
"fr": { "fr": {
"alt": "Bannière du créateur" "alt": "Bannière du créateur"
},
"es": {
"alt": "Banner del creador"
} }
} }
</i18n> </i18n>

View File

@@ -1,64 +1,111 @@
<template> <template>
<div class="album-editor"> <div class="album-editor">
<h2 class="mb-4 text-xl font-semibold"> <h2 class="mb-4 text-xl font-semibold">
{{ t('title') }} {{ t('title') }}
</h2> </h2>
<!-- Drop zone with photos --> <!-- Drop zone with photos -->
<div class="drop-zone" @dragover.prevent @drop.prevent="handleDrop" @click="triggerFileInput"> <div
class="drop-zone"
@click="triggerFileInput"
@dragover.prevent
@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
:icon="mdiPlus"
size="large"
/>
<span class="mt-2 text-sm">{{ t('dropzoneText') }}</span> <span class="mt-2 text-sm">{{ t('dropzoneText') }}</span>
</div> </div>
<!-- Hidden file input --> <!-- Hidden file input -->
<input type="file" ref="fileInput" @change="handleFileUpload" accept="image/*" multiple class="hidden" /> <input
ref="fileInput"
accept="image/*"
class="hidden"
multiple
type="file"
@change="handleFileUpload"
/>
<!-- Photos grid --> <!-- Photos grid -->
<draggable v-model="localImages" class="photos-grid" item-key="id" @end="handleReorder" :filter="'.action-btn'" <draggable
:prevent-on-filter="false"> v-model="localImages"
:filter="'.action-btn'"
:prevent-on-filter="false"
class="photos-grid"
item-key="id"
@end="handleReorder"
>
<template #item="{ element, index }"> <template #item="{ element, index }">
<div class="photo-wrapper"> <div class="photo-wrapper">
<div class="index-bubble">{{ index + 1 }}</div> <div class="index-bubble">{{ index + 1 }}</div>
<img :src="element.image.originalUrl" :alt="'Image ' + (index + 1)" /> <img
:alt="'Image ' + (index + 1)"
:src="element.image.originalUrl"
/>
<!-- Processing spinner overlay --> <!-- Processing spinner overlay -->
<div v-if="element.isProcessing" class="loading-overlay"> <div
<v-progress-circular indeterminate color="primary"></v-progress-circular> 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> <span class="mt-2 text-sm text-white">{{ t('processing') }}</span>
</div> </div>
<!-- Upload spinner overlay --> <!-- Upload spinner overlay -->
<div v-if="element.isUploading" class="loading-overlay uploading"> <div
<v-progress-circular indeterminate color="secondary"></v-progress-circular> 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> <span class="mt-2 text-sm text-white">{{ t('uploading') }}</span>
</div> </div>
<!-- Left arrow --> <!-- Left arrow -->
<button @click.stop="moveImage(index, 'up')" @touchstart.stop="moveImage(index, 'up')" <button
class="action-btn left-btn" :disabled="index === 0" :title="t('moveLeft')"> :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" /> <v-icon :icon="mdiArrowLeft" />
</button> </button>
<!-- Right arrow --> <!-- Right arrow -->
<button @click.stop="moveImage(index, 'down')" @touchstart.stop="moveImage(index, 'down')" <button
class="action-btn right-btn" :disabled="index === localImages.length - 1" :title="t('moveRight')"> :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" /> <v-icon :icon="mdiArrowRight" />
</button> </button>
<!-- Delete button --> <!-- Delete button -->
<button @click.stop="deleteImage(index)" touchstart.stop="deleteImage(index)" class="action-btn delete-btn" <button
:title="t('delete')"> :title="t('delete')"
class="action-btn delete-btn"
touchstart.stop="deleteImage(index)"
@click.stop="deleteImage(index)"
>
<v-icon :icon="mdiDelete" /> <v-icon :icon="mdiDelete" />
</button> </button>
</div> </div>
</template> </template>
</draggable> </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';
@@ -67,8 +114,8 @@ 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']);
@@ -83,7 +130,7 @@ onMounted(() => {
}); });
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 {
@@ -102,7 +149,7 @@ function handleFiles(files) {
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) {
@@ -118,6 +165,7 @@ function handleFiles(files) {
} }
} }
} }
function handleDrop(event) { function handleDrop(event) {
console.log('Drop triggered'); console.log('Drop triggered');
const files = Array.from(event.dataTransfer.files); const files = Array.from(event.dataTransfer.files);
@@ -140,7 +188,6 @@ function handleReorder() {
emit('update:images', localImages.value); 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) {
@@ -155,7 +202,6 @@ function deleteImage(index) {
localImages.value.splice(index, 1); localImages.value.splice(index, 1);
emit('update:images', localImages.value); emit('update:images', localImages.value);
} }
</script> </script>
<style scoped> <style scoped>
@@ -316,15 +362,6 @@ function deleteImage(index) {
"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,14 +1,21 @@
<template> <template>
<div v-if="hasImages" class="album-view"> <div
v-if="hasImages"
class="album-view"
>
<!-- Album Display --> <!-- Album Display -->
<div class="image-grid"> <div class="image-grid">
<div v-for="(url, index) in displayedImages" <div
v-for="(url, index) in displayedImages"
:key="index" :key="index"
class="image-wrapper" class="image-wrapper"
@click="$emit('photo-click', index)"> @click="$emit('photo-click', index)"
<img :src="url" >
<img
:alt="t('creator.sections.album.image')" :alt="t('creator.sections.album.image')"
class="image"/> :src="url"
class="image"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -18,15 +25,15 @@
// 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();
@@ -99,7 +106,6 @@ const gridColumns = computed(() => {
} }
/* Responsive adjustments */ /* Responsive adjustments */
</style> </style>
<i18n> <i18n>
@@ -123,16 +129,6 @@ const gridColumns = computed(() => {
} }
} }
} }
},
"es": {
"creator": {
"sections": {
"album": {
"title": "Álbum de fotos",
"image": "Imagen del álbum"
}
}
}
} }
} }
</i18n> </i18n>

View File

@@ -1,38 +1,72 @@
<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"
fullscreen
transition="dialog-bottom-transition"
@click:outside="closeViewer"
>
<div
class="album-viewer"
@click.self="closeViewer"
>
<!-- Main image container --> <!-- Main image container -->
<div class="image-container"> <div class="image-container">
<img :src="currentImage" :alt="t('viewer.imageAlt', { index: currentIndex + 1 })" class="main-image" /> <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')"
class="nav-btn left-btn"
@click.stop="previousImage"
>
<v-icon
:icon="mdiChevronLeft"
color="white"
size="large"
/>
</button> </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')"
class="nav-btn right-btn"
@click.stop="nextImage"
>
<v-icon
:icon="mdiChevronRight"
color="white"
size="large"
/>
</button> </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')"
class="close-btn"
@click.stop="closeViewer"
>
<v-icon
:icon="mdiClose"
color="white"
size="large"
/>
</button> </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> </div>
</v-dialog> </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';
@@ -41,16 +75,16 @@ 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']);
@@ -60,16 +94,22 @@ const currentIndex = ref(0);
const currentImage = computed(() => props.images[currentIndex.value]); const currentImage = computed(() => props.images[currentIndex.value]);
watch(() => props.modelValue, (newVal) => { watch(
() => props.modelValue,
newVal => {
dialog.value = newVal; dialog.value = newVal;
if (newVal) { if (newVal) {
currentIndex.value = props.startIndex; currentIndex.value = props.startIndex;
} }
}); }
);
watch(() => dialog.value, (newVal) => { watch(
() => dialog.value,
newVal => {
emit('update:modelValue', newVal); emit('update:modelValue', newVal);
}); }
);
function nextImage() { function nextImage() {
if (currentIndex.value < props.images.length - 1) { if (currentIndex.value < props.images.length - 1) {
@@ -162,14 +202,6 @@ function closeViewer() {
"close": "Fermer", "close": "Fermer",
"imageAlt": "Image {index}" "imageAlt": "Image {index}"
} }
},
"es": {
"viewer": {
"previous": "Imagen anterior",
"next": "Imagen siguiente",
"close": "Cerrar",
"imageAlt": "Imagen {index}"
}
} }
} }
</i18n> </i18n>

View File

@@ -1,17 +1,17 @@
<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;
@@ -23,7 +23,6 @@ const {t} = useI18n();
<!-- 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 --> <!-- Portrait that overlaps both sections -->
<div class="absolute left-4 -bottom-2 z-10"> <div class="absolute left-4 -bottom-2 z-10">
<creator-logo /> <creator-logo />
@@ -31,8 +30,7 @@ const {t} = useI18n();
<!-- Desktop version (visible only on écrans moyens et grands) --> <!-- Desktop version (visible only on écrans moyens et grands) -->
<div class="social-info"> <div class="social-info">
<div class="w-36"> <div class="w-36"></div>
</div>
<div class="flex-grow flex flex-row"> <div class="flex-grow flex flex-row">
<div class="flex-grow"> <div class="flex-grow">
<name-title></name-title> <name-title></name-title>
@@ -48,74 +46,84 @@ const {t} = useI18n();
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Section pour les icônes de réseaux sociaux --> <!-- Section pour les icônes de réseaux sociaux -->
<div <div class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative">
class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative"
>
<div class="flex flex-row gap-10"> <div class="flex flex-row gap-10">
<a
<a v-if="brandingStore.value?.socials?.facebookUrl" v-if="brandingStore.value?.socials?.facebookUrl"
:href="brandingStore.value?.socials?.facebookUrl" :href="brandingStore.value?.socials?.facebookUrl"
:title="t('facebook')"
target="_blank" target="_blank"
:title="t('facebook')"> >
<facebook class="social-icon"></facebook> <facebook class="social-icon"></facebook>
</a> </a>
<a v-if="brandingStore.value?.socials?.instagramUrl" <a
v-if="brandingStore.value?.socials?.instagramUrl"
:href="brandingStore.value?.socials?.instagramUrl" :href="brandingStore.value?.socials?.instagramUrl"
:title="t('instagram')"
target="_blank" target="_blank"
:title="t('instagram')"> >
<instagram class="social-icon"></instagram> <instagram class="social-icon"></instagram>
</a> </a>
<a v-if="brandingStore.value?.socials?.linkedInUrl" <a
v-if="brandingStore.value?.socials?.linkedInUrl"
:href="brandingStore.value?.socials?.linkedInUrl" :href="brandingStore.value?.socials?.linkedInUrl"
:title="t('linkedin')"
target="_blank" target="_blank"
:title="t('linkedin')"> >
<linkedin class="social-icon"></linkedin> <linkedin class="social-icon"></linkedin>
</a> </a>
<a v-if="brandingStore.value?.socials?.redditUrl" <a
v-if="brandingStore.value?.socials?.redditUrl"
:href="brandingStore.value?.socials?.redditUrl" :href="brandingStore.value?.socials?.redditUrl"
:title="t('reddit')"
target="_blank" target="_blank"
:title="t('reddit')"> >
<reddit class="social-icon"></reddit> <reddit class="social-icon"></reddit>
</a> </a>
<a v-if="brandingStore.value?.socials?.tikTokUrl" <a
v-if="brandingStore.value?.socials?.tikTokUrl"
:href="brandingStore.value?.socials?.tikTokUrl" :href="brandingStore.value?.socials?.tikTokUrl"
:title="t('tiktok')"
target="_blank" target="_blank"
:title="t('tiktok')"> >
<tiktok class="social-icon"></tiktok> <tiktok class="social-icon"></tiktok>
</a> </a>
<a v-if="brandingStore.value?.socials?.xUrl" <a
v-if="brandingStore.value?.socials?.xUrl"
:href="brandingStore.value?.socials?.xUrl" :href="brandingStore.value?.socials?.xUrl"
:title="t('x')"
target="_blank" target="_blank"
:title="t('x')"> >
<x class="social-icon"></x> <x class="social-icon"></x>
</a> </a>
<a v-if="brandingStore.value?.socials?.youtubeUrl" <a
v-if="brandingStore.value?.socials?.youtubeUrl"
:href="brandingStore.value?.socials?.youtubeUrl" :href="brandingStore.value?.socials?.youtubeUrl"
:title="t('youtube')"
target="_blank" target="_blank"
:title="t('youtube')"> >
<youtube class="social-icon"></youtube> <youtube class="social-icon"></youtube>
</a> </a>
<a v-if="brandingStore.value?.socials?.websiteUrl" <a
v-if="brandingStore.value?.socials?.websiteUrl"
:href="brandingStore.value?.socials?.websiteUrl" :href="brandingStore.value?.socials?.websiteUrl"
:title="t('website')"
target="_blank" target="_blank"
:title="t('website')"> >
<web class="social-icon"></web> <web class="social-icon"></web>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -157,16 +165,6 @@ const {t} = useI18n();
"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

@@ -11,10 +11,10 @@
<div class="file-input-container"> <div class="file-input-container">
<input <input
type="file"
ref="fileInput" ref="fileInput"
accept="image/*" accept="image/*"
class="hidden" class="hidden"
type="file"
@change="onFileSelected" @change="onFileSelected"
/> />
<button <button
@@ -25,29 +25,38 @@
</button> </button>
</div> </div>
<div v-if="errorMessage" class="error-message"> <div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div v-if="showCropper" class="cropper-wrapper"> <div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper <Cropper
ref="cropper" ref="cropper"
:src="fileUrl"
:aspect-ratio="4" :aspect-ratio="4"
:src="fileUrl"
:stencil-props="{ :stencil-props="{
aspectRatio: 4, aspectRatio: 4,
class: 'banner-stencil' class: 'banner-stencil',
}" }"
/> />
</div> </div>
<div v-else class="image-preview-container" <div
v-else
class="image-preview-container"
@click="startEditing" @click="startEditing"
@dragover.prevent @dragover.prevent
@drop.prevent="handleDrop"> @drop.prevent="handleDrop"
>
<img <img
:src="fileUrl || fallbackUrl"
:alt="t('preview')" :alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image" class="preview-image"
/> />
<div class="edit-overlay"> <div class="edit-overlay">
@@ -57,14 +66,18 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
:disabled="isUploading"
class="secondary"
@click="cancel" @click="cancel"
:disabled="isUploading"> >
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()" @click="showCropper ? applyCrop() : publish()"
:disabled="!selectedFile || isUploading"> >
<template v-if="isUploading"> <template v-if="isUploading">
<span class="loading-spinner"></span> <span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%) {{ t('uploading') }} ({{ uploadProgress }}%)
@@ -78,154 +91,150 @@
</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 onFileSelected = event => {
const file = event.target.files[0] const file = event.target.files[0];
if (file) { if (file) {
selectedFile.value = file selectedFile.value = file;
const reader = new FileReader() const reader = new FileReader();
reader.onload = (e) => { reader.onload = e => {
fileUrl.value = e.target.result fileUrl.value = e.target.result;
showCropper.value = true showCropper.value = true;
} };
reader.readAsDataURL(file) reader.readAsDataURL(file);
} else { } else {
selectedFile.value = null selectedFile.value = null;
fileUrl.value = null fileUrl.value = null;
showCropper.value = false showCropper.value = false;
}
} }
};
const startEditing = () => { const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) { if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image) // Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value) const blob = dataURLtoBlob(fileUrl.value);
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' }) selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
showCropper.value = true showCropper.value = true;
} else { } else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input // If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput() triggerFileInput();
}
} }
};
// Helper function to convert data URL to blob // Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => { const dataURLtoBlob = dataURL => {
const arr = dataURL.split(',') const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1] const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]) const bstr = atob(arr[1]);
let n = bstr.length let n = bstr.length;
const u8arr = new Uint8Array(n) const u8arr = new Uint8Array(n);
while (n--) { while (n--) {
u8arr[n] = bstr.charCodeAt(n) u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime })
} }
return new Blob([u8arr], { type: mime });
};
const applyCrop = () => { const applyCrop = () => {
if (!cropper.value) return if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas const canvas = cropper.value.getResult().canvas;
canvas.toBlob((blob) => { canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, { const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type type: selectedFile.value.type,
}) });
selectedFile.value = croppedFile selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL() fileUrl.value = canvas.toDataURL();
showCropper.value = false showCropper.value = false;
}, selectedFile.value.type) }, selectedFile.value.type);
} };
const client = useClient() const client = useClient();
const publish = async () => { const publish = async () => {
if (!selectedFile.value || isUploading.value) return if (!selectedFile.value || isUploading.value) return;
try { try {
isUploading.value = true isUploading.value = true;
uploadProgress.value = 0 uploadProgress.value = 0;
const formData = new FormData() const formData = new FormData();
formData.append('file', selectedFile.value) formData.append('file', selectedFile.value);
const response = await client.post( const response = await client.post(`/api/creators/${props.creator.id}/banner`, formData, {
`/api/creators/${props.creator.id}/banner`, onUploadProgress: progressEvent => {
formData, uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
{ },
onUploadProgress: (progressEvent) => { });
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
}
)
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}` props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`;
fileUrl.value = props.creator.bannerUrl fileUrl.value = props.creator.bannerUrl;
emits('closeRequested') emits('closeRequested');
} catch (error) { } catch (error) {
console.error(error) console.error(error);
errorMessage.value = t('errors.imageUpload') errorMessage.value = t('errors.imageUpload');
} finally { } finally {
isUploading.value = false isUploading.value = false;
uploadProgress.value = 0 uploadProgress.value = 0;
}
} }
};
const cancel = () => { const cancel = () => {
showCropper.value = false showCropper.value = false;
// Reset to original state if we were editing // Reset to original state if we were editing
if (props.creator?.bannerUrl) { if (props.creator?.bannerUrl) {
fileUrl.value = props.creator.bannerUrl fileUrl.value = props.creator.bannerUrl;
selectedFile.value = null selectedFile.value = null;
} else { } else {
fileUrl.value = fallbackUrl fileUrl.value = fallbackUrl;
selectedFile.value = null selectedFile.value = null;
}
emits('closeRequested')
} }
emits('closeRequested');
};
// Add drop handler // Add drop handler
const handleDrop = (event) => { const handleDrop = event => {
const file = event.dataTransfer.files[0] const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith('image/')) {
selectedFile.value = file selectedFile.value = file;
const reader = new FileReader() const reader = new FileReader();
reader.onload = (e) => { reader.onload = e => {
fileUrl.value = e.target.result fileUrl.value = e.target.result;
showCropper.value = true showCropper.value = true;
} };
reader.readAsDataURL(file) reader.readAsDataURL(file);
}
} }
};
</script> </script>
<style scoped> <style scoped>
@@ -363,13 +372,6 @@ const handleDrop = (event) => {
"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,15 +1,15 @@
<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('');
@@ -21,19 +21,19 @@ const userProfileStore = useUserProfileStore();
const { t } = useI18n(); const { t } = useI18n();
function handleCreatorNameReservationIdChanged($event) { function handleCreatorNameReservationIdChanged($event) {
creatorNameReservationId.value = $event creatorNameReservationId.value = $event;
} }
function cancel() { function cancel() {
// if a returnUrl querystring was supplied, prefer it // if a returnUrl querystring was supplied, prefer it
const returnUrl = route.query.returnUrl const returnUrl = route.query.returnUrl;
if (typeof returnUrl === 'string' && returnUrl.length) { if (typeof returnUrl === 'string' && returnUrl.length) {
router.push(returnUrl) router.push(returnUrl);
return return;
} }
// otherwise just go back one step in history // otherwise just go back one step in history
router.back() router.back();
} }
// TODO: The `fetchCreatorProfile` function should be private (push-up to the store)! // TODO: The `fetchCreatorProfile` function should be private (push-up to the store)!
@@ -58,13 +58,11 @@ async function createAccount() {
isOperationPending.value = false; isOperationPending.value = false;
} }
} }
</script> </script>
<template> <template>
<div class="container"> <div class="container">
<div class="card"> <div class="card">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div> </div>
@@ -80,17 +78,18 @@ async function createAccount() {
<div class="card-actions"> <div class="card-actions">
<button <button
class="secondary" class="secondary"
@click="cancel"> @click="cancel"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button <button
class="primary"
:disabled="!canSave || isOperationPending" :disabled="!canSave || isOperationPending"
@click="createAccount"> class="primary"
@click="createAccount"
>
{{ t('create') }} {{ t('create') }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -101,17 +100,13 @@ async function createAccount() {
> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </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>
@@ -131,14 +126,6 @@ async function createAccount() {
"errors": { "errors": {
"unexpected": "Une erreur inattendue s'est produite" "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,33 +1,35 @@
<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 --> <!-- Donation Section -->
<div v-if="brandingStore.value?.acceptDonation" class="section sm:hidden"> <div
<DonationButton :creator-id="brandingStore.value?.id" :creator-name="brandingStore.value?.name" v-if="brandingStore.value?.acceptDonation"
class="section sm:hidden"
>
<DonationButton
:creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id" :on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id"
:on-success-url="baseURL + '/paymentcompleted/' + brandingStore.value?.id" /> :on-success-url="baseURL + '/paymentcompleted/' + brandingStore.value?.id"
/>
</div> </div>
<!-- About Creator Section --> <!-- About Creator Section -->
<div class="section"> <div class="section">
<AboutCreator /> <AboutCreator />
</div> </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 brandingStore = useBrandingStore();
const baseURL = window.location.origin; const baseURL = window.location.origin;
</script> </script>
<style scoped> <style scoped>
@@ -52,13 +54,16 @@ const baseURL = window.location.origin;
@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,
rgba(64, 64, 64, 1) 0%,
rgba(64, 64, 64, 0) 20%,
rgba(64, 64, 64, 0.5) 100%
);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0); linear-gradient(#fff 0 0);
mask-composite: exclude; mask-composite: exclude;
pointer-events: none; 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,38 +1,57 @@
<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"> <div class="size-[110px] rounded-full border-4 border-hPrimary">
<img :src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'" :alt="t('logoAlt')" <img
width="110px" height="110px" class="rounded-full" /> :alt="t('logoAlt')"
:src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'"
class="rounded-full"
height="110px"
width="110px"
/>
</div> </div>
<!-- Tint Effect --> <!-- Tint Effect -->
<div v-if="showTint" class="absolute inset-0 cursor-pointer rounded-full bg-black/25" :title="t('editLogo')"> <div
v-if="showTint"
:title="t('editLogo')"
class="absolute inset-0 cursor-pointer rounded-full bg-black/25"
>
<!-- Top-right Icon --> <!-- Top-right Icon -->
<div <div
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"> class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
<v-icon large :icon="mdiPencil" /> >
<v-icon
:icon="mdiPencil"
large
/>
</div>
</div> </div>
</div> </div>
</div> <v-dialog
v-model="isDialogOpen"
<v-dialog v-model="isDialogOpen" max-width="800px"> max-width="800px"
>
<template #default="{ close }"> <template #default="{ close }">
<creator-logo-editor :creator="brandingStore?.value" <creator-logo-editor
@closeRequested="() => isDialogOpen = false"></creator-logo-editor> :creator="brandingStore?.value"
@closeRequested="() => (isDialogOpen = false)"
></creator-logo-editor>
</template> </template>
</v-dialog> </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();
@@ -51,7 +70,6 @@ const openBannerEditor = () => {
const isCurrentCreator = computed(() => { const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id; return authStore.userId === brandingStore.value.id;
}); });
</script> </script>
<style scoped> <style scoped>
@@ -69,10 +87,6 @@ const isCurrentCreator = computed(() => {
"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

@@ -11,10 +11,10 @@
<div class="file-input-container"> <div class="file-input-container">
<input <input
type="file"
ref="fileInput" ref="fileInput"
accept="image/*" accept="image/*"
class="hidden" class="hidden"
type="file"
@change="onFileSelected" @change="onFileSelected"
/> />
<button <button
@@ -25,31 +25,40 @@
</button> </button>
</div> </div>
<div v-if="errorMessage" class="error-message"> <div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div v-if="showCropper" class="cropper-wrapper"> <div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper <Cropper
ref="cropper" ref="cropper"
:src="fileUrl"
:aspect-ratio="1" :aspect-ratio="1"
:src="fileUrl"
:stencil-component="CircleStencil" :stencil-component="CircleStencil"
:stencil-props="{ :stencil-props="{
aspectRatio: 1, aspectRatio: 1,
class: 'circle-stencil' class: 'circle-stencil',
}" }"
/> />
</div> </div>
<div v-else class="image-preview-container" <div
v-else
class="image-preview-container"
@click="startEditing" @click="startEditing"
@dragover.prevent @dragover.prevent
@drop.prevent="handleDrop"> @drop.prevent="handleDrop"
>
<div class="circular-preview"> <div class="circular-preview">
<img <img
:src="fileUrl || fallbackUrl"
:alt="t('preview')" :alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image" class="preview-image"
/> />
<div class="edit-overlay"> <div class="edit-overlay">
@@ -60,14 +69,18 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
:disabled="isUploading"
class="secondary"
@click="cancel" @click="cancel"
:disabled="isUploading"> >
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()" @click="showCropper ? applyCrop() : publish()"
:disabled="!selectedFile || isUploading"> >
<template v-if="isUploading"> <template v-if="isUploading">
<span class="loading-spinner"></span> <span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%) {{ t('uploading') }} ({{ uploadProgress }}%)
@@ -81,158 +94,154 @@
</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 onFileSelected = event => {
const file = event.target.files[0] const file = event.target.files[0];
if (file) { if (file) {
selectedFile.value = file selectedFile.value = file;
const reader = new FileReader() const reader = new FileReader();
reader.onload = (e) => { reader.onload = e => {
fileUrl.value = e.target.result fileUrl.value = e.target.result;
showCropper.value = true showCropper.value = true;
} };
reader.readAsDataURL(file) reader.readAsDataURL(file);
} else { } else {
selectedFile.value = null selectedFile.value = null;
fileUrl.value = null fileUrl.value = null;
showCropper.value = false showCropper.value = false;
}
} }
};
const startEditing = () => { const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) { if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image) // Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value) const blob = dataURLtoBlob(fileUrl.value);
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' }) selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
showCropper.value = true showCropper.value = true;
} else { } else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input // If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput() triggerFileInput();
}
} }
};
// Helper function to convert data URL to blob // Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => { const dataURLtoBlob = dataURL => {
const arr = dataURL.split(',') const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1] const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]) const bstr = atob(arr[1]);
let n = bstr.length let n = bstr.length;
const u8arr = new Uint8Array(n) const u8arr = new Uint8Array(n);
while (n--) { while (n--) {
u8arr[n] = bstr.charCodeAt(n) u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime })
} }
return new Blob([u8arr], { type: mime });
};
const applyCrop = () => { const applyCrop = () => {
if (!cropper.value) return if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas const canvas = cropper.value.getResult().canvas;
canvas.toBlob((blob) => { canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, { const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type type: selectedFile.value.type,
}) });
selectedFile.value = croppedFile selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL() fileUrl.value = canvas.toDataURL();
showCropper.value = false showCropper.value = false;
}, selectedFile.value.type) }, selectedFile.value.type);
} };
const client = useClient() const client = useClient();
const publish = async () => { const publish = async () => {
if (!selectedFile.value || isUploading.value) return if (!selectedFile.value || isUploading.value) return;
try { try {
isUploading.value = true isUploading.value = true;
uploadProgress.value = 0 uploadProgress.value = 0;
const formData = new FormData() const formData = new FormData();
formData.append('file', selectedFile.value) formData.append('file', selectedFile.value);
const response = await client.post( const response = await client.post(`/api/creators/${props.creator.id}/logo`, formData, {
`/api/creators/${props.creator.id}/logo`, onUploadProgress: progressEvent => {
formData, uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
{ },
onUploadProgress: (progressEvent) => { });
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
}
)
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}` props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`;
if (props.creator.portraitUrl) { if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl fileUrl.value = props.creator.portraitUrl;
} }
emits('closeRequested') emits('closeRequested');
} catch (error) { } catch (error) {
console.error(error) console.error(error);
errorMessage.value = t('errors.imageUpload') errorMessage.value = t('errors.imageUpload');
} finally { } finally {
isUploading.value = false isUploading.value = false;
uploadProgress.value = 0 uploadProgress.value = 0;
}
} }
};
const cancel = () => { const cancel = () => {
showCropper.value = false showCropper.value = false;
// Reset to original state if we were editing // Reset to original state if we were editing
if (props.creator.portraitUrl) { if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl fileUrl.value = props.creator.portraitUrl;
selectedFile.value = null selectedFile.value = null;
} else { } else {
fileUrl.value = fallbackUrl fileUrl.value = fallbackUrl;
selectedFile.value = null selectedFile.value = null;
}
emits('closeRequested')
} }
emits('closeRequested');
};
// Add drop handler // Add drop handler
const handleDrop = (event) => { const handleDrop = event => {
const file = event.dataTransfer.files[0] const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith('image/')) {
selectedFile.value = file selectedFile.value = file;
const reader = new FileReader() const reader = new FileReader();
reader.onload = (e) => { reader.onload = e => {
fileUrl.value = e.target.result fileUrl.value = e.target.result;
showCropper.value = true showCropper.value = true;
} };
reader.readAsDataURL(file) reader.readAsDataURL(file);
}
} }
};
</script> </script>
<style scoped> <style scoped>
@@ -384,14 +393,6 @@ const handleDrop = (event) => {
"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,5 +1,4 @@
<template> <template>
<button <button
class="secondary donation-action" class="secondary donation-action"
@click="openDonationDialog()" @click="openDonationDialog()"
@@ -11,12 +10,11 @@
ref="donationDialogRef" ref="donationDialogRef"
:creator-id="creatorId" :creator-id="creatorId"
:creator-name="creatorName" :creator-name="creatorName"
:on-success-url="onSuccessUrl"
:on-cancelled-url="onCancelledUrl"
:icon-color-class="iconColorClass" :icon-color-class="iconColorClass"
:on-cancelled-url="onCancelledUrl"
:on-success-url="onSuccessUrl"
@close="handleDialogClose" @close="handleDialogClose"
/> />
</template> </template>
<script setup> <script setup>
@@ -71,13 +69,6 @@ function handleDialogClose() {
"isupport": "Je Soutiens" "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,28 +1,25 @@
<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();
@@ -43,7 +40,7 @@ const isCurrentSlug = computed(() => {
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;
@@ -68,7 +65,7 @@ onMounted(() => {
// 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';
} }
}); });
@@ -94,13 +91,13 @@ const handleInput = () => {
// 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;
@@ -111,8 +108,8 @@ const handleInput = () => {
}; };
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;
@@ -123,7 +120,7 @@ const checkNameAvailability = async (nameToCheck) => {
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();
@@ -137,14 +134,14 @@ const checkNameAvailability = async (nameToCheck) => {
// 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 {
@@ -159,22 +156,39 @@ onUnmounted(() => {
cancelCurrentRequest(); cancelCurrentRequest();
clearTimeout(timeout); 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"
:error-messages="validationError"
:label="t('creator.name.label')"
variant="outlined"
@input="handleInput"
>
<template #prepend-inner> <template #prepend-inner>
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span> <span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span>
</template> </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'"
:icon="mdiCheckCircle"
color="green"
/>
<v-icon
v-else-if="reservationState === 'unavailable'"
:icon="mdiCloseCircle"
color="red"
/>
</template> </template>
</v-text-field> </v-text-field>
</template> </template>
@@ -204,17 +218,6 @@ onUnmounted(() => {
} }
} }
} }
},
"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
v-show="brandingStore.value.verified"
:title="t('verified')"
class="text-blue mt-1" class="text-blue mt-1"
:title="t('verified')"> >
<icon-account-verified></icon-account-verified> <icon-account-verified></icon-account-verified>
</div> </div>
</div> </div>
@@ -17,8 +19,8 @@
</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();
@@ -32,9 +34,6 @@ const { t } = useI18n();
}, },
"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
@@ -19,18 +17,10 @@
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>
@@ -87,29 +77,6 @@
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,51 +1,76 @@
<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"> <footer class="flex flex-col gap-10 pt-7 pb-10">
<div class="footer-socials"> <div class="footer-socials">
<a href="https://www.facebook.com/profile.php?id=61556819217561" target="_blank"> <a
href="https://www.facebook.com/profile.php?id=61556819217561"
target="_blank"
>
<facebook class="social-icon"></facebook> <facebook class="social-icon"></facebook>
</a> </a>
<a href="https://www.instagram.com/hutopy.inc/" target="_blank"> <a
href="https://www.instagram.com/hutopy.inc/"
target="_blank"
>
<instagram class="social-icon"></instagram> <instagram class="social-icon"></instagram>
</a> </a>
<a href="https://x.com/Hutopyinc/" target="_blank"> <a
href="https://x.com/Hutopyinc/"
target="_blank"
>
<x class="social-icon"></x> <x class="social-icon"></x>
</a> </a>
</div> </div>
<div class="footer-links"> <div class="footer-links">
<router-link to="/documents/helpandcontact" <router-link
class="link"> class="link"
to="/documents/helpandcontact"
>
{{ t('footer.helpandcontact') }} {{ t('footer.helpandcontact') }}
</router-link> </router-link>
<router-link to="/documents/faq" <router-link
class="link"> class="link"
to="/documents/faq"
>
{{ t('footer.faq') }} {{ t('footer.faq') }}
</router-link> </router-link>
<router-link to="/documents/termsandconditions" <router-link
class="link"> class="link"
to="/documents/guideforcreators"
>
{{ t('footer.creatorguide') }}
</router-link>
<router-link
class="link"
to="/documents/termsandconditions"
>
{{ t('footer.termsandconditions') }} {{ t('footer.termsandconditions') }}
</router-link> </router-link>
<router-link to="/documents/contentpolicy" <router-link
class="link"> class="link"
to="/documents/contentpolicy"
>
{{ t('footer.contentpolicy') }} {{ t('footer.contentpolicy') }}
</router-link> </router-link>
<router-link to="/documents/about" <router-link
class="link"> class="link"
to="/documents/about"
>
{{ t('footer.about') }} {{ t('footer.about') }}
</router-link> </router-link>
<router-link to="/documents/pricing" <router-link
class="link"> class="link"
to="/documents/pricing"
>
{{ t('footer.pricing') }} {{ t('footer.pricing') }}
</router-link> </router-link>
</div> </div>
@@ -53,13 +78,10 @@ const { t } = useI18n();
<div class="footer-copyright"> <div class="footer-copyright">
Hutopy &copy;{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }} Hutopy &copy;{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }}
</div> </div>
</footer> </footer>
</template> </template>
<style scoped> <style scoped>
.footer-socials { .footer-socials {
@apply flex flex-row justify-center; @apply flex flex-row justify-center;
@apply gap-10; @apply gap-10;
@@ -85,7 +107,6 @@ const { t } = useI18n();
@apply tracking-widest font-sans text-sm; @apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400; @apply hover:text-gray-400;
} }
</style> </style>
<i18n> <i18n>
@@ -113,18 +134,6 @@ const { t } = useI18n();
"pricing": "Tarifs", "pricing": "Tarifs",
"allRightsReserved": "Tous Droits Réservés" "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,5 +1,5 @@
<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();
@@ -7,53 +7,63 @@ const { t } = useI18n();
<template> <template>
<div> <div>
<div> <div>
<div class="pa-4 flex flex-col justify-center md:flex-row"> <div class="pa-4 flex flex-col justify-center md:flex-row">
<div class="py-6"> <div class="py-6">
<div> <div>
<img alt="Hutopy Logo" class="md:h-44 logo-image sm:h-28 sm:mx-auto" <img
src="/images/hutopymedia/banners/hutopy.png"> alt="Hutopy Logo"
class="md:h-44 logo-image sm:h-28 sm:mx-auto"
src="/images/hutopymedia/banners/hutopy.png"
/>
</div> </div>
</div> </div>
<div class="flex flex-col space-y-3 header-btn"> <div class="flex flex-col space-y-3 header-btn">
<v-btn <v-btn
class="text-white w-full sm:w-auto inscription-btn-header" class="text-white w-full sm:w-auto inscription-btn-header"
to="/login"> to="/login"
>
{{ t('inscription') }} {{ t('inscription') }}
</v-btn> </v-btn>
<v-btn <v-btn
class="w-full sm:w-auto inscription-btn-header-outlined" class="w-full sm:w-auto inscription-btn-header-outlined"
to="/create-creator" to="/create-creator"
variant="outlined"> variant="outlined"
>
{{ t('createPage') }} {{ t('createPage') }}
</v-btn> </v-btn>
</div> </div>
</div> </div>
</div> </div>
<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="support-container flex flex-col items-center space-y-4 md:flex-row md:space-y-0 md:space-x-6">
<div class="support-text text-justify md:text-left"> <div class="support-text text-justify md:text-left">
<span class="text-white"> {{ t('support') }} </span><br> <span class="text-white">{{ t('support') }}</span>
<span class="text-white"> {{ t('creators') }} </span><br> <br />
<span class="text-white"> {{ t('projects') }} </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> <span class="text-white">{{ t('love') }}</span>
</div> </div>
<img alt="YourHutopy" class="w-48 h-48 md:w-48 md:h-48 object-contain" <img
src="/images/hutopymedia/banners/heart.png"> alt="YourHutopy"
class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png"
/>
</div> </div>
<div class="relative mt-10"> <div class="relative mt-10">
<div
<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"> 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="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> <div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
<img <img
alt="YourHutopy" alt="YourHutopy"
class="max-h-56 mx-auto" class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/hands.png" src="/images/hutopymedia/homepage/hands.png"
> />
<div class="text-md text-justify px-6"> <div class="text-md text-justify px-6">
{{ t('supportDescription') }} {{ t('supportDescription') }}
</div> </div>
@@ -65,7 +75,7 @@ const { t } = useI18n();
alt="YourHutopy" alt="YourHutopy"
class="max-h-56 mx-auto" class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/brain.png" src="/images/hutopymedia/homepage/brain.png"
> />
<div class="text-md text-justify px-6"> <div class="text-md text-justify px-6">
{{ t('creatorDescription') }} {{ t('creatorDescription') }}
</div> </div>
@@ -77,16 +87,21 @@ const { t } = useI18n();
</v-btn> </v-btn>
</div> </div>
</div> </div>
</div> </div>
<div class="max-w-5xl mx-auto px-6 py-8"> <div class="max-w-5xl mx-auto px-6 py-8">
<div class="gap-8 items-start flex flex-col md:flex-row"> <div class="gap-8 items-start flex flex-col md:flex-row">
<!-- Section de texte --> <!-- Section de texte -->
<div class="space-y-6"> <div class="space-y-6">
<img alt="YourHutopy" class="w-full mb-6" src="/images/hutopymedia/homepage/votrehutopy.png"> <img
alt="YourHutopy"
class="w-full mb-6"
src="/images/hutopymedia/homepage/votrehutopy.png"
/>
<div class="space-y-4"> <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('whatIsHutopy') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext"> <p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyDescription') }} {{ t('hutopyDescription') }}
</p> </p>
@@ -106,54 +121,71 @@ const { t } = useI18n();
<!-- Section droite : 4 images --> <!-- Section droite : 4 images -->
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15"> <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" <div>
src="/images/hutopymedia/homepage/grinding.png"></div> <img
<div><img alt="Microphone" class="w-full h-auto object-cover rounded-2xl" alt="Grinding"
src="/images/hutopymedia/homepage/sign.png"></div> class="w-full h-auto object-cover rounded-2xl"
<div><img alt="Girl VR" class="w-full h-auto object-cover rounded-2xl" src="/images/hutopymedia/homepage/grinding.png"
src="/images/hutopymedia/homepage/girlvr.png"></div> />
<div><img alt="Girl Army" class="w-full h-auto object-cover rounded-2xl" </div>
src="/images/hutopymedia/homepage/girlarmy.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> </div>
</div> </div>
<Footer class="mt-10"></Footer> <Footer class="mt-10"></Footer>
</div> </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 { .inscription-btn-header-outlined {
color: #6A0164; 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 { .inscription-btn {
color: white; color: white;
background-color: #6A0164; background-color: #6a0164;
font-size: 18px; font-size: 18px;
height: 40px; height: 40px;
width: auto; width: auto;
@@ -163,13 +195,13 @@ const { t } = useI18n();
} }
.create-btn { .create-btn {
background-color: #6A0164; background-color: #6a0164;
font-size: 18px; font-size: 18px;
height: 48px; height: 48px;
width: auto; width: auto;
padding: 0 32px; padding: 0 32px;
font-weight: bold; font-weight: bold;
border-radius: 10px border-radius: 10px;
} }
.overlay p { .overlay p {
@@ -179,14 +211,13 @@ const { t } = useI18n();
} }
body { body {
background-color: #F4F4F4; background-color: #f4f4f4;
} }
.support-container { .support-container {
display: flex; display: flex;
justify-content: center; /* Centre le bloc horizontalement */ justify-content: center; /* Centre le bloc horizontalement */
align-items: center; /* Centre le bloc verticalement (optionnel) */ align-items: center; /* Centre le bloc verticalement (optionnel) */
} }
.support-text { .support-text {
@@ -197,13 +228,12 @@ body {
} }
.support-text .highlight { .support-text .highlight {
color: #6A0164; /* Remplacez par la couleur souhaitée */ color: #6a0164; /* Remplacez par la couleur souhaitée */
font-weight: bold; /* Mettre en gras */ font-weight: bold; /* Mettre en gras */
} }
.highlight2 { .highlight2 {
color: #B81286; /* Remplacez par la couleur souhaitée */ color: #b81286; /* Remplacez par la couleur souhaitée */
} }
.logo-image { .logo-image {
@@ -217,7 +247,7 @@ body {
} }
.support-text { .support-text {
font-size: 3.0rem; /* Ajustez la taille du texte */ font-size: 3rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */ line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */ text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */ font-weight: bold; /* Rend le texte gras */
@@ -225,7 +255,6 @@ body {
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.header-btn { .header-btn {
margin-top: 60px; margin-top: 60px;
} }
@@ -238,9 +267,8 @@ body {
.homepagetext { .homepagetext {
color: white; color: white;
font-family: "Roboto", sans-serif; font-family: 'Roboto', sans-serif;
} }
</style> </style>
<i18n> <i18n>
@@ -276,22 +304,6 @@ body {
"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,35 +1,33 @@
<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="alias" v-model="alias"
:label="t('label')" :label="t('label')"
variant="outlined"
></v-text-field> ></v-text-field>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button
<button class="secondary" class="secondary"
@click="requestClose"> @click="requestClose"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
@click="requestSave"> class="primary"
@click="requestSave"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -37,13 +35,13 @@ 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>
@@ -55,12 +53,6 @@ const requestSave = () => emit('save', alias.value)
"fr": { "fr": {
"title": "Alias", "title": "Alias",
"label": "Votre alias" "label": "Votre alias"
},
"es": {
"title": "Alias",
"label": "Tu alias"
} }
} }
</i18n> </i18n>

View File

@@ -1,6 +1,5 @@
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title"> <div class="card-title">
{{ t('changePassword') }} {{ t('changePassword') }}
</div> </div>
@@ -8,36 +7,64 @@
<div class="card-content"> <div class="card-content">
<p class="description mb-4">{{ t('passwordDescription') }}</p> <p class="description mb-4">{{ t('passwordDescription') }}</p>
<v-text-field v-model="newPassword" :label="t('newPassword')" :type="showNewPassword ? 'text' : 'password'" <v-text-field
variant="outlined" required :hint="t('passwordRequirements')"> v-model="newPassword"
:hint="t('passwordRequirements')"
:label="t('newPassword')"
:type="showNewPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner> <template v-slot:append-inner>
<v-icon @click="showNewPassword = !showNewPassword" class="visibility-toggle" size="small" <v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye" /> :icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showNewPassword = !showNewPassword"
/>
</template> </template>
</v-text-field> </v-text-field>
<v-text-field v-model="confirmPassword" :label="t('confirmPassword')" <v-text-field
:type="showConfirmPassword ? 'text' : 'password'" variant="outlined" required> v-model="confirmPassword"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner> <template v-slot:append-inner>
<v-icon @click="showConfirmPassword = !showConfirmPassword" class="visibility-toggle" size="small" <v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye" /> :icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showConfirmPassword = !showConfirmPassword"
/>
</template> </template>
</v-text-field> </v-text-field>
<div v-if="errorMessage" class="error-message mb-4"> <div
v-if="errorMessage"
class="error-message mb-4"
>
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')"> <button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" @click="handleChangePassword" :disabled="isLoading"> <button
:disabled="isLoading"
class="primary"
@click="handleChangePassword"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -144,18 +171,6 @@ async function handleChangePassword() {
"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."
} }
} }

View File

@@ -13,12 +13,16 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
@click="cancel"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
@click="save"> class="primary"
@click="save"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -27,15 +31,15 @@
<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']);
@@ -45,10 +49,8 @@ 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');
@@ -71,12 +73,6 @@ const cancel = () => {
"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

@@ -3,14 +3,14 @@ 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>
@@ -21,28 +21,32 @@ const requestSave = () => emit('save', firstname.value, lastname.value)
<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"
@click="requestClose"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
@click="requestSave"> class="primary"
@click="requestSave"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -60,11 +64,6 @@ const requestSave = () => emit('save', firstname.value, lastname.value)
"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

@@ -4,28 +4,36 @@
<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"
:label="t('email')"
type="email"
variant="outlined"
:error-messages="emailErrors" :error-messages="emailErrors"
:label="t('email')"
:rules="emailRules" :rules="emailRules"
class="w-full p-2"
type="email"
validate-on="blur" validate-on="blur"
variant="outlined"
/> />
<v-alert <v-alert
v-if="!!errorMessage" v-if="!!errorMessage"
class="mt-4"
outlined outlined
type="error" type="error"
class="mt-4"> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')"> <button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" @click="saveEmail" :disabled="!canSave || isLoading"> <button
:disabled="!canSave || isLoading"
class="primary"
@click="saveEmail"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -34,7 +42,7 @@
</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';
@@ -46,8 +54,8 @@ 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 || '');
@@ -55,7 +63,7 @@ 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);
}; };
@@ -76,14 +84,12 @@ const emailErrors = computed(() => {
}); });
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;
} }
@@ -96,12 +102,9 @@ async function saveEmail() {
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();
@@ -109,7 +112,7 @@ async function saveEmail() {
// 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 {
@@ -156,19 +159,6 @@ const emit = defineEmits(['closeRequested']);
"errors": { "errors": {
"unexpected": "Une erreur inattendue s'est produite" "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

@@ -6,8 +6,8 @@ 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']);
@@ -18,12 +18,9 @@ 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; props.creator.name = name.value;
emits('closeRequested'); emits('closeRequested');
@@ -52,12 +49,16 @@ const cancel = () => {
></v-text-field> ></v-text-field>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
@click="cancel"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
@click="save"> class="primary"
@click="save"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -65,9 +66,7 @@ const cancel = () => {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
@@ -78,10 +77,6 @@ const cancel = () => {
"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

@@ -4,32 +4,40 @@
<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"
:label="t('phoneNumber')"
type="tel"
variant="outlined"
:error-messages="phoneErrors" :error-messages="phoneErrors"
:rules="phoneRules" :label="t('phoneNumber')"
validate-on="blur"
:placeholder="t('phonePlaceholder')" :placeholder="t('phonePlaceholder')"
:rules="phoneRules"
class="w-full p-2"
maxlength="14"
type="tel"
validate-on="blur"
variant="outlined"
@input="handlePhoneInput" @input="handlePhoneInput"
@keydown="handleKeydown" @keydown="handleKeydown"
maxlength="14"
/> />
<v-alert <v-alert
v-if="!!errorMessage" v-if="!!errorMessage"
class="mt-4"
outlined outlined
type="error" type="error"
class="mt-4"> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')"> <button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" @click="savePhoneNumber" :disabled="!canSave || isLoading"> <button
:disabled="!canSave || isLoading"
class="primary"
@click="savePhoneNumber"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -38,7 +46,7 @@
</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';
@@ -50,12 +58,12 @@ 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) {
@@ -65,7 +73,7 @@ const formatPhoneForDisplay = (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, '');
}; };
@@ -75,7 +83,7 @@ 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, '');
@@ -86,7 +94,7 @@ const formatPhoneNumber = (digits) => {
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);
@@ -97,7 +105,7 @@ const handlePhoneInput = (event) => {
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;
@@ -114,11 +122,11 @@ const handleKeydown = (event) => {
}; };
// 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;
}; };
@@ -144,13 +152,15 @@ const phoneErrors = computed(() => {
}); });
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;
} }
@@ -165,12 +175,9 @@ async function savePhoneNumber() {
// 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();
@@ -178,7 +185,7 @@ async function savePhoneNumber() {
// 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 {
@@ -227,20 +234,6 @@ const emit = defineEmits(['closeRequested']);
"errors": { "errors": {
"unexpected": "Une erreur inattendue s'est produite" "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,14 +1,14 @@
<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 emit = defineEmits(['closeRequested']);
@@ -24,7 +24,7 @@ const errorMessage = ref('');
const isCurrentHandle = ref(false); const isCurrentHandle = ref(false);
// Watch for changes to the new slug to check if it's the same as the current one // Watch for changes to the new slug to check if it's the same as the current one
watch(newSlug, (newValue) => { watch(newSlug, newValue => {
isCurrentHandle.value = newValue === props.creator.slug; isCurrentHandle.value = newValue === props.creator.slug;
if (isCurrentHandle.value) { if (isCurrentHandle.value) {
slugReservationId.value = undefined; slugReservationId.value = undefined;
@@ -43,7 +43,7 @@ async function save() {
errorMessage.value = ''; errorMessage.value = '';
await client.put(`/api/creators/${props.creator.id}/slug`, { await client.put(`/api/creators/${props.creator.id}/slug`, {
slugReservationId: slugReservationId.value slugReservationId: slugReservationId.value,
}); });
await creatorProfileStore.fetchCreatorProfile(); await creatorProfileStore.fetchCreatorProfile();
@@ -74,26 +74,31 @@ const cancel = () => {
<name-editor <name-editor
v-model:name="newSlug" v-model:name="newSlug"
:creator-name-reservation-id="slugReservationId" :creator-name-reservation-id="slugReservationId"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
:original-slug="creator.slug" :original-slug="creator.slug"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
></name-editor> ></name-editor>
<v-alert <v-alert
v-if="!!errorMessage" v-if="!!errorMessage"
class="mt-4"
outlined outlined
type="error" type="error"
class="mt-4"> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
@click="cancel"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
:disabled="!canSave || isOperationPending"
class="primary"
@click="save" @click="save"
:disabled="!canSave || isOperationPending"> >
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -101,8 +106,7 @@ const cancel = () => {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
@@ -111,9 +115,6 @@ const cancel = () => {
}, },
"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

@@ -51,12 +51,16 @@ const cancel = () => {
></v-text-field> ></v-text-field>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
@click="cancel"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
@click="save"> class="primary"
@click="save"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -64,9 +68,7 @@ const cancel = () => {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
@@ -77,10 +79,6 @@ const cancel = () => {
"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

@@ -5,8 +5,8 @@ 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']);
@@ -18,12 +18,9 @@ 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; props.creator.title = title.value;
emits('closeRequested'); emits('closeRequested');
@@ -39,7 +36,6 @@ const cancel = () => {
<template> <template>
<div class="card dialog"> <div class="card dialog">
<div class="card-title"> <div class="card-title">
{{ t('title') }} {{ t('title') }}
</div> </div>
@@ -53,12 +49,16 @@ const cancel = () => {
></v-text-field> ></v-text-field>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
@click="cancel"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
@click="save"> class="primary"
@click="save"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
@@ -66,9 +66,7 @@ const cancel = () => {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
</style>
<i18n> <i18n>
{ {
@@ -79,10 +77,6 @@ const cancel = () => {
"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,87 +1,81 @@
<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 = () => { const cancel = () => {
emits('closeRequested') 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">
<div class="editor-line"> <div class="editor-line">
<facebook class="social-icon"></facebook> <facebook class="social-icon"></facebook>
<input <input
v-model="facebookUrl" v-model="facebookUrl"
class="input-field"
:placeholder="t('facebook')" :placeholder="t('facebook')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
@@ -90,8 +84,8 @@ const cancel = () => {
<instagram class="social-icon"></instagram> <instagram class="social-icon"></instagram>
<input <input
v-model="instagramUrl" v-model="instagramUrl"
class="input-field"
:placeholder="t('instagram')" :placeholder="t('instagram')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
@@ -100,8 +94,8 @@ const cancel = () => {
<linkedin class="social-icon"></linkedin> <linkedin class="social-icon"></linkedin>
<input <input
v-model="linkedInUrl" v-model="linkedInUrl"
class="input-field"
:placeholder="t('linkedin')" :placeholder="t('linkedin')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
@@ -110,8 +104,8 @@ const cancel = () => {
<reddit class="social-icon"></reddit> <reddit class="social-icon"></reddit>
<input <input
v-model="redditUrl" v-model="redditUrl"
class="input-field"
:placeholder="t('reddit')" :placeholder="t('reddit')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
@@ -120,8 +114,8 @@ const cancel = () => {
<tiktok class="social-icon"></tiktok> <tiktok class="social-icon"></tiktok>
<input <input
v-model="tikTokUrl" v-model="tikTokUrl"
class="input-field"
:placeholder="t('tiktok')" :placeholder="t('tiktok')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
@@ -130,8 +124,8 @@ const cancel = () => {
<web class="social-icon"></web> <web class="social-icon"></web>
<input <input
v-model="websiteUrl" v-model="websiteUrl"
class="input-field"
:placeholder="t('website')" :placeholder="t('website')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
@@ -140,8 +134,8 @@ const cancel = () => {
<x class="social-icon"></x> <x class="social-icon"></x>
<input <input
v-model="xUrl" v-model="xUrl"
class="input-field"
:placeholder="t('x')" :placeholder="t('x')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
@@ -150,31 +144,31 @@ const cancel = () => {
<youtube class="social-icon"></youtube> <youtube class="social-icon"></youtube>
<input <input
v-model="youtubeUrl" v-model="youtubeUrl"
class="input-field"
:placeholder="t('youtube')" :placeholder="t('youtube')"
class="input-field"
type="text" type="text"
/> />
</div> </div>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" <button
@click="cancel"> class="secondary"
@click="cancel"
>
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<button class="primary" <button
@click="save"> class="primary"
@click="save"
>
{{ t('save') }} {{ t('save') }}
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.editor-line { .editor-line {
@apply flex flex-row gap-4; @apply flex flex-row gap-4;
@apply items-center; @apply items-center;
@@ -190,9 +184,8 @@ const cancel = () => {
@apply transition duration-200; @apply transition duration-200;
@apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary; @apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary;
@apply hover:ring-hutopyPrimary; @apply hover:ring-hutopyPrimary;
@apply placeholder:text-[#6D6C70] @apply placeholder:text-[#6D6C70];
} }
</style> </style>
<i18n> <i18n>
@@ -202,9 +195,6 @@ const cancel = () => {
}, },
"fr": { "fr": {
"title": "Liens des réseaux sociaux" "title": "Liens des réseaux sociaux"
},
"es": {
"title": "Enlaces de redes sociales"
} }
} }
</i18n> </i18n>