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>
<v-app>
<div class="shell-container">
<div class="shell-side">
<side-bar></side-bar>
<site-bar></site-bar>
</div>
<div class="shell-view">
<router-view></router-view>
</div>
</div>
</v-app>
</template>
<script async setup>
import { mdiFileAccountOutline } from '@mdi/js';
import SideBar from "@/views/main/SideBar.vue";
import { useLanguageStore } from "@/stores/languageStore.js";
import { watch } from "vue";
import { useI18n } from "vue-i18n";
import SiteBar from '@/views/main/SiteBar.vue';
import { useLanguageStore } from '@/stores/languageStore.js';
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
// Watch for language changes and update i18n locale
const languageStore = useLanguageStore();
const { locale } = useI18n();
// Watch for changes to the language store
watch(() => languageStore.locale, (newLocale) => {
watch(
() => languageStore.locale,
newLocale => {
if (newLocale) {
locale.value = newLocale;
}
}, { immediate: true });
},
{ immediate: true }
);
</script>
<style scoped>
.shell-container {
@apply flex flex-col lg:flex-row;
@apply flex flex-col;
@apply w-full;
@apply font-sans;
@apply bg-hBackground text-hOnBackground;
@apply min-h-screen h-full;
}
.shell-side {
@apply lg:fixed lg:max-h-screen;
@apply flex-shrink-0;
}
.shell-view {
@apply flex-grow;
@apply flex justify-center items-center;
@apply w-full;
@apply lg:ml-64;
}
</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 { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
import { VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert } from 'vuetify/components';
import { } from 'vuetify/directives';
import {
VAlert,
VApp,
VBtn,
VDialog,
VForm,
VIcon,
VProgressCircular,
VProgressLinear,
VSnackbar,
VTextarea,
VTextField,
} from 'vuetify/components';
import vueGoogleOauth from 'vue3-google-login';
import { useAuthStore } from "@/stores/authStore.js";
import { useUserProfileStore } from "@/stores/userProfileStore.js";
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
import { useAuthStore } from '@/stores/authStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import Toast, { POSITION } from 'vue-toastification';
import 'vue-toastification/dist/index.css';
import './assets/main.css';
import { createI18n } from 'vue-i18n';
import en from '@/locales/en.json';
import fr from '@/locales/fr.json';
const vuetify = createVuetify({
components: {
VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert
},
directives: {
VDialog,
VApp,
VBtn,
VProgressLinear,
VProgressCircular,
VIcon,
VTextField,
VSnackbar,
VForm,
VTextarea,
VAlert,
},
directives: {},
icons: {
defaultSet: 'mdi',
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({
legacy: false,
fallbackLocale: 'en',
fallbackLocale: 'fr',
messages: {
en: en,
fr: fr,
es: es
}
})
},
});
const pinia = createPinia();

View File

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

View File

@@ -14,27 +14,38 @@
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label for="email" class="form-label">{{ t('email') }}</label>
<label
class="form-label"
for="email"
>
{{ t('email') }}
</label>
<input
id="email"
v-model="email"
type="email"
class="form-input"
required
type="email"
/>
</div>
<button
type="submit"
class="primary w-full"
: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') }}
</button>
<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') }}
</router-link>
</div>
@@ -44,12 +55,18 @@
</div>
<!-- Success message -->
<div v-if="showSuccessMessage" class="notification success">
<div
v-if="showSuccessMessage"
class="notification success"
>
{{ t('resetEmailSent') }}
</div>
<!-- Error message -->
<div v-if="showErrorMessage" class="notification error">
<div
v-if="showErrorMessage"
class="notification error"
>
{{ errorMessage }}
</div>
</div>
@@ -88,7 +105,7 @@ async function handleForgotPassword() {
try {
// Call password reset API
await clientApi.post('api/users/forgot-password', {
email: email.value.trim()
email: email.value.trim(),
});
// Show success message
@@ -137,7 +154,9 @@ async function handleForgotPassword() {
.notification {
@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 {
@@ -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.",
"resetRequestFailed": "Échec de la demande de réinitialisation du mot de passe. Veuillez réessayer.",
"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>

View File

@@ -1,16 +1,20 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="flex flex-col gap-4">
<google-login :callback="googleCallback" popup-type="TOKEN">
<google-login
:callback="googleCallback"
popup-type="TOKEN"
>
<button class="secondary">
<v-icon class="mr-2" :icon="mdiGoogle" />
<v-icon
:icon="mdiGoogle"
class="mr-2"
/>
{{ t('continueWithGoogle') }}
</button>
</google-login>
@@ -25,44 +29,73 @@
<!-- Add email/password form -->
<v-form @submit.prevent="handleLocalLogin">
<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>
<v-icon @click="showPassword = !showPassword" class="visibility-toggle" size="small"
:icon="showPassword ? mdiEyeOff : mdiEye" />
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-btn type="submit" color="primary" block>
<v-btn
block
color="primary"
type="submit"
>
{{ t('signIn') }}
</v-btn>
<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') }}
</a>
</div>
<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') }}
</a>
</div>
<div class="mt-4 text-center">
{{ t('noAccount') }}
<router-link to="/register" class="text-blue-500">
<router-link
class="text-blue-500"
to="/register"
>
{{ t('register') }}
</router-link>
</div>
</div>
</v-form>
</div>
<!-- Error notification -->
<v-snackbar v-model="errorSnackBar" color="error">
<v-snackbar
v-model="errorSnackBar"
color="error"
>
{{ t('loginFailed') }}
</v-snackbar>
</div>
@@ -70,11 +103,11 @@
<script setup>
import { ref } from 'vue';
import { GoogleLogin } from "vue3-google-login";
import { GoogleLogin } from 'vue3-google-login';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiGoogle, mdiEye, mdiEyeOff } from '@mdi/js';
import { mdiEye, mdiEyeOff, mdiGoogle } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
@@ -88,8 +121,8 @@ const showPassword = ref(false);
const props = defineProps({
returnUrl: {
type: String,
default: '/landing'
}
default: '/landing',
},
});
async function handleLocalLogin() {
@@ -176,20 +209,6 @@ function resendVerification() {
"register": "S'inscrire",
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
"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>

View File

@@ -25,8 +25,8 @@
{{ t('success.backToLogin') }}
</router-link>
<router-link
class="text-blue-500 hover:underline"
:to="{ path: '/verify-email', query: { email: userEmail } }"
class="text-blue-500 hover:underline"
>
{{ t('success.resendVerification') }}
</router-link>
@@ -232,26 +232,6 @@
"backToLogin": "Retour à la connexion",
"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>

View File

@@ -259,19 +259,6 @@
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"resetFailed": "Échec de la réinitialisation du mot de passe. Veuillez réessayer ou demander un nouveau lien de réinitialisation.",
"invalidResetLink": "Lien de réinitialisation invalide ou expiré. Veuillez demander une nouvelle réinitialisation de mot de passe."
},
"es": {
"title": "Restablecer su Contraseña",
"newPassword": "Nueva Contraseña",
"confirmPassword": "Confirmar Contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"resetPassword": "Restablecer Contraseña",
"passwordResetSuccess": "¡Su contraseña ha sido restablecida con éxito!",
"proceedToLogin": "Proceder al Inicio de Sesión",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"resetFailed": "Error al restablecer la contraseña. Inténtelo de nuevo o solicite un nuevo enlace de restablecimiento.",
"invalidResetLink": "Enlace de restablecimiento inválido o caducado. Solicite un nuevo restablecimiento de contraseña."
}
}
</i18n>

View File

@@ -2,57 +2,97 @@
<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">
<!-- Loading state while verification is in progress -->
<div v-if="isLoading" class="flex flex-col items-center gap-4">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
<div
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>
</div>
<!-- Success state -->
<div v-else-if="verificationSuccess" class="flex flex-col items-center gap-6">
<v-icon icon="mdi-check-circle" color="green" size="64"></v-icon>
<div
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>
<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>
<!-- Error state -->
<div v-else class="flex flex-col items-center gap-6">
<v-icon icon="mdi-alert-circle" color="error" size="64"></v-icon>
<div
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>
<p>{{ errorMessage || t('error.defaultMessage') }}</p>
<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>
<!-- Resend verification email section -->
<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">
<v-text-field
v-model="resendEmail"
:label="t('resend.emailLabel')"
type="email"
required
:error-messages="resendEmailError"
:label="t('resend.emailLabel')"
required
type="email"
></v-text-field>
<v-btn
type="submit"
color="secondary"
block
:loading="resendLoading"
block
color="secondary"
type="submit"
>
{{ t('resend.button') }}
</v-btn>
<!-- Resend success message -->
<div v-if="resendSuccess" class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm">
<div
v-if="resendSuccess"
class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm"
>
{{ t('resend.success') }}
</div>
<!-- Resend error message -->
<div v-if="resendError" class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
<div
v-if="resendError"
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
>
{{ resendError }}
</div>
</div>
@@ -64,10 +104,10 @@
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { onMounted, ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n();
const router = useRouter();
@@ -131,7 +171,7 @@ async function handleResendVerification() {
try {
await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim()
email: resendEmail.value.trim(),
});
resendSuccess.value = true;
} catch (error) {
@@ -192,28 +232,6 @@ function goToLogin() {
"error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.",
"invalidEmail": "Veuillez entrer une adresse email valide."
}
},
"es": {
"verifying": "Verificando tu correo electrónico...",
"success": {
"title": "¡Correo electrónico verificado con éxito!",
"message": "Tu correo electrónico ha sido verificado. Ahora puedes iniciar sesión en tu cuenta.",
"goToLogin": "Ir al inicio de sesión"
},
"error": {
"title": "Falló la verificación",
"defaultMessage": "No pudimos verificar tu correo electrónico. El enlace puede ser inválido o estar caducado.",
"missingParams": "Faltan parámetros de verificación requeridos.",
"goToLogin": "Ir al inicio de sesión"
},
"resend": {
"title": "Reenviar correo de verificación",
"emailLabel": "Correo electrónico",
"button": "Reenviar correo de verificación",
"success": "Correo de verificación enviado con éxito. Por favor revisa tu bandeja de entrada.",
"error": "Error al enviar el correo de verificación. Por favor, inténtelo de nuevo.",
"invalidEmail": "Por favor, introduce una dirección de correo electrónico válida."
}
}
}
</i18n>

View File

@@ -681,36 +681,6 @@
"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>

View File

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

View File

@@ -1,64 +1,111 @@
<template>
<div class="album-editor">
<h2 class="mb-4 text-xl font-semibold">
{{ t('title') }}
</h2>
<!-- 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 -->
<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>
</div>
<!-- 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 -->
<draggable v-model="localImages" class="photos-grid" item-key="id" @end="handleReorder" :filter="'.action-btn'"
:prevent-on-filter="false">
<draggable
v-model="localImages"
:filter="'.action-btn'"
:prevent-on-filter="false"
class="photos-grid"
item-key="id"
@end="handleReorder"
>
<template #item="{ element, index }">
<div class="photo-wrapper">
<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 -->
<div v-if="element.isProcessing" class="loading-overlay">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<div
v-if="element.isProcessing"
class="loading-overlay"
>
<v-progress-circular
color="primary"
indeterminate
></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('processing') }}</span>
</div>
<!-- Upload spinner overlay -->
<div v-if="element.isUploading" class="loading-overlay uploading">
<v-progress-circular indeterminate color="secondary"></v-progress-circular>
<div
v-if="element.isUploading"
class="loading-overlay uploading"
>
<v-progress-circular
color="secondary"
indeterminate
></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('uploading') }}</span>
</div>
<!-- Left arrow -->
<button @click.stop="moveImage(index, 'up')" @touchstart.stop="moveImage(index, 'up')"
class="action-btn left-btn" :disabled="index === 0" :title="t('moveLeft')">
<button
:disabled="index === 0"
:title="t('moveLeft')"
class="action-btn left-btn"
@click.stop="moveImage(index, 'up')"
@touchstart.stop="moveImage(index, 'up')"
>
<v-icon :icon="mdiArrowLeft" />
</button>
<!-- Right arrow -->
<button @click.stop="moveImage(index, 'down')" @touchstart.stop="moveImage(index, 'down')"
class="action-btn right-btn" :disabled="index === localImages.length - 1" :title="t('moveRight')">
<button
:disabled="index === localImages.length - 1"
:title="t('moveRight')"
class="action-btn right-btn"
@click.stop="moveImage(index, 'down')"
@touchstart.stop="moveImage(index, 'down')"
>
<v-icon :icon="mdiArrowRight" />
</button>
<!-- Delete button -->
<button @click.stop="deleteImage(index)" touchstart.stop="deleteImage(index)" class="action-btn delete-btn"
:title="t('delete')">
<button
:title="t('delete')"
class="action-btn delete-btn"
touchstart.stop="deleteImage(index)"
@click.stop="deleteImage(index)"
>
<v-icon :icon="mdiDelete" />
</button>
</div>
</template>
</draggable>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { v7 } from 'uuid';
import draggable from 'vuedraggable';
@@ -67,8 +114,8 @@ import { mdiArrowLeft, mdiArrowRight, mdiDelete, mdiPlus } from '@mdi/js';
const props = defineProps({
images: {
type: Array,
required: true
}
required: true,
},
});
const emit = defineEmits(['update:images']);
@@ -83,7 +130,7 @@ onMounted(() => {
});
function handleFiles(files) {
console.log('handleFiles:', files)
console.log('handleFiles:', files);
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
@@ -102,7 +149,7 @@ function handleFiles(files) {
console.log('Processing image:', tempImage);
reader.onload = (e) => {
reader.onload = e => {
console.log('Image loaded:', e);
const index = localImages.value.findIndex(local => local.image.id === tempImage.image.id);
if (index !== -1) {
@@ -118,6 +165,7 @@ function handleFiles(files) {
}
}
}
function handleDrop(event) {
console.log('Drop triggered');
const files = Array.from(event.dataTransfer.files);
@@ -140,7 +188,6 @@ function handleReorder() {
emit('update:images', localImages.value);
}
function moveImage(index, direction) {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex >= 0 && newIndex < localImages.value.length) {
@@ -155,7 +202,6 @@ function deleteImage(index) {
localImages.value.splice(index, 1);
emit('update:images', localImages.value);
}
</script>
<style scoped>
@@ -316,15 +362,6 @@ function deleteImage(index) {
"moveLeft": "Déplacer à gauche",
"moveRight": "Déplacer à droite",
"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>

View File

@@ -1,14 +1,21 @@
<template>
<div v-if="hasImages" class="album-view">
<div
v-if="hasImages"
class="album-view"
>
<!-- Album Display -->
<div class="image-grid">
<div v-for="(url, index) in displayedImages"
<div
v-for="(url, index) in displayedImages"
:key="index"
class="image-wrapper"
@click="$emit('photo-click', index)">
<img :src="url"
@click="$emit('photo-click', index)"
>
<img
:alt="t('creator.sections.album.image')"
class="image"/>
:src="url"
class="image"
/>
</div>
</div>
</div>
@@ -18,15 +25,15 @@
// Add 'photo-click' to emits
const emit = defineEmits(['photo-click']);
import { computed, ref, onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
images: {
type: Array,
required: true,
default: () => []
}
default: () => [],
},
});
const { t } = useI18n();
@@ -99,7 +106,6 @@ const gridColumns = computed(() => {
}
/* Responsive adjustments */
</style>
<i18n>
@@ -123,16 +129,6 @@ const gridColumns = computed(() => {
}
}
}
},
"es": {
"creator": {
"sections": {
"album": {
"title": "Álbum de fotos",
"image": "Imagen del álbum"
}
}
}
}
}
</i18n>

View File

@@ -1,38 +1,72 @@
<template>
<v-dialog v-model="dialog" fullscreen :scrim="true" transition="dialog-bottom-transition"
@click:outside="closeViewer">
<div class="album-viewer" @click.self="closeViewer">
<v-dialog
v-model="dialog"
:scrim="true"
fullscreen
transition="dialog-bottom-transition"
@click:outside="closeViewer"
>
<div
class="album-viewer"
@click.self="closeViewer"
>
<!-- Main 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 -->
<button class="nav-btn left-btn" @click.stop="previousImage" :disabled="currentIndex === 0"
:title="t('viewer.previous')">
<v-icon size="large" color="white" :icon="mdiChevronLeft" />
<button
:disabled="currentIndex === 0"
:title="t('viewer.previous')"
class="nav-btn left-btn"
@click.stop="previousImage"
>
<v-icon
:icon="mdiChevronLeft"
color="white"
size="large"
/>
</button>
<button class="nav-btn right-btn" @click.stop="nextImage" :disabled="currentIndex === images.length - 1"
:title="t('viewer.next')">
<v-icon size="large" color="white" :icon="mdiChevronRight" />
<button
:disabled="currentIndex === images.length - 1"
:title="t('viewer.next')"
class="nav-btn right-btn"
@click.stop="nextImage"
>
<v-icon
:icon="mdiChevronRight"
color="white"
size="large"
/>
</button>
<!-- Close button -->
<button class="close-btn" @click.stop="closeViewer" :title="t('viewer.close')">
<v-icon size="large" color="white" :icon="mdiClose" />
<button
:title="t('viewer.close')"
class="close-btn"
@click.stop="closeViewer"
>
<v-icon
:icon="mdiClose"
color="white"
size="large"
/>
</button>
<!-- Image counter -->
<div class="image-counter">
{{ currentIndex + 1 }} / {{ images.length }}
</div>
<div class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</div>
</div>
</div>
</v-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { mdiChevronLeft, mdiChevronRight, mdiClose } from '@mdi/js';
@@ -41,16 +75,16 @@ const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Boolean,
required: true
required: true,
},
images: {
type: Array,
required: true
required: true,
},
startIndex: {
type: Number,
default: 0
}
default: 0,
},
});
const emit = defineEmits(['update:modelValue']);
@@ -60,16 +94,22 @@ const currentIndex = ref(0);
const currentImage = computed(() => props.images[currentIndex.value]);
watch(() => props.modelValue, (newVal) => {
watch(
() => props.modelValue,
newVal => {
dialog.value = newVal;
if (newVal) {
currentIndex.value = props.startIndex;
}
});
}
);
watch(() => dialog.value, (newVal) => {
watch(
() => dialog.value,
newVal => {
emit('update:modelValue', newVal);
});
}
);
function nextImage() {
if (currentIndex.value < props.images.length - 1) {
@@ -162,14 +202,6 @@ function closeViewer() {
"close": "Fermer",
"imageAlt": "Image {index}"
}
},
"es": {
"viewer": {
"previous": "Imagen anterior",
"next": "Imagen siguiente",
"close": "Cerrar",
"imageAlt": "Imagen {index}"
}
}
}
</i18n>

View File

@@ -1,17 +1,17 @@
<script setup>
import { useBrandingStore } from '@/stores/brandingStore.js';
import DonationButton from '@/views/creators/DonationButton.vue';
import CreatorLogo from "@/views/creators/CreatorLogo.vue";
import NameTitle from "@/views/creators/NameTitle.vue";
import Linkedin from "@/views/svg/Linkedin.vue";
import X from "@/views/svg/X.vue";
import Facebook from "@/views/svg/Facebook.vue";
import Instagram from "@/views/svg/Instagram.vue";
import Tiktok from "@/views/svg/Tiktok.vue";
import Reddit from "@/views/svg/Reddit.vue";
import Youtube from "@/views/svg/Youtube.vue";
import Web from "@/views/svg/Web.vue";
import {useI18n} from 'vue-i18n'
import CreatorLogo from '@/views/creators/CreatorLogo.vue';
import NameTitle from '@/views/creators/NameTitle.vue';
import Linkedin from '@/views/svg/Linkedin.vue';
import X from '@/views/svg/X.vue';
import Facebook from '@/views/svg/Facebook.vue';
import Instagram from '@/views/svg/Instagram.vue';
import Tiktok from '@/views/svg/Tiktok.vue';
import Reddit from '@/views/svg/Reddit.vue';
import Youtube from '@/views/svg/Youtube.vue';
import Web from '@/views/svg/Web.vue';
import { useI18n } from 'vue-i18n';
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
@@ -23,7 +23,6 @@ const {t} = useI18n();
<!-- Container principal avec le profil -->
<div class="relative w-full">
<div class="bg-hPrimary text-hOnPrimary relative">
<!-- Portrait that overlaps both sections -->
<div class="absolute left-4 -bottom-2 z-10">
<creator-logo />
@@ -31,8 +30,7 @@ const {t} = useI18n();
<!-- Desktop version (visible only on écrans moyens et grands) -->
<div class="social-info">
<div class="w-36">
</div>
<div class="w-36"></div>
<div class="flex-grow flex flex-row">
<div class="flex-grow">
<name-title></name-title>
@@ -48,74 +46,84 @@ const {t} = useI18n();
</div>
</div>
</div>
</div>
</div>
<!-- Section pour les icônes de réseaux sociaux -->
<div
class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative"
>
<div class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative">
<div class="flex flex-row gap-10">
<a v-if="brandingStore.value?.socials?.facebookUrl"
<a
v-if="brandingStore.value?.socials?.facebookUrl"
:href="brandingStore.value?.socials?.facebookUrl"
:title="t('facebook')"
target="_blank"
:title="t('facebook')">
>
<facebook class="social-icon"></facebook>
</a>
<a v-if="brandingStore.value?.socials?.instagramUrl"
<a
v-if="brandingStore.value?.socials?.instagramUrl"
:href="brandingStore.value?.socials?.instagramUrl"
:title="t('instagram')"
target="_blank"
:title="t('instagram')">
>
<instagram class="social-icon"></instagram>
</a>
<a v-if="brandingStore.value?.socials?.linkedInUrl"
<a
v-if="brandingStore.value?.socials?.linkedInUrl"
:href="brandingStore.value?.socials?.linkedInUrl"
:title="t('linkedin')"
target="_blank"
:title="t('linkedin')">
>
<linkedin class="social-icon"></linkedin>
</a>
<a v-if="brandingStore.value?.socials?.redditUrl"
<a
v-if="brandingStore.value?.socials?.redditUrl"
:href="brandingStore.value?.socials?.redditUrl"
:title="t('reddit')"
target="_blank"
:title="t('reddit')">
>
<reddit class="social-icon"></reddit>
</a>
<a v-if="brandingStore.value?.socials?.tikTokUrl"
<a
v-if="brandingStore.value?.socials?.tikTokUrl"
:href="brandingStore.value?.socials?.tikTokUrl"
:title="t('tiktok')"
target="_blank"
:title="t('tiktok')">
>
<tiktok class="social-icon"></tiktok>
</a>
<a v-if="brandingStore.value?.socials?.xUrl"
<a
v-if="brandingStore.value?.socials?.xUrl"
:href="brandingStore.value?.socials?.xUrl"
:title="t('x')"
target="_blank"
:title="t('x')">
>
<x class="social-icon"></x>
</a>
<a v-if="brandingStore.value?.socials?.youtubeUrl"
<a
v-if="brandingStore.value?.socials?.youtubeUrl"
:href="brandingStore.value?.socials?.youtubeUrl"
:title="t('youtube')"
target="_blank"
:title="t('youtube')">
>
<youtube class="social-icon"></youtube>
</a>
<a v-if="brandingStore.value?.socials?.websiteUrl"
<a
v-if="brandingStore.value?.socials?.websiteUrl"
:href="brandingStore.value?.socials?.websiteUrl"
:title="t('website')"
target="_blank"
:title="t('website')">
>
<web class="social-icon"></web>
</a>
</div>
</div>
</div>
</template>
@@ -157,16 +165,6 @@ const {t} = useI18n();
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Site web"
},
"es": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Sitio web"
}
}
</i18n>

View File

@@ -11,10 +11,10 @@
<div class="file-input-container">
<input
type="file"
ref="fileInput"
accept="image/*"
class="hidden"
type="file"
@change="onFileSelected"
/>
<button
@@ -25,29 +25,38 @@
</button>
</div>
<div v-if="errorMessage" class="error-message">
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<div v-if="showCropper" class="cropper-wrapper">
<div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper
ref="cropper"
:src="fileUrl"
:aspect-ratio="4"
:src="fileUrl"
:stencil-props="{
aspectRatio: 4,
class: 'banner-stencil'
class: 'banner-stencil',
}"
/>
</div>
<div v-else class="image-preview-container"
<div
v-else
class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop">
@drop.prevent="handleDrop"
>
<img
:src="fileUrl || fallbackUrl"
:alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image"
/>
<div class="edit-overlay">
@@ -57,14 +66,18 @@
</div>
<div class="card-actions">
<button class="secondary"
<button
:disabled="isUploading"
class="secondary"
@click="cancel"
:disabled="isUploading">
>
{{ t('cancel') }}
</button>
<button class="primary"
<button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()"
:disabled="!selectedFile || isUploading">
>
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
@@ -78,154 +91,150 @@
</template>
<script setup>
import {ref} from 'vue'
import {useClient} from '@/plugins/api.js'
import { Cropper } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
})
required: true,
},
});
const emits = defineEmits(['closeRequested'])
const emits = defineEmits(['closeRequested']);
const fileInput = ref(null)
const selectedFile = ref(null)
const fileUrl = ref(props.creator?.bannerUrl)
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png'
const errorMessage = ref('')
const showCropper = ref(false)
const cropper = ref(null)
const isUploading = ref(false)
const uploadProgress = ref(0)
const fileInput = ref(null);
const selectedFile = ref(null);
const fileUrl = ref(props.creator?.bannerUrl);
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png';
const errorMessage = ref('');
const showCropper = ref(false);
const cropper = ref(null);
const isUploading = ref(false);
const uploadProgress = ref(0);
// Get translations for this component
const { t } = useI18n()
const { t } = useI18n();
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.value = '' // Reset the input value to ensure the change event fires
fileInput.value.click()
}
fileInput.value.value = ''; // Reset the input value to ensure the change event fires
fileInput.value.click();
}
};
const onFileSelected = (event) => {
const file = event.target.files[0]
const onFileSelected = event => {
const file = event.target.files[0];
if (file) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
} else {
selectedFile.value = null
fileUrl.value = null
showCropper.value = false
}
selectedFile.value = null;
fileUrl.value = null;
showCropper.value = false;
}
};
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value)
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
const blob = dataURLtoBlob(fileUrl.value);
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
showCropper.value = true;
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput()
}
triggerFileInput();
}
};
// Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => {
const arr = dataURL.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
const dataURLtoBlob = dataURL => {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
const applyCrop = () => {
if (!cropper.value) return
if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas
canvas.toBlob((blob) => {
const canvas = cropper.value.getResult().canvas;
canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type
})
selectedFile.value = croppedFile
fileUrl.value = canvas.toDataURL()
showCropper.value = false
}, selectedFile.value.type)
}
type: selectedFile.value.type,
});
selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL();
showCropper.value = false;
}, selectedFile.value.type);
};
const client = useClient()
const client = useClient();
const publish = async () => {
if (!selectedFile.value || isUploading.value) return
if (!selectedFile.value || isUploading.value) return;
try {
isUploading.value = true
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', selectedFile.value)
isUploading.value = true;
uploadProgress.value = 0;
const formData = new FormData();
formData.append('file', selectedFile.value);
const response = await client.post(
`/api/creators/${props.creator.id}/banner`,
formData,
{
onUploadProgress: (progressEvent) => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
}
)
const response = await client.post(`/api/creators/${props.creator.id}/banner`, formData, {
onUploadProgress: progressEvent => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
},
});
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`
fileUrl.value = props.creator.bannerUrl
emits('closeRequested')
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`;
fileUrl.value = props.creator.bannerUrl;
emits('closeRequested');
} catch (error) {
console.error(error)
errorMessage.value = t('errors.imageUpload')
console.error(error);
errorMessage.value = t('errors.imageUpload');
} finally {
isUploading.value = false
uploadProgress.value = 0
}
isUploading.value = false;
uploadProgress.value = 0;
}
};
const cancel = () => {
showCropper.value = false
showCropper.value = false;
// Reset to original state if we were editing
if (props.creator?.bannerUrl) {
fileUrl.value = props.creator.bannerUrl
selectedFile.value = null
fileUrl.value = props.creator.bannerUrl;
selectedFile.value = null;
} else {
fileUrl.value = fallbackUrl
selectedFile.value = null
}
emits('closeRequested')
fileUrl.value = fallbackUrl;
selectedFile.value = null;
}
emits('closeRequested');
};
// Add drop handler
const handleDrop = (event) => {
const file = event.dataTransfer.files[0]
const handleDrop = event => {
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
}
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
}
};
</script>
<style scoped>
@@ -363,13 +372,6 @@ const handleDrop = (event) => {
"chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier",
"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>

View File

@@ -1,15 +1,15 @@
<script setup>
import {computed, ref} from 'vue'
import {useUserProfileStore} from "@/stores/userProfileStore.js";
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
import {useClient} from "@/plugins/api.js";
import {useRouter, useRoute} from "vue-router";
import NameEditor from "@/views/creators/NameEditor.vue";
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useClient } from '@/plugins/api.js';
import { useRoute, useRouter } from 'vue-router';
import NameEditor from '@/views/creators/NameEditor.vue';
import { useI18n } from 'vue-i18n';
const creatorName = ref('');
const creatorNameReservationId = ref(undefined);
const canSave = computed(() => creatorNameReservationId.value !== undefined)
const canSave = computed(() => creatorNameReservationId.value !== undefined);
const isOperationPending = ref(false);
const errorMessage = ref('');
@@ -21,19 +21,19 @@ const userProfileStore = useUserProfileStore();
const { t } = useI18n();
function handleCreatorNameReservationIdChanged($event) {
creatorNameReservationId.value = $event
creatorNameReservationId.value = $event;
}
function cancel() {
// if a returnUrl querystring was supplied, prefer it
const returnUrl = route.query.returnUrl
const returnUrl = route.query.returnUrl;
if (typeof returnUrl === 'string' && returnUrl.length) {
router.push(returnUrl)
return
router.push(returnUrl);
return;
}
// otherwise just go back one step in history
router.back()
router.back();
}
// TODO: The `fetchCreatorProfile` function should be private (push-up to the store)!
@@ -58,13 +58,11 @@ async function createAccount() {
isOperationPending.value = false;
}
}
</script>
<template>
<div class="container">
<div class="card">
<div class="card-title">
{{ t('title') }}
</div>
@@ -80,17 +78,18 @@ async function createAccount() {
<div class="card-actions">
<button
class="secondary"
@click="cancel">
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
:disabled="!canSave || isOperationPending"
@click="createAccount">
class="primary"
@click="createAccount"
>
{{ t('create') }}
</button>
</div>
</div>
</div>
@@ -101,17 +100,13 @@ async function createAccount() {
>
{{ errorMessage }}
</v-alert>
</template>
<style scoped>
.container {
@apply min-h-screen w-full;
@apply flex items-center justify-center;
}
</style>
<i18n>
@@ -131,14 +126,6 @@ async function createAccount() {
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"title": "Crea tu Hutopy",
"cancel": "Cancelar",
"create": "Crear mi página",
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>

View File

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

View File

@@ -141,26 +141,6 @@
"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>

View File

@@ -1,38 +1,57 @@
<template>
<div class="relative" @mouseenter="showTint = isCurrentCreator" @mouseleave="showTint = false"
@click="isCurrentCreator && openBannerEditor()">
<div
class="relative"
@click="isCurrentCreator && openBannerEditor()"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
>
<div class="size-[110px] rounded-full border-4 border-hPrimary">
<img :src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'" :alt="t('logoAlt')"
width="110px" height="110px" class="rounded-full" />
<img
:alt="t('logoAlt')"
:src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'"
class="rounded-full"
height="110px"
width="110px"
/>
</div>
<!-- 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 -->
<div
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg">
<v-icon large :icon="mdiPencil" />
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
>
<v-icon
:icon="mdiPencil"
large
/>
</div>
</div>
</div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px">
<v-dialog
v-model="isDialogOpen"
max-width="800px"
>
<template #default="{ close }">
<creator-logo-editor :creator="brandingStore?.value"
@closeRequested="() => isDialogOpen = false"></creator-logo-editor>
<creator-logo-editor
:creator="brandingStore?.value"
@closeRequested="() => (isDialogOpen = false)"
></creator-logo-editor>
</template>
</v-dialog>
</template>
<script setup>
import { useAuthStore } from "@/stores/authStore.js";
import { useBrandingStore } from "@/stores/brandingStore.js";
import CreatorLogoEditor from "@/views/creators/CreatorLogoEditor.vue";
import { computed, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/authStore.js';
import { useBrandingStore } from '@/stores/brandingStore.js';
import CreatorLogoEditor from '@/views/creators/CreatorLogoEditor.vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { mdiPencil } from '@mdi/js';
const authStore = useAuthStore();
@@ -51,7 +70,6 @@ const openBannerEditor = () => {
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>
<style scoped>
@@ -69,10 +87,6 @@ const isCurrentCreator = computed(() => {
"fr": {
"logoAlt": "Logo du créateur",
"editLogo": "Modifier le logo"
},
"es": {
"logoAlt": "Logo del creador",
"editLogo": "Editar logo"
}
}
</i18n>

View File

@@ -11,10 +11,10 @@
<div class="file-input-container">
<input
type="file"
ref="fileInput"
accept="image/*"
class="hidden"
type="file"
@change="onFileSelected"
/>
<button
@@ -25,31 +25,40 @@
</button>
</div>
<div v-if="errorMessage" class="error-message">
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<div v-if="showCropper" class="cropper-wrapper">
<div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper
ref="cropper"
:src="fileUrl"
:aspect-ratio="1"
:src="fileUrl"
:stencil-component="CircleStencil"
:stencil-props="{
aspectRatio: 1,
class: 'circle-stencil'
class: 'circle-stencil',
}"
/>
</div>
<div v-else class="image-preview-container"
<div
v-else
class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop">
@drop.prevent="handleDrop"
>
<div class="circular-preview">
<img
:src="fileUrl || fallbackUrl"
:alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image"
/>
<div class="edit-overlay">
@@ -60,14 +69,18 @@
</div>
<div class="card-actions">
<button class="secondary"
<button
:disabled="isUploading"
class="secondary"
@click="cancel"
:disabled="isUploading">
>
{{ t('cancel') }}
</button>
<button class="primary"
<button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()"
:disabled="!selectedFile || isUploading">
>
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
@@ -81,158 +94,154 @@
</template>
<script setup>
import {ref} from 'vue'
import {useClient} from '@/plugins/api.js'
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { CircleStencil, Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
})
required: true,
},
});
const emits = defineEmits(['closeRequested'])
const emits = defineEmits(['closeRequested']);
const fileInput = ref(null)
const selectedFile = ref(null)
const fileUrl = ref(props.creator.portraitUrl)
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png'
const errorMessage = ref('')
const showCropper = ref(false)
const cropper = ref(null)
const isUploading = ref(false)
const uploadProgress = ref(0)
const fileInput = ref(null);
const selectedFile = ref(null);
const fileUrl = ref(props.creator.portraitUrl);
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png';
const errorMessage = ref('');
const showCropper = ref(false);
const cropper = ref(null);
const isUploading = ref(false);
const uploadProgress = ref(0);
const TARGET_WIDTH = 200
const TARGET_HEIGHT = 200
const TARGET_WIDTH = 200;
const TARGET_HEIGHT = 200;
const { t } = useI18n();
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.value = '' // Reset the input value to ensure the change event fires
fileInput.value.click()
}
fileInput.value.value = ''; // Reset the input value to ensure the change event fires
fileInput.value.click();
}
};
const onFileSelected = (event) => {
const file = event.target.files[0]
const onFileSelected = event => {
const file = event.target.files[0];
if (file) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
} else {
selectedFile.value = null
fileUrl.value = null
showCropper.value = false
}
selectedFile.value = null;
fileUrl.value = null;
showCropper.value = false;
}
};
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value)
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
const blob = dataURLtoBlob(fileUrl.value);
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
showCropper.value = true;
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput()
}
triggerFileInput();
}
};
// Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => {
const arr = dataURL.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
const dataURLtoBlob = dataURL => {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
const applyCrop = () => {
if (!cropper.value) return
if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas
canvas.toBlob((blob) => {
const canvas = cropper.value.getResult().canvas;
canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type
})
selectedFile.value = croppedFile
fileUrl.value = canvas.toDataURL()
showCropper.value = false
}, selectedFile.value.type)
}
type: selectedFile.value.type,
});
selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL();
showCropper.value = false;
}, selectedFile.value.type);
};
const client = useClient()
const client = useClient();
const publish = async () => {
if (!selectedFile.value || isUploading.value) return
if (!selectedFile.value || isUploading.value) return;
try {
isUploading.value = true
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', selectedFile.value)
isUploading.value = true;
uploadProgress.value = 0;
const formData = new FormData();
formData.append('file', selectedFile.value);
const response = await client.post(
`/api/creators/${props.creator.id}/logo`,
formData,
{
onUploadProgress: (progressEvent) => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
}
)
const response = await client.post(`/api/creators/${props.creator.id}/logo`, formData, {
onUploadProgress: progressEvent => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
},
});
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`;
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl
fileUrl.value = props.creator.portraitUrl;
}
emits('closeRequested')
emits('closeRequested');
} catch (error) {
console.error(error)
errorMessage.value = t('errors.imageUpload')
console.error(error);
errorMessage.value = t('errors.imageUpload');
} finally {
isUploading.value = false
uploadProgress.value = 0
}
isUploading.value = false;
uploadProgress.value = 0;
}
};
const cancel = () => {
showCropper.value = false
showCropper.value = false;
// Reset to original state if we were editing
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl
selectedFile.value = null
fileUrl.value = props.creator.portraitUrl;
selectedFile.value = null;
} else {
fileUrl.value = fallbackUrl
selectedFile.value = null
}
emits('closeRequested')
fileUrl.value = fallbackUrl;
selectedFile.value = null;
}
emits('closeRequested');
};
// Add drop handler
const handleDrop = (event) => {
const file = event.dataTransfer.files[0]
const handleDrop = event => {
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
}
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
}
};
</script>
<style scoped>
@@ -384,14 +393,6 @@ const handleDrop = (event) => {
"chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier",
"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>

View File

@@ -1,5 +1,4 @@
<template>
<button
class="secondary donation-action"
@click="openDonationDialog()"
@@ -11,12 +10,11 @@
ref="donationDialogRef"
:creator-id="creatorId"
:creator-name="creatorName"
:on-success-url="onSuccessUrl"
:on-cancelled-url="onCancelledUrl"
:icon-color-class="iconColorClass"
:on-cancelled-url="onCancelledUrl"
:on-success-url="onSuccessUrl"
@close="handleDialogClose"
/>
</template>
<script setup>
@@ -71,13 +69,6 @@ function handleDialogClose() {
"isupport": "Je Soutiens"
}
}
},
"es": {
"creator": {
"donation": {
"isupport": "Apoyo"
}
}
}
}
</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>

View File

@@ -1,28 +1,25 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from "vue";
import { v7 } from "uuid";
import { useClient } from "@/plugins/api.js";
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { v7 } from 'uuid';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import config from '@/config';
import { mdiCheckCircle, mdiCloseCircle } from '@mdi/js';
const props = defineProps({
name: {
required: true
required: true,
},
creatorNameReservationId: {
required: true
required: true,
},
originalSlug: {
type: String,
default: null
}
default: null,
},
});
const emits = defineEmits([
'update:name',
'update:creatorNameReservationId'
]);
const emits = defineEmits(['update:name', 'update:creatorNameReservationId']);
const name = ref(props.name);
const { t } = useI18n();
@@ -43,7 +40,7 @@ const isCurrentSlug = computed(() => {
const baseUrl = computed(() => `${config.baseUrl}/@`);
// Validation function for the slug
const validateSlug = (slug) => {
const validateSlug = slug => {
if (!slug) {
validationError.value = t('creator.name.errors.required');
return false;
@@ -68,7 +65,7 @@ onMounted(() => {
// If the name is the same as the original slug, set the reservation state to "reserved"
if (isCurrentSlug.value) {
reservationState.value = "reserved";
reservationState.value = 'reserved';
}
});
@@ -94,13 +91,13 @@ const handleInput = () => {
// Validate the slug
if (!validateSlug(currentName)) {
reservationState.value = "unavailable";
reservationState.value = 'unavailable';
return;
}
// If the name is the same as the original slug, set reservation state to "reserved"
if (props.originalSlug && currentName === props.originalSlug) {
reservationState.value = "reserved";
reservationState.value = 'reserved';
lastProcessedName = currentName;
emits('update:name', currentName);
return;
@@ -111,8 +108,8 @@ const handleInput = () => {
};
const client = useClient();
const checkNameAvailability = async (nameToCheck) => {
if (!nameToCheck || nameToCheck.trim() === "") {
const checkNameAvailability = async nameToCheck => {
if (!nameToCheck || nameToCheck.trim() === '') {
reservationState.value = null;
lastProcessedName = nameToCheck;
return;
@@ -123,7 +120,7 @@ const checkNameAvailability = async (nameToCheck) => {
try {
isOperationPending.value = true;
reservationState.value = "loading";
reservationState.value = 'loading';
// Create a new request with cancellation token
const controller = new AbortController();
@@ -137,14 +134,14 @@ const checkNameAvailability = async (nameToCheck) => {
// Only process the response if this is still the current request
if (currentController === controller) {
reservationState.value = "reserved";
reservationState.value = 'reserved';
lastProcessedName = nameToCheck;
emits('update:name', nameToCheck);
}
} catch (error) {
// Only process the error if this is still the current request and it's not an abort error
if (currentController && error.name !== 'AbortError') {
reservationState.value = "unavailable";
reservationState.value = 'unavailable';
lastProcessedName = nameToCheck;
}
} finally {
@@ -159,22 +156,39 @@ onUnmounted(() => {
cancelCurrentRequest();
clearTimeout(timeout);
});
</script>
<template>
<v-text-field variant="outlined" :label="t('creator.name.label')" v-model="name" @input="handleInput"
:error-messages="validationError">
<v-text-field
v-model="name"
:error-messages="validationError"
:label="t('creator.name.label')"
variant="outlined"
@input="handleInput"
>
<template #prepend-inner>
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span>
</template>
<template #append-inner>
<v-progress-circular v-if="reservationState === 'loading'" indeterminate size="24" width="3"
color="grey"></v-progress-circular>
<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-else-if="reservationState === 'unavailable'" color="red" :icon="mdiCloseCircle" />
<v-icon
v-else-if="reservationState === 'reserved'"
:icon="mdiCheckCircle"
color="green"
/>
<v-icon
v-else-if="reservationState === 'unavailable'"
:icon="mdiCloseCircle"
color="red"
/>
</template>
</v-text-field>
</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>

View File

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

View File

@@ -75,13 +75,6 @@
"usernameDefault": "Le créateur",
"receipt": "Un reçu a été envoyé à votre email.",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "¡{creatorName} te agradece!",
"message": "Su pago ha sido procesado con éxito.",
"usernameDefault": "El creador",
"receipt": "Se ha enviado un recibo a su correo electrónico.",
"returnToCreator": "Volver a la página del creador"
}
}
</i18n>

View File

@@ -46,12 +46,6 @@
"message": "Nous n'avons pas pu traiter votre paiement.",
"retry": "Réessayer",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "Pago fallido",
"message": "No pudimos procesar su pago.",
"retry": "Intentar de nuevo",
"returnToCreator": "Volver a la página del creador"
}
}
</i18n>

View File

@@ -3,10 +3,10 @@
<h1>À propos</h1>
<p>
Bienvenue sur la page "À Propos" dHutopy, nous partageons notre histoire, notre mission,
notre vision, et vous psentons l'équipe passionnée qui rend tout cela possible. Hutopy
n'est pas seulement une plateforme ; c'est une communauté, un mouvement, un lieu où la
créativité rencontre la technologie pour créer des expériences inoubliables.
Notre mission chez Hutopy est de développer des outils permettant à chaque utilisateur de se démarquer, autant dans
le monde réel que dans le monde numérique, en cant un véritable pont entre les deux. Que vous soyez artiste de
rue, organisme à but non lucratif ou toute autre personne ayant besoin doutils pour être soutenue, Hutopy est
pour vous.
</p>
<h2>Notre Histoire</h2>
@@ -51,26 +51,6 @@
<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-header">
@@ -81,12 +61,31 @@
<div class="card-body">
<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">
<p>
A pour mission d'assurer le développement du projet tout en cant une interface
qui permettra au projet d'avoir une interaction positive et enrichissante avec Hutopy pour les
utilisateurs.
Avec une vision claire et un engagement sans faille, il a toujours été psent pour veiller à la bonne
réalisation du projet.
</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>
</div>
</div>
@@ -103,7 +102,7 @@
<div class="card-body">
<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">
<p>
Elle façonne l'image dHutopy et engage notre communauté à travers des campagnes
@@ -124,7 +123,7 @@
<div class="card-body">
<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">
<p>
Son expérience d'architecte senior nous permet de développer un logiciel avec
@@ -191,3 +190,5 @@
}
</style>
<script setup lang="ts">
</script>

View File

@@ -1,8 +1,6 @@
<template>
<h1>FAQ</h1>
<h2>Foire Aux Questions</h2>
<h1>Foire Aux Questions</h1>
<p>
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.
</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>
<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.
</p>
@@ -87,29 +77,6 @@
compte. Vous pouvez commencer à utiliser Hutopy et à partager votre contenu sans aucun coût initial.
</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>
<style scoped>

View File

@@ -9,11 +9,11 @@
aussi enrichissante et agréable que possible.
</p>
<h2>FAQ (Foire Aux Questions)</h2>
<h2>Foire Aux Questions</h2>
<p>
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>
<h2>Contactez-Nous</h2>

View File

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

View File

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

View File

@@ -1,51 +1,76 @@
<script setup>
import Instagram from "@/views/svg/Instagram.vue";
import Facebook from "@/views/svg/Facebook.vue";
import X from "@/views/svg/X.vue";
import Instagram from '@/views/svg/Instagram.vue';
import Facebook from '@/views/svg/Facebook.vue';
import X from '@/views/svg/X.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<footer class="flex flex-col gap-10 pt-7 pb-10">
<div class="footer-socials">
<a href="https://www.facebook.com/profile.php?id=61556819217561" target="_blank">
<a
href="https://www.facebook.com/profile.php?id=61556819217561"
target="_blank"
>
<facebook class="social-icon"></facebook>
</a>
<a href="https://www.instagram.com/hutopy.inc/" target="_blank">
<a
href="https://www.instagram.com/hutopy.inc/"
target="_blank"
>
<instagram class="social-icon"></instagram>
</a>
<a href="https://x.com/Hutopyinc/" target="_blank">
<a
href="https://x.com/Hutopyinc/"
target="_blank"
>
<x class="social-icon"></x>
</a>
</div>
<div class="footer-links">
<router-link to="/documents/helpandcontact"
class="link">
<router-link
class="link"
to="/documents/helpandcontact"
>
{{ t('footer.helpandcontact') }}
</router-link>
<router-link to="/documents/faq"
class="link">
<router-link
class="link"
to="/documents/faq"
>
{{ t('footer.faq') }}
</router-link>
<router-link to="/documents/termsandconditions"
class="link">
<router-link
class="link"
to="/documents/guideforcreators"
>
{{ t('footer.creatorguide') }}
</router-link>
<router-link
class="link"
to="/documents/termsandconditions"
>
{{ t('footer.termsandconditions') }}
</router-link>
<router-link to="/documents/contentpolicy"
class="link">
<router-link
class="link"
to="/documents/contentpolicy"
>
{{ t('footer.contentpolicy') }}
</router-link>
<router-link to="/documents/about"
class="link">
<router-link
class="link"
to="/documents/about"
>
{{ t('footer.about') }}
</router-link>
<router-link to="/documents/pricing"
class="link">
<router-link
class="link"
to="/documents/pricing"
>
{{ t('footer.pricing') }}
</router-link>
</div>
@@ -53,13 +78,10 @@ const { t } = useI18n();
<div class="footer-copyright">
Hutopy &copy;{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }}
</div>
</footer>
</template>
<style scoped>
.footer-socials {
@apply flex flex-row justify-center;
@apply gap-10;
@@ -85,7 +107,6 @@ const { t } = useI18n();
@apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400;
}
</style>
<i18n>
@@ -113,18 +134,6 @@ const { t } = useI18n();
"pricing": "Tarifs",
"allRightsReserved": "Tous Droits Réservés"
}
},
"es": {
"footer": {
"helpandcontact": "Ayuda y Contacto",
"faq": "Preguntas Frecuentes",
"creatorguide": "Guía del Creador",
"termsandconditions": "Términos y Condiciones",
"contentpolicy": "Política de Contenido",
"about": "Acerca de",
"pricing": "Precios",
"allRightsReserved": "Todos los Derechos Reservados"
}
}
}
</i18n>

View File

@@ -1,5 +1,5 @@
<script setup>
import Footer from "@/views/main/Footer.vue";
import Footer from '@/views/main/Footer.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
@@ -7,53 +7,63 @@ const { t } = useI18n();
<template>
<div>
<div>
<div class="pa-4 flex flex-col justify-center md:flex-row">
<div class="py-6">
<div>
<img alt="Hutopy Logo" class="md:h-44 logo-image sm:h-28 sm:mx-auto"
src="/images/hutopymedia/banners/hutopy.png">
<img
alt="Hutopy Logo"
class="md:h-44 logo-image sm:h-28 sm:mx-auto"
src="/images/hutopymedia/banners/hutopy.png"
/>
</div>
</div>
<div class="flex flex-col space-y-3 header-btn">
<v-btn
class="text-white w-full sm:w-auto inscription-btn-header"
to="/login">
to="/login"
>
{{ t('inscription') }}
</v-btn>
<v-btn
class="w-full sm:w-auto inscription-btn-header-outlined"
to="/create-creator"
variant="outlined">
variant="outlined"
>
{{ t('createPage') }}
</v-btn>
</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-text text-justify md:text-left">
<span class="text-white"> {{ t('support') }} </span><br>
<span class="text-white"> {{ t('creators') }} </span><br>
<span class="text-white"> {{ t('projects') }} </span><br>
<span class="text-white">{{ t('support') }}</span>
<br />
<span class="text-white">{{ t('creators') }}</span>
<br />
<span class="text-white">{{ t('projects') }}</span>
<br />
<span class="text-white">{{ t('love') }}</span>
</div>
<img alt="YourHutopy" class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png">
<img
alt="YourHutopy"
class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png"
/>
</div>
<div class="relative mt-10">
<div class="flex flex-col lg:flex-row justify-center items-center lg:space-x-14 space-y-6 lg:space-y-0 pa-1">
<div
class="flex flex-col lg:flex-row justify-center items-center lg:space-x-14 space-y-6 lg:space-y-0 pa-1"
>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/hands.png"
>
/>
<div class="text-md text-justify px-6">
{{ t('supportDescription') }}
</div>
@@ -65,7 +75,7 @@ const { t } = useI18n();
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/brain.png"
>
/>
<div class="text-md text-justify px-6">
{{ t('creatorDescription') }}
</div>
@@ -77,16 +87,21 @@ const { t } = useI18n();
</v-btn>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-6 py-8">
<div class="gap-8 items-start flex flex-col md:flex-row">
<!-- Section de texte -->
<div class="space-y-6">
<img alt="YourHutopy" class="w-full mb-6" src="/images/hutopymedia/homepage/votrehutopy.png">
<img
alt="YourHutopy"
class="w-full mb-6"
src="/images/hutopymedia/homepage/votrehutopy.png"
/>
<div class="space-y-4">
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">{{ t('whatIsHutopy') }}</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('whatIsHutopy') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyDescription') }}
</p>
@@ -106,54 +121,71 @@ const { t } = useI18n();
<!-- Section droite : 4 images -->
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15">
<div><img alt="Grinding" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/grinding.png"></div>
<div><img alt="Microphone" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/sign.png"></div>
<div><img alt="Girl VR" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlvr.png"></div>
<div><img alt="Girl Army" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlarmy.png"></div>
<div>
<img
alt="Grinding"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/grinding.png"
/>
</div>
<div>
<img
alt="Microphone"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/sign.png"
/>
</div>
<div>
<img
alt="Girl VR"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlvr.png"
/>
</div>
<div>
<img
alt="Girl Army"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlarmy.png"
/>
</div>
</div>
</div>
</div>
<Footer class="mt-10"></Footer>
</div>
</template>
<style scoped>
.box-text {
color: #6A0164;
color: #6a0164;
font-size: 30px;
font-weight: bold;
}
.inscription-btn-header {
color: white;
background-color: #6A0164;
background-color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
}
.inscription-btn-header-outlined {
color: #6A0164;
color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
}
.inscription-btn {
color: white;
background-color: #6A0164;
background-color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
@@ -163,13 +195,13 @@ const { t } = useI18n();
}
.create-btn {
background-color: #6A0164;
background-color: #6a0164;
font-size: 18px;
height: 48px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px
border-radius: 10px;
}
.overlay p {
@@ -179,14 +211,13 @@ const { t } = useI18n();
}
body {
background-color: #F4F4F4;
background-color: #f4f4f4;
}
.support-container {
display: flex;
justify-content: center; /* Centre le bloc horizontalement */
align-items: center; /* Centre le bloc verticalement (optionnel) */
}
.support-text {
@@ -197,13 +228,12 @@ body {
}
.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 */
}
.highlight2 {
color: #B81286; /* Remplacez par la couleur souhaitée */
color: #b81286; /* Remplacez par la couleur souhaitée */
}
.logo-image {
@@ -217,7 +247,7 @@ body {
}
.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 */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
@@ -225,7 +255,6 @@ body {
}
@media (min-width: 768px) {
.header-btn {
margin-top: 60px;
}
@@ -238,9 +267,8 @@ body {
.homepagetext {
color: white;
font-family: "Roboto", sans-serif;
font-family: 'Roboto', sans-serif;
}
</style>
<i18n>
@@ -276,22 +304,6 @@ body {
"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é.",
"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>

View File

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

View File

@@ -948,39 +948,6 @@
"phoneNumber": "Numéro de téléphone",
"title": "Titre",
"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>

View File

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

View File

@@ -1,6 +1,5 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('changePassword') }}
</div>
@@ -8,36 +7,64 @@
<div class="card-content">
<p class="description mb-4">{{ t('passwordDescription') }}</p>
<v-text-field v-model="newPassword" :label="t('newPassword')" :type="showNewPassword ? 'text' : 'password'"
variant="outlined" required :hint="t('passwordRequirements')">
<v-text-field
v-model="newPassword"
:hint="t('passwordRequirements')"
:label="t('newPassword')"
:type="showNewPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner>
<v-icon @click="showNewPassword = !showNewPassword" class="visibility-toggle" size="small"
:icon="showNewPassword ? mdiEyeOff : mdiEye" />
<v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showNewPassword = !showNewPassword"
/>
</template>
</v-text-field>
<v-text-field v-model="confirmPassword" :label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'" variant="outlined" required>
<v-text-field
v-model="confirmPassword"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner>
<v-icon @click="showConfirmPassword = !showConfirmPassword" class="visibility-toggle" size="small"
:icon="showNewPassword ? mdiEyeOff : mdiEye" />
<v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showConfirmPassword = !showConfirmPassword"
/>
</template>
</v-text-field>
<div v-if="errorMessage" class="error-message mb-4">
<div
v-if="errorMessage"
class="error-message mb-4"
>
{{ errorMessage }}
</div>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
<button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }}
</button>
<button class="primary" @click="handleChangePassword" :disabled="isLoading">
<button
:disabled="isLoading"
class="primary"
@click="handleChangePassword"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
@@ -144,18 +171,6 @@ async function handleChangePassword() {
"passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas",
"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."
},
"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 class="card-actions">
<button class="secondary"
@click="cancel">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
@@ -27,15 +31,15 @@
<script setup>
import { ref } from 'vue';
import {useClient} from "@/plugins/api.js";
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
email: {
required: true,
type: String
}
type: String,
},
});
const emits = defineEmits(['closeRequested']);
@@ -45,10 +49,8 @@ const email = ref(props.email);
const client = useClient();
const save = async () => {
try {
await client.post(
`/api/users/email`,
{
email: email.value
await client.post(`/api/users/email`, {
email: email.value,
});
emits('closeRequested');
@@ -71,12 +73,6 @@ const cancel = () => {
"fr": {
"title": "Changez votre Courriel",
"label": "Votre email"
},
"es": {
"title": "Cambia tu correo electrónico",
"label": "Tu correo electrónico"
}
}
</i18n>

View File

@@ -3,14 +3,14 @@ import {ref} from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['firstname', 'lastname'])
const emit = defineEmits(['close', 'save'])
const props = defineProps(['firstname', 'lastname']);
const emit = defineEmits(['close', 'save']);
const firstname = ref(props.firstname)
const lastname = ref(props.lastname)
const firstname = ref(props.firstname);
const lastname = ref(props.lastname);
const requestClose = () => emit('close')
const requestSave = () => emit('save', firstname.value, lastname.value)
const requestClose = () => emit('close');
const requestSave = () => emit('save', firstname.value, lastname.value);
</script>
<template>
@@ -21,28 +21,32 @@ const requestSave = () => emit('save', firstname.value, lastname.value)
<div class="card-content">
<v-text-field
variant="outlined"
v-model="firstname"
:label="t('firstname')"
variant="outlined"
></v-text-field>
</div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="lastname"
:label="t('lastname')"
variant="outlined"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary"
@click="requestClose">
<button
class="secondary"
@click="requestClose"
>
{{ t('cancel') }}
</button>
<button class="primary"
@click="requestSave">
<button
class="primary"
@click="requestSave"
>
{{ t('save') }}
</button>
</div>
@@ -60,11 +64,6 @@ const requestSave = () => emit('save', firstname.value, lastname.value)
"title": "Nom complet",
"firstname": "Prénom",
"lastname": "Nom"
},
"es": {
"title": "Nombre completo",
"firstname": "Nombre",
"lastname": "Apellido"
}
}
</i18n>

View File

@@ -4,28 +4,36 @@
<div class="card-content">
<v-text-field
v-model="email"
class="w-full p-2"
:label="t('email')"
type="email"
variant="outlined"
:error-messages="emailErrors"
:label="t('email')"
:rules="emailRules"
class="w-full p-2"
type="email"
validate-on="blur"
variant="outlined"
/>
<v-alert
v-if="!!errorMessage"
class="mt-4"
outlined
type="error"
class="mt-4">
>
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
<button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }}
</button>
<button class="primary" @click="saveEmail" :disabled="!canSave || isLoading">
<button
:disabled="!canSave || isLoading"
class="primary"
@click="saveEmail"
>
{{ t('save') }}
</button>
</div>
@@ -34,7 +42,7 @@
</template>
<script setup>
import { ref, computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
@@ -46,8 +54,8 @@ const creatorProfileStore = useCreatorProfileStore();
const props = defineProps({
creator: {
type: Object,
required: true
}
required: true,
},
});
const email = ref(props.creator.presentation?.email || '');
@@ -55,7 +63,7 @@ const isLoading = ref(false);
const errorMessage = ref('');
// Email validation
const isValidEmail = (email) => {
const isValidEmail = email => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
@@ -76,14 +84,12 @@ const emailErrors = computed(() => {
});
const canSave = computed(() => {
return email.value &&
isValidEmail(email.value) &&
email.value !== (props.creator.presentation?.email || '');
return email.value && isValidEmail(email.value) && email.value !== (props.creator.presentation?.email || '');
});
async function saveEmail() {
if (!props.creator.id) {
console.error("Creator ID is missing!");
console.error('Creator ID is missing!');
return;
}
@@ -96,12 +102,9 @@ async function saveEmail() {
errorMessage.value = '';
// Save email
await client.post(
`/api/creators/${props.creator.id}/email`,
{
email: email.value.trim()
}
);
await client.post(`/api/creators/${props.creator.id}/email`, {
email: email.value.trim(),
});
// Refresh creator profile
await creatorProfileStore.fetchCreatorProfile();
@@ -109,7 +112,7 @@ async function saveEmail() {
// Close dialog
emit('closeRequested');
} catch (error) {
console.error("Error saving email:", error);
console.error('Error saving email:', error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
@@ -156,19 +159,6 @@ const emit = defineEmits(['closeRequested']);
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"changeEmail": "Cambiar correo electrónico",
"email": "Correo electrónico",
"save": "Guardar",
"cancel": "Cancelar",
"validation": {
"emailRequired": "El correo electrónico es obligatorio",
"emailInvalid": "Por favor ingrese una dirección de correo electrónico válida"
},
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>

View File

@@ -6,8 +6,8 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
creator: {
required: true
}
required: true,
},
});
const emits = defineEmits(['closeRequested']);
@@ -18,12 +18,9 @@ const client = useClient();
async function save() {
try {
await client.post(
`/api/creators/${props.creator.id}/name`,
{
name: name.value
}
);
await client.post(`/api/creators/${props.creator.id}/name`, {
name: name.value,
});
props.creator.name = name.value;
emits('closeRequested');
@@ -52,12 +49,16 @@ const cancel = () => {
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
@@ -65,9 +66,7 @@ const cancel = () => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
@@ -78,10 +77,6 @@ const cancel = () => {
"fr": {
"title": "Modifier le nom",
"label": "Votre nom"
},
"es": {
"title": "Cambiar nombre",
"label": "Tu nombre"
}
}
</i18n>

View File

@@ -4,32 +4,40 @@
<div class="card-content">
<v-text-field
v-model="displayPhoneNumber"
class="w-full p-2"
:label="t('phoneNumber')"
type="tel"
variant="outlined"
:error-messages="phoneErrors"
:rules="phoneRules"
validate-on="blur"
:label="t('phoneNumber')"
:placeholder="t('phonePlaceholder')"
:rules="phoneRules"
class="w-full p-2"
maxlength="14"
type="tel"
validate-on="blur"
variant="outlined"
@input="handlePhoneInput"
@keydown="handleKeydown"
maxlength="14"
/>
<v-alert
v-if="!!errorMessage"
class="mt-4"
outlined
type="error"
class="mt-4">
>
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
<button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }}
</button>
<button class="primary" @click="savePhoneNumber" :disabled="!canSave || isLoading">
<button
:disabled="!canSave || isLoading"
class="primary"
@click="savePhoneNumber"
>
{{ t('save') }}
</button>
</div>
@@ -38,7 +46,7 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
@@ -50,12 +58,12 @@ const creatorProfileStore = useCreatorProfileStore();
const props = defineProps({
creator: {
type: Object,
required: true
}
required: true,
},
});
// Format existing phone number to display format
const formatPhoneForDisplay = (phone) => {
const formatPhoneForDisplay = phone => {
if (!phone) return '';
const digits = phone.replace(/\D/g, '');
if (digits.length === 10) {
@@ -65,7 +73,7 @@ const formatPhoneForDisplay = (phone) => {
};
// Extract just the digits from formatted phone
const extractDigits = (formattedPhone) => {
const extractDigits = formattedPhone => {
return formattedPhone.replace(/\D/g, '');
};
@@ -75,7 +83,7 @@ const isLoading = ref(false);
const errorMessage = ref('');
// Phone number formatting and validation
const formatPhoneNumber = (digits) => {
const formatPhoneNumber = digits => {
// Remove all non-digits
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)}`;
};
const handlePhoneInput = (event) => {
const handlePhoneInput = event => {
const input = event.target.value;
const digits = extractDigits(input);
@@ -97,7 +105,7 @@ const handlePhoneInput = (event) => {
displayPhoneNumber.value = formatPhoneNumber(digits);
};
const handleKeydown = (event) => {
const handleKeydown = event => {
// Allow backspace, delete, tab, escape, enter
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(phoneDigits, (newDigits) => {
watch(phoneDigits, newDigits => {
displayPhoneNumber.value = formatPhoneNumber(newDigits);
});
const isValidPhoneNumber = (digits) => {
const isValidPhoneNumber = digits => {
return digits.length === 10;
};
@@ -144,13 +152,15 @@ const phoneErrors = computed(() => {
});
const canSave = computed(() => {
return phoneDigits.value.length === 10 &&
phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || '');
return (
phoneDigits.value.length === 10 &&
phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || '')
);
});
async function savePhoneNumber() {
if (!props.creator.id) {
console.error("Creator ID is missing!");
console.error('Creator ID is missing!');
return;
}
@@ -165,12 +175,9 @@ async function savePhoneNumber() {
// Save the formatted phone number
const formattedPhone = formatPhoneNumber(phoneDigits.value);
await client.post(
`/api/creators/${props.creator.id}/phone`,
{
phoneNumber: formattedPhone
}
);
await client.post(`/api/creators/${props.creator.id}/phone`, {
phoneNumber: formattedPhone,
});
// Refresh creator profile
await creatorProfileStore.fetchCreatorProfile();
@@ -178,7 +185,7 @@ async function savePhoneNumber() {
// Close dialog
emit('closeRequested');
} catch (error) {
console.error("Error saving phone number:", error);
console.error('Error saving phone number:', error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
@@ -227,20 +234,6 @@ const emit = defineEmits(['closeRequested']);
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"changePhoneNumber": "Cambiar número de teléfono",
"phoneNumber": "Número de teléfono",
"phonePlaceholder": "(555) 123-4567",
"save": "Guardar",
"cancel": "Cancelar",
"validation": {
"phoneRequired": "El número de teléfono es obligatorio",
"phoneInvalid": "Por favor ingrese un número de teléfono completo de 10 dígitos"
},
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>

View File

@@ -1,14 +1,14 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import {useClient} from "@/plugins/api.js";
import NameEditor from "@/views/creators/NameEditor.vue";
import { useClient } from '@/plugins/api.js';
import NameEditor from '@/views/creators/NameEditor.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
required: true,
},
});
const emit = defineEmits(['closeRequested']);
@@ -24,7 +24,7 @@ const errorMessage = ref('');
const isCurrentHandle = ref(false);
// Watch for changes to the new slug to check if it's the same as the current one
watch(newSlug, (newValue) => {
watch(newSlug, newValue => {
isCurrentHandle.value = newValue === props.creator.slug;
if (isCurrentHandle.value) {
slugReservationId.value = undefined;
@@ -43,7 +43,7 @@ async function save() {
errorMessage.value = '';
await client.put(`/api/creators/${props.creator.id}/slug`, {
slugReservationId: slugReservationId.value
slugReservationId: slugReservationId.value,
});
await creatorProfileStore.fetchCreatorProfile();
@@ -74,26 +74,31 @@ const cancel = () => {
<name-editor
v-model:name="newSlug"
:creator-name-reservation-id="slugReservationId"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
:original-slug="creator.slug"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
></name-editor>
<v-alert
v-if="!!errorMessage"
class="mt-4"
outlined
type="error"
class="mt-4">
>
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button class="secondary"
@click="cancel">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button class="primary"
<button
:disabled="!canSave || isOperationPending"
class="primary"
@click="save"
:disabled="!canSave || isOperationPending">
>
{{ t('save') }}
</button>
</div>
@@ -101,8 +106,7 @@ const cancel = () => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
@@ -111,9 +115,6 @@ const cancel = () => {
},
"fr": {
"title": "Modifier l'identifiant du créateur"
},
"es": {
"title": "Cambiar identificador del creador"
}
}
</i18n>

View File

@@ -51,12 +51,16 @@ const cancel = () => {
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
@@ -64,9 +68,7 @@ const cancel = () => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
@@ -77,10 +79,6 @@ const cancel = () => {
"fr": {
"title": "Modifier l'ID Stripe",
"label": "Votre ID Stripe"
},
"es": {
"title": "Cambiar ID de Stripe",
"label": "Tu ID de Stripe"
}
}
</i18n>

View File

@@ -5,8 +5,8 @@ import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
required: true,
},
});
const emits = defineEmits(['closeRequested']);
@@ -18,12 +18,9 @@ const client = useClient();
async function save() {
try {
await client.post(
`/api/creators/${props.creator.id}/title`,
{
title: title.value
}
);
await client.post(`/api/creators/${props.creator.id}/title`, {
title: title.value,
});
props.creator.title = title.value;
emits('closeRequested');
@@ -39,7 +36,6 @@ const cancel = () => {
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
@@ -53,12 +49,16 @@ const cancel = () => {
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
@@ -66,9 +66,7 @@ const cancel = () => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
@@ -79,10 +77,6 @@ const cancel = () => {
"fr": {
"title": "Modifier le titre",
"label": "Votre titre"
},
"es": {
"title": "Cambiar título",
"label": "Tu título"
}
}
</i18n>

View File

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