feat: pivot to social media workflow app
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-24 12:58:35 -04:00
parent 0f4acc1b6d
commit df3e602015
349 changed files with 18685 additions and 16010 deletions

View File

@@ -1,6 +1,4 @@
VITE_API_URL=https://localhost:5001/
VITE_APP_BASE_URL=http://localhost:5173
VITE_APP_API_URL=http://localhost:5173/api
VITE_API_URL=http://192.168.1.2:5000
VITE_STRIPE_API_KEY=pk_test_51OoveVDrRyqXtNdB2st1NgA8WQA9rhgGaf3q7bCpAOoQyyRS30HMCzGeHba7meVGCSPfb1BVWmOTmFOcr9MkKf5H00bLu5MqsS
VITE_GOOGLE_CLIENT_ID=213344094492-9dbaet2gaschju3hj1sgv1umk0qpd833.apps.googleusercontent.com
VITE_FACEBOOK_APP_ID
VITE_FACEBOOK_APP_ID=1076433907621883

View File

@@ -1,7 +1,6 @@
VITE_API_URL=https://hutopy-backend-api.azurewebsites.net
VITE_APP_BASE_URL=https://hutopy.ca
VITE_APP_API_URL=https://hutopy.ca/api
VITE_STRIPE_API_KEY=51OoveVDrRyqXtNdBAxIo183PujtqFyU0xUMK9YNtIijcHeDlcLN6pqkZWHbgaBA0FHrwLMSoy3yVLN33NX8ExOxL00MSZwgJN7
VITE_GOOGLE_CLIENT_ID=213344094492-7c83lqoh7mnjgadpeqo2lcs1krhbsnnd.apps.googleusercontent.com
VITE_FACEBOOK_APP_ID=1076433907621883
AZURE_SUBSCRIPTION_ID=46feb20f-3ae1-495a-830b-a31f7b76483d
AZURE_TENANT_ID=2f389c0d-131d-4de4-a7ac-03bab7e7a04f
AZURE_TENANT_ID=2f389c0d-131d-4de4-a7ac-03bab7e7a04f

4
frontend/.gitignore vendored
View File

@@ -1,7 +1,3 @@
# Ignore cert localhost
localhost-key.pem
localhost.pem
# Environment files
.env.local
.env.*.local

View File

@@ -7,13 +7,9 @@ Hutopia frontEnd. Using vue3 and vuetify3.
## System Setup
Setup SSL certificates for localhost on your machine. Use the following commands to generate and store the certificates.
Local frontend runtime configuration lives in `.env.development` for development and `.env.production` for production. Update those files when changing API endpoints or OAuth client ids.
```sh
openssl genrsa -out localhost-key.pem
openssl req -new -key localhost-key.pem -out csr.pem
openssl x509 -req -days 365 -in csr.pem -signkey localhost-key.pem -out localhost.pem
```
The dev server runs over HTTP on `http://localhost:5173`.
## Recommended IDE Setup

View File

@@ -1,137 +0,0 @@

# Setting Up SSL for Local Development
## Installing Chocolatey (Windows Only)
### What is Chocolatey?
Chocolatey is a package manager for Windows, making it easy to install and manage software packages via the command line.
### Steps to Install Chocolatey:
1. **Open PowerShell as Administrator**:
- Press `Windows + X` and select **Windows PowerShell (Admin)** from the menu.
2. **Run the Following Command to Install Chocolatey**:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
```
3. **Verify the Installation**:
Once the installation is complete, you can verify Chocolatey is installed by running the following command:
```powershell
choco --version
```
If Chocolatey is installed correctly, it will output the current version number.
### Common Chocolatey Commands
- **Install a package**:
```powershell
choco install <package_name>
```
- **Uninstall a package**:
```powershell
choco uninstall <package_name>
```
- **Upgrade all installed packages**:
```powershell
choco upgrade all
```
### Additional Notes
- Chocolatey is required for installing `mkcert` and other software dependencies.
- Make sure to always run PowerShell as Administrator when using Chocolatey to install or manage packages.
---
## Setting Up SSL for Local Development
To ensure that your local development environment runs with SSL, follow these steps to generate a locally trusted SSL certificate using `mkcert`:
### Requirements
- Install [Node.js](https://nodejs.org) (which includes npm)
- Install [mkcert](https://github.com/FiloSottile/mkcert)
### Steps
1. **Install `mkcert`**:
- Install `mkcert` using Chocolatey on Windows (or use an appropriate package manager for other OS).
- For Windows users, open PowerShell as Administrator and run:
```powershell
choco install mkcert
```
- For macOS users, run:
```bash
brew install mkcert
```
2. **Install the Local Certificate Authority (CA)**:
After installing `mkcert`, run the following command to install the local CA:
```bash
mkcert -install
```
This will set up a trusted local CA to issue certificates.
3. **Generate SSL Certificates**:
In your project root (or a preferred directory), generate the SSL certificate and key for `localhost`:
```bash
mkcert localhost
```
This will generate two files:
- `localhost.pem` (the certificate)
- `localhost-key.pem` (the private key)
4. **Update the `vite.config.js`**:
Ensure your project is set up to use the generated certificate. Your `vite.config.js` should contain the following lines to enable SSL for the development server:
```javascript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import fs from 'fs';
import path from 'path';
export default defineConfig({
plugins: [vue()],
server: {
https: {
key: fs.readFileSync(path.resolve(__dirname, 'path_to_your_key/localhost-key.pem')),
cert: fs.readFileSync(path.resolve(__dirname, 'path_to_your_cert/localhost.pem')),
},
host: 'localhost',
},
});
```
Replace `path_to_your_key` and `path_to_your_cert` with the location of your generated certificate and key files.
5. **Start the Development Server**:
Run the development server with SSL by using:
```bash
npm run dev
```
6. **Access the Application**:
Open your browser and navigate to `https://localhost:3000` (or the port your Vite server is running on).
### Additional Notes
- If you encounter any issues related to SSL, ensure that the local CA is properly installed by running `mkcert -install` again.
- Each developer should follow these steps to generate their own trusted SSL certificate. Avoid committing the generated `.pem` and `.key` files to version control for security reasons.

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport">
<title>Hutopy</title>
<title>Socialize</title>
</head>
<body>
@@ -13,4 +13,4 @@
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>

View File

@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC2322I83McDEox
Pfpq3bNTaza/DtOEa80QuSnhjK0yk+qUJKYJByfPWQz6GhPmI9YpIRKlZtB5aPs8
4qTBgbY/w6DxX5tRXaCwlVe6DN/8aKBS4vcAEryfIqPdAUMqog9sp74AGDGEAzKn
28FduqX9FQ9TC+id5GY9NeiwTx2g1wM6Id5PycBZgCSxZcPEg5229doeg7LsmEQj
837ZwJ6Aavxnyn5bCyZh4wUmxb8lAyFZokd3szY+NLhw3OnKJZ0mYVxHvrRxCzlG
gaobDFqV3UWTq+a42S6WvS4JKtZq+LHAAy1kZpSY0tfFdUwTuT/lxevNy7VLFVup
gpWGcofpAgMBAAECggEAU5DuAPMe2uZS0QW9dTAyTiBkOBKSXaTVZJr4pHUggEhP
nbrRlLaMXpgW8gMQrM4bg1f1qVe+VHzAsiXvm+2mVqUS2roRw7DBSXA1UnOntzQo
bzgAHyxwvVebAdcd1lGQMtrEXE6x8d10PHiTeD1etLP2+MAsYFqKzdXgqxC8PU69
I3ee/O6noGbpzddm5je93qoqncfIO6zd1PqYYkr93+9yffn+dbeOxWOnPHkZNiRB
tYd4D5Cfin2NL+8pQ5BBPdt8xktOFKEhFTOgazV9qwU6ZJAg+UbwUKP1mogpNMhQ
4Ci8T+RQmUfBIq07a1h6ksCTbtS4ByXs8HEA95+FwQKBgQDEeBQJkteDxthZFDMt
vO3TaHoFptKIYgJ6zxJR4ngo2UOc/cPM876qLNvw2p/6E2hnpGMYAZb6o9ipAQQR
WZm52F29X/rtmU2E/03QPE/fCdj3KfZUxtA+xbJ0ecqKgv99Hi0mkJrIUEWgO5n3
0CG87J1dR1HUPqJjbDYkpmz9PQKBgQDuSLKvzipOxXpnJlV0x9PZXWzx+elCT1Hm
uK8j5bagb+MjQdh+u4g3Kgf2g73pl8w80kQp9vExJcmmfLNTcPpbeUL1BXF40NdE
AP/DChN+ynZCFpmE3zIOjy9B7txq+qnu10yrxF6vaUVbjoVZ3d6mZ0QQJ45exqUy
p4S1u864HQKBgHarVOcHc/dbhtgfVF5fDIOySmnZfrb0BC1rn9Qn54481RMhUEAe
Rd8CI4MSeqiRSnG3oEcixq/zgW1reKqGJU1UvCIjtCwJegJINxb9Jv1ANHXuOaSx
RZ10yjqCSe1p/Kn1LS5rD6LIoZWMCo7df1Ne1BpAdtOtVWaaOQXgJFq9AoGAJx5K
L3ByI6Jp2NtDNjvD/LBIvWTgtWEeOflhz0vb8nTL3jLmHtAcqam9yuuP1vRztBx0
0krXB9GDTFC2g+FNSI0cv+rX2RS38lMTqepSjwMf7POW2mhl6Fv7TyCukOV71lkE
HkLLpJJsr34zSDCTZ9AWLWzBA7Aq2KkFsWwWoMUCgYBFYcc3qhMP08fsjSO0Tlu6
Bqmxm1t/+1bGGOPtZOQNcKQRLJfa/c7Cp2F6vYK1yXeautJvoZxrbVptS39yGgMd
I90MkxH5XpFpJYd3tX9QP021AdmXRtk0KBIQobs/y2bnxiT+kcELEq17g1V7MAft
WI16saz8M5wPtM62SxKjxQ==
-----END PRIVATE KEY-----

View File

@@ -1,25 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIETDCCArSgAwIBAgIQBSpXJsGF49z8CNN9ViHXjTANBgkqhkiG9w0BAQsFADCB
iTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMS8wLQYDVQQLDCZKTy1E
RVZcbG93cmFAam8tZGV2IChKb25hdGhhbiBCb3VyZG9uKTE2MDQGA1UEAwwtbWtj
ZXJ0IEpPLURFVlxsb3dyYUBqby1kZXYgKEpvbmF0aGFuIEJvdXJkb24pMB4XDTI0
MTAwODAzNDIxNFoXDTI3MDEwODA0NDIxNFowWjEnMCUGA1UEChMebWtjZXJ0IGRl
dmVsb3BtZW50IGNlcnRpZmljYXRlMS8wLQYDVQQLDCZKTy1ERVZcbG93cmFAam8t
ZGV2IChKb25hdGhhbiBCb3VyZG9uKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBALbfbYjzcxwMSjE9+mrds1NrNr8O04RrzRC5KeGMrTKT6pQkpgkHJ89Z
DPoaE+Yj1ikhEqVm0Hlo+zzipMGBtj/DoPFfm1FdoLCVV7oM3/xooFLi9wASvJ8i
o90BQyqiD2ynvgAYMYQDMqfbwV26pf0VD1ML6J3kZj016LBPHaDXAzoh3k/JwFmA
JLFlw8SDnbb12h6DsuyYRCPzftnAnoBq/GfKflsLJmHjBSbFvyUDIVmiR3ezNj40
uHDc6colnSZhXEe+tHELOUaBqhsMWpXdRZOr5rjZLpa9Lgkq1mr4scADLWRmlJjS
18V1TBO5P+XF683LtUsVW6mClYZyh+kCAwEAAaNeMFwwDgYDVR0PAQH/BAQDAgWg
MBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFMJlZrGHE5r1pdB1VMFr
fa/gSQhoMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAYEA
u+p2G45y2ReLLgC1oIDc3j/Gl+mXQ9AMgj/aIrRE/iZy6wedd2/2AEKpK9e9Dp/z
ckXZoB9D1gcfigQV+xwjAGO939eteDJiYlksB99ujKP5lP6wxJV2kWuQ5THB9b0x
P/td5U2/Va0wElRwg2q7k8+IYRye9A0dK+3ofiFQ3zThgmeq0tBC0DkhFqVVbdO1
1uhgp2SyF7iHB6pIELBlWAXo50wFx+smshFUxX1FT7Y1SbvknZvqFWyyQvD4lymG
EOInURWL4MaMM+JuIbgOabVawaG6sBXhjHNsoYtm6ttbpaMNRpprJ/skY816FtMY
FLq5CkJep9qyNy0Y6N02pb6LPRgoSv9dgx3uY9iD4mgf2vUx9vZBvLvAzF1nQ+PE
WJMlcYry0jE0xy37jNLpUqYALf3gYVwYwnxqe0ytKwHrDXcdkDXROnMJNYsKjZbc
MO4yatpPclBgod6yF4RLc5B08f+jOWzqsHdUSUPITeVZSFaE5UjColhT3l9JxiCB
-----END CERTIFICATE-----

View File

@@ -1,53 +1,100 @@
<template>
<v-app>
<div class="shell-container">
<div class="shell-side">
<site-bar></site-bar>
</div>
<app-bar
:show-brand="true"
:collapse-brand="showsAppSidebar && !isSidebarExpanded"
/>
<div class="shell-view">
<router-view></router-view>
<div
class="shell-main"
:class="{ 'shell-main-app': showsAppSidebar }"
>
<template v-if="showsAppSidebar">
<div class="shell-sidebar-wrap">
<app-sidebar :is-expanded="isSidebarExpanded" />
<button
class="sidebar-boundary-toggle"
type="button"
@click="isSidebarExpanded = !isSidebarExpanded"
>
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
</button>
</div>
</template>
<div class="shell-view">
<router-view></router-view>
</div>
</div>
</div>
</v-app>
</template>
<script async setup>
import SiteBar from '@/views/main/SiteBar.vue';
import { useLanguageStore } from '@/stores/languageStore.js';
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/authStore.js';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import AppBar from '@/views/main/AppBar.vue';
import AppSidebar from '@/views/main/AppSidebar.vue';
// Watch for language changes and update i18n locale
const languageStore = useLanguageStore();
const { locale } = useI18n();
const route = useRoute();
const authStore = useAuthStore();
const isSidebarExpanded = ref(true);
// Watch for changes to the language store
watch(
() => languageStore.locale,
newLocale => {
if (newLocale) {
locale.value = newLocale;
}
},
{ immediate: true }
const showsAppSidebar = computed(() =>
authStore.isAuthenticated && route.path.startsWith('/app')
);
watch(showsAppSidebar, value => {
if (!value) {
isSidebarExpanded.value = true;
return;
}
isSidebarExpanded.value = true;
}, { immediate: true });
</script>
<style scoped>
.shell-container {
@apply flex flex-col;
@apply w-full;
@apply font-sans;
@apply bg-hBackground text-hOnBackground;
@apply min-h-screen flex flex-col;
@apply w-full font-sans;
background:
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(52, 211, 153, 0.16), transparent 24%),
linear-gradient(180deg, #fffaf2 0%, #f6efe2 100%);
color: #172033;
}
.shell-side {
@apply flex-shrink-0;
.shell-main {
@apply relative flex flex-1 flex-col;
}
.shell-main-app {
@apply md:flex-row md:items-start;
}
.shell-sidebar-wrap {
@apply relative flex-shrink-0;
}
.sidebar-boundary-toggle {
@apply absolute left-full top-8 z-10 flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
background: rgba(255, 250, 242, 0.98);
border-color: rgba(23, 32, 51, 0.12);
color: #44516a;
box-shadow: 0 12px 28px rgba(23, 32, 51, 0.12);
}
.sidebar-boundary-toggle:hover {
background: #172033;
color: #fffaf2;
}
.shell-view {
@apply flex-grow;
@apply flex justify-center items-center;
@apply flex min-w-0 flex-1;
}
</style>

View File

@@ -3,21 +3,19 @@
@tailwind utilities;
:root {
/* Branding Colors */
--hutopy-primary: #6B0065;
--hutopy-secondary: #A30E79;
/* UI COLORS */
--h-background: #1c181c;
--h-on-background: #e2e5e9;
--h-surface: #252225;
--h-on-surface: #e2e5e9;
--h-primary: #242b2b;
--h-on-primary: #e2e5e9;
--h-secondary: #e7e5ea;
--h-on-secondary: #000000;
--h-tertiary: #466568;
--h-on-tertiary: #bdb6b6;
--socialize-primary: #172033;
--socialize-accent: #ff8a3d;
--socialize-highlight: #2fa58d;
--h-background: #fffaf2;
--h-on-background: #172033;
--h-surface: #ffffff;
--h-on-surface: #172033;
--h-primary: #172033;
--h-on-primary: #fffaf2;
--h-secondary: #fff3e2;
--h-on-secondary: #172033;
--h-tertiary: #d9f6ee;
--h-on-tertiary: #0f766e;
--h-error: #bc2f2f;
--h-on-error: #ffffff;
}

View File

@@ -0,0 +1,79 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
name: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
src: {
type: String,
default: null,
},
size: {
type: String,
default: 'md',
},
});
const initials = computed(() => {
const basis = props.name?.trim() || props.email?.trim() || '?';
const parts = basis.split(/[\s@._-]+/).filter(Boolean);
if (!parts.length) {
return '?';
}
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`.toUpperCase();
});
const classes = computed(() => ({
avatar: true,
'avatar-sm': props.size === 'sm',
'avatar-md': props.size === 'md',
'avatar-lg': props.size === 'lg',
}));
</script>
<template>
<div :class="classes">
<img
v-if="src"
:src="src"
:alt="name || email || 'Avatar'"
/>
<span v-else>{{ initials }}</span>
</div>
</template>
<style scoped>
.avatar {
@apply inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-black uppercase;
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16) 0%, rgba(255, 138, 61, 0.18) 100%);
color: #172033;
}
.avatar img {
@apply h-full w-full object-cover;
}
.avatar-sm {
@apply h-9 w-9 text-xs;
}
.avatar-md {
@apply h-11 w-11 text-sm;
}
.avatar-lg {
@apply h-14 w-14 text-base;
}
</style>

View File

@@ -0,0 +1,365 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'Edit image',
},
confirmLabel: {
type: String,
default: 'Save image',
},
aspectRatio: {
type: Number,
default: 1,
},
uploadLabel: {
type: String,
default: 'Choose image',
},
isSaving: {
type: Boolean,
default: false,
},
initialUrl: {
type: String,
default: '',
},
sourceLabel: {
type: String,
default: 'Image URL',
},
loadLabel: {
type: String,
default: 'Load URL',
},
});
const emit = defineEmits(['update:modelValue', 'save']);
const fileInput = ref(null);
const cropper = ref(null);
const imageUrl = ref(null);
const remoteUrl = ref('');
const error = ref(null);
const isReady = computed(() => Boolean(imageUrl.value));
function closeDialog() {
emit('update:modelValue', false);
}
function revokeImageUrl() {
if (imageUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(imageUrl.value);
}
}
function resetState() {
revokeImageUrl();
imageUrl.value = props.initialUrl || null;
remoteUrl.value = props.initialUrl || '';
error.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
}
function chooseImage() {
fileInput.value?.click();
}
function onFileSelected(event) {
const [file] = event.target.files ?? [];
if (!file) {
return;
}
revokeImageUrl();
imageUrl.value = URL.createObjectURL(file);
error.value = null;
}
function loadImageFromUrl() {
if (!remoteUrl.value) {
error.value = 'Enter an image URL before loading it.';
return;
}
revokeImageUrl();
imageUrl.value = remoteUrl.value;
error.value = null;
}
function zoom(factor) {
cropper.value?.zoom(factor);
}
function rotate(angle) {
cropper.value?.rotate(angle);
}
async function saveCrop() {
const result = cropper.value?.getResult();
const canvas = result?.canvas;
if (!canvas) {
error.value = 'Select an image before saving.';
return;
}
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png', 0.92));
if (!blob) {
error.value = 'The edited image could not be prepared.';
return;
}
const file = new File([blob], 'cropped-image.png', { type: 'image/png' });
const dataUrl = canvas.toDataURL('image/png', 0.92);
await emit('save', { file, dataUrl, sourceUrl: remoteUrl.value || imageUrl.value });
}
watch(
() => props.modelValue,
isOpen => {
if (isOpen) {
resetState();
} else {
resetState();
}
}
);
onBeforeUnmount(() => {
revokeImageUrl();
});
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="920"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="cropper-card">
<div class="cropper-header">
<div>
<div class="cropper-eyebrow">Image editor</div>
<h2>{{ title }}</h2>
</div>
<button
class="plain-button"
:disabled="isSaving"
@click="closeDialog"
>
Close
</button>
</div>
<div class="cropper-actions">
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden-input"
@change="onFileSelected"
/>
<button
class="action-button"
:disabled="isSaving"
@click="chooseImage"
>
{{ uploadLabel }}
</button>
<div class="url-controls">
<input
v-model="remoteUrl"
type="url"
class="url-input"
:placeholder="sourceLabel"
:disabled="isSaving"
/>
<button
class="action-button secondary"
:disabled="isSaving"
@click="loadImageFromUrl"
>
{{ loadLabel }}
</button>
</div>
<button
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="zoom(1.15)"
>
Zoom in
</button>
<button
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="zoom(0.85)"
>
Zoom out
</button>
<button
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="rotate(-90)"
>
Rotate left
</button>
<button
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="rotate(90)"
>
Rotate right
</button>
</div>
<div
v-if="error"
class="error-message"
>
{{ error }}
</div>
<div
v-if="isReady"
class="cropper-stage"
>
<Cropper
ref="cropper"
:src="imageUrl"
:stencil-props="{ aspectRatio }"
image-restriction="stencil"
/>
</div>
<div
v-else
class="empty-state"
>
Choose an image to crop and upload.
</div>
<div class="footer-actions">
<button
class="action-button secondary"
:disabled="isSaving"
@click="closeDialog"
>
Cancel
</button>
<button
class="action-button"
:disabled="!isReady || isSaving"
@click="saveCrop"
>
<v-progress-circular
v-if="isSaving"
indeterminate
:size="16"
:width="2"
/>
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
</button>
</div>
</div>
</v-dialog>
</template>
<style scoped>
.cropper-card {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
}
.cropper-header {
@apply flex items-start justify-between gap-4;
}
.cropper-eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.cropper-header h2 {
@apply mt-2 text-2xl font-black;
color: #172033;
}
.cropper-actions,
.footer-actions {
@apply flex flex-wrap gap-3;
}
.url-controls {
@apply flex min-w-full flex-wrap gap-3 md:min-w-0 md:flex-1;
}
.url-input {
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.92);
color: #172033;
}
.footer-actions {
@apply justify-end;
}
.action-button,
.plain-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-3 text-sm font-bold transition;
}
.action-button {
background: #172033;
color: #fffaf2;
}
.action-button.secondary,
.plain-button {
background: rgba(255, 255, 255, 0.84);
color: #172033;
border: 1px solid rgba(23, 32, 51, 0.12);
}
.cropper-stage {
@apply overflow-hidden rounded-[1.5rem] border;
height: 28rem;
border-color: rgba(23, 32, 51, 0.08);
background: #fffaf2;
}
.empty-state,
.error-message {
@apply rounded-[1.25rem] border p-4 text-sm;
}
.empty-state {
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
background: rgba(255, 250, 242, 0.9);
}
.error-message {
border-color: rgba(185, 28, 28, 0.12);
color: #b91c1c;
background: rgba(254, 226, 226, 0.75);
}
.hidden-input {
display: none;
}
</style>

View File

@@ -1,13 +1,11 @@
import {onMounted, ref} from "vue";
import {useHead} from "@vueuse/head";
import {useAuthStore} from "@/stores/authStore.js";
import config from "@/config.js";
export function useFacebookLogin() {
const isSdkLoaded = ref(false);
/* TODO: FIND THE ACTUAL HUTOPY'S APP_ID */
const FACEBOOK_APP_ID = "1076433907621883";
useHead({
script: [
{
@@ -33,7 +31,7 @@ export function useFacebookLogin() {
const initializeFacebookSDK = () => {
window.fbAsyncInit = function () {
FB.init({
appId: FACEBOOK_APP_ID,
appId: config.facebookAppId,
xfbml: true,
version: 'v22.0'
});

View File

@@ -1,7 +1,18 @@
// Environment-specific configuration
const config = {
baseUrl: import.meta.env.VITE_APP_BASE_URL || 'https://hutopy.ca',
apiUrl: import.meta.env.VITE_APP_API_URL || 'https://hutopy.ca/api',
};
function getRequiredEnv(name) {
const value = import.meta.env[name];
export default config;
if (!value) {
throw new Error(`${name} is not provided`);
}
return value;
}
const config = Object.freeze({
apiUrl: getRequiredEnv('VITE_API_URL'),
googleClientId: getRequiredEnv('VITE_GOOGLE_CLIENT_ID'),
facebookAppId: getRequiredEnv('VITE_FACEBOOK_APP_ID'),
stripeApiKey: getRequiredEnv('VITE_STRIPE_API_KEY'),
});
export default config;

View File

@@ -33,9 +33,437 @@
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Website",
"common": {
"cancel": "Cancel",
"creating": "Creating..."
},
"workspaceSelector": {
"createAction": "Add workspace"
},
"workspaceCreate": {
"eyebrow": "Workspace",
"title": "Create a new workspace",
"description": "Set up a new workspace with its own slug, timezone, members, workflow, and connectors.",
"previewTitle": "Workspace URL",
"previewDescription": "The slug becomes the stable identifier used for the workspace.",
"formTitle": "Workspace details",
"formDescription": "Start with the core fields now. Members, workflow, and connectors can be configured right after creation.",
"createAction": "Create workspace",
"slugHint": "Workspace slug preview: {slug}",
"fields": {
"name": "Workspace name",
"namePlaceholder": "Northwind Studio",
"slug": "Workspace slug",
"slugPlaceholder": "northwind-studio",
"timeZone": "Time zone"
},
"errors": {
"required": "All workspace fields are required.",
"createFailed": "The workspace could not be created."
}
},
"nav": {
"brandCaption": "Approval workflow",
"workspace": "Workspace",
"notifications": "Notifications",
"dashboard": "Dashboard",
"overview": "Overview",
"workspacePlan": "Content",
"mediaLibrary": "Media Library",
"channels": "Channels",
"projects": "Campaigns",
"reviewQueue": "Review Queue",
"content": "Content",
"profile": "Profile",
"signIn": "Sign in",
"settings": "Settings",
"language": "Language",
"signOut": "Sign out",
"noWorkspace": "No workspace"
},
"notifications": {
"title": "Notifications",
"unread": "unread",
"loading": "Loading notifications...",
"empty": "No workflow notifications yet.",
"events": {
"approvalRequested": "Approval requested",
"approvalDecisionRecorded": "Approval decision recorded",
"commentCreated": "Comment added",
"commentResolved": "Comment resolved",
"contentCreated": "Content item created",
"revisionCreated": "Revision created",
"statusUpdated": "Status updated",
"assetLinked": "Asset linked",
"assetRevisionCreated": "Asset revision created"
}
},
"sidebar": {
"allClients": "All clients",
"allChannels": "All channels",
"allProjects": "All campaigns",
"allReviewItems": "Full review queue",
"noClients": "No clients yet.",
"noChannels": "No channels yet.",
"noProjects": "No campaigns yet.",
"noReviewItems": "No review items right now."
},
"settings": {
"eyebrow": "Settings",
"title": "Account settings",
"userInformation": "User information",
"workspaces": "Workspaces",
"integrations": "Integrations"
},
"dashboard": {
"eyebrow": "Workspace schedule",
"title": "Schedule",
"description": "See what is scheduled for a given day and review the posting agenda in order.",
"workspaceLabel": "Active workspace",
"loading": "Loading workspace data...",
"calendarKicker": "Daily agenda",
"executionKicker": "Next up",
"riskKicker": "Delivery risk",
"reviewKicker": "Review pulse",
"upcomingContent": "Upcoming content",
"deliveryRisks": "What can slip",
"overdueItems": "Overdue items",
"approvalBlockers": "Awaiting approval or revisions",
"unscheduledProjects": "Campaigns without scheduled content",
"reviewQueueSnapshot": "Review queue snapshot",
"emptyUpcoming": "No upcoming scheduled content.",
"emptyOverdue": "Nothing overdue right now.",
"emptyApproval": "No approval blockers at the moment.",
"emptyProjects": "Every campaign has at least one scheduled content item.",
"emptyReviewQueue": "No active review queue items.",
"previousDay": "Previous day",
"nextDay": "Next day",
"today": "Today",
"month": "Month",
"week": "Week",
"campaignDeadline": "Campaign deadline",
"emptyPeriod": "No scheduled items.",
"daySummary": "{content} content items · {projects} campaign deadlines",
"moreItems": "+{count} more",
"emptyDayAgenda": "No content is scheduled for this day.",
"projectProgress": "{scheduled} scheduled · {approved} approved",
"missingSchedule": "Needs content scheduled",
"noDueDate": "No due date",
"labels": {
"unassignedProject": "Unassigned campaign"
},
"readiness": {
"building": "In production",
"approval": "Awaiting approval",
"rework": "Needs revision",
"ready": "Ready to publish",
"published": "Published",
"blocked": "Blocked",
"archived": "Archived",
"scheduled": "Scheduled",
"missing": "No content scheduled"
},
"stats": {
"scheduledThisDay": "Scheduled this day",
"overdue": "Overdue",
"awaitingApproval": "Awaiting approval",
"readyToShip": "Ready to ship"
}
},
"overview": {
"eyebrow": "Portfolio overview",
"title": "Cross-workspace timeline",
"description": "See upcoming deliveries, risks, and activity across every workspace you can access.",
"loading": "Loading overview data...",
"workspacesKicker": "Access scope",
"workspaceRollup": "Workspace rollup",
"timelineKicker": "Upcoming",
"upcomingTitle": "Scheduled across workspaces",
"riskKicker": "Watch list",
"risksTitle": "Items already at risk",
"activityKicker": "Recent activity",
"activityTitle": "Latest workflow events",
"emptyUpcoming": "No upcoming scheduled items across your workspaces.",
"emptyRisks": "No cross-workspace delivery risks right now.",
"emptyActivity": "No recent workflow activity yet.",
"labels": {
"projects": "campaigns",
"upcoming": "upcoming",
"blocked": "blocked"
},
"stats": {
"workspaces": "Workspaces",
"projects": "Campaigns",
"upcoming": "Upcoming items",
"blockers": "At-risk items"
}
},
"clients": {
"eyebrow": "Client management",
"title": "Clients",
"description": "Client accounts, brand identity, and primary approval contacts.",
"newClient": "New client",
"createTitle": "Create client",
"loading": "Loading clients...",
"empty": "No clients are available for the active workspace.",
"noPrimaryContact": "No primary contact set",
"noPrimaryContactEmail": "No primary contact email set",
"errors": {
"nameRequired": "Client name is required.",
"createFailed": "The client could not be created."
},
"fields": {
"name": "Client name",
"portraitUrl": "Client logo or portrait URL",
"primaryContactName": "Primary contact name",
"primaryContactEmail": "Primary contact email",
"primaryContactPortraitUrl": "Primary contact portrait URL"
}
},
"projects": {
"eyebrow": "Campaign planning",
"title": "Campaigns",
"description": "Campaigns grouped inside the active workspace by status, date range, and planning notes.",
"newProject": "New campaign",
"createTitle": "Create campaign",
"loading": "Loading campaigns...",
"empty": "No campaigns are available for the active workspace.",
"unknownClient": "Unknown client",
"noDateRange": "No date range",
"errors": {
"required": "Campaign name and date range are required.",
"invalidDateRange": "The end date must be on or after the start date.",
"workspaceAccountRequired": "This workspace needs an operational account before campaigns can be created.",
"createFailed": "The campaign could not be created."
},
"fields": {
"client": "Client",
"selectClient": "Select a client",
"startDate": "Start date",
"endDate": "End date",
"name": "Campaign name",
"description": "Description",
"notes": "Notes"
}
},
"channels": {
"title": "Channels",
"description": "Add channels to the workspace.",
"createTitle": "Create channel",
"empty": "No channels are available for the active workspace yet.",
"emptyAction": "Add a channel for {network}",
"nextDue": "Next due",
"noScheduled": "Nothing scheduled",
"fields": {
"name": "Channel name",
"network": "Network"
},
"metrics": {
"scheduled": "Scheduled",
"ready": "Ready",
"blocked": "Blocked"
},
"errors": {
"createFailed": "The channel could not be created."
}
},
"reviewQueue": {
"eyebrow": "Review workflow",
"title": "Review queue",
"description": "Pending approvals, revisions, and change requests for the active workspace.",
"empty": "No review items are available for the active workspace."
},
"contentItems": {
"eyebrow": "Content workflow",
"title": "Content items",
"description": "Reviewable units with assets, copy, and approval status inside the active workspace.",
"newItem": "New content item",
"createTitle": "Create content item",
"loading": "Loading content items...",
"empty": "No content items are available for the active workspace.",
"noDueDate": "No due date",
"assetsHelper": "Google Drive assets are now linked from the content item detail page after creation.",
"errors": {
"required": "Title, campaign, message, and targets are required.",
"workspaceAccountRequired": "This workspace needs an operational account before content can be created.",
"createFailed": "The content item could not be created."
},
"fields": {
"title": "Title",
"client": "Client",
"selectClient": "Select a client",
"project": "Campaign",
"selectProject": "Select a campaign",
"dueDate": "Due date",
"publicationTargets": "Publication targets",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
"publicationMessage": "Publication message",
"hashtags": "Hashtags",
"hashtagsPlaceholder": "#launch #brand #campaign",
"assets": "Assets"
}
},
"userSettings": {
"eyebrow": "User information",
"title": "Profile and identity",
"description": "Manage the portrait and account details shown inside the workspace.",
"updatePortrait": "Update portrait",
"accountDetails": "Account details",
"accountDetailsDescription": "Additional account editing fields can be added here next.",
"alias": "Alias",
"fullName": "Full name",
"email": "Email",
"noEmail": "No email set",
"cropperTitle": "Update user portrait",
"savePortrait": "Save portrait",
"choosePortrait": "Choose portrait"
},
"workspaceSettings": {
"eyebrow": "Settings",
"title": "Workspace settings",
"description": "Configure the current workspace across general details, members, workflow, and connectors.",
"currentWorkspace": "Current workspace",
"noWorkspaceSelected": "No workspace selected",
"activeWorkspace": "Active workspace",
"contextNote": "These settings apply to the current workspace only.",
"inviteTitle": "Invite workspace members",
"inviteDescription": "Invite clients, subcontractors, or teammates into the active workspace.",
"inviteEmpty": "No pending invites for this workspace yet.",
"sendInvite": "Send invite",
"reset": "Reset",
"errors": {
"required": "All workspace fields are required.",
"createFailed": "The workspace could not be created.",
"inviteRequired": "Email and role are required to invite a member.",
"inviteFailed": "The workspace invite could not be created."
},
"fields": {
"name": "Workspace name",
"slug": "Workspace slug",
"timeZone": "Time zone",
"memberEmail": "Member email",
"memberRole": "Role"
},
"roles": {
"administrator": "Administrator",
"manager": "Manager",
"client": "Client reviewer",
"provider": "Subcontractor",
"workspaceMember": "Workspace member"
},
"summary": {
"name": "Name",
"slug": "Slug",
"timeZone": "Time zone",
"created": "Created"
},
"tabs": {
"general": "General",
"members": "Members",
"workflow": "Workflow",
"connectors": "Connectors"
},
"members": {
"inviteTitle": "Invite",
"activeTitle": "Members",
"activeDescription": "See everyone who currently belongs to the active workspace.",
"activeEmpty": "No members found for this workspace.",
"pendingTitle": "Pending invitations",
"pendingDescription": "Track who has been invited into the active workspace."
},
"connectors": {
"title": "Connectors",
"description": "Manage workspace-level connectors that feed operational features like the media library.",
"openMediaLibrary": "Open media library",
"googleDrive": {
"title": "Google Drive",
"description": "This connector should power the workspace media sync for images, videos, and other shared files.",
"status": "Pending setup"
}
},
"general": {
"summaryTitle": "Workspace summary",
"summaryDescription": "Reference details for the workspace currently in context."
},
"approvals": {
"flowTitle": "Approval flow",
"flowDescription": "Personalize how content moves through internal review, client review, and publishing for this workspace.",
"previewTitle": "Flow preview",
"previewDescription": "This is the sequence the workspace will use based on the current configuration.",
"saved": "Approval flow saved for this workspace in this browser.",
"fields": {
"requireInternalReview": "Require internal review",
"internalApproversRequired": "Internal approvers required",
"requireClientReview": "Require client review",
"clientApproversRequired": "Client approvers required",
"defaultReviewerRole": "Default reviewer role",
"publishBehaviour": "After final approval"
},
"fieldHelp": {
"requireInternalReview": "Content must be approved internally before client review can begin.",
"requireClientReview": "Content must still pass through client approval before publication."
},
"publishBehaviour": {
"manual": "Mark ready to publish",
"auto": "Auto-advance to ready"
},
"steps": {
"internal": "Internal review",
"client": "Client review",
"publish": "Publishing handoff"
},
"stepDetail": {
"approverCount": "{count} approver(s) required",
"autoPublish": "Content moves to ready automatically after the final approval.",
"manualPublish": "Content stays in a manual ready-to-publish handoff after the final approval."
}
}
},
"integrations": {
"eyebrow": "Integrations",
"title": "Google Drive and API keys",
"description": "This is where workspace-level integrations and credential configuration should live.",
"statusLabel": "Status",
"pendingTitle": "Configuration UI pending",
"googleDrive": {
"title": "Google Drive",
"description": "Configure the workspace connection used for asset linking and revision intake.",
"nextStep": "Next step: add stored workspace integration settings and connect them to the asset-link flow."
},
"apiKeys": {
"title": "API keys",
"description": "Workspace-scoped secrets and external service credentials should be managed here.",
"nextStep": "Next step: add secure backend persistence and masked key management."
}
},
"mediaLibrary": {
"eyebrow": "Media library",
"title": "Workspace media",
"description": "Manage the shared image and video library that should sync with Google Drive for this workspace.",
"syncCard": {
"title": "Google Drive sync",
"description": "Use this area to connect the workspace drive, pull approved media in, and keep the library aligned with external folders."
},
"mediaTypesTitle": "Supported media",
"mediaTypesDescription": "The library should become the single place to browse visual assets before they are linked into content work.",
"mediaTypes": {
"images": "Images, graphics, and brand visuals",
"videos": "Videos, reels, and motion exports"
},
"workflowTitle": "Planned workflow",
"workflowDescription": "This page is the intended home for the Google Drive sync flow we discussed.",
"workflow": {
"connectDrive": "Connect the workspace Google Drive source.",
"syncAssets": "Sync image and video assets into the internal library.",
"organizeLibrary": "Review, tag, and reuse media from one workspace-level place."
},
"statusLabel": "Status",
"pendingTitle": "Management UI pending",
"pendingDescription": "The navigation and page entry point are in place. Next step is wiring actual Drive sync, listing, filters, and asset actions."
},
"errors": {
"unexpected": "An unexpected error occurred",
"imageLoad": "Error loading image",
"imageUpload": "Error uploading image"
}
}
}

View File

@@ -33,9 +33,437 @@
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Site web",
"common": {
"cancel": "Annuler",
"creating": "Création..."
},
"workspaceSelector": {
"createAction": "Ajouter un espace"
},
"workspaceCreate": {
"eyebrow": "Espace",
"title": "Creer un nouvel espace",
"description": "Configurez un nouvel espace avec son slug, son fuseau horaire, ses membres, son workflow et ses connecteurs.",
"previewTitle": "URL de l'espace",
"previewDescription": "Le slug devient l'identifiant stable utilise pour l'espace.",
"formTitle": "Details de l'espace",
"formDescription": "Commencez par les champs essentiels. Les membres, le workflow et les connecteurs peuvent etre configures juste apres la creation.",
"createAction": "Creer l'espace",
"slugHint": "Apercu du slug : {slug}",
"fields": {
"name": "Nom de l'espace",
"namePlaceholder": "Northwind Studio",
"slug": "Slug de l'espace",
"slugPlaceholder": "northwind-studio",
"timeZone": "Fuseau horaire"
},
"errors": {
"required": "Tous les champs de l'espace sont requis.",
"createFailed": "L'espace n'a pas pu etre cree."
}
},
"nav": {
"brandCaption": "Flux d'approbation",
"workspace": "Espace de travail",
"notifications": "Notifications",
"dashboard": "Tableau de bord",
"overview": "Vue globale",
"workspacePlan": "Contenu",
"mediaLibrary": "Bibliotheque media",
"channels": "Canaux",
"projects": "Campagnes",
"reviewQueue": "File de révision",
"content": "Contenu",
"profile": "Profil",
"signIn": "Se connecter",
"settings": "Paramètres",
"language": "Langue",
"signOut": "Se déconnecter",
"noWorkspace": "Aucun espace"
},
"notifications": {
"title": "Notifications",
"unread": "non lues",
"loading": "Chargement des notifications...",
"empty": "Aucune notification de workflow pour le moment.",
"events": {
"approvalRequested": "Approbation demandée",
"approvalDecisionRecorded": "Décision d'approbation enregistrée",
"commentCreated": "Commentaire ajouté",
"commentResolved": "Commentaire résolu",
"contentCreated": "Élément de contenu créé",
"revisionCreated": "Révision créée",
"statusUpdated": "Statut mis à jour",
"assetLinked": "Ressource liée",
"assetRevisionCreated": "Révision de ressource créée"
}
},
"sidebar": {
"allClients": "Tous les clients",
"allChannels": "Tous les canaux",
"allProjects": "Toutes les campagnes",
"allReviewItems": "File de révision complète",
"noClients": "Aucun client pour le moment.",
"noChannels": "Aucun canal pour le moment.",
"noProjects": "Aucune campagne pour le moment.",
"noReviewItems": "Aucun élément à réviser pour le moment."
},
"settings": {
"eyebrow": "Paramètres",
"title": "Paramètres du compte",
"userInformation": "Informations utilisateur",
"workspaces": "Espaces de travail",
"integrations": "Intégrations"
},
"dashboard": {
"eyebrow": "Calendrier de l'espace",
"title": "Calendrier",
"description": "Voyez ce qui est prévu pour une journée donnée et consultez l'agenda des publications dans l'ordre.",
"workspaceLabel": "Espace actif",
"loading": "Chargement des données de l'espace...",
"calendarKicker": "Agenda du jour",
"executionKicker": "À venir",
"riskKicker": "Risque de livraison",
"reviewKicker": "État des révisions",
"upcomingContent": "Contenu à venir",
"deliveryRisks": "Ce qui peut glisser",
"overdueItems": "Éléments en retard",
"approvalBlockers": "En attente d'approbation ou de révision",
"unscheduledProjects": "Campagnes sans contenu planifié",
"reviewQueueSnapshot": "Aperçu de la file de révision",
"emptyUpcoming": "Aucun contenu planifié à venir.",
"emptyOverdue": "Rien n'est en retard pour le moment.",
"emptyApproval": "Aucun blocage d'approbation pour le moment.",
"emptyProjects": "Chaque campagne a au moins un élément de contenu planifié.",
"emptyReviewQueue": "Aucun élément actif dans la file de révision.",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"today": "Aujourd'hui",
"month": "Mois",
"week": "Semaine",
"campaignDeadline": "Échéance de campagne",
"emptyPeriod": "Aucun élément planifié.",
"daySummary": "{content} contenus · {projects} échéances de campagne",
"moreItems": "+{count} autres",
"emptyDayAgenda": "Aucun contenu n'est planifié pour cette journée.",
"projectProgress": "{scheduled} planifiés · {approved} approuvés",
"missingSchedule": "Contenu à planifier",
"noDueDate": "Aucune échéance",
"labels": {
"unassignedProject": "Campagne non attribuée"
},
"readiness": {
"building": "En production",
"approval": "En attente d'approbation",
"rework": "Révision requise",
"ready": "Prêt à publier",
"published": "Publié",
"blocked": "Bloqué",
"archived": "Archivé",
"scheduled": "Planifié",
"missing": "Aucun contenu planifié"
},
"stats": {
"scheduledThisDay": "Planifiés ce jour",
"overdue": "En retard",
"awaitingApproval": "En attente d'approbation",
"readyToShip": "Prêts à livrer"
}
},
"overview": {
"eyebrow": "Vue portefeuille",
"title": "Chronologie multi-espaces",
"description": "Suivez les livraisons à venir, les risques et l'activité sur tous les espaces auxquels vous avez accès.",
"loading": "Chargement des données globales...",
"workspacesKicker": "Périmètre d'accès",
"workspaceRollup": "Synthèse des espaces",
"timelineKicker": "À venir",
"upcomingTitle": "Planifié sur tous les espaces",
"riskKicker": "À surveiller",
"risksTitle": "Éléments déjà à risque",
"activityKicker": "Activité récente",
"activityTitle": "Derniers événements du workflow",
"emptyUpcoming": "Aucun élément planifié à venir sur vos espaces.",
"emptyRisks": "Aucun risque de livraison multi-espace pour le moment.",
"emptyActivity": "Aucune activité récente du workflow.",
"labels": {
"projects": "campagnes",
"upcoming": "à venir",
"blocked": "bloqués"
},
"stats": {
"workspaces": "Espaces",
"projects": "Campagnes",
"upcoming": "Éléments à venir",
"blockers": "Éléments à risque"
}
},
"clients": {
"eyebrow": "Gestion client",
"title": "Clients",
"description": "Comptes clients, identité de marque et contacts principaux d'approbation.",
"newClient": "Nouveau client",
"createTitle": "Créer un client",
"loading": "Chargement des clients...",
"empty": "Aucun client n'est disponible pour l'espace actif.",
"noPrimaryContact": "Aucun contact principal défini",
"noPrimaryContactEmail": "Aucun email de contact principal défini",
"errors": {
"nameRequired": "Le nom du client est requis.",
"createFailed": "Le client n'a pas pu être créé."
},
"fields": {
"name": "Nom du client",
"portraitUrl": "URL du logo ou portrait du client",
"primaryContactName": "Nom du contact principal",
"primaryContactEmail": "Email du contact principal",
"primaryContactPortraitUrl": "URL du portrait du contact principal"
}
},
"projects": {
"eyebrow": "Planification des campagnes",
"title": "Campagnes",
"description": "Campagnes regroupées dans l'espace actif par statut, plage de dates et notes de planification.",
"newProject": "Nouvelle campagne",
"createTitle": "Créer une campagne",
"loading": "Chargement des campagnes...",
"empty": "Aucune campagne n'est disponible pour l'espace actif.",
"unknownClient": "Client inconnu",
"noDateRange": "Aucune plage de dates",
"errors": {
"required": "Le nom de la campagne et la plage de dates sont requis.",
"invalidDateRange": "La date de fin doit être postérieure ou égale à la date de début.",
"workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer des campagnes.",
"createFailed": "La campagne n'a pas pu être créée."
},
"fields": {
"client": "Client",
"selectClient": "Sélectionner un client",
"startDate": "Date de début",
"endDate": "Date de fin",
"name": "Nom de la campagne",
"description": "Description",
"notes": "Notes"
}
},
"channels": {
"title": "Canaux",
"description": "Ajoutez des canaux à l'espace.",
"createTitle": "Créer un canal",
"empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.",
"emptyAction": "Ajouter un canal pour {network}",
"nextDue": "Prochaine échéance",
"noScheduled": "Rien de planifié",
"fields": {
"name": "Nom du canal",
"network": "Réseau"
},
"metrics": {
"scheduled": "Planifié",
"ready": "Prêt",
"blocked": "Bloqué"
},
"errors": {
"createFailed": "Le canal n'a pas pu être créé."
}
},
"reviewQueue": {
"eyebrow": "Flux de révision",
"title": "File de révision",
"description": "Approbations, révisions et demandes de changement en attente pour l'espace actif.",
"empty": "Aucun élément de révision n'est disponible pour l'espace actif."
},
"contentItems": {
"eyebrow": "Flux de contenu",
"title": "Éléments de contenu",
"description": "Unités révisables avec ressources, texte et statut d'approbation dans l'espace actif.",
"newItem": "Nouvel élément de contenu",
"createTitle": "Créer un élément de contenu",
"loading": "Chargement des éléments de contenu...",
"empty": "Aucun élément de contenu n'est disponible pour l'espace actif.",
"noDueDate": "Aucune échéance",
"assetsHelper": "Les ressources Google Drive sont maintenant liées depuis la page de détail de l'élément après sa création.",
"errors": {
"required": "Le titre, la campagne, le message et les cibles sont requis.",
"workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer du contenu.",
"createFailed": "L'élément de contenu n'a pas pu être créé."
},
"fields": {
"title": "Titre",
"client": "Client",
"selectClient": "Sélectionner un client",
"project": "Campagne",
"selectProject": "Sélectionner une campagne",
"dueDate": "Date d'échéance",
"publicationTargets": "Cibles de publication",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
"publicationMessage": "Message de publication",
"hashtags": "Hashtags",
"hashtagsPlaceholder": "#lancement #marque #campagne",
"assets": "Ressources"
}
},
"userSettings": {
"eyebrow": "Informations utilisateur",
"title": "Profil et identité",
"description": "Gérez le portrait et les informations du compte affichés dans l'espace.",
"updatePortrait": "Mettre à jour le portrait",
"accountDetails": "Détails du compte",
"accountDetailsDescription": "Des champs supplémentaires d'édition du compte peuvent être ajoutés ici ensuite.",
"alias": "Alias",
"fullName": "Nom complet",
"email": "Email",
"noEmail": "Aucun email défini",
"cropperTitle": "Mettre à jour le portrait utilisateur",
"savePortrait": "Enregistrer le portrait",
"choosePortrait": "Choisir un portrait"
},
"workspaceSettings": {
"eyebrow": "Paramètres",
"title": "Paramètres de l'espace",
"description": "Configurez l'espace courant avec les sections general, membres, workflow et connecteurs.",
"currentWorkspace": "Espace actuel",
"noWorkspaceSelected": "Aucun espace sélectionné",
"activeWorkspace": "Espace actif",
"contextNote": "Ces paramètres s'appliquent uniquement à l'espace courant.",
"inviteTitle": "Inviter des membres",
"inviteDescription": "Invitez des clients, sous-traitants ou collègues dans l'espace actif.",
"inviteEmpty": "Aucune invitation en attente pour cet espace.",
"sendInvite": "Envoyer l'invitation",
"reset": "Réinitialiser",
"errors": {
"required": "Tous les champs de l'espace sont requis.",
"createFailed": "L'espace n'a pas pu être créé.",
"inviteRequired": "L'email et le rôle sont requis pour inviter un membre.",
"inviteFailed": "L'invitation de l'espace n'a pas pu être créée."
},
"fields": {
"name": "Nom de l'espace",
"slug": "Slug de l'espace",
"timeZone": "Fuseau horaire",
"memberEmail": "Email du membre",
"memberRole": "Rôle"
},
"roles": {
"administrator": "Administrateur",
"manager": "Gestionnaire",
"client": "Réviseur client",
"provider": "Sous-traitant",
"workspaceMember": "Membre de l'espace"
},
"summary": {
"name": "Nom",
"slug": "Slug",
"timeZone": "Fuseau horaire",
"created": "Créé"
},
"tabs": {
"general": "Général",
"members": "Membres",
"workflow": "Workflow",
"connectors": "Connecteurs"
},
"members": {
"inviteTitle": "Inviter",
"activeTitle": "Membres",
"activeDescription": "Voyez toutes les personnes qui appartiennent actuellement à l'espace actif.",
"activeEmpty": "Aucun membre trouvé pour cet espace.",
"pendingTitle": "Invitations en attente",
"pendingDescription": "Suivez les personnes invitées dans l'espace actif."
},
"connectors": {
"title": "Connecteurs",
"description": "Gerez les connecteurs au niveau de l'espace qui alimentent des fonctions comme la bibliotheque media.",
"openMediaLibrary": "Ouvrir la bibliotheque media",
"googleDrive": {
"title": "Google Drive",
"description": "Ce connecteur doit alimenter la synchronisation media de l'espace pour les images, videos et autres fichiers partages.",
"status": "Configuration en attente"
}
},
"general": {
"summaryTitle": "Résumé de l'espace",
"summaryDescription": "Détails de référence pour l'espace actuellement en contexte."
},
"approvals": {
"flowTitle": "Flux d'approbation",
"flowDescription": "Personnalisez le passage du contenu par la révision interne, la révision client et la mise en publication pour cet espace.",
"previewTitle": "Aperçu du flux",
"previewDescription": "Voici la séquence que l'espace utilisera selon la configuration actuelle.",
"saved": "Le flux d'approbation a été enregistré pour cet espace dans ce navigateur.",
"fields": {
"requireInternalReview": "Exiger une révision interne",
"internalApproversRequired": "Approbateurs internes requis",
"requireClientReview": "Exiger une révision client",
"clientApproversRequired": "Approbateurs client requis",
"defaultReviewerRole": "Rôle du réviseur par défaut",
"publishBehaviour": "Après l'approbation finale"
},
"fieldHelp": {
"requireInternalReview": "Le contenu doit être approuvé en interne avant de passer à la révision client.",
"requireClientReview": "Le contenu doit encore passer par une approbation client avant la publication."
},
"publishBehaviour": {
"manual": "Marquer prêt à publier",
"auto": "Passer automatiquement à prêt"
},
"steps": {
"internal": "Révision interne",
"client": "Révision client",
"publish": "Passage à la publication"
},
"stepDetail": {
"approverCount": "{count} approbateur(s) requis",
"autoPublish": "Le contenu passe automatiquement à prêt après l'approbation finale.",
"manualPublish": "Le contenu reste dans une étape manuelle prêt à publier après l'approbation finale."
}
}
},
"integrations": {
"eyebrow": "Intégrations",
"title": "Google Drive et clés API",
"description": "C'est ici que doivent vivre les intégrations au niveau de l'espace et la configuration des identifiants.",
"statusLabel": "Statut",
"pendingTitle": "Interface de configuration en attente",
"googleDrive": {
"title": "Google Drive",
"description": "Configurez la connexion de l'espace utilisée pour la liaison des ressources et l'entrée des révisions.",
"nextStep": "Prochaine étape : ajouter des paramètres d'intégration stockés pour l'espace et les connecter au flux de liaison des ressources."
},
"apiKeys": {
"title": "Clés API",
"description": "Les secrets de l'espace et identifiants de services externes doivent être gérés ici.",
"nextStep": "Prochaine étape : ajouter une persistance backend sécurisée et une gestion masquée des clés."
}
},
"mediaLibrary": {
"eyebrow": "Bibliotheque media",
"title": "Medias de l'espace",
"description": "Gerez la bibliotheque partagee d'images et de videos qui devrait se synchroniser avec Google Drive pour cet espace.",
"syncCard": {
"title": "Synchronisation Google Drive",
"description": "Cette zone servira a connecter le Drive de l'espace, importer les medias approuves et garder la bibliotheque alignee sur les dossiers externes."
},
"mediaTypesTitle": "Medias pris en charge",
"mediaTypesDescription": "La bibliotheque doit devenir l'endroit unique pour parcourir les ressources visuelles avant de les lier au contenu.",
"mediaTypes": {
"images": "Images, visuels graphiques et elements de marque",
"videos": "Videos, reels et exports en mouvement"
},
"workflowTitle": "Flux prevu",
"workflowDescription": "Cette page est le point d'entree prevu pour le flux de synchronisation Google Drive dont on a parle.",
"workflow": {
"connectDrive": "Connecter la source Google Drive de l'espace.",
"syncAssets": "Synchroniser les images et videos dans la bibliotheque interne.",
"organizeLibrary": "Reviser, etiqueter et reutiliser les medias depuis un seul endroit au niveau de l'espace."
},
"statusLabel": "Statut",
"pendingTitle": "Interface de gestion en attente",
"pendingDescription": "L'entree de navigation et la page sont en place. La prochaine etape est de brancher la vraie synchro Drive, le listing, les filtres et les actions sur les ressources."
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite",
"imageLoad": "Erreur lors du chargement de l'image",
"imageUpload": "Erreur lors du téléchargement de l'image"
}
}
}

View File

@@ -21,13 +21,18 @@ import {
import vueGoogleOauth from 'vue3-google-login';
import { useAuthStore } from '@/stores/authStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import Toast, { POSITION } from 'vue-toastification';
import 'vue-toastification/dist/index.css';
import './assets/main.css';
import { createI18n } from 'vue-i18n';
import en from '@/locales/en.json';
import fr from '@/locales/fr.json';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useReviewQueueStore } from '@/stores/reviewQueueStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
import { useClientsStore } from '@/stores/clientsStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
import { useNotificationsStore } from '@/stores/notificationsStore.js';
import { useChannelsStore } from '@/stores/channelsStore.js';
import { i18n } from '@/plugins/i18n.js';
import config from '@/config.js';
const vuetify = createVuetify({
components: {
@@ -51,15 +56,6 @@ const vuetify = createVuetify({
},
});
const i18n = createI18n({
legacy: false,
fallbackLocale: 'fr',
messages: {
en: en,
fr: fr,
},
});
const pinia = createPinia();
const app = createApp(App)
@@ -68,7 +64,7 @@ const app = createApp(App)
.use(router)
.use(i18n)
.use(vueGoogleOauth, {
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
clientId: config.googleClientId,
})
.use(Toast, {
position: POSITION.TOP_CENTER,
@@ -76,6 +72,12 @@ const app = createApp(App)
useAuthStore();
useUserProfileStore();
useCreatorProfileStore();
useWorkspaceStore();
useClientsStore();
useProjectsStore();
useChannelsStore();
useReviewQueueStore();
useContentItemsStore();
useNotificationsStore();
app.mount('#app');

View File

@@ -1,13 +1,10 @@
import axios from 'axios';
import { useAuthStore } from '@/stores/authStore.js';
import config from '@/config.js';
export function useClient() {
if (!import.meta.env.VITE_API_URL) {
throw new Error('VITE_API_URL is not provided');
}
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
baseURL: config.apiUrl,
headers: {
'Content-Type': 'application/json'
}

View File

@@ -0,0 +1,12 @@
import { createI18n } from 'vue-i18n';
import en from '@/locales/en.json';
import fr from '@/locales/fr.json';
export const i18n = createI18n({
legacy: false,
fallbackLocale: 'en',
messages: {
en,
fr,
},
});

View File

@@ -1,115 +1,147 @@
import { useAuthStore } from '@/stores/authStore.js';
import { createRouter, createWebHistory } from 'vue-router';
import CreatorHome from '@/views/creators/CreatorHome.vue';
import CreatorLayout from '@/views/creators/CreatorLayout.vue';
const LoginView = () => import('@/views/auth/LoginView.vue');
const About = () => import('@/views/documentation/About.vue');
const ContentPolicy = () => import('@/views/documentation/ContentPolicy.vue');
const CreatorGuide = () => import('@/views/documentation/CreatorGuide.vue');
const DocumentationLayout = () => import('@/views/documentation/DocumentationLayout.vue');
const FAQ = () => import('@/views/documentation/FAQ.vue');
const HelpAndContact = () => import('@/views/documentation/HelpAndContact.vue');
const Pricing = () => import('@/views/documentation/Pricing.vue');
const TermsAndConditions = () => import('@/views/documentation/TermsAndConditions.vue');
const ProfilePage = () => import('@/views/profile/ProfilePage.vue');
const PaymentCompleted = () => import('@/views/creators/PaymentCompleted.vue');
const PaymentFailed = () => import('@/views/creators/PaymentFailed.vue');
const Landing = () => import('@/views/main/Landing.vue');
const CreateCreator = () => import('@/views/creators/CreateCreator.vue');
const RegisterView = () => import('@/views/auth/RegisterView.vue');
const ForgotPasswordView = () => import('@/views/auth/ForgotPasswordView.vue');
const ResetPasswordView = () => import('@/views/auth/ResetPasswordView.vue');
const VerifyEmailView = () => import('@/views/auth/VerifyEmailView.vue');
const OverviewView = () => import('@/views/app/OverviewView.vue');
const DashboardView = () => import('@/views/app/DashboardView.vue');
const ChannelsView = () => import('@/views/app/ChannelsView.vue');
const CampaignsView = () => import('@/views/app/ProjectsView.vue');
const CampaignDetailView = () => import('@/views/app/ProjectDetailView.vue');
const MediaLibraryView = () => import('@/views/app/MediaLibraryView.vue');
const WorkspaceCreateView = () => import('@/views/app/WorkspaceCreateView.vue');
const SettingsLayoutView = () => import('@/views/app/SettingsLayoutView.vue');
const UserSettingsView = () => import('@/views/app/UserSettingsView.vue');
const IntegrationsSettingsView = () => import('@/views/app/IntegrationsSettingsView.vue');
const WorkspaceSettingsView = () => import('@/views/app/WorkspaceSettingsView.vue');
const ReviewQueueView = () => import('@/views/app/ReviewQueueView.vue');
const ContentItemsView = () => import('@/views/app/ContentItemsView.vue');
const ContentItemDetailView = () => import('@/views/app/ContentItemDetailView.vue');
const routes = [
{
path: '/landing',
path: '/',
name: 'landing',
component: Landing,
},
{
path: '/',
redirect: { name: 'landing' },
path: '/app',
redirect: { name: 'dashboard' },
},
{
path: '/@:creator',
component: CreatorLayout,
path: '/app/dashboard',
name: 'dashboard',
component: OverviewView,
meta: { requiresAuth: true },
},
{
path: '/app/workspace',
name: 'workspace-dashboard',
component: DashboardView,
meta: { requiresAuth: true },
},
{
path: '/app/channels',
name: 'channels',
component: ChannelsView,
meta: { requiresAuth: true },
},
{
path: '/app/media-library',
name: 'media-library',
component: MediaLibraryView,
meta: { requiresAuth: true },
},
{
path: '/app/campaigns',
name: 'campaigns',
component: CampaignsView,
meta: { requiresAuth: true },
},
{
path: '/app/campaigns/:projectId',
name: 'campaign-detail',
component: CampaignDetailView,
meta: { requiresAuth: true },
},
{
path: '/app/reviews',
name: 'review-queue',
component: ReviewQueueView,
meta: { requiresAuth: true },
},
{
path: '/app/workspace-settings',
name: 'workspace-settings',
component: WorkspaceSettingsView,
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
},
{
path: '/app/workspaces/new',
name: 'workspace-create',
component: WorkspaceCreateView,
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
},
{
path: '/app/settings',
component: SettingsLayoutView,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'creator',
component: CreatorHome,
redirect: { name: 'settings-user-information' },
},
{
path: 'tip-completed',
name: 'PaymentCompleted',
component: PaymentCompleted,
path: 'user-information',
name: 'settings-user-information',
component: UserSettingsView,
},
{
path: 'tip-cancelled',
name: 'PaymentFailed',
component: PaymentFailed,
path: 'workspaces',
name: 'settings-workspaces',
component: WorkspaceSettingsView,
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
},
{
path: 'integrations',
name: 'settings-integrations',
component: IntegrationsSettingsView,
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
},
],
},
{
path: '/documents',
component: DocumentationLayout,
children: [
{
path: 'helpandcontact',
name: 'helpandcontact',
component: HelpAndContact,
},
{
path: 'termsandconditions',
name: 'termsandconditions',
component: TermsAndConditions,
},
{
path: 'contentpolicy',
name: 'contentpolicy',
component: ContentPolicy,
},
{
path: 'faq',
name: 'FAQ',
component: FAQ,
},
{
path: 'about',
name: 'about',
component: About,
},
{
path: 'pricing',
name: 'pricing',
component: Pricing,
},
],
path: '/app/content',
name: 'content-items',
component: ContentItemsView,
meta: { requiresAuth: true },
},
{
path: '/app/content/new',
name: 'content-item-create',
component: ContentItemDetailView,
meta: { requiresAuth: true },
},
{
path: '/app/content/:id',
name: 'content-item-detail',
component: ContentItemDetailView,
meta: { requiresAuth: true },
},
{
path: '/login',
name: 'login',
component: LoginView,
meta: { notAuthenticated: true },
props: route => ({ returnUrl: route.query.returnUrl || '/landing' }),
props: route => ({ returnUrl: route.query.returnUrl || '/app/dashboard' }),
},
{
path: '/profile',
name: 'profile',
component: ProfilePage,
meta: { requiresAuth: true },
},
{
path: '/create-creator',
name: 'create-creator',
component: CreateCreator,
meta: { requiresAuth: true },
redirect: { name: 'dashboard' },
},
{
path: '/register',
@@ -136,6 +168,10 @@ const routes = [
component: VerifyEmailView,
meta: { notAuthenticated: true },
},
{
path: '/:pathMatch(.*)*',
redirect: { name: 'landing' },
},
];
const router = createRouter({
@@ -154,10 +190,16 @@ router.beforeEach((to, from, next) => {
query: { returnUrl: to.fullPath },
});
} else {
const requiredRoles = to.matched.flatMap(record => record.meta.roles ?? []);
if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) {
next({ name: 'dashboard' });
return;
}
next();
}
} else if (to.matched.some(record => record.meta.notAuthenticated)) {
if (authStore.isAuthenticated) next({ name: 'landing' });
if (authStore.isAuthenticated) next({ name: 'dashboard' });
else next();
} else {
next();

View File

@@ -24,6 +24,20 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userRoles = computed(() => {
const claims = tokenClaims.value ?? {};
const candidates = [
claims.role,
claims.roles,
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
return [...new Set(candidates)];
});
const persona = computed(() => tokenClaims.value?.persona ?? null);
const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager'));
const isClient = computed(() => userRoles.value.includes('Client'));
const isProvider = computed(() => userRoles.value.includes('Provider'));
function updateTokens(data) {
if (!data?.accessToken || !data?.refreshToken) {
@@ -259,11 +273,21 @@ export const useAuthStore = defineStore('auth', () => {
}
}
function hasAnyRole(roles) {
return roles.some(role => userRoles.value.includes(role));
}
return {
accessToken,
refreshToken,
isAuthenticated,
userId,
userRoles,
persona,
hasAnyRole,
isManager,
isClient,
isProvider,
isRefreshing,
login,
loginWithGoogle,

View File

@@ -1,96 +0,0 @@
import {defineStore} from 'pinia'
import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core";
import {ref, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
export const useBrandingStore = defineStore(
'branding',
() => {
const currentBrand = ref(undefined)
const loading = ref(false)
const error = ref(null)
const notFound = ref(false)
const value = useSessionStorage(
'branding',
{},
{writeDefaults: false})
const presentationInfos = ref([])
const router = useRouter()
const route = useRoute()
watch(
() => route.params.creator,
async (creator) => {
// Extract just the creator name from the path (remove any additional segments)
const creatorName = creator ? creator.split('/')[0] : undefined;
await updateBrand(creatorName);
}
)
async function updateBrand(newBrand) {
loading.value = true
error.value = null
notFound.value = false
if (newBrand !== currentBrand.value) {
if (newBrand !== undefined) {
const result = await fetchCreatorData(newBrand)
if (result.success) {
value.value = result.data
currentBrand.value = newBrand
presentationInfos.value = result.data?.presentationInfos
} else {
// Handle different error types
if (result.status === 404) {
notFound.value = true
error.value = 'Creator not found'
} else {
error.value = result.error || 'Failed to load creator'
}
value.value = {}
currentBrand.value = undefined
presentationInfos.value = []
}
} else {
value.value = {}
currentBrand.value = undefined
presentationInfos.value = []
}
}
loading.value = false
}
const fetchCreatorData = async (creatorAlias) => {
try {
const client = useClient()
const response = await client.get(`/api/creators/@${creatorAlias}`)
return { success: true, data: response.data }
} catch (error) {
console.error('Error fetching creator data:', error)
if (error.response?.status === 404) {
return { success: false, status: 404, error: 'Creator not found' }
}
return {
success: false,
status: error.response?.status || 500,
error: error.message || 'Unknown error occurred'
}
}
}
return {
currentBrand,
value,
loading,
error,
notFound,
updateBrand,
presentationInfos
}
})

View File

@@ -0,0 +1,122 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
export const useChannelsStore = defineStore('channels', () => {
const workspaceStore = useWorkspaceStore();
const contentItemsStore = useContentItemsStore();
const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, {
serializer: {
read: value => (value ? JSON.parse(value) : {}),
write: value => JSON.stringify(value ?? {}),
},
});
const channels = computed(() => {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
return [];
}
const derivedChannels = new Map();
const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
for (const item of contentItemsStore.items) {
for (const name of parseTargets(item.publicationTargets)) {
const key = slugify(name);
const existing = derivedChannels.get(key) ?? {
id: key,
name,
network: null,
source: 'derived',
};
derivedChannels.set(key, existing);
}
}
for (const channel of customChannels) {
derivedChannels.set(channel.id, {
...channel,
source: 'custom',
});
}
return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name));
});
const availableNetworks = [
'Instagram',
'TikTok',
'Facebook',
'LinkedIn',
'YouTube',
'X',
'Reddit',
'Website',
];
function createChannel(payload) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
throw new Error('An active workspace is required to create a channel.');
}
const normalizedName = payload.name.trim();
const normalizedNetwork = payload.network.trim();
if (!normalizedName) {
throw new Error('Channel name is required.');
}
if (!normalizedNetwork) {
throw new Error('Network is required.');
}
if (!availableNetworks.includes(normalizedNetwork)) {
throw new Error('Selected network is invalid.');
}
const existing = channels.value.some(channel =>
channel.name.toLowerCase() === normalizedName.toLowerCase()
&& (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase()
);
if (existing) {
throw new Error('A channel with this name already exists for the selected network.');
}
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
customChannelsByWorkspace.value = {
...customChannelsByWorkspace.value,
[currentWorkspaceId]: [
...next,
{
id: slugify(`${normalizedNetwork}-${normalizedName}`),
name: normalizedName,
network: normalizedNetwork,
},
],
};
}
function parseTargets(value) {
return (value ?? '')
.split(/[,\n]+/)
.map(target => target.trim())
.filter(Boolean);
}
function slugify(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
return {
availableNetworks,
channels,
createChannel,
};
});

View File

@@ -0,0 +1,182 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useClientsStore = defineStore('clients', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const clients = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const isUploadingPortrait = ref(false);
const error = ref(null);
const operationalClient = computed(() => {
if (!clients.value.length) {
return null;
}
return clients.value.find(candidate => candidate.name === workspaceStore.activeWorkspace?.name)
?? clients.value[0];
});
async function fetchClients() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
clients.value = [];
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/clients', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
clients.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch clients:', fetchError);
clients.value = [];
error.value = 'Failed to load clients.';
} finally {
isLoading.value = false;
}
}
async function createClient(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a client.');
}
if (isCreating.value) {
throw new Error('A client creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/clients', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
clients.value = [...clients.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (createError) {
console.error('Failed to create client:', createError);
error.value = 'Failed to create client.';
throw createError;
} finally {
isCreating.value = false;
}
}
async function updateClient(clientId, payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to update a client.');
}
if (isUpdating.value) {
throw new Error('A client update request is already in progress.');
}
isUpdating.value = true;
error.value = null;
try {
const response = await client.put(`/api/clients/${clientId}`, payload);
if (response.data) {
clients.value = clients.value
.map(candidate => candidate.id === clientId ? response.data : candidate)
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (updateError) {
console.error('Failed to update client:', updateError);
error.value = 'Failed to update client.';
throw updateError;
} finally {
isUpdating.value = false;
}
}
async function uploadClientPortrait(clientId, file) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to upload a client logo.');
}
if (isUploadingPortrait.value) {
throw new Error('A client logo upload is already in progress.');
}
isUploadingPortrait.value = true;
error.value = null;
try {
const formData = new FormData();
formData.append('file', file, file.name || 'client-logo.png');
const response = await client.post(`/api/clients/${clientId}/portrait`, formData);
const blobUrl = response.data?.blobUrl;
if (blobUrl) {
clients.value = clients.value.map(candidate =>
candidate.id === clientId
? { ...candidate, portraitUrl: `${blobUrl}?${Date.now()}` }
: candidate
);
}
return response.data;
} catch (uploadError) {
console.error('Failed to upload client logo:', uploadError);
error.value = 'Failed to upload client logo.';
throw uploadError;
} finally {
isUploadingPortrait.value = false;
}
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
clients.value = [];
error.value = null;
return;
}
await fetchClients();
},
{ immediate: true }
);
return {
clients,
operationalClient,
isLoading,
isCreating,
isUpdating,
isUploadingPortrait,
error,
fetchClients,
createClient,
updateClient,
uploadClientPortrait,
};
});

View File

@@ -0,0 +1,255 @@
import { reactive, ref } from 'vue';
import { defineStore } from 'pinia';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useContentItemDetailStore = defineStore('content-item-detail', () => {
const workspaceStore = useWorkspaceStore();
const client = useClient();
const item = ref(null);
const revisions = ref([]);
const assets = ref([]);
const comments = ref([]);
const approvals = ref([]);
const notifications = ref([]);
const isLoading = ref(false);
const error = ref(null);
const actions = reactive({
revision: false,
asset: false,
assetRevision: false,
comment: false,
approval: false,
decision: false,
status: false,
});
function reset() {
item.value = null;
revisions.value = [];
assets.value = [];
comments.value = [];
approvals.value = [];
notifications.value = [];
error.value = null;
}
async function fetchContentItemDetail(contentItemId) {
isLoading.value = true;
error.value = null;
try {
const [
itemResponse,
revisionsResponse,
assetsResponse,
commentsResponse,
approvalsResponse,
notificationsResponse,
] = await Promise.all([
client.get(`/api/content-items/${contentItemId}`),
client.get(`/api/content-items/${contentItemId}/revisions`),
client.get('/api/assets', { params: { contentItemId } }),
client.get('/api/comments', { params: { contentItemId } }),
client.get('/api/approvals', { params: { contentItemId } }),
client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
contentItemId,
},
}),
]);
item.value = itemResponse.data;
revisions.value = revisionsResponse.data ?? [];
assets.value = assetsResponse.data ?? [];
comments.value = commentsResponse.data ?? [];
approvals.value = approvalsResponse.data ?? [];
notifications.value = notificationsResponse.data ?? [];
} catch (fetchError) {
console.error('Failed to load content item detail:', fetchError);
reset();
error.value = 'Failed to load the content item detail.';
} finally {
isLoading.value = false;
}
}
async function createRevision(contentItemId, payload) {
actions.revision = true;
try {
const response = await client.post(`/api/content-items/${contentItemId}/revisions`, payload);
if (response.data) {
revisions.value = [response.data, ...revisions.value];
await fetchContentItemDetail(contentItemId);
}
return response.data;
} finally {
actions.revision = false;
}
}
async function addGoogleDriveAsset(contentItemId, payload) {
actions.asset = true;
try {
const response = await client.post('/api/assets/google-drive', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
assets.value = [...assets.value, response.data];
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.asset = false;
}
}
async function addAssetRevision(contentItemId, assetId, payload) {
actions.assetRevision = true;
try {
const response = await client.post(`/api/assets/${assetId}/revisions`, payload);
if (response.data) {
await fetchAssets(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.assetRevision = false;
}
}
async function addComment(contentItemId, payload) {
actions.comment = true;
try {
const response = await client.post('/api/comments', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
comments.value = [...comments.value, response.data];
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.comment = false;
}
}
async function resolveComment(contentItemId, commentId) {
actions.comment = true;
try {
const response = await client.post(`/api/comments/${commentId}/resolve`);
if (response.data) {
comments.value = comments.value.map(comment => comment.id === commentId ? response.data : comment);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.comment = false;
}
}
async function createApproval(contentItemId, payload) {
actions.approval = true;
try {
const response = await client.post('/api/approvals', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
approvals.value = [response.data, ...approvals.value];
await fetchContentItem(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.approval = false;
}
}
async function submitDecision(contentItemId, approvalId, payload) {
actions.decision = true;
try {
const response = await client.post(`/api/approvals/${approvalId}/decisions`, payload);
if (response.data) {
approvals.value = approvals.value.map(approval => approval.id === approvalId ? response.data : approval);
await fetchContentItem(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.decision = false;
}
}
async function updateStatus(contentItemId, status) {
actions.status = true;
try {
const response = await client.post(`/api/content-items/${contentItemId}/status`, { status });
item.value = response.data;
await fetchNotifications(contentItemId);
return response.data;
} finally {
actions.status = false;
}
}
async function fetchContentItem(contentItemId) {
const response = await client.get(`/api/content-items/${contentItemId}`);
item.value = response.data;
return response.data;
}
async function fetchAssets(contentItemId) {
const response = await client.get('/api/assets', { params: { contentItemId } });
assets.value = response.data ?? [];
return assets.value;
}
async function fetchNotifications(contentItemId) {
const response = await client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
contentItemId,
},
});
notifications.value = response.data ?? [];
return notifications.value;
}
return {
item,
revisions,
assets,
comments,
approvals,
notifications,
isLoading,
error,
actions,
reset,
fetchContentItemDetail,
createRevision,
addGoogleDriveAsset,
addAssetRevision,
addComment,
resolveComment,
createApproval,
submitDecision,
updateStatus,
};
});

View File

@@ -0,0 +1,112 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useContentItemsStore = defineStore('content-items', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const items = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
const activeCount = computed(() =>
items.value.filter(item => item.status !== 'Approved' && item.status !== 'Published' && item.status !== 'Archived')
.length
);
async function fetchContentItems(filters = {}) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
items.value = [];
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/content-items', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
clientId: filters.clientId,
projectId: filters.projectId,
},
});
items.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch content items:', fetchError);
items.value = [];
error.value = 'Failed to load content items.';
} finally {
isLoading.value = false;
}
}
async function createContentItem(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a content item.');
}
if (isCreating.value) {
throw new Error('A content item creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/content-items', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
items.value = [response.data, ...items.value];
}
return response.data;
} catch (createError) {
console.error('Failed to create content item:', createError);
error.value = 'Failed to create content item.';
throw createError;
} finally {
isCreating.value = false;
}
}
async function fetchContentItem(id) {
const response = await client.get(`/api/content-items/${id}`);
return response.data;
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
items.value = [];
error.value = null;
return;
}
await fetchContentItems();
},
{ immediate: true }
);
return {
items,
isLoading,
isCreating,
error,
activeCount,
fetchContentItems,
fetchContentItem,
createContentItem,
};
});

View File

@@ -1,86 +0,0 @@
import {useClient} from '@/plugins/api.js';
import {useAuthStore} from '@/stores/authStore.js';
import {useSessionStorage} from '@vueuse/core';
import {defineStore} from 'pinia';
import {computed, watch} from 'vue';
import {useRouter} from 'vue-router';
export const useCreatorProfileStore = defineStore(
'creator-profile',
() => {
const router = useRouter();
const authStore = useAuthStore();
watch(
() => authStore.isAuthenticated,
async (newValue) => {
if (newValue) {
await fetchCreatorProfile();
if (value.value && value.value.name !== undefined) {
await router.push(`/@${value.value.slug}`);
} else {
await router.push('/');
}
} else if (!authStore.isRefreshing) {
value.value = undefined;
}
}
);
const value = useSessionStorage(
'creator-profile',
{},
{
writeDefaults: false,
storage: window.sessionStorage,
serializer: {
read: (value) => value ? JSON.parse(value) : undefined,
write: (value) => value ? JSON.stringify(value) : undefined
}
}
);
const hasCreator = computed(
() => value.value && Object.getOwnPropertyNames(value.value).length >= 1
);
const client = useClient();
async function fetchCreatorProfile() {
try {
const response = await client.get(`/api/creators/profile`);
value.value = response.data;
} catch (error) {
value.value = undefined;
}
}
async function removeCreatorPage() {
try {
await client.delete(`/api/creators/@${value.value.slug}`)
await fetchCreatorProfile();
}
catch(error) {
console.error(error);
}
}
async function restoreCreatorPage() {
try {
await client.put(`/api/creators/@${value.value.slug}/restore`, {})
await fetchCreatorProfile();
}
catch(error) {
console.error(error);
}
}
return {
creator: value,
hasCreator,
removeCreatorPage,
restoreCreatorPage,
fetchCreatorProfile
};
});

View File

@@ -1,15 +1,13 @@
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { i18n } from '@/plugins/i18n.js';
const ALLOWED_LOCALES = ['en', 'fr'];
const DEFAULT_LOCALE = 'fr';
const DEFAULT_LOCALE = 'en';
export const useLanguageStore = defineStore('language', () => {
const storedLocale = useSessionStorage('user-locale', DEFAULT_LOCALE);
// Get i18n instance (provided globally)
const { locale } = useI18n();
const locale = i18n.global.locale;
function sanitizeLocale(value) {
return ALLOWED_LOCALES.includes(value) ? value : DEFAULT_LOCALE;
@@ -18,15 +16,11 @@ export const useLanguageStore = defineStore('language', () => {
// Initialize locale with a sanitized value
const initial = sanitizeLocale(storedLocale.value);
storedLocale.value = initial;
if (locale) {
locale.value = initial;
}
locale.value = initial;
function setLocale(newLocale) {
const next = sanitizeLocale(newLocale);
if (locale) {
locale.value = next;
}
locale.value = next;
storedLocale.value = next;
}

View File

@@ -0,0 +1,89 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useNotificationsStore = defineStore('notifications', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const items = ref([]);
const isLoading = ref(false);
const error = ref(null);
const unreadCount = computed(() =>
items.value.filter(item => !item.readAt).length
);
const recentItems = computed(() => items.value.slice(0, 6));
function reset() {
items.value = [];
error.value = null;
}
async function fetchNotifications() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
reset();
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
items.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch notifications:', fetchError);
items.value = [];
error.value = 'Failed to load notifications.';
} finally {
isLoading.value = false;
}
}
async function markAsRead(notificationId) {
try {
await client.post(`/api/notifications/${notificationId}/read`);
items.value = items.value.map(item =>
item.id === notificationId
? { ...item, readAt: item.readAt ?? new Date().toISOString() }
: item
);
} catch (markError) {
console.error('Failed to mark notification as read:', markError);
}
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
reset();
return;
}
await fetchNotifications();
},
{ immediate: true }
);
return {
items,
recentItems,
unreadCount,
isLoading,
error,
reset,
fetchNotifications,
markAsRead,
};
});

View File

@@ -0,0 +1,99 @@
import { ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useProjectsStore = defineStore('projects', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const projects = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
async function fetchProjects() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
projects.value = [];
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/projects', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
projects.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch projects:', fetchError);
projects.value = [];
error.value = 'Failed to load projects.';
} finally {
isLoading.value = false;
}
}
async function createProject(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a project.');
}
if (isCreating.value) {
throw new Error('A project creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/projects', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
projects.value = [...projects.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (createError) {
console.error('Failed to create project:', createError);
error.value = 'Failed to create project.';
throw createError;
} finally {
isCreating.value = false;
}
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
projects.value = [];
error.value = null;
return;
}
await fetchProjects();
},
{ immediate: true }
);
return {
projects,
isLoading,
isCreating,
error,
fetchProjects,
createProject,
};
});

View File

@@ -0,0 +1,49 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
const stageByStatus = {
Draft: 'Draft',
'In internal review': 'Internal review',
'Changes requested internally': 'Internal changes requested',
'Internal changes in progress': 'Internal revision',
'Ready for client review': 'Ready for client review',
'In client review': 'Client review',
'Changes requested by client': 'Client changes requested',
'Client changes in progress': 'Client revision',
Approved: 'Approved',
Rejected: 'Rejected',
'Ready to publish': 'Ready to publish',
Published: 'Published',
Archived: 'Archived',
};
export const useReviewQueueStore = defineStore('review-queue', () => {
const contentItemsStore = useContentItemsStore();
const projectsStore = useProjectsStore();
const items = computed(() =>
contentItemsStore.items
.filter(item => item.status !== 'Draft' && item.status !== 'Published' && item.status !== 'Archived')
.map(item => {
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
return {
id: item.id,
title: item.title,
projectName: project?.name ?? 'Unknown campaign',
stage: stageByStatus[item.status] ?? item.status,
status: item.status,
dueLabel: item.dueDate ? `Due ${new Date(item.dueDate).toLocaleDateString()}` : 'No due date',
};
})
);
const urgentItems = computed(() => items.value.slice(0, 5));
return {
items,
urgentItems,
};
});

View File

@@ -50,9 +50,15 @@ export const useUserProfileStore = defineStore(
const portraitUrl = computed(() => {
return value.value && value.value.portraitUrl
? value.value.portraitUrl
: '/images/usersmedia/anonyme/profilepictures/profileAnonymeSquare.png'
: null
})
const roles = computed(() => value.value?.userRoles ?? [])
const persona = computed(() => value.value?.persona ?? null)
const authorizedWorkspaceIds = computed(() => value.value?.authorizedWorkspaceIds ?? [])
const authorizedClientIds = computed(() => value.value?.authorizedClientIds ?? [])
const authorizedProjectIds = computed(() => value.value?.authorizedProjectIds ?? [])
async function fetchCurrentUserProfile() {
try {
const client = useClient()
@@ -153,7 +159,7 @@ export const useUserProfileStore = defineStore(
try {
const client = useClient()
const formData = new FormData();
formData.append('file', selectedFile)
formData.append('file', selectedFile, selectedFile.name || 'portrait.png')
const response = await client.post(
`/api/users/portrait`,
@@ -170,6 +176,11 @@ export const useUserProfileStore = defineStore(
alias,
fullname,
portraitUrl,
roles,
persona,
authorizedWorkspaceIds,
authorizedClientIds,
authorizedProjectIds,
changeFullname,
changeAlias,
changeBirthday,

View File

@@ -0,0 +1,208 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useClient } from '@/plugins/api.js';
export const useWorkspaceStore = defineStore('workspace', () => {
const authStore = useAuthStore();
const client = useClient();
const workspaces = ref([]);
const activeWorkspaceId = ref(null);
const isLoading = ref(false);
const isCreating = ref(false);
const invitesByWorkspace = ref({});
const membersByWorkspace = ref({});
const isInvitesLoading = ref(false);
const isMembersLoading = ref(false);
const isInviting = ref(false);
const error = ref(null);
const activeWorkspace = computed(() =>
workspaces.value.find(workspace => workspace.id === activeWorkspaceId.value) ?? null
);
async function fetchWorkspaces() {
if (!authStore.isAuthenticated) {
workspaces.value = [];
activeWorkspaceId.value = null;
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/workspaces');
workspaces.value = response.data ?? [];
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
}
} catch (fetchError) {
console.error('Failed to fetch workspaces:', fetchError);
workspaces.value = [];
activeWorkspaceId.value = null;
error.value = 'Failed to load workspaces.';
} finally {
isLoading.value = false;
}
}
async function createWorkspace(payload) {
if (!authStore.isAuthenticated) {
throw new Error('You must be authenticated to create a workspace.');
}
if (isCreating.value) {
throw new Error('A workspace creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/workspaces', payload);
if (response.data) {
workspaces.value = [...workspaces.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
activeWorkspaceId.value = response.data.id;
try {
await client.post('/api/clients', {
workspaceId: response.data.id,
name: response.data.name,
});
} catch (hiddenClientError) {
console.error('Failed to provision operational client for workspace:', hiddenClientError);
}
}
return response.data;
} catch (createError) {
console.error('Failed to create workspace:', createError);
error.value = 'Failed to create workspace.';
throw createError;
} finally {
isCreating.value = false;
}
}
function setActiveWorkspace(workspaceId) {
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
activeWorkspaceId.value = workspaceId;
}
}
async function fetchInvites(workspaceId = activeWorkspaceId.value) {
if (!authStore.isAuthenticated || !workspaceId) {
invitesByWorkspace.value = {};
return [];
}
isInvitesLoading.value = true;
try {
const response = await client.get(`/api/workspaces/${workspaceId}/invites`);
invitesByWorkspace.value = {
...invitesByWorkspace.value,
[workspaceId]: response.data ?? [],
};
return invitesByWorkspace.value[workspaceId];
} catch (fetchError) {
console.error('Failed to fetch workspace invites:', fetchError);
throw fetchError;
} finally {
isInvitesLoading.value = false;
}
}
async function fetchMembers(workspaceId = activeWorkspaceId.value) {
if (!authStore.isAuthenticated || !workspaceId) {
membersByWorkspace.value = {};
return [];
}
isMembersLoading.value = true;
try {
const response = await client.get(`/api/workspaces/${workspaceId}/members`);
membersByWorkspace.value = {
...membersByWorkspace.value,
[workspaceId]: response.data ?? [],
};
return membersByWorkspace.value[workspaceId];
} catch (fetchError) {
console.error('Failed to fetch workspace members:', fetchError);
throw fetchError;
} finally {
isMembersLoading.value = false;
}
}
async function inviteMember(payload) {
if (!authStore.isAuthenticated || !activeWorkspaceId.value) {
throw new Error('You must be authenticated to invite a workspace member.');
}
if (isInviting.value) {
throw new Error('A workspace invite request is already in progress.');
}
isInviting.value = true;
try {
const response = await client.post(`/api/workspaces/${activeWorkspaceId.value}/invites`, payload);
invitesByWorkspace.value = {
...invitesByWorkspace.value,
[activeWorkspaceId.value]: [response.data, ...(invitesByWorkspace.value[activeWorkspaceId.value] ?? [])],
};
return response.data;
} catch (inviteError) {
console.error('Failed to create workspace invite:', inviteError);
throw inviteError;
} finally {
isInviting.value = false;
}
}
watch(
() => authStore.isAuthenticated,
async isAuthenticated => {
if (!isAuthenticated) {
workspaces.value = [];
activeWorkspaceId.value = null;
error.value = null;
return;
}
await fetchWorkspaces();
},
{ immediate: true }
);
return {
workspaces,
activeWorkspaceId,
activeWorkspace,
isLoading,
isCreating,
invitesByWorkspace,
membersByWorkspace,
isInvitesLoading,
isMembersLoading,
isInviting,
error,
fetchWorkspaces,
createWorkspace,
fetchInvites,
fetchMembers,
inviteMember,
setActiveWorkspace,
};
});

View File

@@ -0,0 +1,376 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
import { useChannelsStore } from '@/stores/channelsStore.js';
import {
mdiClose,
mdiFacebook,
mdiInstagram,
mdiLinkedin,
mdiMusicNote,
mdiPlus,
mdiReddit,
mdiWeb,
mdiYoutube,
} from '@mdi/js';
const route = useRoute();
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
const contentItemsStore = useContentItemsStore();
const channelsStore = useChannelsStore();
const isCreateFormVisible = ref(false);
const formError = ref(null);
const activeNetwork = ref('Instagram');
const form = reactive({
name: '',
network: 'Instagram',
});
const networkOptions = [
{ value: 'Instagram', icon: mdiInstagram },
{ value: 'TikTok', icon: mdiMusicNote },
{ value: 'Facebook', icon: mdiFacebook },
{ value: 'LinkedIn', icon: mdiLinkedin },
{ value: 'YouTube', icon: mdiYoutube },
{ value: 'X', icon: mdiClose },
{ value: 'Reddit', icon: mdiReddit },
{ value: 'Website', icon: mdiWeb },
];
const configuredChannels = computed(() =>
channelsStore.channels
.filter(channel => channel.network)
.map(channel => {
const metrics = buildMetrics(channel.name);
return {
...channel,
...metrics,
};
})
);
const channelsForActiveNetwork = computed(() =>
configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
);
function buildMetrics(channelName) {
const matches = contentItemsStore.items.filter(item =>
parseTargets(item.publicationTargets).some(target => target.toLowerCase() === channelName.toLowerCase())
);
return {
scheduled: matches.length,
nextDueDate: matches
.filter(item => item.dueDate)
.sort((left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime())[0]?.dueDate ?? null,
readyCount: matches.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length,
blockedCount: matches.filter(item =>
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
).length,
};
}
function resetForm() {
form.name = '';
form.network = activeNetwork.value;
formError.value = null;
}
function openCreateForm(network = activeNetwork.value) {
activeNetwork.value = network;
resetForm();
form.network = network;
isCreateFormVisible.value = true;
}
function submitForm() {
formError.value = null;
try {
channelsStore.createChannel({
name: form.name,
network: form.network,
});
isCreateFormVisible.value = false;
resetForm();
} catch (error) {
formError.value = error.message ?? t('channels.errors.createFailed');
}
}
function parseTargets(value) {
return (value ?? '')
.split(/[,\n]+/)
.map(target => target.trim())
.filter(Boolean);
}
watch(
() => route.query.create,
createValue => {
if (createValue === 'true') {
openCreateForm(activeNetwork.value);
}
},
{ immediate: true }
);
</script>
<template>
<section class="page-shell">
<div class="header">
<h1>{{ t('channels.title') }}</h1>
<p>{{ t('channels.description') }}</p>
</div>
<div class="network-tabs">
<button
v-for="network in networkOptions"
:key="network.value"
type="button"
class="network-tab"
:class="{ active: activeNetwork === network.value }"
@click="activeNetwork = network.value"
>
<v-icon :icon="network.icon" />
<span>{{ network.value }}</span>
</button>
</div>
<div
v-if="isCreateFormVisible"
class="create-panel"
>
<div class="panel-header">
<strong>{{ t('channels.createTitle') }}</strong>
<span>{{ form.network }}</span>
</div>
<div
v-if="formError"
class="page-message error"
>
{{ formError }}
</div>
<div class="form-grid">
<label class="field">
<span>{{ t('channels.fields.name') }}</span>
<input
v-model="form.name"
type="text"
/>
</label>
</div>
<div class="panel-actions">
<button
class="secondary"
type="button"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
class="primary"
type="button"
@click="submitForm"
>
{{ t('channels.createTitle') }}
</button>
</div>
</div>
<div
v-if="channelsForActiveNetwork.length"
class="channel-grid"
>
<article
v-for="channel in channelsForActiveNetwork"
:key="channel.id"
class="channel-card"
>
<div class="channel-header">
<strong>{{ channel.name }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
</div>
<div class="channel-metrics">
<div>
<small>{{ t('channels.metrics.scheduled') }}</small>
<strong>{{ channel.scheduled }}</strong>
</div>
<div>
<small>{{ t('channels.metrics.ready') }}</small>
<strong>{{ channel.readyCount }}</strong>
</div>
<div>
<small>{{ t('channels.metrics.blocked') }}</small>
<strong>{{ channel.blockedCount }}</strong>
</div>
</div>
<div class="channel-footer">
<span>{{ t('channels.nextDue') }}</span>
<em>{{ channel.nextDueDate ? new Date(channel.nextDueDate).toLocaleDateString() : t('channels.noScheduled') }}</em>
</div>
</article>
</div>
<button
v-else
type="button"
class="empty-state"
@click="openCreateForm(activeNetwork)"
>
<v-icon :icon="mdiPlus" />
<span>{{ t('channels.emptyAction', { network: activeNetwork }) }}</span>
</button>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.header h1 {
@apply text-4xl font-black;
color: #172033;
}
.header p,
.network-tab span,
.channel-header span,
.channel-footer span,
.channel-footer em,
.channel-metrics small,
.page-message,
.empty-state span {
@apply text-sm leading-6 not-italic;
color: #526178;
}
.network-tabs {
@apply flex flex-wrap gap-3;
}
.network-tab {
@apply inline-flex items-center gap-2 rounded-full border px-4 py-3 transition;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95);
color: #526178;
}
.network-tab.active,
.network-tab:hover {
border-color: rgba(255, 138, 61, 0.28);
background: rgba(255, 138, 61, 0.1);
color: #172033;
}
.channel-grid {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
}
.channel-card,
.create-panel,
.empty-state {
@apply flex flex-col gap-5 rounded-[1.5rem] border p-5;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
}
.empty-state {
@apply items-center justify-center text-center;
}
.create-button,
.primary,
.secondary {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
}
.primary {
background: #172033;
color: #fffaf2;
}
.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.panel-header {
@apply flex items-center justify-between gap-4;
}
.panel-header strong,
.field,
.channel-header strong,
.channel-metrics strong {
color: #172033;
}
.panel-header span {
@apply text-sm font-semibold;
color: #526178;
}
.form-grid {
@apply grid gap-4;
}
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
}
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95);
}
.panel-actions {
@apply flex justify-end gap-3;
}
.channel-header,
.channel-footer {
@apply flex items-center justify-between gap-4;
}
.channel-header strong {
@apply text-xl font-black;
}
.channel-metrics {
@apply grid grid-cols-3 gap-3 rounded-[1rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.channel-metrics div {
@apply flex flex-col gap-1;
}
.channel-metrics strong {
@apply text-2xl font-black;
}
.page-message {
@apply rounded-[1.25rem] border p-4 font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
}
.page-message.error {
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,712 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import AppAvatar from '@/components/AppAvatar.vue';
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import { useAuthStore } from '@/stores/authStore.js';
import { useClientsStore } from '@/stores/clientsStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
const authStore = useAuthStore();
const route = useRoute();
const clientsStore = useClientsStore();
const projectsStore = useProjectsStore();
const contentItemsStore = useContentItemsStore();
const isEditFormVisible = ref(false);
const isPortraitDialogOpen = ref(false);
const portraitDialogTarget = ref('client');
const formError = ref(null);
const form = reactive({
name: '',
status: 'Active',
portraitUrl: '',
primaryContactName: '',
primaryContactEmail: '',
primaryContactPortraitUrl: '',
});
const portraitDialogMeta = computed(() => {
if (portraitDialogTarget.value === 'contact') {
return {
title: 'Update primary contact portrait',
confirmLabel: 'Apply portrait',
uploadLabel: 'Choose portrait',
initialUrl: form.primaryContactPortraitUrl,
};
}
return {
title: 'Update client logo',
confirmLabel: 'Apply logo',
uploadLabel: 'Choose logo',
initialUrl: form.portraitUrl,
};
});
const client = computed(() =>
clientsStore.clients.find(candidate => candidate.id === route.params.clientId) ?? null
);
const scopedProjects = computed(() =>
projectsStore.projects
.filter(project => project.clientId === route.params.clientId)
.sort((left, right) => {
const leftDue = left.endDate ? new Date(left.endDate).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.endDate ? new Date(right.endDate).getTime() : Number.MAX_SAFE_INTEGER;
return leftDue - rightDue;
})
);
const currentProjects = computed(() =>
scopedProjects.value.filter(project => project.status !== 'Completed' && project.status !== 'Archived')
);
const pastProjects = computed(() =>
scopedProjects.value.filter(project => project.status === 'Completed' || project.status === 'Archived')
);
const itemCountByProjectId = computed(() => {
const counts = new Map();
for (const item of contentItemsStore.items.filter(candidate => candidate.clientId === route.params.clientId)) {
counts.set(item.projectId, (counts.get(item.projectId) ?? 0) + 1);
}
return counts;
});
function formatProjectDateRange(project) {
if (!project?.startDate || !project?.endDate) {
return 'No date range';
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(new Date(project.startDate), new Date(project.endDate));
}
function syncForm() {
if (!client.value) {
return;
}
form.name = client.value.name ?? '';
form.status = client.value.status ?? 'Active';
form.portraitUrl = client.value.portraitUrl ?? '';
form.primaryContactName = client.value.primaryContactName ?? '';
form.primaryContactEmail = client.value.primaryContactEmail ?? '';
form.primaryContactPortraitUrl = client.value.primaryContactPortraitUrl ?? '';
formError.value = null;
}
function openEditForm() {
syncForm();
isEditFormVisible.value = true;
}
async function submitEditForm() {
if (!client.value || clientsStore.isUpdating) {
return;
}
formError.value = null;
if (!form.name || !form.status) {
formError.value = 'Client name and status are required.';
return;
}
try {
await clientsStore.updateClient(client.value.id, {
name: form.name,
status: form.status,
portraitUrl: form.portraitUrl,
primaryContactName: form.primaryContactName,
primaryContactEmail: form.primaryContactEmail,
primaryContactPortraitUrl: form.primaryContactPortraitUrl,
});
isEditFormVisible.value = false;
} catch (error) {
formError.value = 'The client could not be updated.';
}
}
function openPortraitDialog(target) {
portraitDialogTarget.value = target;
isPortraitDialogOpen.value = true;
}
function clearPortrait(target) {
if (target === 'contact') {
form.primaryContactPortraitUrl = '';
return;
}
form.portraitUrl = '';
}
async function savePortraitImage(result) {
if (portraitDialogTarget.value === 'contact') {
form.primaryContactPortraitUrl = result.dataUrl;
} else {
form.portraitUrl = result.dataUrl;
}
isPortraitDialogOpen.value = false;
}
watch(client, () => {
if (!isEditFormVisible.value) {
syncForm();
}
}, { immediate: true });
</script>
<template>
<section class="page-shell">
<div
v-if="!client"
class="page-message error"
>
The selected client could not be found in the active workspace.
</div>
<template v-else>
<div class="hero">
<div class="hero-main">
<router-link
class="breadcrumb"
:to="{ name: 'clients' }"
>
Clients
</router-link>
<h1>{{ client.name }}</h1>
<div class="hero-meta">
<span class="hero-status">{{ client.status }}</span>
</div>
<p>The client area scopes projects and content so review stays inside one account.</p>
</div>
</div>
<div class="stats-grid">
<article class="stat-card">
<span>Current campaigns</span>
<strong>{{ currentProjects.length }}</strong>
</article>
<article class="stat-card">
<span>Past campaigns</span>
<strong>{{ pastProjects.length }}</strong>
</article>
<article class="stat-card">
<span>Total content items</span>
<strong>{{ contentItemsStore.items.filter(item => item.clientId === client.id).length }}</strong>
</article>
</div>
<div class="scope-actions">
<router-link
v-if="authStore.isManager || authStore.isProvider"
:to="{ name: 'content-item-create' }"
class="scope-button"
>
New content for {{ client.name }}
</router-link>
</div>
<div class="section details-section">
<div class="section-header">
<strong>Client details</strong>
<button
v-if="authStore.isManager"
class="scope-button scope-button-secondary"
@click="isEditFormVisible ? (isEditFormVisible = false) : openEditForm()"
>
{{ isEditFormVisible ? 'Close editor' : 'Edit details' }}
</button>
</div>
<div
v-if="!isEditFormVisible"
class="details-grid"
>
<div class="detail-row detail-row-wide">
<span>Client</span>
<div class="identity-row">
<AppAvatar
:name="client.name"
:src="client.portraitUrl"
size="md"
/>
<div>
<strong>{{ client.name }}</strong>
<small>{{ client.status }}</small>
</div>
</div>
</div>
<div class="detail-row">
<span>Primary contact</span>
<strong>{{ client.primaryContactName || 'No primary contact set' }}</strong>
</div>
<div class="detail-row">
<span>Email</span>
<strong>{{ client.primaryContactEmail || 'No primary contact email set' }}</strong>
</div>
<div class="detail-row detail-row-wide">
<span>Primary contact portrait</span>
<div class="identity-row">
<AppAvatar
:name="client.primaryContactName || client.primaryContactEmail || client.name"
:src="client.primaryContactPortraitUrl"
size="md"
/>
<div>
<strong>{{ client.primaryContactName || 'Contact portrait' }}</strong>
<small>{{ client.primaryContactPortraitUrl ? 'Custom portrait set' : 'Using initials fallback' }}</small>
</div>
</div>
</div>
</div>
<template v-else>
<div
v-if="formError"
class="page-message error"
>
{{ formError }}
</div>
<div class="form-grid">
<label class="field field-wide">
<span>Client name</span>
<input
v-model="form.name"
type="text"
:disabled="clientsStore.isUpdating"
/>
</label>
<label class="field">
<span>Status</span>
<select
v-model="form.status"
:disabled="clientsStore.isUpdating"
>
<option value="Active">Active</option>
<option value="Paused">Paused</option>
<option value="Archived">Archived</option>
</select>
</label>
<div class="field field-wide image-field">
<span>Client logo</span>
<div class="image-picker-card">
<AppAvatar
:name="form.name || client.name"
:src="form.portraitUrl"
size="lg"
/>
<div class="image-picker-copy">
<strong>{{ form.portraitUrl ? 'Custom logo selected' : 'No logo selected' }}</strong>
<small>Use a local file or a remote image URL, then crop and scale it.</small>
</div>
<div class="image-picker-actions">
<button
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating"
@click="openPortraitDialog('client')"
>
Change image
</button>
<button
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating || !form.portraitUrl"
@click="clearPortrait('client')"
>
Remove
</button>
</div>
</div>
</div>
<label class="field">
<span>Primary contact name</span>
<input
v-model="form.primaryContactName"
type="text"
:disabled="clientsStore.isUpdating"
/>
</label>
<label class="field">
<span>Primary contact email</span>
<input
v-model="form.primaryContactEmail"
type="email"
:disabled="clientsStore.isUpdating"
/>
</label>
<div class="field field-wide image-field">
<span>Primary contact portrait</span>
<div class="image-picker-card">
<AppAvatar
:name="form.primaryContactName || form.primaryContactEmail || form.name"
:src="form.primaryContactPortraitUrl"
size="lg"
/>
<div class="image-picker-copy">
<strong>{{ form.primaryContactPortraitUrl ? 'Custom portrait selected' : 'No portrait selected' }}</strong>
<small>Use a local file or a remote image URL, then crop and scale it.</small>
</div>
<div class="image-picker-actions">
<button
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating"
@click="openPortraitDialog('contact')"
>
Change image
</button>
<button
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating || !form.primaryContactPortraitUrl"
@click="clearPortrait('contact')"
>
Remove
</button>
</div>
</div>
</div>
</div>
<div class="panel-actions">
<button
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating"
@click="isEditFormVisible = false"
>
Cancel
</button>
<button
class="scope-button"
:disabled="clientsStore.isUpdating"
@click="submitEditForm"
>
<v-progress-circular
v-if="clientsStore.isUpdating"
indeterminate
:size="16"
:width="2"
/>
<span>{{ clientsStore.isUpdating ? 'Saving...' : 'Save client' }}</span>
</button>
</div>
</template>
</div>
<ImageCropperDialog
v-model="isPortraitDialogOpen"
:title="portraitDialogMeta.title"
:confirm-label="portraitDialogMeta.confirmLabel"
:upload-label="portraitDialogMeta.uploadLabel"
:initial-url="portraitDialogMeta.initialUrl"
:is-saving="clientsStore.isUpdating"
@save="savePortraitImage"
/>
<div class="section">
<div class="section-header">
<strong>Current campaigns</strong>
<span>{{ currentProjects.length }} active</span>
</div>
<div
v-if="currentProjects.length"
class="project-list"
>
<router-link
v-for="project in currentProjects"
:key="project.id"
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
class="project-card"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.status }}</span>
</div>
<div class="project-meta">
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
<em>{{ formatProjectDateRange(project) }}</em>
</div>
</router-link>
</div>
<div
v-else
class="page-message"
>
No current campaigns are attached to this client.
</div>
</div>
<div class="section">
<div class="section-header">
<strong>Past campaigns</strong>
<span>{{ pastProjects.length }} archived or completed</span>
</div>
<div
v-if="pastProjects.length"
class="project-list"
>
<router-link
v-for="project in pastProjects"
:key="project.id"
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
class="project-card muted"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.status }}</span>
</div>
<div class="project-meta">
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
<em>{{ formatProjectDateRange(project) }}</em>
</div>
</router-link>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.hero,
.stat-card,
.project-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.hero {
@apply flex flex-col gap-4 p-6;
}
.hero-main h1,
.stat-card strong,
.project-card strong,
.contact-card strong {
color: #172033;
}
.hero-main h1 {
@apply mt-2 text-4xl font-black;
}
.hero-main p,
.breadcrumb,
.stat-card span,
.project-card span,
.project-card small,
.project-card em,
.section-header span {
@apply text-sm leading-6 not-italic;
color: #526178;
}
.breadcrumb {
@apply font-bold uppercase tracking-[0.18em];
color: #0f766e;
}
.hero-meta {
@apply mt-3 flex items-center gap-3;
}
.hero-status {
@apply inline-flex items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.18em];
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.stats-grid {
@apply grid gap-4 md:grid-cols-3;
}
.stat-card {
@apply p-5;
}
.stat-card strong {
@apply mt-3 block text-4xl font-black;
}
.section {
@apply flex flex-col gap-4;
}
.details-section {
@apply rounded-[1.5rem] border p-5;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
}
.scope-actions {
@apply flex flex-wrap justify-start gap-3;
}
.scope-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold no-underline transition;
background: #172033;
color: #fffaf2;
}
.scope-button:hover {
background: #0f172a;
}
.scope-button-secondary {
background: rgba(255, 255, 255, 0.92);
color: #172033;
border: 1px solid rgba(23, 32, 51, 0.12);
}
.scope-button-secondary:hover {
background: rgba(23, 32, 51, 0.06);
}
.details-grid {
@apply grid gap-4 md:grid-cols-2;
}
.detail-row {
@apply flex flex-col gap-2 rounded-[1.25rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.detail-row-wide {
@apply md:col-span-2;
}
.detail-row small {
@apply text-sm leading-6;
color: #526178;
}
.identity-row {
@apply flex items-center gap-4;
}
.identity-row div {
@apply flex min-w-0 flex-col;
}
.identity-row strong {
@apply truncate text-base font-bold;
color: #172033;
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: #172033;
}
.field-wide {
@apply md:col-span-2;
}
.field input,
.field select {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
background: white;
color: #172033;
}
.image-field {
@apply gap-3;
}
.image-picker-card {
@apply flex flex-col gap-4 rounded-[1.25rem] border p-4 lg:flex-row lg:items-center lg:justify-between;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.image-picker-copy {
@apply flex min-w-0 flex-1 flex-col gap-1;
}
.image-picker-copy strong {
color: #172033;
}
.image-picker-copy small {
@apply text-sm leading-6;
color: #526178;
}
.image-picker-actions {
@apply flex flex-wrap gap-3;
}
.panel-actions {
@apply flex flex-col gap-3 sm:flex-row sm:justify-end;
}
.section-header {
@apply flex items-center justify-between gap-4;
}
.section-header strong {
@apply text-lg font-black;
color: #172033;
}
.project-list {
@apply grid gap-4 md:grid-cols-2;
}
.project-card {
@apply flex flex-col gap-4 p-5 no-underline transition;
}
.project-card:hover {
transform: translateY(-2px);
}
.project-card.muted {
background: rgba(255, 250, 242, 0.88);
}
.project-card span {
@apply uppercase tracking-[0.16em];
}
.project-meta {
@apply flex items-center justify-between gap-3;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,366 @@
<script setup>
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClientsStore } from '@/stores/clientsStore.js';
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const clientsStore = useClientsStore();
const { t } = useI18n();
const isCreateFormVisible = ref(false);
const formError = ref(null);
const form = reactive({
name: '',
portraitUrl: '',
primaryContactName: '',
primaryContactEmail: '',
primaryContactPortraitUrl: '',
});
function resetForm() {
form.name = '';
form.portraitUrl = '';
form.primaryContactName = '';
form.primaryContactEmail = '';
form.primaryContactPortraitUrl = '';
formError.value = null;
}
function openCreateForm() {
resetForm();
isCreateFormVisible.value = true;
}
async function submitForm() {
if (clientsStore.isCreating) {
return;
}
formError.value = null;
if (!form.name) {
formError.value = t('clients.errors.nameRequired');
return;
}
try {
await clientsStore.createClient({
name: form.name,
portraitUrl: form.portraitUrl,
primaryContactName: form.primaryContactName,
primaryContactEmail: form.primaryContactEmail,
primaryContactPortraitUrl: form.primaryContactPortraitUrl,
});
isCreateFormVisible.value = false;
resetForm();
} catch (error) {
formError.value = t('clients.errors.createFailed');
}
}
</script>
<template>
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('clients.eyebrow') }}</div>
<h1>{{ t('clients.title') }}</h1>
<p>{{ t('clients.description') }}</p>
</div>
</div>
<div class="action-row">
<button
v-if="authStore.isManager"
class="create-button"
@click="openCreateForm"
>
{{ t('clients.newClient') }}
</button>
</div>
<div
v-if="isCreateFormVisible"
class="create-panel"
>
<div class="panel-header">
<strong>{{ t('clients.createTitle') }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
</div>
<div
v-if="formError"
class="page-message error"
>
{{ formError }}
</div>
<div class="form-grid">
<label class="field field-wide">
<span>{{ t('clients.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:disabled="clientsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('clients.fields.portraitUrl') }}</span>
<input
v-model="form.portraitUrl"
type="url"
placeholder="https://..."
:disabled="clientsStore.isCreating"
/>
</label>
<label class="field">
<span>{{ t('clients.fields.primaryContactName') }}</span>
<input
v-model="form.primaryContactName"
type="text"
:disabled="clientsStore.isCreating"
/>
</label>
<label class="field">
<span>{{ t('clients.fields.primaryContactEmail') }}</span>
<input
v-model="form.primaryContactEmail"
type="email"
:disabled="clientsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('clients.fields.primaryContactPortraitUrl') }}</span>
<input
v-model="form.primaryContactPortraitUrl"
type="url"
placeholder="https://..."
:disabled="clientsStore.isCreating"
/>
</label>
</div>
<div class="panel-actions">
<button
class="secondary"
:disabled="clientsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
class="primary"
:disabled="clientsStore.isCreating"
@click="submitForm"
>
<v-progress-circular
v-if="clientsStore.isCreating"
indeterminate
:size="16"
:width="2"
/>
<span>{{ clientsStore.isCreating ? t('common.creating') : t('clients.createTitle') }}</span>
</button>
</div>
</div>
<div
v-if="clientsStore.isLoading"
class="page-message"
>
{{ t('clients.loading') }}
</div>
<div
v-else-if="clientsStore.error"
class="page-message error"
>
{{ clientsStore.error }}
</div>
<div class="grid-list">
<router-link
v-for="client in clientsStore.clients"
:key="client.id"
:to="{ name: 'client-detail', params: { clientId: client.id } }"
class="client-card"
>
<div class="client-card-header">
<div>
<strong>{{ client.name }}</strong>
<span>{{ client.status }}</span>
</div>
<AppAvatar
:name="client.name"
:src="client.portraitUrl"
/>
</div>
<em>{{ client.primaryContactName || t('clients.noPrimaryContact') }}</em>
<small>{{ client.primaryContactEmail || t('clients.noPrimaryContactEmail') }}</small>
</router-link>
</div>
<div
v-if="!clientsStore.isLoading && !clientsStore.clients.length"
class="page-message"
>
{{ t('clients.empty') }}
</div>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.header p {
@apply mt-2 text-sm leading-6;
color: #526178;
}
.header {
@apply flex flex-col gap-3;
}
.action-row {
@apply flex justify-start;
}
.create-button,
.primary,
.secondary {
@apply rounded-full px-5 py-3 text-sm font-bold transition;
}
.primary,
.secondary {
@apply inline-flex items-center justify-center gap-2;
}
.create-button,
.primary {
background: #172033;
color: white;
}
.create-button:hover,
.primary:hover {
background: #0f172a;
}
.secondary {
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.12);
color: #172033;
}
.create-panel {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
}
.panel-header {
@apply flex flex-col gap-1 md:flex-row md:items-center md:justify-between;
}
.panel-header strong {
@apply text-lg font-black;
color: #172033;
}
.panel-header span {
@apply text-sm font-semibold;
color: #526178;
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: #172033;
}
.field.field-wide {
@apply md:col-span-2;
}
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
background: white;
color: #172033;
}
.panel-actions {
@apply flex flex-col gap-3 sm:flex-row sm:justify-end;
}
.grid-list {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
.client-card {
@apply flex flex-col gap-3 rounded-[1.5rem] border p-5;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
text-decoration: none;
}
.client-card-header {
@apply flex items-start justify-between gap-4;
}
.client-card strong {
@apply text-xl font-black;
color: #172033;
}
.client-card span {
@apply text-sm font-semibold uppercase tracking-[0.18em];
color: #ff8a3d;
}
.client-card em {
@apply text-sm leading-6 not-italic;
color: #526178;
}
.client-card small {
@apply text-xs leading-5;
color: #7b8798;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/authStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
const { t } = useI18n();
const authStore = useAuthStore();
const contentItemsStore = useContentItemsStore();
</script>
<template>
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('contentItems.eyebrow') }}</div>
<h1>{{ t('contentItems.title') }}</h1>
<p>{{ t('contentItems.description') }}</p>
</div>
<router-link
v-if="authStore.isManager || authStore.isProvider"
:to="{ name: 'content-item-create' }"
class="create-button"
>
{{ t('contentItems.newItem') }}
</router-link>
</div>
<div
v-if="contentItemsStore.isLoading"
class="page-message"
>
{{ t('contentItems.loading') }}
</div>
<div
v-else-if="contentItemsStore.error"
class="page-message error"
>
{{ contentItemsStore.error }}
</div>
<div
v-else-if="contentItemsStore.items.length"
class="item-grid"
>
<router-link
v-for="item in contentItemsStore.items"
:key="item.id"
:to="{ name: 'content-item-detail', params: { id: item.id } }"
class="item-card"
>
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
<strong>{{ item.title }}</strong>
<span>{{ item.publicationTargets }}</span>
<div class="status-row">
<em>{{ item.status }}</em>
<small>{{ item.dueDate ? new Date(item.dueDate).toLocaleDateString() : t('contentItems.noDueDate') }}</small>
</div>
</router-link>
</div>
<div
v-else
class="page-message"
>
{{ t('contentItems.empty') }}
</div>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.header {
@apply flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.header p,
.item-card span,
.status-row em,
.status-row small {
@apply text-sm leading-6 not-italic;
color: #526178;
}
.create-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition;
background: #172033;
color: #fffaf2;
}
.page-message,
.item-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.page-message {
@apply p-5 text-sm;
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
.item-grid {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
}
.item-card {
@apply flex flex-col gap-4 p-5 no-underline transition;
}
.item-card:hover {
transform: translateY(-1px);
}
.item-card strong {
color: #172033;
}
.version-chip {
@apply w-fit rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.status-row {
@apply flex items-center justify-between gap-3;
}
</style>

View File

@@ -0,0 +1,620 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
const { t, locale } = useI18n();
const workspaceStore = useWorkspaceStore();
const projectsStore = useProjectsStore();
const contentItemsStore = useContentItemsStore();
const today = startOfDay(new Date());
const viewMode = ref('month');
const cursorDate = ref(today);
const contentStatusMeta = {
Draft: { tone: 'production', readiness: 'building' },
'In internal review': { tone: 'approval', readiness: 'approval' },
'Changes requested internally': { tone: 'risk', readiness: 'rework' },
'Internal changes in progress': { tone: 'production', readiness: 'building' },
'Ready for client review': { tone: 'approval', readiness: 'approval' },
'In client review': { tone: 'approval', readiness: 'approval' },
'Changes requested by client': { tone: 'risk', readiness: 'rework' },
'Client changes in progress': { tone: 'production', readiness: 'building' },
Approved: { tone: 'ready', readiness: 'ready' },
'Ready to publish': { tone: 'ready', readiness: 'ready' },
Published: { tone: 'published', readiness: 'published' },
Rejected: { tone: 'risk', readiness: 'blocked' },
Archived: { tone: 'muted', readiness: 'archived' },
};
const contentItemsByProjectId = computed(() => {
const grouped = new Map();
for (const item of contentItemsStore.items) {
const existing = grouped.get(item.projectId) ?? [];
existing.push(item);
grouped.set(item.projectId, existing);
}
return grouped;
});
const calendarEntries = computed(() => {
const projectEntries = projectsStore.projects
.filter(project => project.endDate || project.startDate)
.map(project => buildProjectEntry(project));
const contentEntries = contentItemsStore.items
.filter(item => item.dueDate && item.status !== 'Archived')
.map(item => buildContentEntry(item));
return [...projectEntries, ...contentEntries].sort(sortByDate);
});
const entriesByDay = computed(() => {
const grouped = new Map();
for (const entry of calendarEntries.value) {
const existing = grouped.get(entry.dayKey) ?? [];
existing.push(entry);
grouped.set(entry.dayKey, existing);
}
return grouped;
});
const visibleDays = computed(() => {
if (viewMode.value === 'week') {
const start = startOfWeek(cursorDate.value);
return Array.from({ length: 7 }, (_, index) => {
const date = addDays(start, index);
return buildDay(date, false);
});
}
const start = startOfWeek(startOfMonth(cursorDate.value));
const end = endOfWeek(endOfMonth(cursorDate.value));
const days = [];
let current = start;
while (current <= end) {
days.push(buildDay(current, current.getMonth() !== cursorDate.value.getMonth()));
current = addDays(current, 1);
}
return days;
});
const weekdayLabels = computed(() => {
const base = startOfWeek(cursorDate.value);
return Array.from({ length: 7 }, (_, index) =>
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(addDays(base, index))
);
});
const periodLabel = computed(() => {
if (viewMode.value === 'week') {
const start = startOfWeek(cursorDate.value);
const end = addDays(start, 6);
const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
if (sameMonth) {
return new Intl.DateTimeFormat(locale.value, {
month: 'long',
day: 'numeric',
year: 'numeric',
}).formatRange(start, end);
}
return new Intl.DateTimeFormat(locale.value, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(start, end);
}
return new Intl.DateTimeFormat(locale.value, {
month: 'long',
year: 'numeric',
}).format(cursorDate.value);
});
const isLoading = computed(() =>
workspaceStore.isLoading || projectsStore.isLoading || contentItemsStore.isLoading
);
const pageError = computed(() =>
workspaceStore.error || projectsStore.error || contentItemsStore.error
);
function buildDay(date, isOutsideMonth) {
const key = dateKey(date);
return {
key,
date,
entries: entriesByDay.value.get(key) ?? [],
isOutsideMonth,
isToday: key === dateKey(today),
};
}
function buildContentEntry(item) {
const statusMeta = contentStatusMeta[item.status] ?? { tone: 'production', readiness: 'building' };
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
return {
id: item.id,
type: 'content',
title: item.title,
subtitle: project?.name ?? t('dashboard.labels.unassignedProject'),
scheduledAt: new Date(item.dueDate),
dayKey: dateKey(item.dueDate),
timeLabel: formatHour(item.dueDate),
tone: statusMeta.tone,
route: { name: 'content-item-detail', params: { id: item.id } },
};
}
function buildProjectEntry(project) {
const projectItems = contentItemsByProjectId.value.get(project.id) ?? [];
const approvedCount = projectItems.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length;
return {
id: project.id,
type: 'project',
title: project.name,
subtitle: projectItems.length
? t('dashboard.projectProgress', { scheduled: projectItems.length, approved: approvedCount })
: t('dashboard.readiness.missing'),
scheduledAt: new Date(project.endDate ?? project.startDate),
dayKey: dateKey(project.endDate ?? project.startDate),
timeLabel: t('dashboard.campaignDeadline'),
tone: projectItems.length ? 'project' : 'risk',
route: { name: 'campaign-detail', params: { projectId: project.id } },
};
}
function setView(mode) {
viewMode.value = mode;
cursorDate.value = mode === 'month' ? startOfMonth(cursorDate.value) : startOfWeek(cursorDate.value);
}
function shiftPeriod(direction) {
cursorDate.value = viewMode.value === 'month'
? addMonths(cursorDate.value, direction)
: addDays(cursorDate.value, direction * 7);
}
function jumpToToday() {
cursorDate.value = today;
}
function formatDayNumber(date) {
return new Intl.DateTimeFormat(locale.value, { day: 'numeric' }).format(date);
}
function formatHour(value) {
return new Intl.DateTimeFormat(locale.value, {
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function startOfDay(value) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
return date;
}
function startOfWeek(value) {
const date = startOfDay(value);
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
return addDays(date, diff);
}
function endOfWeek(value) {
return addDays(startOfWeek(value), 6);
}
function startOfMonth(value) {
const date = startOfDay(value);
date.setDate(1);
return date;
}
function endOfMonth(value) {
const date = startOfMonth(value);
date.setMonth(date.getMonth() + 1);
date.setDate(0);
return date;
}
function addDays(value, amount) {
const date = startOfDay(value);
date.setDate(date.getDate() + amount);
return date;
}
function addMonths(value, amount) {
const date = startOfMonth(value);
date.setMonth(date.getMonth() + amount);
return date;
}
function dateKey(value) {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function sortByDate(left, right) {
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
}
</script>
<template>
<section class="calendar-shell">
<div
v-if="isLoading"
class="page-message"
>
{{ t('dashboard.loading') }}
</div>
<div
v-else-if="pageError"
class="page-message error"
>
{{ pageError }}
</div>
<article
v-else
class="calendar-card"
>
<div class="calendar-toolbar">
<div class="calendar-nav">
<button
class="icon-button"
type="button"
@click="shiftPeriod(-1)"
>
<v-icon :icon="mdiChevronLeft" />
</button>
<div class="calendar-period">{{ periodLabel }}</div>
<button
class="icon-button"
type="button"
@click="shiftPeriod(1)"
>
<v-icon :icon="mdiChevronRight" />
</button>
</div>
<div class="calendar-controls">
<button
class="text-button"
type="button"
@click="jumpToToday"
>
{{ t('today') }}
</button>
<div class="view-toggle">
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'month' }"
type="button"
@click="setView('month')"
>
{{ t('dashboard.month') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'week' }"
type="button"
@click="setView('week')"
>
{{ t('dashboard.week') }}
</button>
</div>
</div>
</div>
<div class="calendar-grid calendar-grid-head">
<div
v-for="label in weekdayLabels"
:key="label"
class="weekday-label"
>
{{ label }}
</div>
</div>
<div
class="calendar-grid"
:class="viewMode === 'week' ? 'calendar-grid-week' : 'calendar-grid-month'"
>
<div
v-for="day in visibleDays"
:key="day.key"
class="calendar-day"
:class="{
'calendar-day-outside': day.isOutsideMonth,
'calendar-day-today': day.isToday,
'calendar-day-week': viewMode === 'week',
}"
>
<div class="day-number">
{{ formatDayNumber(day.date) }}
</div>
<div
v-if="day.entries.length"
class="day-entries"
>
<router-link
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
:key="`${entry.type}-${entry.id}`"
:to="entry.route"
class="calendar-entry"
:class="entry.tone"
>
<span class="entry-time">{{ entry.timeLabel }}</span>
<strong>{{ entry.title }}</strong>
<span>{{ entry.subtitle }}</span>
</router-link>
<div
v-if="viewMode === 'month' && day.entries.length > 3"
class="entry-more"
>
{{ t('dashboard.moreItems', { count: day.entries.length - 3 }) }}
</div>
</div>
<div
v-else-if="viewMode === 'week'"
class="day-empty"
>
{{ t('dashboard.emptyPeriod') }}
</div>
</div>
</div>
</article>
</section>
</template>
<style scoped>
.calendar-shell {
@apply mx-auto w-full max-w-7xl px-5 py-8 md:px-8;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.88);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
.calendar-card {
@apply rounded-[1.75rem] border p-4 md:p-5;
background: rgba(255, 255, 255, 0.94);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.calendar-toolbar {
@apply mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between;
}
.calendar-nav,
.calendar-controls {
@apply flex items-center gap-2;
}
.calendar-controls {
@apply flex-wrap justify-end;
}
.calendar-period {
@apply min-w-0 px-2 text-base font-bold md:text-lg;
color: #172033;
}
.icon-button,
.text-button,
.toggle-button {
@apply inline-flex items-center justify-center rounded-full border px-3 py-2 text-sm font-semibold transition;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
}
.icon-button {
@apply h-10 w-10 px-0 py-0;
}
.icon-button:hover,
.text-button:hover,
.toggle-button:hover {
background: #eef4ff;
}
.view-toggle {
@apply inline-flex rounded-full border p-1;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
}
.toggle-button {
@apply border-0 bg-transparent;
}
.toggle-button-active {
background: #172033;
color: #ffffff;
}
.calendar-grid {
@apply grid gap-3;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.calendar-grid-head {
@apply mb-3;
}
.weekday-label {
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
color: #526178;
}
.calendar-day {
@apply min-h-[8.5rem] rounded-[1.25rem] border p-3;
background: linear-gradient(180deg, rgba(255, 253, 248, 0.8) 0%, rgba(255, 255, 255, 0.96) 100%);
border-color: rgba(23, 32, 51, 0.08);
}
.calendar-day-week {
@apply min-h-[22rem];
}
.calendar-day-outside {
opacity: 0.48;
}
.calendar-day-today {
border-color: rgba(15, 118, 110, 0.22);
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.18);
}
.day-number {
@apply mb-3 text-sm font-bold;
color: #172033;
}
.day-entries {
@apply flex flex-col gap-2;
}
.calendar-entry {
@apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition;
}
.calendar-entry:hover {
transform: translateY(-1px);
}
.calendar-entry strong {
@apply text-sm font-bold;
color: #172033;
}
.calendar-entry span {
@apply text-xs leading-5;
color: #526178;
}
.entry-time {
@apply text-[0.7rem] font-bold uppercase tracking-[0.12em];
color: #0f766e;
}
.entry-more,
.day-empty {
@apply px-1 text-xs font-semibold;
color: #526178;
}
.calendar-entry.production {
background: #fff7ed;
border-color: rgba(249, 115, 22, 0.18);
}
.calendar-entry.approval {
background: #eff6ff;
border-color: rgba(37, 99, 235, 0.16);
}
.calendar-entry.ready {
background: #ecfdf5;
border-color: rgba(5, 150, 105, 0.16);
}
.calendar-entry.risk {
background: #fef2f2;
border-color: rgba(220, 38, 38, 0.16);
}
.calendar-entry.project {
background: #f8fafc;
border-color: rgba(71, 85, 105, 0.18);
border-style: dashed;
}
.calendar-entry.published,
.calendar-entry.muted {
background: #f8fafc;
border-color: rgba(148, 163, 184, 0.18);
}
@media (max-width: 960px) {
.calendar-shell {
@apply px-4 py-6;
}
.calendar-grid {
gap: 0.5rem;
}
.weekday-label {
@apply text-[0.65rem];
}
.calendar-day {
@apply min-h-[7rem] p-2;
}
.calendar-day-week {
@apply min-h-[18rem];
}
.calendar-entry {
@apply px-2 py-2;
}
}
@media (max-width: 720px) {
.calendar-toolbar {
@apply items-stretch;
}
.calendar-nav,
.calendar-controls {
@apply justify-between;
}
.calendar-grid-head,
.calendar-grid {
min-width: 46rem;
}
.calendar-card {
overflow-x: auto;
}
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<section class="page-shell">
<div class="page-header">
<div class="eyebrow">{{ t('integrations.eyebrow') }}</div>
<h1>{{ t('integrations.title') }}</h1>
<p>{{ t('integrations.description') }}</p>
</div>
<div class="panel">
<div class="panel-heading">
<strong>{{ t('integrations.googleDrive.title') }}</strong>
<span>{{ t('integrations.googleDrive.description') }}</span>
</div>
<div class="placeholder-block">
<span>{{ t('integrations.statusLabel') }}</span>
<strong>{{ t('integrations.pendingTitle') }}</strong>
<small>{{ t('integrations.googleDrive.nextStep') }}</small>
</div>
</div>
<div class="panel">
<div class="panel-heading">
<strong>{{ t('integrations.apiKeys.title') }}</strong>
<span>{{ t('integrations.apiKeys.description') }}</span>
</div>
<div class="placeholder-block">
<span>{{ t('integrations.statusLabel') }}</span>
<strong>{{ t('integrations.pendingTitle') }}</strong>
<small>{{ t('integrations.apiKeys.nextStep') }}</small>
</div>
</div>
</section>
</template>
<style scoped>
.page-shell {
@apply flex flex-col gap-6;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.page-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.page-header p,
.panel-heading span,
.placeholder-block span,
.placeholder-block small {
@apply text-sm leading-6;
color: #526178;
}
.panel {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.panel-heading {
@apply flex flex-col gap-2;
}
.panel-heading strong,
.placeholder-block strong {
color: #172033;
}
.panel-heading strong {
@apply text-lg font-black;
}
.placeholder-block {
@apply flex flex-col gap-2 rounded-[1.25rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.placeholder-block strong {
@apply text-xl font-black;
}
</style>

View File

@@ -0,0 +1,222 @@
<script setup>
import { useI18n } from 'vue-i18n';
import {
mdiCheckCircleOutline,
mdiCloudSyncOutline,
mdiFolderGoogleDrive,
mdiImageMultipleOutline,
mdiVideoOutline,
} from '@mdi/js';
const { t } = useI18n();
const mediaTypes = [
{ label: t('mediaLibrary.mediaTypes.images'), icon: mdiImageMultipleOutline },
{ label: t('mediaLibrary.mediaTypes.videos'), icon: mdiVideoOutline },
];
const workflowSteps = [
t('mediaLibrary.workflow.connectDrive'),
t('mediaLibrary.workflow.syncAssets'),
t('mediaLibrary.workflow.organizeLibrary'),
];
</script>
<template>
<section class="page-shell">
<div class="hero">
<div class="hero-copy">
<div class="eyebrow">{{ t('mediaLibrary.eyebrow') }}</div>
<h1>{{ t('mediaLibrary.title') }}</h1>
<p>{{ t('mediaLibrary.description') }}</p>
</div>
<div class="hero-card">
<div class="hero-card-icon">
<v-icon :icon="mdiFolderGoogleDrive" />
</div>
<strong>{{ t('mediaLibrary.syncCard.title') }}</strong>
<span>{{ t('mediaLibrary.syncCard.description') }}</span>
</div>
</div>
<div class="content-grid">
<article class="panel">
<div class="panel-header">
<strong>{{ t('mediaLibrary.mediaTypesTitle') }}</strong>
<span>{{ t('mediaLibrary.mediaTypesDescription') }}</span>
</div>
<div class="media-type-list">
<div
v-for="type in mediaTypes"
:key="type.label"
class="media-type-item"
>
<v-icon :icon="type.icon" />
<span>{{ type.label }}</span>
</div>
</div>
</article>
<article class="panel">
<div class="panel-header">
<strong>{{ t('mediaLibrary.workflowTitle') }}</strong>
<span>{{ t('mediaLibrary.workflowDescription') }}</span>
</div>
<div class="workflow-list">
<div
v-for="step in workflowSteps"
:key="step"
class="workflow-item"
>
<v-icon
:icon="mdiCheckCircleOutline"
class="workflow-icon"
/>
<span>{{ step }}</span>
</div>
</div>
</article>
</div>
<article class="status-panel">
<div class="status-copy">
<div class="status-label">
<v-icon :icon="mdiCloudSyncOutline" />
<span>{{ t('mediaLibrary.statusLabel') }}</span>
</div>
<strong>{{ t('mediaLibrary.pendingTitle') }}</strong>
<p>{{ t('mediaLibrary.pendingDescription') }}</p>
</div>
</article>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.hero {
@apply grid gap-4 lg:grid-cols-[minmax(0,1.45fr)_minmax(18rem,0.8fr)];
}
.hero-copy,
.hero-card,
.panel,
.status-panel {
@apply rounded-[1.75rem] border;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.hero-copy {
@apply p-6 md:p-8;
background:
radial-gradient(circle at top left, rgba(14, 165, 164, 0.18), transparent 45%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 255, 0.92));
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.hero-copy h1 {
@apply mt-3 text-4xl font-black;
color: #172033;
}
.hero-copy p,
.hero-card span,
.panel-header span,
.media-type-item span,
.workflow-item span,
.status-copy p,
.status-label span {
@apply text-sm leading-6;
color: #526178;
}
.hero-card {
@apply flex flex-col justify-between gap-5 p-6;
background:
linear-gradient(180deg, rgba(255, 250, 242, 0.96), rgba(255, 255, 255, 0.96));
}
.hero-card-icon,
.media-type-item,
.workflow-item,
.status-label {
@apply inline-flex items-center gap-3;
}
.hero-card-icon,
.media-type-item {
@apply w-fit rounded-full px-3 py-2;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
}
.hero-card strong,
.panel-header strong,
.status-copy strong {
color: #172033;
}
.hero-card strong {
@apply text-2xl font-black;
}
.content-grid {
@apply grid gap-6 lg:grid-cols-2;
}
.panel,
.status-panel {
@apply flex flex-col gap-5 p-6;
}
.panel-header {
@apply flex flex-col gap-2;
}
.panel-header strong {
@apply text-xl font-black;
}
.media-type-list,
.workflow-list {
@apply flex flex-col gap-3;
}
.media-type-item,
.workflow-item {
@apply rounded-[1.1rem] border px-4 py-3;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.9);
}
.workflow-icon {
color: #0f766e;
}
.status-panel {
background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
}
.status-copy {
@apply flex flex-col gap-3;
}
.status-label {
@apply text-xs font-bold uppercase tracking-[0.2em];
color: #0f766e;
}
.status-copy strong {
@apply text-2xl font-black;
}
</style>

View File

@@ -0,0 +1,418 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
const { locale, t } = useI18n();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const isLoading = ref(false);
const error = ref(null);
const projects = ref([]);
const contentItems = ref([]);
const notifications = ref([]);
const workspaceMap = computed(() =>
new Map(workspaceStore.workspaces.map(workspace => [workspace.id, workspace]))
);
const workspaceStats = computed(() =>
workspaceStore.workspaces.map(workspace => {
const workspaceProjects = projects.value.filter(project => project.workspaceId === workspace.id);
const workspaceContent = contentItems.value.filter(item => item.workspaceId === workspace.id);
const upcomingCount = workspaceContent.filter(item => {
if (!item.dueDate) {
return false;
}
return startOfDay(item.dueDate) >= today.value;
}).length;
const blockingCount = workspaceContent.filter(item =>
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
).length;
return {
id: workspace.id,
name: workspace.name,
timeZone: workspace.timeZone,
projectCount: workspaceProjects.length,
contentCount: workspaceContent.length,
upcomingCount,
blockingCount,
};
})
);
const today = computed(() => startOfDay(new Date()));
const upcomingEvents = computed(() =>
contentItems.value
.filter(item => item.dueDate)
.map(item => ({
id: item.id,
title: item.title,
date: startOfDay(item.dueDate),
status: item.status,
workspaceId: item.workspaceId,
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
route: { name: 'content-item-detail', params: { id: item.id } },
}))
.filter(item => item.date >= today.value)
.sort((left, right) => left.date.getTime() - right.date.getTime())
.slice(0, 10)
);
const crossWorkspaceRisks = computed(() =>
contentItems.value
.filter(item => item.dueDate)
.map(item => ({
id: item.id,
title: item.title,
date: startOfDay(item.dueDate),
status: item.status,
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
route: { name: 'content-item-detail', params: { id: item.id } },
}))
.filter(item =>
item.date < today.value && !['Approved', 'Ready to publish', 'Published', 'Archived'].includes(item.status)
)
.sort((left, right) => left.date.getTime() - right.date.getTime())
.slice(0, 6)
);
const activityFeed = computed(() =>
notifications.value
.map(item => ({
...item,
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
}))
.slice(0, 8)
);
const overviewStats = computed(() => [
{ label: t('overview.stats.workspaces'), value: workspaceStore.workspaces.length },
{ label: t('overview.stats.projects'), value: projects.value.length },
{ label: t('overview.stats.upcoming'), value: upcomingEvents.value.length },
{ label: t('overview.stats.blockers'), value: crossWorkspaceRisks.value.length },
]);
async function loadOverview() {
if (!authStore.isAuthenticated) {
projects.value = [];
contentItems.value = [];
notifications.value = [];
return;
}
isLoading.value = true;
error.value = null;
try {
const [projectsResponse, contentItemsResponse, notificationsResponse] = await Promise.all([
client.get('/api/projects'),
client.get('/api/content-items'),
client.get('/api/notifications'),
]);
projects.value = projectsResponse.data ?? [];
contentItems.value = contentItemsResponse.data ?? [];
notifications.value = notificationsResponse.data ?? [];
} catch (loadError) {
console.error('Failed to load cross-workspace overview:', loadError);
error.value = 'Failed to load overview data.';
projects.value = [];
contentItems.value = [];
notifications.value = [];
} finally {
isLoading.value = false;
}
}
function formatDate(value) {
return new Intl.DateTimeFormat(locale.value, {
month: 'short',
day: 'numeric',
}).format(new Date(value));
}
function formatDateTime(value) {
return new Intl.DateTimeFormat(locale.value, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function startOfDay(value) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
return date;
}
watch(
() => authStore.isAuthenticated,
async isAuthenticated => {
if (isAuthenticated) {
await loadOverview();
} else {
projects.value = [];
contentItems.value = [];
notifications.value = [];
}
},
{ immediate: true }
);
watch(
() => workspaceStore.workspaces.length,
async () => {
if (authStore.isAuthenticated) {
await loadOverview();
}
}
);
onMounted(async () => {
if (authStore.isAuthenticated) {
await loadOverview();
}
});
</script>
<template>
<section class="page-shell">
<div class="page-header">
<div>
<div class="eyebrow">{{ t('overview.eyebrow') }}</div>
<h1>{{ t('overview.title') }}</h1>
<p>{{ t('overview.description') }}</p>
</div>
</div>
<div
v-if="isLoading"
class="page-message"
>
{{ t('overview.loading') }}
</div>
<div
v-else-if="error"
class="page-message error"
>
{{ error }}
</div>
<template v-else>
<div class="stats-grid">
<article
v-for="stat in overviewStats"
:key="stat.label"
class="stat-card"
>
<span>{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
</article>
</div>
<div class="overview-grid">
<article class="panel">
<div class="panel-kicker">{{ t('overview.workspacesKicker') }}</div>
<div class="panel-title">{{ t('overview.workspaceRollup') }}</div>
<div class="workspace-stack">
<button
v-for="workspace in workspaceStats"
:key="workspace.id"
class="workspace-row"
type="button"
@click="workspaceStore.setActiveWorkspace(workspace.id)"
>
<div>
<strong>{{ workspace.name }}</strong>
<span>{{ workspace.timeZone }}</span>
</div>
<div class="workspace-meta">
<small>{{ workspace.projectCount }} {{ t('overview.labels.projects') }}</small>
<small>{{ workspace.upcomingCount }} {{ t('overview.labels.upcoming') }}</small>
<small>{{ workspace.blockingCount }} {{ t('overview.labels.blocked') }}</small>
</div>
</button>
</div>
</article>
<article class="panel">
<div class="panel-kicker">{{ t('overview.timelineKicker') }}</div>
<div class="panel-title">{{ t('overview.upcomingTitle') }}</div>
<router-link
v-for="item in upcomingEvents"
:key="item.id"
:to="item.route"
class="list-row"
>
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.workspaceName }} · {{ item.status }}</span>
</div>
<em>{{ formatDate(item.date) }}</em>
</router-link>
<div
v-if="!upcomingEvents.length"
class="empty-state"
>
{{ t('overview.emptyUpcoming') }}
</div>
</article>
<article class="panel">
<div class="panel-kicker">{{ t('overview.riskKicker') }}</div>
<div class="panel-title">{{ t('overview.risksTitle') }}</div>
<router-link
v-for="item in crossWorkspaceRisks"
:key="item.id"
:to="item.route"
class="list-row alert"
>
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.workspaceName }} · {{ item.status }}</span>
</div>
<em>{{ formatDate(item.date) }}</em>
</router-link>
<div
v-if="!crossWorkspaceRisks.length"
class="empty-state"
>
{{ t('overview.emptyRisks') }}
</div>
</article>
<article class="panel">
<div class="panel-kicker">{{ t('overview.activityKicker') }}</div>
<div class="panel-title">{{ t('overview.activityTitle') }}</div>
<div
v-for="item in activityFeed"
:key="item.id"
class="list-row"
>
<div>
<strong>{{ item.workspaceName }}</strong>
<span>{{ item.message }}</span>
</div>
<em>{{ formatDateTime(item.createdAt) }}</em>
</div>
<div
v-if="!activityFeed.length"
class="empty-state"
>
{{ t('overview.emptyActivity') }}
</div>
</article>
</div>
</template>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.page-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.page-header p,
.stat-card span,
.list-row span,
.workspace-row span,
.empty-state {
@apply text-sm leading-6;
color: #526178;
}
.eyebrow,
.panel-kicker {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.stats-grid {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-4;
}
.overview-grid {
@apply grid gap-4 xl:grid-cols-2;
}
.stat-card,
.panel {
@apply rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.panel {
@apply flex flex-col gap-4;
}
.panel-title,
.workspace-row strong,
.list-row strong {
color: #172033;
}
.panel-title {
@apply text-2xl font-black;
}
.stat-card strong {
@apply mt-3 block text-4xl font-black;
color: #172033;
}
.workspace-stack {
@apply flex flex-col gap-3;
}
.workspace-row,
.list-row {
@apply flex items-start justify-between gap-4 rounded-[1.1rem] border p-4 text-left no-underline;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.workspace-row.alert,
.list-row.alert {
background: #fff1f2;
border-color: rgba(225, 29, 72, 0.14);
}
.workspace-meta {
@apply flex flex-col items-end gap-1;
}
.workspace-meta small,
.list-row em {
@apply text-sm font-semibold not-italic;
color: #172033;
}
.page-message,
.empty-state {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
}
.page-message.error {
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
const authStore = useAuthStore();
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const projectsStore = useProjectsStore();
const contentItemsStore = useContentItemsStore();
const project = computed(() =>
projectsStore.projects.find(candidate => candidate.id === route.params.projectId) ?? null
);
const scopedItems = computed(() =>
contentItemsStore.items
.filter(item => item.projectId === route.params.projectId)
.sort((left, right) => {
const leftDue = left.dueDate ? new Date(left.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.dueDate ? new Date(right.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
return leftDue - rightDue;
})
);
function formatProjectDateRange(projectValue) {
if (!projectValue?.startDate || !projectValue?.endDate) {
return 'No date range';
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(new Date(projectValue.startDate), new Date(projectValue.endDate));
}
</script>
<template>
<section class="page-shell">
<div
v-if="!project"
class="page-message error"
>
The selected campaign could not be found in the active workspace.
</div>
<template v-else>
<div class="hero">
<div>
<div class="breadcrumb-row">
<router-link
class="breadcrumb"
:to="{ name: 'workspace-dashboard' }"
>
Workspace
</router-link>
<span>/</span>
<router-link
class="breadcrumb"
:to="{ name: 'campaigns' }"
>
Campaigns
</router-link>
</div>
<h1>{{ project.name }}</h1>
<p>{{ project.description || `${workspaceStore.activeWorkspace?.name} delivery stream with only the content scheduled in this campaign.` }}</p>
</div>
<div class="hero-meta">
<div class="meta-chip">{{ project.status }}</div>
<div class="meta-copy">{{ formatProjectDateRange(project) }}</div>
</div>
</div>
<div
v-if="project.notes"
class="page-message"
>
{{ project.notes }}
</div>
<div class="section-header">
<strong>Content items</strong>
<span>{{ scopedItems.length }} scheduled in this campaign</span>
</div>
<div class="scope-actions">
<router-link
v-if="authStore.isManager || authStore.isProvider"
:to="{ name: 'content-item-create', query: { projectId: project.id } }"
class="scope-button"
>
New content in {{ project.name }}
</router-link>
</div>
<div
v-if="scopedItems.length"
class="content-grid"
>
<router-link
v-for="item in scopedItems"
:key="item.id"
:to="{ name: 'content-item-detail', params: { id: item.id } }"
class="content-card"
>
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
<strong>{{ item.title }}</strong>
<span>{{ item.publicationTargets }}</span>
<div class="status-row">
<em>{{ item.status }}</em>
<small>{{ item.dueDate ? new Date(item.dueDate).toLocaleDateString() : 'No due date' }}</small>
</div>
</router-link>
</div>
<div
v-else
class="page-message"
>
No content items are attached to this campaign yet.
</div>
</template>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.hero,
.content-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.hero {
@apply flex flex-col gap-5 p-6 lg:flex-row lg:items-start lg:justify-between;
}
.breadcrumb-row {
@apply flex items-center gap-2 text-sm;
color: #0f766e;
}
.breadcrumb,
.hero p,
.meta-copy,
.section-header span,
.content-card span,
.status-row small,
.status-row em {
@apply text-sm leading-6 not-italic;
color: #526178;
}
.breadcrumb {
@apply font-bold uppercase tracking-[0.16em];
color: #0f766e;
}
.hero h1,
.section-header strong,
.content-card strong {
color: #172033;
}
.hero h1 {
@apply mt-2 text-4xl font-black;
}
.hero-meta {
@apply flex flex-wrap items-start gap-3;
}
.meta-chip,
.version-chip {
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.section-header {
@apply flex items-center justify-between gap-4;
}
.scope-actions {
@apply flex justify-start;
}
.scope-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition;
background: #172033;
color: #fffaf2;
}
.scope-button:hover {
background: #0f172a;
}
.section-header strong {
@apply text-lg font-black;
}
.content-grid {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
}
.content-card {
@apply flex flex-col gap-4 p-5 no-underline;
}
.status-row {
@apply flex items-center justify-between gap-3;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,376 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/authStore.js';
import { useClientsStore } from '@/stores/clientsStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
const route = useRoute();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const clientsStore = useClientsStore();
const projectsStore = useProjectsStore();
const { t } = useI18n();
const isCreateFormVisible = ref(false);
const formError = ref(null);
const form = reactive({
name: '',
startDate: '',
endDate: '',
description: '',
notes: '',
});
const operationalClient = computed(() => clientsStore.operationalClient);
function resetForm() {
form.name = '';
form.startDate = '';
form.endDate = '';
form.description = '';
form.notes = '';
formError.value = null;
}
function openCreateForm() {
resetForm();
isCreateFormVisible.value = true;
}
async function submitForm() {
if (projectsStore.isCreating) {
return;
}
formError.value = null;
if (!form.name || !form.startDate || !form.endDate) {
formError.value = t('projects.errors.required');
return;
}
if (new Date(form.endDate) < new Date(form.startDate)) {
formError.value = t('projects.errors.invalidDateRange');
return;
}
if (!operationalClient.value?.id) {
formError.value = t('projects.errors.workspaceAccountRequired');
return;
}
try {
await projectsStore.createProject({
clientId: operationalClient.value.id,
name: form.name,
startDate: new Date(form.startDate).toISOString(),
endDate: new Date(form.endDate).toISOString(),
description: form.description,
notes: form.notes,
});
isCreateFormVisible.value = false;
resetForm();
} catch (error) {
formError.value = t('projects.errors.createFailed');
}
}
watch(
() => route.query.create,
createValue => {
if (createValue === 'true') {
openCreateForm();
}
},
{ immediate: true }
);
function formatProjectDateRange(project) {
if (!project?.startDate || !project?.endDate) {
return t('projects.noDateRange');
}
const start = new Date(project.startDate);
const end = new Date(project.endDate);
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(start, end);
}
</script>
<template>
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('projects.eyebrow') }}</div>
<h1>{{ t('projects.title') }}</h1>
<p>{{ t('projects.description') }}</p>
</div>
</div>
<div class="action-row">
<button
v-if="authStore.isManager"
class="create-button"
@click="openCreateForm"
>
{{ t('projects.newProject') }}
</button>
</div>
<div
v-if="isCreateFormVisible"
class="create-panel"
>
<div class="panel-header">
<strong>{{ t('projects.createTitle') }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
</div>
<div
v-if="formError"
class="page-message error"
>
{{ formError }}
</div>
<div class="form-grid">
<label class="field">
<span>{{ t('projects.fields.startDate') }}</span>
<input
v-model="form.startDate"
type="date"
:disabled="projectsStore.isCreating"
/>
</label>
<label class="field">
<span>{{ t('projects.fields.endDate') }}</span>
<input
v-model="form.endDate"
type="date"
:disabled="projectsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:disabled="projectsStore.isCreating"
/>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.description') }}</span>
<textarea
v-model="form.description"
:disabled="projectsStore.isCreating"
></textarea>
</label>
<label class="field field-wide">
<span>{{ t('projects.fields.notes') }}</span>
<textarea
v-model="form.notes"
:disabled="projectsStore.isCreating"
></textarea>
</label>
</div>
<div class="panel-actions">
<button
class="secondary"
:disabled="projectsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
class="primary"
:disabled="projectsStore.isCreating"
@click="submitForm"
>
<v-progress-circular
v-if="projectsStore.isCreating"
indeterminate
:size="16"
:width="2"
/>
<span>{{ projectsStore.isCreating ? t('common.creating') : t('projects.createTitle') }}</span>
</button>
</div>
</div>
<div
v-if="projectsStore.isLoading"
class="page-message"
>
{{ t('projects.loading') }}
</div>
<div
v-else-if="projectsStore.error"
class="page-message error"
>
{{ projectsStore.error }}
</div>
<div class="project-stack">
<router-link
v-for="project in projectsStore.projects"
:key="project.id"
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
class="project-row"
>
<div>
<strong>{{ project.name }}</strong>
<span>{{ project.description || project.status }}</span>
</div>
<div class="project-meta">
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
<em>{{ formatProjectDateRange(project) }}</em>
</div>
</router-link>
</div>
<div
v-if="!projectsStore.isLoading && !projectsStore.projects.length"
class="page-message"
>
{{ t('projects.empty') }}
</div>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.header p,
.panel-header span,
.project-row span,
.project-meta span,
.project-meta em {
@apply text-sm leading-6 not-italic;
color: #526178;
}
.action-row {
@apply flex justify-end;
}
.create-button,
.primary,
.secondary {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
}
.create-button,
.primary {
background: #172033;
color: #fffaf2;
}
.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.create-panel,
.project-row {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.create-panel {
@apply flex flex-col gap-5 p-5;
}
.panel-header {
@apply flex flex-col gap-2;
}
.panel-header strong,
.project-row strong {
color: #172033;
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: #172033;
}
.field-wide {
@apply md:col-span-2;
}
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95);
color: #172033;
}
.field textarea {
@apply min-h-28 rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95);
color: #172033;
resize: vertical;
}
.panel-actions {
@apply flex justify-end gap-3;
}
.project-stack {
@apply flex flex-col gap-4;
}
.project-row {
@apply flex flex-col justify-between gap-4 p-5 no-underline lg:flex-row lg:items-center;
}
.project-row strong {
@apply block text-xl font-black;
}
.project-meta {
@apply flex flex-col items-start gap-1 lg:items-end;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
}
.page-message.error {
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useReviewQueueStore } from '@/stores/reviewQueueStore.js';
const { t } = useI18n();
const reviewQueueStore = useReviewQueueStore();
</script>
<template>
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('reviewQueue.eyebrow') }}</div>
<h1>{{ t('reviewQueue.title') }}</h1>
<p>{{ t('reviewQueue.description') }}</p>
</div>
</div>
<div class="queue-list">
<article
v-for="item in reviewQueueStore.items"
:key="item.id"
class="queue-row"
>
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.projectName }} · {{ item.stage }}</span>
</div>
<div class="queue-meta">
<em>{{ item.status }}</em>
<small>{{ item.dueLabel }}</small>
</div>
</article>
</div>
<div
v-if="!reviewQueueStore.items.length"
class="page-message"
>
{{ t('reviewQueue.empty') }}
</div>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.header p {
@apply mt-3 max-w-2xl text-sm leading-6;
color: #526178;
}
.queue-list {
@apply flex flex-col gap-4;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
}
.queue-row {
@apply flex flex-col justify-between gap-4 rounded-[1.5rem] border p-5 lg:flex-row lg:items-center;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
}
.queue-row strong {
@apply block text-xl font-black;
color: #172033;
}
.queue-row span,
.queue-meta span,
.queue-meta small {
@apply text-sm leading-6;
color: #526178;
}
.queue-meta {
@apply flex flex-col items-start gap-1 lg:items-end;
}
.queue-meta em {
@apply text-sm font-semibold uppercase tracking-[0.16em] not-italic;
color: #ef4444;
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/authStore.js';
const authStore = useAuthStore();
const { t } = useI18n();
</script>
<template>
<section class="settings-shell">
<aside class="settings-nav">
<div class="settings-nav-header">
<div class="eyebrow">{{ t('settings.eyebrow') }}</div>
<h1>{{ t('settings.title') }}</h1>
</div>
<router-link
:to="{ name: 'settings-user-information' }"
class="settings-link"
>
{{ t('settings.userInformation') }}
</router-link>
<router-link
v-if="authStore.isManager"
:to="{ name: 'settings-workspaces' }"
class="settings-link"
>
{{ t('settings.workspaces') }}
</router-link>
<router-link
v-if="authStore.isManager"
:to="{ name: 'settings-integrations' }"
class="settings-link"
>
{{ t('settings.integrations') }}
</router-link>
</aside>
<div class="settings-content">
<router-view />
</div>
</section>
</template>
<style scoped>
.settings-shell {
@apply mx-auto grid w-full max-w-7xl gap-4 px-5 py-8 md:px-8 xl:grid-cols-[16rem_minmax(0,1fr)];
}
.settings-nav,
.settings-content :deep(.page-shell) {
min-width: 0;
}
.settings-nav {
@apply flex h-fit flex-col gap-2 rounded-[1.75rem] border p-4;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.settings-nav-header {
@apply mb-2 px-2;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.settings-nav-header h1 {
@apply mt-2 text-2xl font-black;
color: #172033;
}
.settings-link {
@apply rounded-[1rem] px-4 py-3 text-sm font-semibold no-underline transition;
color: #526178;
}
.settings-link:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.settings-link.router-link-active {
background: #172033;
color: #fffaf2;
}
.settings-content {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,163 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
const userProfileStore = useUserProfileStore();
const { t } = useI18n();
const isPortraitDialogOpen = ref(false);
const isSavingPortrait = ref(false);
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
const alias = computed(() => userProfileStore.alias);
const fullname = computed(() => userProfileStore.fullname);
async function savePortrait(result) {
isSavingPortrait.value = true;
try {
await userProfileStore.changePortrait(result.file);
isPortraitDialogOpen.value = false;
} finally {
isSavingPortrait.value = false;
}
}
</script>
<template>
<section class="page-shell">
<div class="page-header">
<div class="eyebrow">{{ t('userSettings.eyebrow') }}</div>
<h1>{{ t('userSettings.title') }}</h1>
<p>{{ t('userSettings.description') }}</p>
</div>
<div class="panel hero-panel">
<div class="hero-identity">
<AppAvatar
:name="alias"
:src="userProfileStore.portraitUrl"
size="lg"
/>
<div>
<strong>{{ alias }}</strong>
<span>{{ fullname }}</span>
<small>{{ email }}</small>
</div>
</div>
<button
class="primary-button"
@click="isPortraitDialogOpen = true"
>
{{ t('userSettings.updatePortrait') }}
</button>
</div>
<div class="panel">
<div class="panel-heading">
<strong>{{ t('userSettings.accountDetails') }}</strong>
<span>{{ t('userSettings.accountDetailsDescription') }}</span>
</div>
<div class="details-grid">
<div class="detail-row">
<span>{{ t('userSettings.alias') }}</span>
<strong>{{ alias }}</strong>
</div>
<div class="detail-row">
<span>{{ t('userSettings.fullName') }}</span>
<strong>{{ fullname }}</strong>
</div>
<div class="detail-row">
<span>{{ t('userSettings.email') }}</span>
<strong>{{ email }}</strong>
</div>
</div>
</div>
<ImageCropperDialog
v-model="isPortraitDialogOpen"
:title="t('userSettings.cropperTitle')"
:confirm-label="t('userSettings.savePortrait')"
:upload-label="t('userSettings.choosePortrait')"
:is-saving="isSavingPortrait"
@save="savePortrait"
/>
</section>
</template>
<style scoped>
.page-shell {
@apply flex flex-col gap-6;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
}
.page-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
}
.page-header p,
.panel-heading span,
.hero-identity span,
.hero-identity small,
.detail-row span {
@apply text-sm leading-6;
color: #526178;
}
.panel {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.hero-panel {
@apply flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between;
}
.hero-identity {
@apply flex items-center gap-4;
}
.hero-identity strong,
.panel-heading strong,
.detail-row strong {
color: #172033;
}
.hero-identity strong {
@apply text-2xl font-black;
}
.panel-heading {
@apply flex flex-col gap-2;
}
.panel-heading strong {
@apply text-lg font-black;
}
.details-grid {
@apply grid gap-4 md:grid-cols-2;
}
.detail-row {
@apply flex flex-col gap-1 rounded-[1.25rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.primary-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
background: #172033;
color: #fffaf2;
}
</style>

View File

@@ -0,0 +1,271 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
const router = useRouter();
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
const form = reactive({
name: '',
slug: '',
timeZone: computedDefaultTimeZone(),
});
const formError = ref(null);
const previewSlug = computed(() => {
if (form.slug.trim()) {
return slugify(form.slug);
}
return slugify(form.name);
});
function computedDefaultTimeZone() {
return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal';
}
function slugify(value) {
return (value ?? '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function submitForm() {
if (workspaceStore.isCreating) {
return;
}
formError.value = null;
const name = form.name.trim();
const slug = slugify(form.slug || form.name);
const timeZone = form.timeZone.trim();
if (!name || !slug || !timeZone) {
formError.value = t('workspaceCreate.errors.required');
return;
}
try {
await workspaceStore.createWorkspace({
name,
slug,
timeZone,
});
await router.push({ name: 'workspace-settings' });
} catch (error) {
formError.value = t('workspaceCreate.errors.createFailed');
}
}
async function cancel() {
await router.push({ name: 'workspace-settings' });
}
</script>
<template>
<section class="page-shell">
<div class="hero">
<div class="hero-copy">
<div class="eyebrow">{{ t('workspaceCreate.eyebrow') }}</div>
<h1>{{ t('workspaceCreate.title') }}</h1>
<p>{{ t('workspaceCreate.description') }}</p>
</div>
<div class="hero-note">
<strong>{{ t('workspaceCreate.previewTitle') }}</strong>
<span>{{ t('workspaceCreate.previewDescription') }}</span>
<code>{{ previewSlug || 'workspace-slug' }}</code>
</div>
</div>
<article class="create-card">
<div class="card-header">
<strong>{{ t('workspaceCreate.formTitle') }}</strong>
<span>{{ t('workspaceCreate.formDescription') }}</span>
</div>
<div
v-if="formError"
class="page-message error"
>
{{ formError }}
</div>
<form
class="form-grid"
@submit.prevent="submitForm"
>
<label class="field field-wide">
<span>{{ t('workspaceCreate.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:placeholder="t('workspaceCreate.fields.namePlaceholder')"
:disabled="workspaceStore.isCreating"
/>
</label>
<label class="field">
<span>{{ t('workspaceCreate.fields.slug') }}</span>
<input
v-model="form.slug"
type="text"
:placeholder="t('workspaceCreate.fields.slugPlaceholder')"
:disabled="workspaceStore.isCreating"
/>
<small>{{ t('workspaceCreate.slugHint', { slug: previewSlug || 'workspace-slug' }) }}</small>
</label>
<label class="field">
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
<input
v-model="form.timeZone"
type="text"
:disabled="workspaceStore.isCreating"
/>
</label>
<div class="panel-actions field-wide">
<button
class="secondary"
type="button"
:disabled="workspaceStore.isCreating"
@click="cancel"
>
{{ t('common.cancel') }}
</button>
<button
class="primary"
type="submit"
:disabled="workspaceStore.isCreating"
>
{{ workspaceStore.isCreating ? t('common.creating') : t('workspaceCreate.createAction') }}
</button>
</div>
</form>
</article>
</section>
</template>
<style scoped>
.page-shell {
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
}
.hero {
@apply grid gap-4 lg:grid-cols-[minmax(0,1.3fr)_minmax(18rem,0.8fr)];
}
.hero-copy,
.hero-note,
.create-card {
@apply rounded-[1.75rem] border;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.92);
}
.hero-copy {
@apply p-6 md:p-8;
background:
radial-gradient(circle at top left, rgba(255, 138, 61, 0.16), transparent 38%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(255, 247, 237, 0.92));
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #c2410c;
}
.hero-copy h1 {
@apply mt-3 text-4xl font-black;
color: #172033;
}
.hero-copy p,
.hero-note span,
.card-header span,
.field small {
@apply text-sm leading-6;
color: #526178;
}
.hero-note,
.create-card {
@apply flex flex-col gap-4 p-6;
}
.hero-note strong,
.card-header strong {
color: #172033;
}
.hero-note strong {
@apply text-xl font-black;
}
.hero-note code {
@apply rounded-[1rem] px-3 py-2 text-sm;
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.card-header {
@apply flex flex-col gap-2;
}
.card-header strong {
@apply text-2xl font-black;
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2;
}
.field-wide {
@apply md:col-span-2;
}
.field span {
@apply text-sm font-semibold;
color: #172033;
}
.field input {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
outline: none;
}
.panel-actions {
@apply flex flex-wrap justify-end gap-3 pt-2;
}
.primary,
.secondary {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold transition;
}
.primary {
background: #172033;
color: #fffaf2;
}
.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
</style>

View File

@@ -0,0 +1,559 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import {
mdiAccountGroupOutline,
mdiCheckCircleOutline,
mdiCogOutline,
mdiFolderGoogleDrive,
mdiImageMultipleOutline,
mdiTuneVariant,
} from '@mdi/js';
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
const activeTab = ref('general');
const inviteForm = reactive({
email: '',
role: 'workspaceMember',
});
const pendingInvites = computed(() =>
workspaceStore.invitesByWorkspace[workspaceStore.activeWorkspaceId] ?? []
);
const workspaceMembers = computed(() =>
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
);
const settingsTabs = computed(() => [
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
]);
const workflowSteps = computed(() => [
{
key: 'internal',
title: t('workspaceSettings.approvals.steps.internal'),
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
},
{
key: 'client',
title: t('workspaceSettings.approvals.steps.client'),
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
},
{
key: 'publish',
title: t('workspaceSettings.approvals.steps.publish'),
detail: t('workspaceSettings.approvals.stepDetail.manualPublish'),
},
]);
watch(
() => workspaceStore.activeWorkspaceId,
async workspaceId => {
if (!workspaceId) {
return;
}
try {
await workspaceStore.fetchInvites(workspaceId);
await workspaceStore.fetchMembers(workspaceId);
} catch (error) {
console.error('Failed to load workspace people data:', error);
}
},
{ immediate: true }
);
async function submitInvite() {
if (!inviteForm.email.trim() || !inviteForm.role) {
return;
}
try {
await workspaceStore.inviteMember({
email: inviteForm.email.trim(),
role: inviteForm.role,
});
inviteForm.email = '';
inviteForm.role = 'workspaceMember';
} catch (error) {
console.error('Failed to invite workspace member:', error);
}
}
function formatDate(value) {
if (!value) {
return '';
}
return new Date(value).toLocaleString();
}
function translateRole(role) {
if (!role) {
return '';
}
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
return t(`workspaceSettings.roles.${normalizedRole}`, role);
}
</script>
<template>
<section class="workspace-settings-shell">
<div class="workspace-settings-hero">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.currentWorkspace') }}</span>
<h1>{{ workspaceStore.activeWorkspace?.name || t('workspaceSettings.noWorkspaceSelected') }}</h1>
<p>{{ t('workspaceSettings.description') }}</p>
</div>
<div class="tab-strip">
<button
v-for="tab in settingsTabs"
:key="tab.key"
type="button"
class="tab-button"
:class="{ 'tab-button-active': activeTab === tab.key }"
@click="activeTab = tab.key"
>
<v-icon :icon="tab.icon" />
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<div
v-if="activeTab === 'general'"
class="workspace-settings-grid workspace-settings-grid-single"
>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
</div>
<dl
v-if="workspaceStore.activeWorkspace"
class="summary-grid"
>
<div>
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
</div>
</dl>
</article>
</div>
<div
v-else-if="activeTab === 'members'"
class="workspace-settings-grid workspace-settings-grid-single"
>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.members.inviteTitle') }}</span>
<p>{{ t('workspaceSettings.inviteDescription') }}</p>
</div>
<form
class="form-stack"
@submit.prevent="submitInvite"
>
<label class="field">
<span>{{ t('workspaceSettings.fields.memberEmail') }}</span>
<input
v-model="inviteForm.email"
type="email"
/>
</label>
<label class="field">
<span>{{ t('workspaceSettings.fields.memberRole') }}</span>
<select v-model="inviteForm.role">
<option value="workspaceMember">{{ t('workspaceSettings.roles.workspaceMember') }}</option>
<option value="client">{{ t('workspaceSettings.roles.client') }}</option>
<option value="provider">{{ t('workspaceSettings.roles.provider') }}</option>
</select>
</label>
<button
class="primary-button"
type="submit"
>
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
</button>
</form>
</article>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.members.pendingTitle') }}</span>
<p>{{ t('workspaceSettings.members.pendingDescription') }}</p>
</div>
<div
v-if="workspaceStore.isInvitesLoading"
class="empty-state"
>
{{ t('loading') }}
</div>
<div
v-else-if="pendingInvites.length"
class="invite-list"
>
<div
v-for="invite in pendingInvites"
:key="invite.id"
class="invite-row"
>
<div>
<strong>{{ invite.email }}</strong>
<span>{{ t(`workspaceSettings.roles.${invite.role}`) }}</span>
</div>
<small>{{ formatDate(invite.createdAt) }}</small>
</div>
</div>
<div
v-else
class="empty-state"
>
{{ t('workspaceSettings.inviteEmpty') }}
</div>
</article>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.members.activeTitle') }}</span>
<p>{{ t('workspaceSettings.members.activeDescription') }}</p>
</div>
<div
v-if="workspaceStore.isMembersLoading"
class="empty-state"
>
{{ t('loading') }}
</div>
<div
v-else-if="workspaceMembers.length"
class="invite-list"
>
<div
v-for="member in workspaceMembers"
:key="member.id"
class="invite-row"
>
<div>
<strong>{{ member.displayName }}</strong>
<span>{{ member.email }}</span>
<span>{{ member.roles.map(translateRole).join(' · ') }}</span>
</div>
</div>
</div>
<div
v-else
class="empty-state"
>
{{ t('workspaceSettings.members.activeEmpty') }}
</div>
</article>
</div>
<div
v-else-if="activeTab === 'workflow'"
class="workflow-grid"
>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.approvals.flowTitle') }}</span>
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
</div>
<div class="workflow-rule-list">
<div class="workflow-rule">
<strong>{{ t('workspaceSettings.approvals.fields.requireInternalReview') }}</strong>
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireInternalReview') }}</span>
</div>
<div class="workflow-rule">
<strong>{{ t('workspaceSettings.approvals.fields.requireClientReview') }}</strong>
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireClientReview') }}</span>
</div>
<div class="workflow-rule">
<strong>{{ t('workspaceSettings.approvals.fields.publishBehaviour') }}</strong>
<span>{{ t('workspaceSettings.approvals.publishBehaviour.manual') }}</span>
</div>
</div>
</article>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.approvals.previewTitle') }}</span>
<p>{{ t('workspaceSettings.approvals.previewDescription') }}</p>
</div>
<div class="workflow-steps">
<div
v-for="step in workflowSteps"
:key="step.key"
class="workflow-step"
>
<div class="workflow-step-icon">
<v-icon :icon="mdiCheckCircleOutline" />
</div>
<div class="workflow-step-copy">
<strong>{{ step.title }}</strong>
<span>{{ step.detail }}</span>
</div>
</div>
</div>
</article>
</div>
<div
v-else
class="workspace-settings-grid"
>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.connectors.title') }}</span>
<p>{{ t('workspaceSettings.connectors.description') }}</p>
</div>
<div class="connector-list">
<div class="connector-row">
<div class="connector-main">
<div class="connector-icon">
<v-icon :icon="mdiFolderGoogleDrive" />
</div>
<div class="connector-copy">
<strong>{{ t('workspaceSettings.connectors.googleDrive.title') }}</strong>
<span>{{ t('workspaceSettings.connectors.googleDrive.description') }}</span>
</div>
</div>
<div class="connector-status">
{{ t('workspaceSettings.connectors.googleDrive.status') }}
</div>
</div>
</div>
<router-link
:to="{ name: 'media-library' }"
class="connector-link"
>
<v-icon :icon="mdiImageMultipleOutline" />
<span>{{ t('workspaceSettings.connectors.openMediaLibrary') }}</span>
</router-link>
</article>
</div>
</section>
</template>
<style scoped>
.workspace-settings-shell {
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
}
.workspace-settings-hero {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 38%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
border-color: rgba(23, 32, 51, 0.08);
}
.workspace-settings-grid {
@apply grid gap-4 lg:grid-cols-2;
}
.workflow-grid {
@apply grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)];
}
.workspace-settings-grid-single {
@apply lg:grid-cols-1;
}
.settings-card {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.section-copy {
@apply flex flex-col gap-2;
}
.tab-strip {
@apply flex flex-wrap gap-3;
}
.tab-button {
@apply inline-flex items-center gap-3 rounded-full px-4 py-3 text-sm font-semibold transition;
background: rgba(23, 32, 51, 0.06);
color: #526178;
}
.tab-button-active {
background: #172033;
color: #fffaf2;
}
.section-kicker {
@apply text-xs font-bold uppercase tracking-[0.2em];
color: #0f766e;
}
.section-copy h1,
.summary-grid dd,
.invite-row strong,
.connector-copy strong,
.connector-status,
.workflow-rule strong,
.workflow-step-copy strong {
color: #172033;
}
.section-copy h1 {
@apply text-3xl font-black;
}
.section-copy p,
.summary-grid dt,
.invite-row span,
.invite-row small,
.empty-state,
.connector-copy span,
.connector-link span,
.workflow-rule span,
.workflow-step-copy span {
@apply text-sm leading-6;
color: #526178;
}
.summary-grid {
@apply grid gap-4 sm:grid-cols-2;
}
.summary-grid div {
@apply rounded-[1rem] border p-4;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
}
.summary-grid dt {
@apply text-xs font-bold uppercase tracking-[0.16em];
}
.summary-grid dd {
@apply mt-2 text-base font-semibold;
}
.form-stack {
@apply flex flex-col gap-4;
}
.field {
@apply flex flex-col gap-2;
}
.field span {
@apply text-sm font-semibold;
color: #172033;
}
.field input,
.field select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
outline: none;
}
.primary-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold;
background: #172033;
color: #fffaf2;
}
.invite-list,
.connector-list,
.workflow-rule-list,
.workflow-steps {
@apply flex flex-col gap-3;
}
.invite-row,
.empty-state,
.connector-row,
.workflow-rule,
.workflow-step {
@apply rounded-[1rem] border px-4 py-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.invite-row {
@apply flex items-start justify-between gap-4;
}
.invite-row div,
.connector-copy,
.workflow-rule,
.workflow-step-copy {
@apply flex flex-col gap-1;
}
.connector-row {
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
}
.connector-main,
.workflow-step {
@apply flex items-start gap-4;
}
.connector-icon,
.workflow-step-icon {
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl;
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
}
.connector-status {
@apply inline-flex w-fit items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.18em];
background: rgba(245, 158, 11, 0.14);
}
.connector-link {
@apply inline-flex w-fit items-center gap-3 rounded-full px-5 py-3 text-sm font-semibold no-underline transition;
background: #172033;
color: #fffaf2;
}
.connector-link:hover {
background: #0f172a;
}
</style>

View File

@@ -1,686 +0,0 @@
<template>
<div
class="relative p-4"
@mouseenter="showEditButtons = isLoggedIn && creatorProfileStore.creator?.id === brandingStore.value.id"
@mouseleave="showEditButtons = false"
>
<!-- Edit buttons with absolute positioning -->
<div
v-if="showEditButtons || isEditMode"
class="absolute right-4 top-4 flex gap-2"
>
<!-- Edit button with pencil icon -->
<button
v-if="!isEditMode"
:title="t('edit')"
class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
@click="toggleEditMode()"
>
<v-icon
:icon="mdiPencil"
large
/>
</button>
<!-- Save button -->
<button
v-if="isEditMode"
:disabled="isSaving || !canSave"
:title="t('save')"
class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
@click="saveChanges()"
>
<template v-if="isSaving">
<v-progress-circular
color="white"
indeterminate
size="20"
width="2"
/>
</template>
<template v-else>
<v-icon :icon="mdiCheck" />
</template>
</button>
<!-- Cancel button -->
<button
v-if="isEditMode"
:title="t('cancel')"
class="flex size-12 items-center justify-center rounded-full bg-red-500 shadow-lg"
@click="cancelEdit"
>
<v-icon
:icon="mdiClose"
large
/>
</button>
</div>
<!-- MainPage -->
<div class="flex flex-col">
<h1 class="mb-4 flex justify-start text-center text-2xl font-bold">
{{ t('creator.sections.about.title') }}
</h1>
<div>
<!-- Description Section -->
<div>
<div v-if="!isEditMode">
<p
v-if="description"
class="mb-6 whitespace-pre-line text-justify text-lg"
>
{{ description }}
</p>
</div>
<v-textarea
v-if="isEditMode"
v-model="editableDescription"
:counter="2000"
:error-messages="descriptionError"
:label="t('creator.sections.about.description')"
:rules="[
v => !!v || t('creator.validation.descriptionRequired'),
v => v.length <= 2000 || t('creator.validation.descriptionTooLong'),
]"
auto-grow
class="w-full p-2 py-6"
rows="5"
variant="outlined"
></v-textarea>
</div>
<!-- Video Section -->
<div
v-if="videoUrl || isEditMode"
:class="[
'content-section',
{
'rounded-t-xl': hasImages && !isEditMode,
'rounded-xl': !hasImages && !isEditMode,
},
]"
>
<div
v-if="!isEditMode && videoUrl"
class="video-container"
>
<iframe
:src="youtubeEmbedUrl"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="video-frame"
title="YouTube video player"
></iframe>
</div>
<div v-if="isEditMode">
<v-text-field
v-model="editableVideoUrl"
:error-messages="videoUrlError"
:label="t('creator.fields.videoUrl')"
class="w-full p-2"
type="text"
variant="outlined"
/>
</div>
</div>
<!-- Photos Section using Album component -->
<div>
<!-- Use AlbumView for display mode -->
<AlbumView
v-if="!isEditMode && hasImages"
:class="[
'content-section',
{
'rounded-b-xl': videoUrl && !isEditMode,
'rounded-xl': !videoUrl && !isEditMode,
},
]"
:images="thumbnailUrls"
@photo-click="handlePhotoClick"
/>
<AlbumViewer
v-model="showAlbumViewer"
:images="originalUrls"
:start-index="selectedPhotoIndex"
/>
<!-- Use AlbumEditor for edit mode -->
<AlbumEditor
v-if="isEditMode"
:images="photos"
@update:images="updateImages"
/>
</div>
<!-- Contact Information Section -->
<div
v-if="phoneNumber || email"
class="contact-info mt-6"
>
<!-- Phone Number -->
<div
v-if="phoneNumber"
class="contact-capsule"
@click="callPhone"
>
<v-icon
:icon="mdiPhone"
class="contact-icon"
/>
<span class="contact-text">{{ phoneNumber }}</span>
</div>
<!-- Email -->
<div
v-if="email"
class="contact-capsule"
@click="sendEmail"
>
<v-icon
:icon="mdiEmail"
class="contact-icon"
/>
<span class="contact-text">{{ email }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useBrandingStore } from '@/stores/brandingStore.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useI18n } from 'vue-i18n';
import { buildEmbedUrl, extractVideoId, isValidYouTubeUrlOrId } from '@/utils/youtube';
import AlbumEditor from '@/views/creators/AlbumEditor.vue';
import AlbumView from '@/views/creators/AlbumView.vue';
import AlbumViewer from './AlbumViewer.vue';
import { useToast } from 'vue-toastification';
import { mdiCheck, mdiClose, mdiEmail, mdiPencil, mdiPhone } from '@mdi/js';
const { t } = useI18n();
const creatorProfileStore = useCreatorProfileStore();
const brandingStore = useBrandingStore();
const client = useClient();
const toast = useToast();
// Fetch album data
const isLoadingAlbum = ref(false);
const isLoading = ref(true);
const isSaving = ref(false);
const isLoggedIn = true;
const isEditMode = ref(false);
const showEditButtons = ref(false);
// Variables réactives pour les données
const description = ref('');
const videoUrl = ref('');
const phoneNumber = ref('');
const email = ref('');
const photos = ref([]); //before was thumbnailUrls
const albumId = ref(null);
const originalPhotos = ref([]);
// Add these refs with your other refs
const showAlbumViewer = ref(false);
const selectedPhotoIndex = ref(0);
// Editable fields
const editableDescription = ref('');
const editableVideoUrl = ref('');
const videoUrlError = ref('');
const descriptionError = ref('');
function callPhone() {
if (phoneNumber.value) {
toast.info('Calling your contact');
// Remove formatting and create tel: link
const cleanPhone = phoneNumber.value.replace(/\D/g, '');
window.location.href = `tel:+1${cleanPhone}`;
}
}
function sendEmail() {
if (email.value) {
window.location.href = `mailto:${email.value}`;
}
}
// Computed property to check if we can save
const canSave = computed(() => {
if (isSaving.value == true) {
return false;
}
// Check if description is empty or only whitespace
if (!editableDescription.value || editableDescription.value.trim() === '') {
return false;
}
// Check if description is too long
if (editableDescription.value.length > 2000) {
return false;
}
// Check if video URL is invalid (if one is provided)
if (editableVideoUrl.value && !validateVideoUrl(editableVideoUrl.value)) {
return false;
}
return true;
});
const thumbnailUrls = computed(() => {
return photos.value.map(photo => photo.image.thumbnailUrl);
});
// Add this computed property to get the original image URLs
const originalUrls = computed(() => {
return photos.value.map(photo => photo.image.originalUrl);
});
// Computed property to check if there are images
const hasImages = computed(() => {
// Only consider it has images if there are actual image URLs (not empty strings)
return photos.value.length > 0;
});
// Computed property for YouTube embed URL
const youtubeEmbedUrl = computed(() => {
if (!videoUrl.value) return '';
return buildEmbedUrl(videoUrl.value);
});
// Validate video URL
function validateVideoUrl(url) {
if (!url) {
videoUrlError.value = '';
return true;
}
if (!isValidYouTubeUrlOrId(url)) {
videoUrlError.value = t('creator.validation.invalidYoutubeUrl');
return false;
}
videoUrlError.value = '';
return true;
}
// Watch for changes in editableVideoUrl
watch(editableVideoUrl, newValue => {
validateVideoUrl(newValue);
});
// Activer/désactiver le mode édition
function toggleEditMode() {
isEditMode.value = !isEditMode.value;
if (isEditMode.value) {
// Charger les valeurs pour l'édition
editableDescription.value = description.value;
editableVideoUrl.value = videoUrl.value;
videoUrlError.value = '';
}
}
watch(
() => ({
id: brandingStore.value?.id,
presentation: brandingStore.value?.presentation,
}),
async ({ id, presentation }, previousValue) => {
// Only proceed if we have both id and presentation, and the id has changed
if (id && presentation && id !== previousValue?.id) {
console.log('Watcher triggered: Loading data for creator ID:', id);
// Load presentation data
description.value = presentation.description || '';
videoUrl.value = presentation.videoUrl || '';
phoneNumber.value = presentation.phoneNumber || '';
email.value = presentation.email || '';
// Fetch album data
await fetchAlbumData();
}
},
{ immediate: true, deep: true }
);
async function fetchAlbumData() {
if (isLoadingAlbum.value) {
console.log('Album data already loading, skipping duplicate request');
return;
}
console.log('in fetchAlbumData()');
if (!brandingStore.value?.id) return;
isLoadingAlbum.value = true;
const creatorId = brandingStore.value.id;
try {
// Try to get the album
const response = await client.get(`/api/albums/${creatorId}`);
if (response.data && response.data.photos) {
// Store original photos for comparison
originalPhotos.value = response.data.photos;
// Extract photo URLs from the album photos
photos.value = response.data.photos.map(photo => ({
file: null,
image: photo,
isProcessing: false,
isUploading: false,
}));
albumId.value = creatorId;
} else {
// Initialize with an empty array instead of empty slots
console.log('WOW! You found how to get here! Take a look at the stack!');
photos.value = [];
originalPhotos.value = [];
}
} catch (error) {
photos.value = [];
originalPhotos.value = [];
} finally {
isLoadingAlbum.value = false;
}
}
// Charger les données au montage
onMounted(async () => {
if (!brandingStore.value?.presentation) return;
description.value = brandingStore.value.presentation.description || '';
videoUrl.value = brandingStore.value.presentation.videoUrl || '';
phoneNumber.value = brandingStore.value.presentation.phoneNumber || '';
email.value = brandingStore.value.presentation.email || '';
});
// Update images from Album component
function updateImages(newImages) {
photos.value = newImages;
}
async function saveChanges() {
if (!brandingStore.value?.id) {
console.error("L'ID du créateur est manquant !");
return;
}
// Validate description is not empty
if (!editableDescription.value || editableDescription.value.trim() === '') {
descriptionError.value = t('creator.validation.descriptionRequired');
return;
}
// Validate description length
if (editableDescription.value.length > 2000) {
descriptionError.value = t('creator.validation.descriptionTooLong');
return;
}
// Validate video URL before saving
if (!validateVideoUrl(editableVideoUrl.value)) {
return;
}
try {
isLoading.value = true;
// Save presentation info
const presentationResponse = await client.post(
`/api/creators/${brandingStore.value.id}/presentation-infos`,
{
description: editableDescription.value || '',
videoUrl: editableVideoUrl.value || null,
}
);
// Mettre à jour les valeurs locales pour refléter les changements
description.value = editableDescription.value;
videoUrl.value = extractVideoId(editableVideoUrl.value) || '';
// Check for deleted photos
const photosOriginalUrls = photos.value.map(photo => photo.image.originalUrl);
const deletedPhotos = originalPhotos.value.filter(originalPhoto => {
// If the photo URL is not in the current images array, it was deleted
return !photosOriginalUrls.includes(originalPhoto.originalUrl);
});
const newImages = photos.value.filter(
photo => photo && photo.image && photo.image.originalUrl.startsWith('data:')
);
console.log('originalPhotos', originalPhotos.value);
console.log('photos', photos.value);
console.log('deletedPhotos', deletedPhotos);
console.log('newImages', newImages);
// Save album photos if they've changed
if (photos.value.length > 0 || deletedPhotos.length > 0) {
console.log('We got pending changes');
// Create the Album if we do not have one yet
if (albumId.value == null) {
console.log('We do not have an album yet');
try {
await client.post('/api/albums', {
albumId: brandingStore.value.id,
title: `${brandingStore.value.name}'s Album`,
description: 'Photo album for the creator',
});
albumId.value = brandingStore.value.id;
} catch (error) {
// Album might already exist, which is fine
console.log("Couldn't create an Album", error);
}
}
// Delete removed photos
for (const photo of deletedPhotos) {
try {
await client.delete(`/api/albums/${albumId.value}/photos/${photo.id}`);
} catch (error) {
console.error('Error deleting photo:', error);
}
}
// Now add or update photos
for (let i = 0; i < newImages.length; i++) {
const imageData = newImages[i];
imageData.isUploading = true;
console.log('Image Data to be uploaded:', imageData);
// This is a new image that needs to be uploaded
const photoId = crypto.randomUUID();
// Convert data URL to file
const response = await fetch(imageData.image.originalUrl);
const blob = await response.blob();
const file = new File([blob], imageData.file.name, { type: imageData.file.type });
const formData = new FormData();
formData.append('file', file);
await client.post(`/api/albums/${albumId.value}/photos`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
params: {
photoId: photoId,
},
});
imageData.isUploading = false;
}
// Refresh album data after changes
await fetchAlbumData();
}
isEditMode.value = false;
} catch (error) {
console.error('Erreur lors de la sauvegarde :', error);
} finally {
isLoading.value = false;
}
}
function cancelEdit() {
// Restaurer les valeurs d'origine
editableDescription.value = description.value;
editableVideoUrl.value = videoUrl.value;
// Désactiver le mode édition
isEditMode.value = false;
}
// Add this function to handle photo clicks
function handlePhotoClick(index) {
selectedPhotoIndex.value = index;
showAlbumViewer.value = true;
}
</script>
<style scoped>
.content-section {
@apply w-full overflow-hidden;
@apply cursor-pointer;
}
.video-container {
position: relative;
width: 100%;
padding-top: 31.25%;
/* Reduced from 56.25% to make it shorter while maintaining aspect ratio */
max-height: 40vh;
}
.video-frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
/* Add responsive breakpoints */
@media (max-width: 640px) {
.video-container {
padding-top: 35%;
max-height: 35vh;
}
}
@media (min-width: 1024px) {
.video-container {
padding-top: 30%;
max-height: 38vh;
}
}
.contact-info {
@apply flex flex-col items-center gap-3;
}
.contact-capsule {
@apply flex items-center gap-2 px-2 py-1 bg-hSurface;
@apply rounded-xl cursor-pointer transition-all duration-200;
@apply hover:shadow-md min-w-fit;
@apply border border-hutopyPrimary;
}
.contact-capsule:hover {
@apply transform scale-105;
}
.contact-icon {
@apply text-hutopyPrimary;
@apply text-xl;
}
.contact-text {
@apply text-hOnSurface font-medium text-base;
}
/* Formatting styles for description */
.text-justify {
line-height: 1.6;
}
/* Add some spacing between paragraphs */
.text-justify p {
margin-bottom: 1rem;
}
</style>
<i18n>
{
"en": {
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"creator": {
"sections": {
"about": {
"title": "About",
"description": "Description",
"contactInfo": "Contact Information",
"characters": "characters",
"formattingHint": "Tip: Use line breaks and emojis to make your description more engaging!"
},
"photos": {
"title": "Photos",
"image": "Image"
}
},
"fields": {
"videoUrl": "Video URL",
"phoneNumber": "Phone Number",
"email": "Email"
},
"validation": {
"invalidYoutubeUrl": "Please enter a valid YouTube URL or video ID",
"descriptionTooLong": "Description cannot exceed 2000 characters",
"descriptionRequired": "Description is required"
}
}
},
"fr": {
"edit": "Modifier",
"save": "Enregistrer",
"cancel": "Annuler",
"creator": {
"sections": {
"about": {
"title": "À propos",
"description": "Description",
"contactInfo": "Informations de contact",
"characters": "caractères",
"formattingHint": "Astuce : Utilisez des sauts de ligne et des émojis pour rendre votre description plus attrayante !"
},
"photos": {
"title": "Photos",
"image": "Image"
}
},
"fields": {
"videoUrl": "URL de la vidéo",
"phoneNumber": "Numéro de téléphone",
"email": "Email"
},
"validation": {
"invalidYoutubeUrl": "Veuillez entrer une URL YouTube ou un ID de vidéo valide",
"descriptionTooLong": "La description ne peut pas dépasser 2000 caractères",
"descriptionRequired": "La description est obligatoire"
}
}
}
}
</i18n>

View File

@@ -1,81 +0,0 @@
<template>
<div class="relative">
<!-- Banner Container with mouse events -->
<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"
>
<!-- Top-right Icon -->
<div
class="absolute right-4 top-4 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
>
<v-icon
:icon="mdiPencil"
large
/>
</div>
</div>
</div>
</div>
<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 { useI18n } from 'vue-i18n';
import { mdiPencil } from '@mdi/js';
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
const { t } = useI18n();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>
<style scoped></style>
<i18n>
{
"en": {
"alt": "Creator banner"
},
"fr": {
"alt": "Bannière du créateur"
}
}
</i18n>

View File

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

View File

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

View File

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

View File

@@ -1,170 +0,0 @@
<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';
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
const { t } = useI18n();
</script>
<template>
<div class="flex flex-column w-full">
<!-- 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 />
</div>
<!-- Desktop version (visible only on écrans moyens et grands) -->
<div class="social-info">
<div class="w-36"></div>
<div class="flex-grow flex flex-row">
<div class="flex-grow">
<name-title></name-title>
</div>
<div class="hidden sm:flex pr-6">
<DonationButton
v-if="brandingStore.value?.acceptDonation"
:creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/@' + brandingStore.value.slug + '/tip-cancelled'"
:on-success-url="baseURL + '/@' + brandingStore.value.slug + '/tip-completed'"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Section pour les icônes de réseaux sociaux -->
<div class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative">
<div class="flex flex-row gap-10">
<a
v-if="brandingStore.value?.socials?.facebookUrl"
:href="brandingStore.value?.socials?.facebookUrl"
:title="t('facebook')"
target="_blank"
>
<facebook class="social-icon"></facebook>
</a>
<a
v-if="brandingStore.value?.socials?.instagramUrl"
:href="brandingStore.value?.socials?.instagramUrl"
:title="t('instagram')"
target="_blank"
>
<instagram class="social-icon"></instagram>
</a>
<a
v-if="brandingStore.value?.socials?.linkedInUrl"
:href="brandingStore.value?.socials?.linkedInUrl"
:title="t('linkedin')"
target="_blank"
>
<linkedin class="social-icon"></linkedin>
</a>
<a
v-if="brandingStore.value?.socials?.redditUrl"
:href="brandingStore.value?.socials?.redditUrl"
:title="t('reddit')"
target="_blank"
>
<reddit class="social-icon"></reddit>
</a>
<a
v-if="brandingStore.value?.socials?.tikTokUrl"
:href="brandingStore.value?.socials?.tikTokUrl"
:title="t('tiktok')"
target="_blank"
>
<tiktok class="social-icon"></tiktok>
</a>
<a
v-if="brandingStore.value?.socials?.xUrl"
:href="brandingStore.value?.socials?.xUrl"
:title="t('x')"
target="_blank"
>
<x class="social-icon"></x>
</a>
<a
v-if="brandingStore.value?.socials?.youtubeUrl"
:href="brandingStore.value?.socials?.youtubeUrl"
:title="t('youtube')"
target="_blank"
>
<youtube class="social-icon"></youtube>
</a>
<a
v-if="brandingStore.value?.socials?.websiteUrl"
:href="brandingStore.value?.socials?.websiteUrl"
:title="t('website')"
target="_blank"
>
<web class="social-icon"></web>
</a>
</div>
</div>
</div>
</template>
<style scoped>
.social-icon {
@apply w-5 h-5;
@apply text-base;
@apply transform transition-transform duration-200;
@apply hover:scale-125 hover:text-fuchsia-900;
}
.social-info {
@apply flex flex-row;
@apply py-4 w-full;
@apply justify-center;
@apply max-h-52;
}
</style>
<i18n>
{
"en": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Website"
},
"fr": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Site web"
}
}
</i18n>

View File

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

View File

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

View File

@@ -1,69 +0,0 @@
<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"
:on-cancelled-url="baseURL + '/paymentfailed/' + 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';
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
</script>
<style scoped>
.creator-home {
@apply w-full;
@apply p-5;
}
.content-sections {
@apply flex flex-col;
@apply gap-5;
}
.section {
@apply rounded-2xl;
@apply shadow-2xl;
@apply relative;
}
.section::before {
content: '';
@apply absolute inset-0;
@apply rounded-2xl;
@apply p-[1px];
background: linear-gradient(
135deg,
rgba(64, 64, 64, 1) 0%,
rgba(64, 64, 64, 0) 20%,
rgba(64, 64, 64, 0.5) 100%
);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
</style>

View File

@@ -1,146 +0,0 @@
<script async setup>
import { useBrandingStore } from '@/stores/brandingStore.js';
import { onMounted } from 'vue';
import Footer from '@/views/main/Footer.vue';
import { useI18n } from 'vue-i18n';
import ActualBanner from '@/views/creators/ActualBanner.vue';
import BannerActions from '@/views/creators/BannerActions.vue';
import { useRouter } from 'vue-router';
const brandingStore = useBrandingStore();
const creatorName = window.location.pathname.split('/@')[1]?.split('/')[0] || '';
const { t } = useI18n();
const router = useRouter();
onMounted(async () => {
await brandingStore.updateBrand(creatorName);
});
const goHome = () => {
router.push('/landing');
};
const goBack = () => {
router.go(-1);
};
</script>
<template>
<div class="min-h-screen max-w-[1024px] bg-hSurface text-hOnSurface">
<div v-if="brandingStore.loading">
<v-progress-linear indeterminate></v-progress-linear>
</div>
<!-- 404 Error State -->
<div
v-else-if="brandingStore.notFound"
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<div class="text-center max-w-md">
<div class="text-6xl font-bold text-gray-400 mb-4">404</div>
<h1 class="text-2xl font-semibold mb-4">{{ t('creator.notFound.title') }}</h1>
<p class="text-gray-600 mb-8">{{ t('creator.notFound.message', { creator: creatorName }) }}</p>
<div class="space-y-4">
<v-btn
class="w-full"
color="primary"
size="large"
@click="goHome"
>
{{ t('creator.notFound.goHome') }}
</v-btn>
<v-btn
class="w-full"
size="large"
variant="outlined"
@click="goBack"
>
{{ t('creator.notFound.goBack') }}
</v-btn>
</div>
</div>
</div>
<!-- Error State (non-404 errors) -->
<div
v-else-if="brandingStore.error && !brandingStore.notFound"
class="flex flex-col items-center justify-center min-h-screen p-8"
>
<div class="text-center max-w-md">
<div class="text-4xl font-bold text-red-400 mb-4"></div>
<h1 class="text-2xl font-semibold mb-4">{{ t('creator.error.title') }}</h1>
<p class="text-gray-600 mb-8">{{ t('creator.error.message') }}</p>
<v-btn
class="w-full"
color="primary"
size="large"
@click="goHome"
>
{{ t('creator.error.goHome') }}
</v-btn>
</div>
</div>
<!-- Success State -->
<div v-else>
<div
v-if="brandingStore.value.isDeleted"
class="bg-red-500 p-2 m-4 text-center font-semibold"
>
{{ t('creator.layout.deletion.pending') }}
</div>
<actual-banner></actual-banner>
<banner-actions></banner-actions>
<router-view></router-view>
<Footer></Footer>
</div>
</div>
</template>
<i18n>
{
"en": {
"creator": {
"layout": {
"deletion": {
"pending": "This creator page is pending deletion"
}
},
"notFound": {
"title": "Creator Not Found",
"message": "The creator '{creator}' doesn't exist or may have been removed.",
"goHome": "Go to Home",
"goBack": "Go Back"
},
"error": {
"title": "Something Went Wrong",
"message": "We're having trouble loading this creator page. Please try again later.",
"goHome": "Go to Home"
}
}
},
"fr": {
"creator": {
"layout": {
"deletion": {
"pending": "Cette page créateur est en attente de suppression"
}
},
"notFound": {
"title": "Créateur Introuvable",
"message": "Le créateur '{creator}' n'existe pas ou a peut-être été supprimé.",
"goHome": "Aller à l'accueil",
"goBack": "Retour"
},
"error": {
"title": "Quelque chose s'est mal passé",
"message": "Nous avons des difficultés à charger cette page créateur. Veuillez réessayer plus tard.",
"goHome": "Aller à l'accueil"
}
}
}
}
</i18n>

View File

@@ -1,92 +0,0 @@
<template>
<div
class="relative"
@click="isCurrentCreator && openBannerEditor()"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
>
<div class="size-[110px] rounded-full border-4 border-hPrimary">
<img
:alt="t('logoAlt')"
:src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'"
class="rounded-full"
height="110px"
width="110px"
/>
</div>
<!-- Tint Effect -->
<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
:icon="mdiPencil"
large
/>
</div>
</div>
</div>
<v-dialog
v-model="isDialogOpen"
max-width="800px"
>
<template #default="{ close }">
<creator-logo-editor
:creator="brandingStore?.value"
@closeRequested="() => (isDialogOpen = false)"
></creator-logo-editor>
</template>
</v-dialog>
</template>
<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 { mdiPencil } from '@mdi/js';
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
const { t } = useI18n();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>
<style scoped>
.logo-image {
@apply border-4 border-solid border-hTertiary;
}
</style>
<i18n>
{
"en": {
"logoAlt": "Creator logo",
"editLogo": "Edit logo"
},
"fr": {
"logoAlt": "Logo du créateur",
"editLogo": "Modifier le logo"
}
}
</i18n>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,223 +0,0 @@
<script setup>
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,
},
creatorNameReservationId: {
required: true,
},
originalSlug: {
type: String,
default: null,
},
});
const emits = defineEmits(['update:name', 'update:creatorNameReservationId']);
const name = ref(props.name);
const { t } = useI18n();
const isOperationPending = ref(false);
const reservationState = ref(null);
const validationError = ref('');
// Use the reservationId from props if provided, otherwise generate a new one
const reservationId = ref(props.creatorNameReservationId || v7());
// Check if the current name is the same as the original slug
const isCurrentSlug = computed(() => {
return props.originalSlug && name.value === props.originalSlug;
});
// Base URL for display
const baseUrl = computed(() => `${config.baseUrl}/@`);
// Validation function for the slug
const validateSlug = slug => {
if (!slug) {
validationError.value = t('creator.name.errors.required');
return false;
}
// Only allow letters, numbers, and hyphens
const validSlugRegex = /^[a-zA-Z0-9-]+$/;
if (!validSlugRegex.test(slug)) {
validationError.value = t('creator.name.errors.invalid');
return false;
}
validationError.value = '';
return true;
};
// Ensure we emit the reservationId on mount if we generated a new one
onMounted(() => {
if (!props.creatorNameReservationId) {
emits('update:creatorNameReservationId', reservationId.value);
}
// If the name is the same as the original slug, set the reservation state to "reserved"
if (isCurrentSlug.value) {
reservationState.value = 'reserved';
}
});
// Request handling
let currentController = null;
let timeout = null;
let lastProcessedName = '';
const cancelCurrentRequest = () => {
if (currentController) {
currentController.abort();
currentController = null;
}
};
const handleInput = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const currentName = name.value;
if (currentName === lastProcessedName) {
return; // Skip if we've already processed this exact name
}
// Validate the slug
if (!validateSlug(currentName)) {
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';
lastProcessedName = currentName;
emits('update:name', currentName);
return;
}
checkNameAvailability(currentName);
}, 200);
};
const client = useClient();
const checkNameAvailability = async nameToCheck => {
if (!nameToCheck || nameToCheck.trim() === '') {
reservationState.value = null;
lastProcessedName = nameToCheck;
return;
}
// Cancel any ongoing request
cancelCurrentRequest();
try {
isOperationPending.value = true;
reservationState.value = 'loading';
// Create a new request with cancellation token
const controller = new AbortController();
currentController = controller;
await client.post(
`/api/creators/@${encodeURIComponent(nameToCheck)}/reserve`,
{ reservationId: reservationId.value },
{ signal: controller.signal }
);
// Only process the response if this is still the current request
if (currentController === controller) {
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';
lastProcessedName = nameToCheck;
}
} finally {
if (currentController) {
isOperationPending.value = false;
}
}
};
// Cleanup on component unmount
onUnmounted(() => {
cancelCurrentRequest();
clearTimeout(timeout);
});
</script>
<template>
<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'"
color="grey"
indeterminate
size="24"
width="3"
></v-progress-circular>
<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>
<style scoped></style>
<i18n>
{
"en": {
"creator": {
"name": {
"label": "Your creator handle",
"errors": {
"required": "Creator handle is required",
"invalid": "Only letters, numbers, and hyphens are allowed"
}
}
}
},
"fr": {
"creator": {
"name": {
"label": "Votre identifiant de créateur",
"errors": {
"required": "L'identifiant est requis",
"invalid": "Seules les lettres, chiffres et tirets sont autorisés"
}
}
}
}
}
</i18n>

View File

@@ -1,39 +0,0 @@
<template>
<div class="flex flex-col text-hOnPrimary">
<div class="flex items-center gap-2">
<span class="capitalize text-3xl">
{{ brandingStore.value.name }}
</span>
<div
v-show="brandingStore.value.verified"
:title="t('verified')"
class="text-blue mt-1"
>
<icon-account-verified></icon-account-verified>
</div>
</div>
<span class="capitalize text-lg">
{{ brandingStore.value.title }}
</span>
</div>
</template>
<script setup>
import IconAccountVerified from '@/components/icons/IconAccountVerified.vue';
import { useBrandingStore } from '@/stores/brandingStore.js';
import { useI18n } from 'vue-i18n';
const brandingStore = useBrandingStore();
const { t } = useI18n();
</script>
<i18n>
{
"en": {
"verified": "Verified Account"
},
"fr": {
"verified": "Compte vérifié"
}
}
</i18n>

View File

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

View File

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

View File

@@ -1,194 +0,0 @@
<template>
<h1>À propos</h1>
<p>
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 créant 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>
<p>
Hutopy a été fondée en 2024, née de l'idée simple mais puissante que chaque créateur qu'il
soit grand ou petit, novice ou expérimenté, devrait avoir accès aux outils et au soutien
nécessaires pour partager sa passion avec le monde.
</p>
<h2>Notre Mission</h2>
<p>
Notre mission est de démocratiser la création de contenu numérique, en offrant une
plateforme accessible, intuitive et puissante qui permet aux créateurs de tout horizon de
s'exprimer, d'innover et de connecter avec une audience mondiale. Nous nous engageons à
fournir les outils, les ressources et le soutien nécessaires pour que chaque voix puisse
être entendue.
</p>
<h2>
Notre Vision
</h2>
<p>
Nous envisageons un monde la barrière entre les créateurs et leur audience est réduite au
minimum, les idées, l'expertise et les histoires peuvent circuler librement et sans
entrave. Hutopy aspire à être au cœur de cet écosystème créatif et professionnel, en étant
une source d'inspiration, une plateforme de lancement et un foyer pour tous.
</p>
<h2>
Notre Équipe
</h2>
<p>
Derrière Hutopy, il y a une équipe de penseurs innovants, de créatifs passionnés et de
technologues dévoués, tous unis par le désir de soutenir la communauté des créateurs de
contenu. Notre équipe est notre plus grande force, chaque membre apportant une expertise
unique et une perspective fraîche à notre mission commune.
</p>
<div class="members">
<div class="card">
<div class="card-header">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profilePascal.png"
alt="Pascal Marchesseault">
</div>
<div class="card-body">
<div class="member-name">Pascal Marchesseault</div>
<div class="member-title">Président-directeur général</div>
<div class="member-description">
<p>
Avec une vision claire et un engagement sans faille, il a toujours été présent 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>
</div>
<div class="card">
<div class="card-header">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileChloe.png"
alt="Chloé Beaugrand">
</div>
<div class="card-body">
<div class="member-name">Chloé Beaugrand</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
innovantes et impactantes.
</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/Jonathan.png"
alt="Édouard Letarte">
</div>
<div class="card-body">
<div class="member-name">Jonathan Bourdon</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
une durabilité qui nous permettra de nous développer pendant de longues années.
</p>
</div>
</div>
</div>
</div>
<p>
Chez Hutopy, nous sommes plus qu'une plateforme ; nous sommes une famille dédiée à la
réussite de nos créateurs. Nous vous invitons à nous joindre dans cette aventure
passionnante, à partager votre créativité et votre expertise avec le monde et à faire
dHutopy votre utopie. Merci de faire partie de notre histoire.
</p>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
.members {
@apply mb-12;
@apply flex flex-wrap;
@apply w-full justify-center;
}
.card {
@apply flex flex-col;
@apply bg-hPrimary rounded-2xl text-hOnPrimary;
@apply font-sans text-base;
@apply p-2;
@apply w-72;
}
.card-header {
@apply rounded-xl;
}
.card-body {
@apply w-full
}
.member-profile-picture {
@apply rounded-t-xl;
}
.member-name {
@apply font-bold text-2xl;
@apply mb-2;
}
.member-title {
@apply font-semibold text-lg;
@apply mb-4;
}
.member-description {
@apply font-sans text-lg text-justify;
}
</style>
<script setup lang="ts">
</script>

View File

@@ -1,275 +0,0 @@
<template>
<div>
<h1>Politique de Contenu</h1>
<h2>Introduction</h2>
<p>
Hutopy vise à offrir une plateforme sécurisée, inclusive et respectueuse les créateurs peuvent partager leur
travail et interagir avec une communauté engagée. Pour maintenir cet environnement, nous avons établi des lignes
directrices claires concernant le type de contenu autorisé sur notre plateforme. En utilisant Hutopy, vous
acceptez
de respecter cette politique de contenu.
</p>
<h2>Contenu Autorisé</h2>
<ul>
<li>
<p>
Hutopy encourage la publication de contenu créatif, éducatif et inspirant dans divers formats, y compris :
</p>
</li>
<li>
<p>
<b>Arts visuels et design</b>
Illustrations, photographies, designs graphiques respectant le droit d'auteur.
</p>
</li>
<li>
<p>
<b>Éducation et apprentissage</b>
Tutoriels, cours en ligne, webinaires qui favorisent l'apprentissage et le développement personnel.
</p>
</li>
<li>
<p>
<b>Contenu écrit</b>
Articles, blogs, poésies qui enrichissent les discussions et partagent des connaissances.
</p>
</li>
<li>
<p>
<b>Multimédia</b>
Vidéos, podcasts et musique originales qui respectent les droits d'auteur et encouragent l'expression
créative.
</p>
</li>
</ul>
<h2>Contenu Interdit</h2>
<ul>
<p>
Pour protéger notre communauté, certains types de contenu ne sont pas autorisés sur Hutopy, incluant mais non
limité à :
</p>
<li>
<p>
<b>Contenu illégal</b>
Tout contenu promouvant des activités illégales ou fournissant des instructions pour
commettre des actes illégaux.
</p>
</li>
<li>
<p>
<b>Harcèlement et discours de haine</b>
Contenu visant à harceler, menacer, ou promouvoir la haine contre des individus ou des groupes basés sur la
race, l'ethnie, la religion, le genre, l'orientation sexuelle, l'identité degenre ou tout autre caractère
distinctif.
</p>
</li>
<li>
<p>
<b>Contenu pour adultes</b>
Matériel pornographique ou explicitement sexuel.
</p>
</li>
<li>
<p>
<b>Violence et contenu graphique</b>
Images ou descriptions de violence excessive, gore ou choquantes.
</p>
</li>
<li>
<p>
<b>Publicité mensongère et spam</b>
Contenu trompeur, frauduleux ou spammy.
</p>
</li>
</ul>
<h2>Droits d'Auteur et Propriété Intellectuelle</h2>
<p>
Respect des Droits : Vous devez posséder les droits sur le contenu que vous publiez sur Hutopy ou avoir
l'autorisation expresse du détenteur des droits pour utiliser ce contenu.
</p>
<p>
Attribution : Lorsque vous utilisez ou adaptez le contenu protégé par des droits d'auteur appartenant à autrui,
une
attribution claire et correcte doit être fournie.
</p>
<h2>Modération et Signalement</h2>
<p>
<b>Modération</b>
Hutopy utilise à la fois des modérateurs humains et des outils automatisés pour surveiller et évaluer le contenu
publié, garantissant le respect de cette politique.
</p>
<p>
<b>Signalement</b>
Les utilisateurs de Hutopy sont encouragés à signaler tout contenu qu'ils considèrent comme enfreignant notre
politique de contenu via les outils de signalement disponibles sur la plateforme.
</p>
<h2>Conséquences des Violations</h2>
<p>
La violation de notre politique de contenu peut entraîner des actions allant de l'avertissement à la suppression
du contenu ou à la suspension, voire à la résiliation du compte utilisateur.
</p>
<h2>Révisions de la Politique</h2>
<p>
Hutopy se réserve le droit de modifier cette politique de contenu à tout moment pour refléter les changements dans
nos pratiques ou pour se conformer à de nouvelles réglementations légales.
</p>
<h2>Dans le cas dune non conformité aux politiques de contenus</h2>
<ul>
<li>
<p>
<b>Suspension des Fonds</b>
Les montants accumulés sur le compte de l'utilisateur en question seront suspendus temporairement le temps de
l'évaluation.
</p>
</li>
<li>
<p>
<b>Redistribution à des Œuvres de Charité</b>
Si, après évaluation, le contenu est définitivement jugé non conforme à nos clauses de conformité, les fonds
suspendus seront redistribués à des œuvres de charité choisies par Hutopy. L'utilisateur concerné sera informé
de cette décision et des raisons de la non-conformité de son contenu.
</p>
</li>
<li>
<p>
Cette mesure vise à renforcer la responsabilité des créateurs quant au type de contenu partagé sur Hutopy,
tout
en soutenant des causes bénéfiques en cas de violation de nos directives.
</p>
</li>
</ul>
<h2>Engagement dHutopy</h2>
<p>
Hutopy s'engage fermement à maintenir une plateforme sûre et respectueuse pour tous ses utilisateurs. Nous
prenons
une position intransigeante contre toute forme d'exploitation humaine et nous travaillons activement pour
prévenir, identifier et combattre les comportements et contenus exploitants. Notre mission est de créer un
environnement où la créativité et l'expression personnelle peuvent s'épanouir sans crainte d'exploitation ou
d'abus.
</p>
<h2>Politique de Tolérance Zéro</h2>
<p>
Nous appliquons une politique de tolérance zéro à l'égard de :
</p>
<ul>
<li>Exploitation sexuelle : Cela inclut, mais n'est pas limité à, la pornographie infantile, le trafic sexuel,
et
le harcèlement sexuel.
</li>
<li>
Travail forcé : Nous nous opposons à toute forme de travail forcé ou de servitude, y compris le travail des
enfants.
</li>
<li>
Exploitation financière : Cela comprend les arnaques, la fraude et tout autre type d'exploitation financière.
</li>
</ul>
<h2>Politique de Tolérance Zéro et Signalement/Actions</h2>
<ul>
<li>Mécanismes de Signalement : Hutopy fournit des outils faciles à utiliser pour signaler rapidement tout
contenu
ou comportement suspect d'exploitation. Nous encourageons vivement les utilisateurs à utiliser ces outils
s'ils
rencontrent ou soupçonnent des cas d'exploitation.
</li>
<li>Réponse Rapide : Notre équipe dédiée examine tous les signalements avec la plus grande attention et prend
des
mesures immédiates pour adresser les problèmes signalés. Cela peut inclure la suppression de contenu, la
suspension de comptes, et, si nécessaire, le signalement aux autorités compétentes.
</li>
</ul>
<h2>Collaboration avec les Autorités</h2>
<p>
Nous collaborons étroitement avec les autorités et les organisations spécialisées pour combattre l'exploitation
sous
toutes ses formes. Hutopy est déterminé à respecter toutes les lois applicables et à coopérer avec les autorités
dans leurs efforts de lutte contre l'exploitation et l'abus.
</p>
<h2>Engagements des Utilisateurs</h2>
<p>
En rejoignant Hutopy, les utilisateurs s'engagent à respecter nos principes anti-exploitation et à contribuer à
la
création d'un espace sûr pour tous. Tout manquement à ces engagements entraînera des conséquences sérieuses,
conformément à notre politique de tolérance zéro.
</p>
<h2>Modération et Signalement</h2>
<ul>
<li>Modération : Hutopy utilise à la fois des modérateurs humains et des outils automatisés pour surveiller et
évaluer le contenu publié, garantissant le respect de cette politique.
</li>
<li>Signalement : Les utilisateurs dHutopy sont encouragés à signaler tout contenu qu'ils considèrent comme
enfreignant notre politique de contenu via les outils de signalement disponibles sur la plateforme.
</li>
</ul>
<h2>Conséquences des Violations</h2>
<p>
La violation de notre politique de contenu peut entraîner des actions allant de l'avertissement à la suppression
du
contenu ou à la suspension, voire à la résiliation du compte utilisateur.
</p>
<h2>Révisions de la Politique</h2>
<p>
Hutopy se réserve le droit de modifier cette politique de contenu à tout moment pour refléter les changements
dans
nos pratiques ou pour se conformer à de nouvelles réglementations légales.
</p>
<h2>Contact</h2>
<p>
Si vous avez des questions sur cette politique de contenu ou sur la manière dont nous l'appliquons, veuillez
contacter notre équipe d'assistance à <a href="mailto:support@hutopy.com">support@hutopy.com</a>.
</p>
</div>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
</style>
<script setup lang="ts">
</script>

View File

@@ -1,72 +0,0 @@
<template>
<h1>Guide pour les Créateurs</h1>
<h2>Bienvenue dans la Communauté de Créateurs dHutopy</h2>
<p>
Félicitations pour avoir choisi Hutopy pour partager votre créativité et votre savoir ! Ce guide est conçu pour
vous aider à maximiser votre présence sur la plateforme, à engager votre audience et à tirer le meilleur parti des
outils à votre disposition.
</p>
<h2>Création de Votre Profil de Créateur</h2>
<p>
<b>Personnalisez Votre Profil</b>
Ajoutez une photo de profil, une bannière et une bio qui reflète votre personnalité et
votre marque de créateur.
</p>
<p>
<b>Liens et Contacts</b>
Intégrez des liens vers vos autres plateformes sociales.
</p>
<h2>Publication de Contenu</h2>
<p>
<b>Diversifiez Votre Contenu</b>
Explorez différents formats vidéos, articles, podcasts pour captiver divers segments d'audience.
</p>
<p>
<b>Planification et Consistance</b>
Publiez régulièrement pour garder votre audience engagée. Utilisez l'outil de planification dHutopy pour organiser
vos publications à l'avance.
</p>
<h2>Engagement avec Votre Audience</h2>
<p>
<b>Interagissez</b>
Répondez aux commentaires, participez à des discussions et créez des sondages pour encourager l'interaction.
</p>
<p>
<b>Analysez Vos Performances</b>
Utilisez les outils d'analyse dHutopy pour comprendre ce qui résonne avec votre audience et ajustez votre
stratégie en conséquence.
</p>
<h2>Monétisation</h2>
<p>
<b>Explorez les Options</b>
Hutopy offre plusieurs voies de monétisation, y compris les abonnements payants, les dons et le programme
d'ambassadeur. Choisissez ce qui convient le mieux à votre contenu et à votre audience.
</p>
<h2>Croissance et Développement</h2>
<p>
<b>Continuez à Apprendre</b>
Utilisez le Centre de Ressources Éducatives dHutopy pour améliorer vos compétences et rester à jour sur
les tendances du secteur. (À venir)
</p>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
</style>

View File

@@ -1,12 +0,0 @@
<script setup>
import Footer from "@/views/main/Footer.vue";
</script>
<template>
<div class="min-h-screen w-full max-w-[1024px] mx-auto bg-hSurface text-hOnSurface p-6">
<router-view></router-view>
<div>
<Footer></Footer>
</div>
</div>
</template>

View File

@@ -1,84 +0,0 @@
<template>
<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
fréquemment posées sur notre plateforme. Explorez nos réponses détaillées pour optimiser votre utilisation de
Hutopy et résoudre vos problèmes en un instant. Consultez régulièrement notre FAQ pour rester informé des
dernières fonctionnalités.
</p>
<h2>Comment puis-je créer un compte sur Hutopy?</h2>
<p>
Créer un compte est simple! Visitez notre page d'inscription, remplissez les informations requises, et suivez les
instructions pour confirmer votre adresse e-mail ou vous connecter via les partenaires de connexion. Vous pourrez
commencer à explorer et à interagir avec la communauté Hutopy immédiatement après.
</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 des
dons de la part des utilisateurs.
</p>
<h2>Comment puis-je modifier mon profil?</h2>
<p>
Connectez-vous à votre compte, accédez à votre profil, puis cliquez sur "Éditer le profil" pour modifier vos
informations, ajouter une bio, changer votre photo de profil, et plus encore.
</p>
<h2>Est-il possible de supprimer mon compte?</h2>
<p>
Oui, vous pouvez faire la suppression de votre compte sur votre profil dans la section plus. Notez que cette
action est irréversible.
</p>
<h2>Que faire si j'oublie mon mot de passe?</h2>
<p>
Sur la page de connexion, cliquez sur "Mot de passe oublié ?" et suivez les instructions pour réinitialiser votre
mot de passe via votre adresse courriel.
</p>
<h2>Comment signaler un contenu inapproprié?</h2>
<p>
Si vous rencontrez du contenu qui viole nos directives, cliquer sur les trois petits points en haut de la
publication et cliquez sur le bouton "Signaler" associé au contenu en question pour alerter notre équipe de
modération.
</p>
<h2>Comment puis-je contacter le support Hutopy?</h2>
<p>
Pour toute assistance, vous pouvez nous contacter via notre formulaire en ligne ou par e-mail à
support@hutopy.com, ou via nos réseaux sociaux. Notre équipe s'efforce de répondre rapidement à toutes les
demandes.
</p>
<h2>Quels sont les frais pour les créateurs sur Hutopy?</h2>
<p>
Hutopy prélève une commission de 12% + 0,30$ sur chaque transaction réalisée sur la plateforme, que ce soit pour
les abonnements, les dons ou tout autre revenu généré par les créateurs. Cette commission nous aide à couvrir les
coûts de maintenance de la plateforme, de la bande passante, d'assistance utilisateur, des frais de transaction de
Stripe et le développement continu pour améliorer votre expérience sur Hutopy.
</p>
<h2>Y a-t-il des frais pour s'inscrire ou pour maintenir mon compte sur Hutopy?</h2>
<p>
Non, l'inscription sur Hutopy est gratuite, et il n'y a pas de frais mensuels ou annuels pour maintenir votre
compte. Vous pouvez commencer à utiliser Hutopy et à partager votre contenu sans aucun coût initial.
</p>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
</style>

View File

@@ -1,57 +0,0 @@
<template>
<h1>Aide et contact</h1>
<p>
Bienvenue dans notre espace d'assistance ! Que vous soyez un créateur à la recherche de conseils pour optimiser
votre présence sur Hutopy, ou un utilisateur curieux d'en apprendre plus sur notre plateforme, vous êtes au bon
endroit. Notre objectif est de vous fournir tout le soutien nécessaire pour que votre expérience sur Hutopy soit
aussi enrichissante et agréable que possible.
</p>
<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 <a href="FAQ" style="color: #a30e79;">FAQ</a>
</p>
<h2>Contactez-Nous</h2>
<p>
Nous sommes toujours ravis d'entendre nos utilisateurs ! Que ce soit pour partager vos retours, poser une question
spécifique, ou demander des renseignements sur des partenariats, n'hésitez pas à nous contacter.
</p>
<ul>
<li>
Par E-mail : <a href="mailto:info@hutopy.com" style="color: #a30e79;">
info@hutopy.com</a>
</li>
<li>
Réseaux Sociaux : Nous sommes actifs sur
<a href="https://www.facebook.com/Hutopy" style="color: #a30e79;">Facebook</a>
et <a href="https://www.instagram.com/hutopy.inc" style="color: #a30e79;">Instagram</a>
</li>
<li>Suivez-nous pour rester informé et interagir avec notre communauté.</li>
</ul>
<h2>Assistance Technique</h2>
<p>
Rencontrez-vous un problème technique ?
Notre équipe d'assistance est pour vous aider : <a href="mailto:support@hutopy.com" style="color: #a30e79;">support@hutopy.com</a>
</p>
<p>
Nous sommes pour rendre votre expérience sur Hutopy aussi fluide et positive que possible. N'hésitez pas à nous
contacter pour toute aide supplémentaire !
</p>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
</style>

View File

@@ -1,39 +0,0 @@
<template>
<h1>Frais</h1>
<p>
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>
Pour chaque transaction, un frais minime assure la sécurité et la fiabilité des paiements, grâce à un partenaire de
confiance mondialement reconnu. Ce dernier sécurise des milliards en transactions chaque année pour une diversité
d'entreprises, à des entreprises en démarrage aux conglomérats établis. Ce gage de sécurité est disponible pour une
somme de 2,9 % plus un 0,30 $ par transaction, une petite contribution pour la tranquillité d'esprit et la
protection de vos revenus.
</p>
<p>
Notre modèle tarifaire, pensé pour la simplicité et la transparence, a pour ambition ultime d'optimiser vos gains.
Chez Hutopy, la notion de partenariat prend tout son sens : votre épanouissement est au cœur de nos préoccupations.
Bénéficiez d'une plateforme qui élargit votre horizon créatif et entrepreneurial, tout en vous assurant que vos
intérêts et ceux de vos donnateurs sont précieusement gardés.
</p>
<p>
Hutopy est plus qu'une plateforme ; c'est une communauté la transformation de la passion en profit devient
réalité, grâce au soutien indéfectible d'une équipe dévouée à enrichir votre parcours. Nous vous invitons à nous
rejoindre pour explorer ensemble les avenues de succès, tout en vous garantissant une part conséquente de vos
revenus. Embarquez dans une aventure où votre présence en ligne ne connaît pas de limites, soutenue par Hutopy,
votre allié dans la quête du succès.
</p>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
</style>

View File

@@ -1,105 +0,0 @@
<template>
<h1>Conditions générales</h1>
<h2>Bienvenue sur Hutopy</h2>
<p>
En accédant à la plateforme Hutopy et en l'utilisant, vous acceptez de vous conformer aux conditions générales
d'utilisation suivantes, qui sont conçues pour assurer une expérience sûre, respectueuse et positive pour tous les
utilisateurs. Ces conditions s'appliquent à tous les visiteurs, utilisateurs et autres personnes qui accèdent ou
utilisent le service.
</p>
<h2>Utilisation Acceptable</h2>
<p>
1. Contenu : Vous vous engagez à ne pas publier de contenu illégal, diffamatoire, abusif, pornographique, haineux,
raciste ou de toute autre nature susceptible de causer du tort. Tout contenu publié reste sous votre responsabilité.
</p>
<p>
2. Comportement : Tout comportement visant à nuire à d'autres utilisateurs, à la plateforme ou à ses opérations est
strictement interdit. Cela inclut le piratage, la diffusion de logiciels malveillants et les tentatives
d'hameçonnage.
</p>
<h2>Droits de Propriété Intellectuelle</h2>
<p>
Le contenu publié sur Hutopy par les utilisateurs reste la propriété de leurs créateurs respectifs. En publiant du
contenu sur Hutopy, vous accordez à la plateforme une licence non exclusive, transférable, libre de droits et
mondiale pour utiliser, reproduire, modifier, publier, traduire et distribuer ce contenu dans tout média.
</p>
<h2>Confidentialité</h2>
<p>
La protection de vos données personnelles est de la plus haute importance pour Hutopy. Votre information est
collectée et utilisée conformément à notre politique de confidentialité.
</p>
<h2>Limitation de Responsabilité</h2>
<p>
Hutopy et ses affiliés ne seront pas responsables des dommages indirects, accidentels, spéciaux, consécutifs ou
punitifs, y compris sans limitation, la perte de profits, de données ou d'usage, que ce soit dans une action
contractuelle, délictuelle y compris la négligence ou autre, découlant de ou en relation avec l'accès ou
l'utilisation de la plateforme Hutopy.
</p>
<h2>Clause de Non-Poursuite</h2>
<p>
En acceptant ces conditions générales d'utilisation, vous convenez qu'en aucun cas Hutopy, ses dirigeants, employés,
partenaires, agents, fournisseurs ou affiliés ne pourront être tenus responsables de dommages directs, indirects,
accidentels, spéciaux, consécutifs ou exemplaires résultant de votre utilisation de la plateforme Hutopy. Par
conséquent, vous renoncez expressément à tout droit de poursuivre Hutopy et ses affiliés pour toute réclamation liée
à votre utilisation de la plateforme.
</p>
<h2>Gestion du Contenu Inapproprié et Sanctions Financières</h2>
<p>
Hutopy s'engage à maintenir un environnement sûr et respectueux pour tous ses utilisateurs. Ainsi, tout contenu
publié sur la plateforme est sujet à une évaluation de conformité avec nos directives et nos standards éthiques.
Dans l'éventualité le contenu d'un utilisateur est jugé inapproprié, offensant ou en violation avec nos
Acceptation des Conditions
</p>
<p>
Votre accès et votre utilisation continue de la plateforme Hutopy constituent votre acceptation des présentes
conditions générales et de toutes les modifications futures. Il est de votre responsabilité de vous tenir informé
des mises à jour de ces conditions.
</p>
<p>
Nous vous encourageons à utiliser Hutopy de manière responsable et conforme à nos directives, afin de contribuer à
une communauté positive et enrichissante pour tous.
</p>
<h2>Modifications des Conditions</h2>
<p>
Hutopy se réserve le droit de modifier ou de remplacer ces conditions à tout moment. Il est de votre responsabilité
de revoir régulièrement ces conditions pour vous tenir informé des mises à jour.
</p>
<h2>Résiliation</h2>
<p>
Hutopy peut résilier ou suspendre votre accès à la plateforme immédiatement, sans préavis ni responsabilité, pour
quelque raison que ce soit, y compris, sans limitation, si vous violez les conditions.
</p>
<h2>Loi Applicable</h2>
<p>
Ces conditions seront régies et interprétées conformément aux lois du pays/juridiction où est basée la plateforme,
sans égard à ses conflits de dispositions légales.
</p>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
</style>

View File

@@ -1,25 +0,0 @@
h1 {
@apply text-hOnBackground;
@apply flex items-center uppercase;
@apply font-sans font-bold text-4xl md:text-8xl;
@apply tracking-widest;
@apply mb-12;
}
h2 {
@apply text-hOnBackground;
@apply font-sans font-bold text-2xl md:text-4xl;
@apply mb-6;
}
p {
@apply text-hOnBackground;
@apply font-sans font-normal text-lg;
@apply tracking-normal;
@apply mb-6;
@apply text-justify;
}
ul {
@apply mb-6;
}

View File

@@ -0,0 +1,356 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import {
mdiChevronDown,
mdiCogOutline,
mdiLogin,
mdiPlus,
} from '@mdi/js';
const props = defineProps({
showBrand: {
type: Boolean,
default: true,
},
collapseBrand: {
type: Boolean,
default: false,
},
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
const authStore = useAuthStore();
const isWorkspaceMenuOpen = ref(false);
const workspaceMenuRef = ref(null);
const canSwitchWorkspaces = computed(() => workspaceStore.workspaces.length > 1);
const canManageWorkspaces = computed(() => authStore.isManager);
const canOpenWorkspaceMenu = computed(() => canSwitchWorkspaces.value || canManageWorkspaces.value);
const activeWorkspaceName = computed(() =>
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
);
const appBarActions = computed(() => {
if (!authStore.isAuthenticated) {
return [];
}
switch (route.name) {
case 'workspace-dashboard':
case 'content-items':
return authStore.isManager || authStore.isProvider
? [{
key: 'create-content',
label: t('contentItems.newItem'),
icon: mdiPlus,
route: { name: 'content-item-create' },
}]
: [];
case 'campaigns':
return [{
key: 'create-campaign',
label: t('projects.newProject'),
icon: mdiPlus,
route: { name: 'campaigns', query: { create: 'true' } },
}];
case 'channels':
return [{
key: 'create-channel',
label: t('channels.createTitle'),
icon: mdiPlus,
route: { name: 'channels', query: { create: 'true' } },
}];
case 'workspace-settings':
case 'settings-user-information':
case 'settings-workspaces':
case 'settings-integrations':
return [{
key: 'settings',
label: t('nav.settings'),
icon: mdiCogOutline,
route: { name: 'settings-user-information' },
}];
default:
return [];
}
});
function toggleWorkspaceMenu() {
if (!canOpenWorkspaceMenu.value) {
return;
}
isWorkspaceMenuOpen.value = !isWorkspaceMenuOpen.value;
}
function chooseWorkspace(workspaceId) {
workspaceStore.setActiveWorkspace(workspaceId);
isWorkspaceMenuOpen.value = false;
}
async function openCreateWorkspace() {
isWorkspaceMenuOpen.value = false;
await router.push({ name: 'workspace-create' });
}
function handleDocumentClick(event) {
if (workspaceMenuRef.value && !workspaceMenuRef.value.contains(event.target)) {
isWorkspaceMenuOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
});
</script>
<template>
<nav class="side-container">
<div class="brand-block">
<router-link
v-if="showBrand"
class="brand-link"
:class="{ 'brand-link-collapsed': collapseBrand }"
to="/"
>
<span class="brand-mark">S</span>
<div v-if="!collapseBrand">
<div class="brand-name">Socialize</div>
<div class="brand-caption">{{ t('nav.brandCaption') }}</div>
</div>
</router-link>
</div>
<div class="side-menu">
<div class="side-menu-items side-menu-left">
<div
v-if="authStore.isAuthenticated"
ref="workspaceMenuRef"
class="user-menu-wrap"
>
<button
class="menu-item-action workspace-trigger"
:class="{ 'workspace-trigger-static': !canOpenWorkspaceMenu }"
@click.stop="toggleWorkspaceMenu"
>
<span class="workspace-trigger-mark">W</span>
<span class="label workspace-trigger-label">{{ activeWorkspaceName }}</span>
<v-icon
v-if="canOpenWorkspaceMenu"
:icon="mdiChevronDown"
class="user-trigger-icon"
:class="{ 'user-trigger-icon-open': isWorkspaceMenuOpen }"
/>
</button>
<div
v-if="isWorkspaceMenuOpen"
class="user-menu"
>
<button
v-for="workspace in workspaceStore.workspaces"
:key="workspace.id"
class="user-menu-item"
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
@click="chooseWorkspace(workspace.id)"
>
<span>{{ workspace.name }}</span>
<small>{{ workspace.timeZone }}</small>
</button>
<button
v-if="canManageWorkspaces"
class="user-menu-item user-menu-item-create"
type="button"
@click="openCreateWorkspace"
>
<span>{{ t('workspaceSelector.createAction') }}</span>
<v-icon :icon="mdiPlus" />
</button>
</div>
</div>
</div>
<div class="side-menu-items side-menu-right">
<template v-if="!authStore.isAuthenticated">
<router-link to="/login">
<button class="menu-item-action">
<v-icon :icon="mdiLogin" />
<span class="label">{{ t('nav.signIn') }}</span>
</button>
</router-link>
</template>
<router-link
v-for="action in appBarActions"
:key="action.key"
:to="action.route"
class="menu-action-link"
>
<button class="menu-item-action">
<v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span>
</button>
</router-link>
</div>
</div>
</nav>
</template>
<style scoped>
.side-container {
@apply sticky top-0 z-10 flex flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between;
background: rgba(255, 250, 242, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
isolation: isolate;
}
.brand-block {
@apply flex items-center gap-3;
}
.brand-link {
@apply flex items-center gap-3 no-underline;
color: inherit;
}
.brand-link-collapsed {
@apply gap-0;
}
.brand-mark {
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
color: #fffaf2;
}
.brand-name {
@apply text-lg font-black uppercase tracking-[0.18em];
color: #172033;
}
.brand-caption {
@apply text-xs uppercase tracking-[0.24em];
color: #5d6b82;
}
.side-menu {
@apply flex flex-1 items-center justify-between gap-3;
}
.side-menu-items {
@apply flex flex-wrap items-center justify-end gap-2;
overflow: visible;
}
.side-menu-left {
@apply justify-start;
}
.side-menu-right {
@apply justify-end;
}
.label {
@apply hidden text-nowrap md:inline;
}
.menu-item-action {
@apply flex h-11 items-center gap-3 rounded-full px-4 transition-colors;
background: rgba(255, 255, 255, 0.8);
color: #172033;
border: 1px solid rgba(23, 32, 51, 0.06);
}
.menu-item-action:hover {
background: #172033;
color: #fffaf2;
}
.menu-item-action i {
@apply text-xl;
}
.user-menu-wrap {
@apply relative;
z-index: 20;
}
.workspace-trigger {
@apply max-w-[18rem] pl-2 pr-3;
}
.user-trigger-icon {
@apply text-base;
}
.user-trigger-icon-open {
transform: rotate(180deg);
}
.workspace-trigger-static {
cursor: default;
}
.workspace-trigger-mark {
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-black uppercase;
background: linear-gradient(135deg, rgba(255, 138, 61, 0.16), rgba(239, 68, 68, 0.14));
color: #c2410c;
}
.workspace-trigger-label {
@apply max-w-[11rem] truncate;
}
.user-menu {
@apply absolute right-0 top-[calc(100%+0.75rem)] flex min-w-[14rem] flex-col gap-1 rounded-[1.25rem] border p-2;
background: rgba(255, 255, 255, 0.96);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
z-index: 40;
}
.user-menu-item {
@apply flex items-center gap-3 rounded-[0.9rem] px-3 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
}
.user-menu-item:hover {
background: rgba(23, 32, 51, 0.06);
}
.user-menu-item-danger {
color: #b91c1c;
}
.user-menu-item-active {
background: rgba(255, 138, 61, 0.12);
color: #c2410c;
}
.user-menu-item small {
@apply ml-auto text-xs font-medium;
color: #526178;
}
.user-menu-item-create {
@apply justify-between border border-dashed;
border-color: rgba(23, 32, 51, 0.12);
}
.menu-action-link {
@apply no-underline;
}
</style>

View File

@@ -0,0 +1,895 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import { useAuthStore } from '@/stores/authStore.js';
import { useChannelsStore } from '@/stores/channelsStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { useNotificationsStore } from '@/stores/notificationsStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import {
mdiBellOutline,
mdiCalendarMonthOutline,
mdiChevronDown,
mdiCogOutline,
mdiFolderOutline,
mdiHomeOutline,
mdiImageMultipleOutline,
mdiLan,
mdiMagnify,
mdiPlus,
} from '@mdi/js';
const props = defineProps({
isExpanded: {
type: Boolean,
default: true,
},
});
const router = useRouter();
const { t } = useI18n();
const route = useRoute();
const authStore = useAuthStore();
const channelsStore = useChannelsStore();
const contentItemsStore = useContentItemsStore();
const languageStore = useLanguageStore();
const notificationsStore = useNotificationsStore();
const projectsStore = useProjectsStore();
const userProfileStore = useUserProfileStore();
const isUserMenuOpen = ref(false);
const isNotificationsOpen = ref(false);
const isSearchFocused = ref(false);
const searchQuery = ref('');
const userMenuRef = ref(null);
const notificationsRef = ref(null);
const searchRef = ref(null);
const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/workspace', labelKey: 'nav.workspacePlan', icon: mdiCalendarMonthOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/workspace-settings', labelKey: 'nav.settings', icon: mdiCogOutline },
];
const openSections = ref({
channels: false,
projects: false,
});
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
const projectResults = computed(() => {
if (!normalizedSearchQuery.value) {
return [];
}
return projectsStore.projects
.filter(project => project.name.toLowerCase().includes(normalizedSearchQuery.value))
.slice(0, 5)
.map(project => ({
id: project.id,
label: project.name,
description: 'Campaign',
route: { name: 'campaign-detail', params: { projectId: project.id } },
}));
});
const contentResults = computed(() => {
if (!normalizedSearchQuery.value) {
return [];
}
return contentItemsStore.items
.filter(item => {
const titleMatch = item.title.toLowerCase().includes(normalizedSearchQuery.value);
const hashtagMatch = (item.hashtags ?? '').toLowerCase().includes(normalizedSearchQuery.value);
return titleMatch || hashtagMatch;
})
.slice(0, 6)
.map(item => ({
id: item.id,
label: item.title,
description: item.hashtags || item.publicationTargets,
route: { name: 'content-item-detail', params: { id: item.id } },
}));
});
const hasSearchResults = computed(() =>
projectResults.value.length > 0 || contentResults.value.length > 0
);
const isSearchOpen = computed(() => isSearchFocused.value && normalizedSearchQuery.value.length > 0);
const notificationTitleMap = computed(() => ({
'approval.requested': t('notifications.events.approvalRequested'),
'approval.decision.recorded': t('notifications.events.approvalDecisionRecorded'),
'comment.created': t('notifications.events.commentCreated'),
'comment.resolved': t('notifications.events.commentResolved'),
'content-item.created': t('notifications.events.contentCreated'),
'content-item.revision.created': t('notifications.events.revisionCreated'),
'content-item.status.updated': t('notifications.events.statusUpdated'),
'asset.google-drive-linked': t('notifications.events.assetLinked'),
'asset.revision.created': t('notifications.events.assetRevisionCreated'),
}));
function toggleSection(sectionName) {
openSections.value[sectionName] = !openSections.value[sectionName];
}
function toggleNotifications() {
isNotificationsOpen.value = !isNotificationsOpen.value;
}
function toggleUserMenu() {
if (!props.isExpanded) {
return;
}
isUserMenuOpen.value = !isUserMenuOpen.value;
}
function toggleLanguage() {
const nextLocale = languageStore.locale === 'en' ? 'fr' : 'en';
languageStore.setLocale(nextLocale);
isUserMenuOpen.value = false;
}
async function openProfile() {
isUserMenuOpen.value = false;
await router.push({ name: 'settings-user-information' });
}
function formatNotificationTitle(notification) {
return notificationTitleMap.value[notification.eventType] ?? notification.message;
}
function formatNotificationDate(value) {
if (!value) {
return '';
}
return new Date(value).toLocaleString();
}
async function openNotification(notification) {
if (!notification.readAt) {
await notificationsStore.markAsRead(notification.id);
}
isNotificationsOpen.value = false;
if (notification.contentItemId) {
await router.push({ name: 'content-item-detail', params: { id: notification.contentItemId } });
}
}
async function openSearchResult(result) {
isSearchFocused.value = false;
await router.push(result.route);
}
function handleLogout() {
isUserMenuOpen.value = false;
authStore.logout();
}
function handleDocumentClick(event) {
if (searchRef.value && !searchRef.value.contains(event.target)) {
isSearchFocused.value = false;
}
if (isNotificationsOpen.value && notificationsRef.value && !notificationsRef.value.contains(event.target)) {
isNotificationsOpen.value = false;
}
if (isUserMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(event.target)) {
isUserMenuOpen.value = false;
}
}
watch(
() => route.path,
path => {
if (path.startsWith('/app/channels')) {
openSections.value.channels = true;
}
if (path.startsWith('/app/campaigns')) {
openSections.value.projects = true;
}
},
{ immediate: true }
);
watch(
() => props.isExpanded,
isExpanded => {
if (!isExpanded) {
isUserMenuOpen.value = false;
}
}
);
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
});
</script>
<template>
<aside
class="app-sidebar"
:class="{ 'app-sidebar-collapsed': !isExpanded }"
>
<div class="app-sidebar-inner">
<div
v-if="authStore.isAuthenticated"
class="sidebar-section sidebar-utilities"
>
<div
ref="searchRef"
class="sidebar-search-wrap"
>
<label
class="sidebar-search"
:class="{ 'sidebar-search-open': isSearchOpen }"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<input
v-if="isExpanded"
v-model="searchQuery"
type="search"
class="sidebar-search-input"
placeholder="Search"
@focus="isSearchFocused = true"
/>
</label>
<div
v-if="isSearchOpen"
class="sidebar-floating-panel"
>
<div
v-if="projectResults.length"
class="sidebar-search-group"
>
<strong>Campaigns</strong>
<button
v-for="result in projectResults"
:key="`project-${result.id}`"
class="sidebar-search-result"
@click="openSearchResult(result)"
>
<span>{{ result.label }}</span>
<small>{{ result.description }}</small>
</button>
</div>
<div
v-if="contentResults.length"
class="sidebar-search-group"
>
<strong>Content items</strong>
<button
v-for="result in contentResults"
:key="`content-${result.id}`"
class="sidebar-search-result"
@click="openSearchResult(result)"
>
<span>{{ result.label }}</span>
<small>{{ result.description }}</small>
</button>
</div>
<div
v-if="!hasSearchResults"
class="sidebar-search-empty"
>
No results found.
</div>
</div>
</div>
<div
ref="notificationsRef"
class="sidebar-notifications-wrap"
>
<button
class="sidebar-link sidebar-utility-link"
type="button"
@click.stop="toggleNotifications"
>
<span class="sidebar-link-main">
<span class="sidebar-notification-icon-wrap">
<v-icon :icon="mdiBellOutline" />
<span
v-if="notificationsStore.unreadCount"
class="sidebar-notification-badge"
>
{{ Math.min(notificationsStore.unreadCount, 9) }}
</span>
</span>
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('notifications.title') }}
</span>
</span>
</button>
<div
v-if="isExpanded && isNotificationsOpen"
class="sidebar-floating-panel sidebar-notifications-panel"
>
<div class="sidebar-notifications-header">
<strong>{{ t('notifications.title') }}</strong>
<span>{{ notificationsStore.unreadCount }} {{ t('notifications.unread') }}</span>
</div>
<div
v-if="notificationsStore.isLoading"
class="sidebar-notifications-empty"
>
{{ t('notifications.loading') }}
</div>
<div
v-else-if="notificationsStore.error"
class="sidebar-notifications-empty"
>
{{ notificationsStore.error }}
</div>
<button
v-for="notification in notificationsStore.recentItems"
:key="notification.id"
class="sidebar-notification-row"
:class="{ 'sidebar-notification-row-unread': !notification.readAt }"
@click="openNotification(notification)"
>
<strong>{{ formatNotificationTitle(notification) }}</strong>
<span>{{ notification.message }}</span>
<small>{{ formatNotificationDate(notification.createdAt) }}</small>
</button>
<div
v-if="!notificationsStore.isLoading && !notificationsStore.recentItems.length"
class="sidebar-notifications-empty"
>
{{ t('notifications.empty') }}
</div>
</div>
</div>
</div>
<div class="sidebar-section">
<router-link
v-for="link in primaryLinks"
:key="link.to"
:to="link.to"
class="sidebar-link"
active-class="sidebar-link-active"
:title="!isExpanded ? t(link.labelKey) : null"
>
<v-icon :icon="link.icon" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t(link.labelKey) }}
</span>
</router-link>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/campaigns"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.projects') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiFolderOutline" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.projects') }}
</span>
</span>
</router-link>
<router-link
v-if="isExpanded"
to="/app/campaigns?create=true"
class="sidebar-section-action"
:title="t('projects.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
<button
v-if="isExpanded"
class="sidebar-section-toggle"
type="button"
@click="toggleSection('projects')"
>
<v-icon
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.projects }"
/>
</button>
</div>
<div
v-if="isExpanded && openSections.projects"
class="sidebar-sublist"
>
<router-link
to="/app/campaigns"
class="sidebar-sublink sidebar-sublink-overview"
active-class="sidebar-sublink-active"
>
<span>{{ t('sidebar.allProjects') }}</span>
</router-link>
<router-link
v-for="project in projectsStore.projects"
:key="project.id"
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
class="sidebar-sublink"
active-class="sidebar-sublink-active"
>
<span>{{ project.name }}</span>
</router-link>
<div
v-if="!projectsStore.projects.length"
class="sidebar-empty"
>
{{ t('sidebar.noProjects') }}
</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
to="/app/channels"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
:title="!isExpanded ? t('nav.channels') : null"
>
<span class="sidebar-link-main">
<v-icon :icon="mdiLan" />
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t('nav.channels') }}
</span>
</span>
</router-link>
<router-link
v-if="isExpanded"
to="/app/channels?create=true"
class="sidebar-section-action"
:title="t('channels.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
<button
v-if="isExpanded"
class="sidebar-section-toggle"
type="button"
@click="toggleSection('channels')"
>
<v-icon
:icon="mdiChevronDown"
class="sidebar-chevron"
:class="{ 'sidebar-chevron-open': openSections.channels }"
/>
</button>
</div>
<div
v-if="isExpanded && openSections.channels"
class="sidebar-sublist"
>
<router-link
to="/app/channels"
class="sidebar-sublink sidebar-sublink-overview"
active-class="sidebar-sublink-active"
>
<span>{{ t('sidebar.allChannels') }}</span>
</router-link>
<router-link
v-for="channel in channelsStore.channels"
:key="channel.id"
:to="{ name: 'channels', query: { channel: channel.id } }"
class="sidebar-sublink"
active-class="sidebar-sublink-active"
>
<span>{{ channel.name }}</span>
</router-link>
<div
v-if="!channelsStore.channels.length"
class="sidebar-empty"
>
{{ t('sidebar.noChannels') }}
</div>
</div>
</div>
<div
v-if="authStore.isAuthenticated"
ref="userMenuRef"
class="sidebar-workspace sidebar-workspace-bottom"
>
<button
class="sidebar-workspace-trigger"
type="button"
:title="!isExpanded ? userProfileStore.alias : null"
@click.stop="toggleUserMenu"
>
<AppAvatar
:name="userProfileStore.alias"
:src="userProfileStore.portraitUrl"
size="sm"
/>
<span
v-if="isExpanded"
class="sidebar-workspace-label"
>
{{ userProfileStore.alias }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-workspace-icon"
:class="{ 'sidebar-workspace-icon-open': isUserMenuOpen }"
/>
</button>
<div
v-if="isExpanded && isUserMenuOpen"
class="sidebar-workspace-menu"
>
<button
class="sidebar-workspace-option"
type="button"
@click="openProfile"
>
{{ t('nav.profile') }}
</button>
<button
class="sidebar-workspace-option"
type="button"
@click="toggleLanguage"
>
{{ t('nav.language') }}
</button>
<button
class="sidebar-workspace-option sidebar-workspace-option-danger"
type="button"
@click="handleLogout"
>
{{ t('nav.signOut') }}
</button>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.app-sidebar {
@apply w-[19rem] flex-shrink-0 px-4 pb-4 pt-4 transition-[width,padding] duration-200 md:sticky md:top-24 md:h-[calc(100vh-6rem)] md:pt-0;
border-right: 1px solid rgba(23, 32, 51, 0.08);
}
.app-sidebar-inner {
@apply flex h-full flex-col gap-4 overflow-y-auto py-3 pr-3;
}
.sidebar-utilities {
@apply gap-3 pb-1;
}
.sidebar-search-wrap,
.sidebar-notifications-wrap {
@apply relative;
}
.sidebar-search {
@apply flex h-11 items-center gap-3 rounded-[1.1rem] border px-4 transition-colors;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.06);
color: #526178;
}
.sidebar-search-open,
.sidebar-search:focus-within {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(23, 32, 51, 0.1);
}
.sidebar-search-icon {
@apply text-xl;
}
.sidebar-search-input {
@apply min-w-0 flex-1 border-0 bg-transparent p-0 text-sm;
color: #172033;
outline: none;
}
.sidebar-search-input::placeholder {
color: #7a8799;
}
.sidebar-floating-panel {
@apply absolute left-0 right-0 top-[calc(100%+0.6rem)] z-40 flex flex-col gap-3 rounded-[1.25rem] border p-3;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-search-group {
@apply flex flex-col gap-1;
}
.sidebar-search-group strong {
@apply px-2 text-xs font-black uppercase tracking-[0.18em];
color: #5d6b82;
}
.sidebar-search-result {
@apply flex flex-col gap-1 rounded-[0.95rem] px-3 py-3 text-left transition-colors;
color: #172033;
}
.sidebar-search-result:hover {
background: rgba(23, 32, 51, 0.06);
}
.sidebar-search-result small,
.sidebar-search-empty {
@apply text-xs leading-5;
color: #526178;
}
.sidebar-search-empty {
@apply px-2 py-1;
}
.sidebar-utility-link {
@apply w-full justify-between text-left;
}
.sidebar-notification-icon-wrap {
@apply relative flex items-center justify-center;
}
.sidebar-notification-badge {
@apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black;
background: #ef4444;
color: #fffaf2;
}
.sidebar-notifications-panel {
@apply gap-1;
}
.sidebar-notifications-header {
@apply mb-1 flex items-center justify-between gap-3 px-3 py-2;
}
.sidebar-notifications-header strong {
@apply text-sm font-black;
color: #172033;
}
.sidebar-notifications-header span,
.sidebar-notifications-empty,
.sidebar-notification-row span,
.sidebar-notification-row small {
@apply text-xs leading-5;
color: #526178;
}
.sidebar-notifications-empty {
@apply px-3 py-4;
}
.sidebar-notification-row {
@apply flex flex-col gap-1 rounded-[0.9rem] px-3 py-3 text-left transition-colors;
}
.sidebar-notification-row:hover {
background: rgba(23, 32, 51, 0.06);
}
.sidebar-notification-row-unread {
background: rgba(15, 118, 110, 0.08);
}
.sidebar-notification-row strong {
@apply text-sm font-semibold;
color: #172033;
}
.sidebar-workspace {
@apply relative flex flex-col gap-2;
}
.sidebar-workspace-bottom {
@apply mt-auto pt-4;
border-top: 1px solid rgba(23, 32, 51, 0.08);
}
.sidebar-workspace-kicker {
@apply px-4 text-[10px] font-bold uppercase tracking-[0.22em];
color: #7a8799;
}
.sidebar-workspace-trigger {
@apply flex w-full items-center gap-3 rounded-[1.1rem] px-4 py-3 text-left transition-colors;
background: rgba(23, 32, 51, 0.04);
color: #172033;
}
.sidebar-workspace-trigger:hover {
background: rgba(23, 32, 51, 0.07);
}
.sidebar-workspace-label {
@apply flex-1 truncate text-sm font-semibold;
}
.sidebar-workspace-icon {
@apply text-base transition-transform;
color: #5d6b82;
}
.sidebar-workspace-icon-open {
transform: rotate(180deg);
}
.sidebar-workspace-menu {
@apply absolute bottom-[calc(100%+0.5rem)] left-0 right-0 z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-workspace-option {
@apply rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
}
.sidebar-workspace-option:hover {
background: rgba(23, 32, 51, 0.05);
}
.sidebar-workspace-option-danger {
color: #b91c1c;
}
.sidebar-section {
@apply flex flex-col gap-2;
}
.sidebar-section-header {
@apply flex items-center gap-2;
}
.sidebar-link {
@apply flex min-w-0 items-center gap-3 rounded-[1.1rem] px-4 py-3 text-sm font-semibold no-underline transition-colors;
color: #44516a;
}
.sidebar-link:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.sidebar-link-active {
background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), rgba(239, 68, 68, 0.1));
color: #172033;
box-shadow: inset 0 0 0 1px rgba(255, 138, 61, 0.2);
}
.sidebar-link-section {
@apply flex-1;
}
.sidebar-link-main {
@apply flex min-w-0 items-center gap-3;
}
.sidebar-link-label {
@apply truncate;
}
.sidebar-section-toggle {
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors;
color: #526178;
}
.sidebar-section-action {
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors no-underline;
color: #526178;
}
.sidebar-section-action:hover,
.sidebar-section-toggle:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.sidebar-chevron {
@apply text-base transition-transform;
}
.sidebar-chevron-open {
transform: rotate(180deg);
}
.sidebar-link :deep(.v-icon) {
@apply text-xl;
}
.sidebar-sublist {
@apply flex flex-col gap-1 pl-4;
}
.sidebar-sublink {
@apply flex flex-col rounded-[1rem] px-4 py-3 text-sm no-underline transition-colors;
color: #526178;
}
.sidebar-sublink:hover,
.sidebar-sublink-active {
background: rgba(23, 32, 51, 0.05);
color: #172033;
}
.sidebar-sublink strong {
@apply font-semibold;
}
.sidebar-sublink small,
.sidebar-empty {
@apply text-xs;
color: #7a8799;
}
.app-sidebar-collapsed {
@apply w-[5.5rem] px-3;
}
.app-sidebar-collapsed .sidebar-search {
@apply justify-center px-0;
}
.app-sidebar-collapsed .sidebar-floating-panel {
left: calc(100% + 0.75rem);
right: auto;
width: min(22rem, calc(100vw - 7rem));
}
</style>

View File

@@ -1,139 +0,0 @@
<script setup>
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"
>
<facebook class="social-icon"></facebook>
</a>
<a
href="https://www.instagram.com/hutopy.inc/"
target="_blank"
>
<instagram class="social-icon"></instagram>
</a>
<a
href="https://x.com/Hutopyinc/"
target="_blank"
>
<x class="social-icon"></x>
</a>
</div>
<div class="footer-links">
<router-link
class="link"
to="/documents/helpandcontact"
>
{{ t('footer.helpandcontact') }}
</router-link>
<router-link
class="link"
to="/documents/faq"
>
{{ t('footer.faq') }}
</router-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
class="link"
to="/documents/contentpolicy"
>
{{ t('footer.contentpolicy') }}
</router-link>
<router-link
class="link"
to="/documents/about"
>
{{ t('footer.about') }}
</router-link>
<router-link
class="link"
to="/documents/pricing"
>
{{ t('footer.pricing') }}
</router-link>
</div>
<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;
}
.footer-links {
@apply flex flex-row flex-wrap justify-center;
@apply gap-4 px-4;
}
.footer-copyright {
@apply flex justify-center;
@apply text-hOnBackground tracking-widest font-sans text-sm;
}
.social-icon {
@apply fill-current w-6 h-6;
@apply text-hOnBackground;
}
.link {
@apply text-hOnBackground;
@apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400;
}
</style>
<i18n>
{
"en": {
"footer": {
"helpandcontact": "Help & Contact",
"faq": "FAQ",
"creatorguide": "Creator Guide",
"termsandconditions": "Terms & Conditions",
"contentpolicy": "Content Policy",
"about": "About",
"pricing": "Pricing",
"allRightsReserved": "All Rights Reserved"
}
},
"fr": {
"footer": {
"helpandcontact": "Aide & Contact",
"faq": "FAQ",
"creatorguide": "Guide du Créateur",
"termsandconditions": "Conditions Générales",
"contentpolicy": "Politique de Contenu",
"about": "À Propos",
"pricing": "Tarifs",
"allRightsReserved": "Tous Droits Réservés"
}
}
}
</i18n>

View File

@@ -1,309 +1,192 @@
<script setup>
import Footer from '@/views/main/Footer.vue';
import { useI18n } from 'vue-i18n';
import { computed } from 'vue';
const { t } = useI18n();
const pillars = computed(() => [
{
eyebrow: 'Single source of truth',
title: 'Comments, revisions, decisions, and due dates stay attached to one content item.',
},
{
eyebrow: 'Built for agencies',
title: 'Coordinate internal teams, providers, and client approvers without chasing email threads.',
},
{
eyebrow: 'Google Drive first',
title: 'Keep Drive as the asset owner when clients require it, while centralizing workflow in Socialize.',
},
]);
const workflow = computed(() => [
'Create a content item with copy, targets, due dates, and review notes.',
'Attach Google Drive assets and register revisions as feedback comes in.',
'Request internal review, then client approval, with a clear audit trail.',
'Mark the item ready for publishing handoff once approvals are complete.',
]);
</script>
<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"
/>
</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"
>
{{ t('inscription') }}
</v-btn>
<v-btn
class="w-full sm:w-auto inscription-btn-header-outlined"
to="/create-creator"
variant="outlined"
>
{{ t('createPage') }}
</v-btn>
<div class="landing-shell">
<section class="hero-card">
<div class="hero-copy">
<div class="eyebrow">Social media approval workflow</div>
<h1>Replace Drive links, scattered comments, and manual follow-up with one review system.</h1>
<p>
Socialize is being rebuilt as an agency workflow product for content review, revision tracking,
client approval, and publication readiness.
</p>
<div class="hero-actions">
<router-link to="/login">
<button class="primary">Open the app</button>
</router-link>
<router-link to="/register">
<button class="secondary">Create an internal account</button>
</router-link>
</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('love') }}</span>
<div class="hero-panel">
<div class="hero-panel-title">Version 1 workflow</div>
<ol class="workflow-list">
<li
v-for="step in workflow"
:key="step"
>
{{ step }}
</li>
</ol>
</div>
<img
alt="YourHutopy"
class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png"
/>
</div>
</section>
<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"
<section class="pillars-grid">
<article
v-for="pillar in pillars"
:key="pillar.title"
class="pillar-card"
>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/hands.png"
/>
<div class="text-md text-justify px-6">
{{ t('supportDescription') }}
</div>
<!-- <v-btn>Soutenir</v-btn> -->
<div class="eyebrow">{{ pillar.eyebrow }}</div>
<p>{{ pillar.title }}</p>
</article>
</section>
<section class="focus-card">
<div>
<div class="eyebrow">Current build focus</div>
<h2>Phase 1 into Phase 2: retire the creator product surface and install the workflow domain shell.</h2>
</div>
<div class="focus-metrics">
<div>
<strong>Clients</strong>
<span>Brands and businesses under one workspace</span>
</div>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('create') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/brain.png"
/>
<div class="text-md text-justify px-6">
{{ t('creatorDescription') }}
</div>
<v-btn
class="inscription-btn"
to="/login"
>
{{ t('signup') }}
</v-btn>
<div>
<strong>Campaigns</strong>
<span>Grouped work tied to timelines, approvals, and delivery goals</span>
</div>
<div>
<strong>Content items</strong>
<span>Reviewable units with assets, copy, and approvals</span>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-6 py-8">
<div class="gap-8 items-start flex flex-col md:flex-row">
<!-- Section de texte -->
<div class="space-y-6">
<img
alt="YourHutopy"
class="w-full mb-6"
src="/images/hutopymedia/homepage/votrehutopy.png"
/>
<div class="space-y-4">
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('whatIsHutopy') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyDescription') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyValues') }}
</p>
<div class="flex justify-center">
<v-btn
class="text-white mt-12 flex items-center justify-center round create-btn"
to="/create-creator"
>
{{ t('createPage') }}
</v-btn>
</div>
</div>
</div>
<!-- Section droite : 4 images -->
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15">
<div>
<img
alt="Grinding"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/grinding.png"
/>
</div>
<div>
<img
alt="Microphone"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/sign.png"
/>
</div>
<div>
<img
alt="Girl VR"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlvr.png"
/>
</div>
<div>
<img
alt="Girl Army"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlarmy.png"
/>
</div>
</div>
</div>
</div>
<Footer class="mt-10"></Footer>
</section>
</div>
</template>
<style scoped>
.box-text {
color: #6a0164;
font-size: 30px;
font-weight: bold;
.landing-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-8 px-5 py-8 md:px-8 md:py-12;
}
.inscription-btn-header {
color: white;
background-color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
.hero-card {
@apply grid gap-6 rounded-[2rem] p-6 md:grid-cols-[1.4fr_0.9fr] md:p-10;
background: linear-gradient(145deg, #172033 0%, #25324b 65%, #314766 100%);
color: #fffaf2;
box-shadow: 0 30px 80px rgba(23, 32, 51, 0.18);
}
.inscription-btn-header-outlined {
color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
.hero-copy {
@apply flex flex-col gap-5;
}
.inscription-btn {
color: white;
background-color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px;
.hero-copy h1 {
@apply max-w-3xl text-4xl font-black leading-tight md:text-6xl;
}
.create-btn {
background-color: #6a0164;
font-size: 18px;
height: 48px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px;
.hero-copy p {
@apply max-w-2xl text-base leading-7 md:text-lg;
color: rgba(255, 250, 242, 0.84);
}
.overlay p {
color: white;
font-size: 1.5rem;
text-align: center;
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.26em];
color: #ffb26b;
}
body {
background-color: #f4f4f4;
.hero-actions {
@apply flex flex-col gap-3 sm:flex-row;
}
.support-container {
display: flex;
justify-content: center; /* Centre le bloc horizontalement */
align-items: center; /* Centre le bloc verticalement (optionnel) */
.hero-panel {
@apply rounded-[1.5rem] p-6;
background: rgba(255, 250, 242, 0.08);
border: 1px solid rgba(255, 250, 242, 0.12);
}
.support-text {
font-size: 2.2rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
.hero-panel-title {
@apply mb-4 text-sm font-bold uppercase tracking-[0.22em];
color: #7dd3c7;
}
.support-text .highlight {
color: #6a0164; /* Remplacez par la couleur souhaitée */
font-weight: bold; /* Mettre en gras */
.workflow-list {
@apply flex list-decimal flex-col gap-4 pl-5;
}
.highlight2 {
color: #b81286; /* Remplacez par la couleur souhaitée */
.workflow-list li {
@apply text-sm leading-6 md:text-base;
}
.logo-image {
margin-left: auto;
.pillars-grid {
@apply grid gap-4 md:grid-cols-3;
}
@media (min-width: 640px) {
.header-btn {
margin-top: 25px;
margin-bottom: 25px;
}
.support-text {
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 */
}
.pillar-card {
@apply rounded-[1.5rem] p-6;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
@media (min-width: 768px) {
.header-btn {
margin-top: 60px;
}
.logo-image {
margin-right: 20px;
margin-left: 0;
}
.pillar-card p {
@apply mt-3 text-lg font-semibold leading-7;
color: #172033;
}
.homepagetext {
color: white;
font-family: 'Roboto', sans-serif;
.focus-card {
@apply grid gap-6 rounded-[1.75rem] p-6 md:grid-cols-[1fr_1.1fr] md:p-8;
background: linear-gradient(135deg, rgba(255, 138, 61, 0.12), rgba(52, 211, 153, 0.1));
border: 1px solid rgba(23, 32, 51, 0.08);
}
.focus-card h2 {
@apply mt-3 text-2xl font-black leading-tight md:text-3xl;
color: #172033;
}
.focus-metrics {
@apply grid gap-4 md:grid-cols-3;
}
.focus-metrics div {
@apply rounded-[1.25rem] bg-white/70 p-5;
border: 1px solid rgba(23, 32, 51, 0.06);
}
.focus-metrics strong {
@apply block text-sm font-black uppercase tracking-[0.18em];
color: #0f766e;
}
.focus-metrics span {
@apply mt-2 block text-sm leading-6;
color: #3f4d63;
}
</style>
<i18n>
{
"en": {
"inscription": "Sign Up",
"createPage": "Create Page",
"support": "Support",
"creators": "Creators",
"projects": "Projects",
"love": "Love",
"supportText": "Support",
"supportDescription": "Support your favorite creators and help them grow. Your contributions make a real difference in their creative journey.",
"create": "Create",
"creatorDescription": "Create your own page and start your creative journey. Share your passion with the world and build your community.",
"signup": "Sign Up",
"whatIsHutopy": "What is Hutopy?",
"hutopyDescription": "Hutopy is a platform that connects creators with their audience. We provide tools and features to help creators monetize their content and build their community.",
"hutopyValues": "Our values are centered around creativity, community, and support. We believe in empowering creators to pursue their passions and build sustainable careers."
},
"fr": {
"inscription": "S'inscrire",
"createPage": "Créer une Page",
"support": "Soutenir",
"creators": "Créateurs",
"projects": "Projets",
"love": "Passion",
"supportText": "Soutenir",
"supportDescription": "Soutenez vos créateurs préférés et aidez-les à grandir. Vos contributions font une réelle différence dans leur parcours créatif.",
"create": "Créer",
"creatorDescription": "Créez votre propre page et commencez votre parcours créatif. Partagez votre passion avec le monde et construisez votre communauté.",
"signup": "S'inscrire",
"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."
}
}
</i18n>

View File

@@ -1,192 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/authStore.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { mdiAccount, mdiFileAccountOutline, mdiLogin, mdiLogout, mdiTranslateVariant } from '@mdi/js';
const { locale, t } = useI18n();
const languageStore = useLanguageStore();
const userProfileStore = useUserProfileStore();
const creatorProfileStore = useCreatorProfileStore();
const authStore = useAuthStore();
function toggleLanguage() {
const languages = ['fr', 'en'];
const currentIndex = languages.indexOf(locale.value);
const nextIndex = (currentIndex + 1) % languages.length;
languageStore.setLocale(languages[nextIndex]);
}
function handleLogout() {
authStore.logout();
}
</script>
<template>
<nav class="side-container">
<div class="side-logo">
<router-link to="/@hutopy">
<img
alt="hutopy logo"
height="50"
src="/images/hutopy-logo.png"
/>
</router-link>
</div>
<div class="side-menu">
<div
v-if="authStore.isAuthenticated"
class="side-menu-portrait"
>
<img
:src="userProfileStore.portraitUrl"
alt="Profile Image"
class="rounded-full"
referrerpolicy="no-referrer"
/>
<span class="profile-label">{{ userProfileStore.alias }}</span>
</div>
<div class="side-menu-items">
<template v-if="authStore.isAuthenticated">
<router-link
v-if="creatorProfileStore.hasCreator"
:to="`/@${creatorProfileStore.creator.slug}`"
>
<button class="menu-item-action">
<v-icon :icon="mdiFileAccountOutline" />
<span class="label">{{ t('sidebar.myPage') }}</span>
</button>
</router-link>
<router-link
v-else
to="/create-creator"
>
<button class="menu-item-action">
<v-icon :icon="mdiFileAccountOutline" />
<span class="label">{{ t('sidebar.myPage') }}</span>
</button>
</router-link>
</template>
<template v-if="authStore.isAuthenticated">
<router-link to="/profile">
<button class="menu-item-action">
<v-icon :icon="mdiAccount" />
<span class="label">{{ t('sidebar.myProfile') }}</span>
</button>
</router-link>
</template>
<button
class="menu-item-action"
@click="toggleLanguage"
>
<v-icon :icon="mdiTranslateVariant" />
<span class="label">{{ locale }}</span>
</button>
<template v-if="!authStore.isAuthenticated">
<router-link to="/login">
<button class="menu-item-action">
<v-icon :icon="mdiLogin" />
<span class="label">{{ t('sidebar.signIn') }}</span>
</button>
</router-link>
</template>
<div v-else>
<button
class="menu-item-action"
@click="handleLogout"
>
<v-icon :icon="mdiLogout" />
<span class="label">{{ t('sidebar.signOut') }}</span>
</button>
</div>
</div>
</div>
</nav>
</template>
<style scoped>
.side-container {
@apply bg-hSurface text-hOnSurface;
@apply flex;
@apply max-h-screen;
@apply h-16;
}
.side-logo {
@apply flex flex-grow;
@apply items-center justify-start p-4;
}
.side-menu {
@apply flex gap-4 p-6;
@apply items-center;
@apply flex-row-reverse;
}
.side-menu-portrait {
@apply w-10 h-10;
@apply -ml-1;
@apply flex items-center justify-start;
}
.side-menu-items {
@apply flex gap-2;
@apply flex-row;
}
.profile-label {
@apply ml-5;
@apply text-lg font-sans capitalize;
@apply font-semibold;
@apply hidden;
@apply min-w-40 truncate;
}
.label {
@apply text-nowrap;
@apply ml-4;
@apply hidden;
}
.menu-item-action {
@apply bg-hSurface text-hOnSurface hover:mix-blend-screen;
@apply capitalize;
@apply flex items-center gap-3 p-2 rounded-full md:rounded-full;
@apply mx-0;
@apply lg:pl-2;
@apply w-10 h-10 justify-center lg:w-full lg:h-auto lg:justify-normal;
i {
@apply text-xl;
}
}
</style>
<i18n>
{
"en": {
"sidebar": {
"myPage": "My Page",
"myProfile": "My Profile",
"signIn": "Sign In",
"signOut": "Sign Out"
}
},
"fr": {
"sidebar": {
"myPage": "Ma Page",
"myProfile": "Mon Profil",
"signIn": "Se Connecter",
"signOut": "Se Déconnecter"
}
}
}
</i18n>

View File

@@ -1,953 +0,0 @@
<script setup>
import { computed, markRaw, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useClient } from '@/plugins/api.js';
import SocialsDialog from './creators/SocialsDialog.vue';
import AliasDialog from '@/views/profile/account/AliasDialog.vue';
import FullnameDialog from '@/views/profile/account/FullnameDialog.vue';
import EmailDialog from '@/views/profile/account/EmailDialog.vue';
import ChangePasswordDialog from '@/views/profile/account/ChangePasswordDialog.vue';
import ChangeStripeIdDialog from '@/views/profile/creators/ChangeStripeIdDialog.vue';
import ChangeNameDialog from '@/views/profile/creators/ChangeNameDialog.vue';
import ChangeSlugDialog from '@/views/profile/creators/ChangeSlugDialog.vue';
import ChangeTitleDialog from '@/views/profile/creators/ChangeTitleDialog.vue';
import ChangePhoneDialog from '@/views/profile/creators/ChangePhoneDialog.vue';
import ChangeEmailDialog from '@/views/profile/creators/ChangeEmailDialog.vue';
import Youtube from '@/views/svg/Youtube.vue';
import Web from '@/views/svg/Web.vue';
import Reddit from '@/views/svg/Reddit.vue';
import X from '@/views/svg/X.vue';
import Linkedin from '@/views/svg/Linkedin.vue';
import Tiktok from '@/views/svg/Tiktok.vue';
import Instagram from '@/views/svg/Instagram.vue';
import Facebook from '@/views/svg/Facebook.vue';
import { useI18n } from 'vue-i18n';
import QRCodeVue from 'qrcode.vue';
import { mdiCheck, mdiChevronRight, mdiContentCopy, mdiCreditCard, mdiCreditCardOff, mdiDownload } from '@mdi/js';
import { useToast } from 'vue-toastification';
import hutopyLogo from '@/assets/hutopy-icon-white-circle.png';
const { t } = useI18n();
const userProfileStore = useUserProfileStore();
const creatorProfileStore = useCreatorProfileStore();
const baseURL = window.location.origin;
const client = useClient();
const route = useRoute();
const router = useRouter();
const toast = useToast();
// Copy URL state
const copySuccess = ref(false);
const copyButtonRef = ref(null);
// Computed properties for Stripe status
const stripeReady = computed(() => {
return stripeStatus.value === 'fully_configured';
});
const stripeStatus = computed(() => {
console.log('stripeStatus');
const creator = creatorProfileStore.creator;
if (!creator.isStripeAccountPresent) {
return 'not_configured';
}
if (!creator.isStripeDetailsSubmitted) {
return 'needs_more_info';
}
if (!creator.isStripeChargesEnabled || !creator.isStripePayoutReady) {
return 'pending_verification';
}
return 'fully_configured';
});
const stripeStatusText = computed(() => {
switch (stripeStatus.value) {
case 'not_configured':
return t('notConfigured');
case 'needs_more_info':
return t('needsMoreInfo');
case 'pending_verification':
return t('pendingVerification');
default:
return t('configured');
}
});
const stripeButtonText = computed(() => {
switch (stripeStatus.value) {
case 'not_configured':
return t('configureStripe');
case 'needs_more_info':
case 'pending_verification':
return t('continueStripeSetup');
case 'fully_configured':
return t('removeStripe');
default:
return t('removeStripe');
}
});
const imageSettings = ref({
src: hutopyLogo,
x: undefined,
y: undefined,
width: 64,
height: 64,
excavate: false,
});
async function checkStripeAccountStatus() {
try {
const response = await client.post('/api/stripe/check-status');
if (response.data && response.data.stripeAccount) {
creatorProfileStore.creator.isStripeAccountPresent = response.data.isStripeAccountPresent;
creatorProfileStore.creator.isStripeDetailsSubmitted = response.data.isStripeDetailsSubmitted;
creatorProfileStore.creator.isStripePayoutReady = response.data.isStripePayoutReady;
creatorProfileStore.creator.isStripeChargesEnabled = response.data.isStripeChargesEnabled;
toast.success('Your Stripe account is connected and ready for payouts.');
} else {
toast.success('Your Stripe account is connected.');
}
} catch (error) {
toast.error('We couldnt verify your Stripe connection. Please try again.');
}
}
onMounted(async () => {
const { stripe } = route.query;
if (stripe === 'complete') {
await checkStripeAccountStatus();
}
if (stripe === 'refresh') {
toast.warning('You didnt finish connecting your Stripe account. Please try again.');
}
if (stripe) {
await router.replace({ query: { ...route.query, stripe: undefined } });
}
});
async function copyCreatorUrl() {
try {
const url = `${baseURL}/@${creatorProfileStore.creator.slug}`;
await navigator.clipboard.writeText(url);
copySuccess.value = true;
setTimeout(() => {
copySuccess.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
// ### Fullname
const dialogEditFullnameShown = ref(false);
function openEditFullname() {
dialogEditFullnameShown.value = true;
}
function handleCloseEditFullname() {
dialogEditFullnameShown.value = false;
}
function handleSaveEditFullname(firstname, lastname) {
userProfileStore.changeFullname(firstname, lastname);
dialogEditFullnameShown.value = false;
}
// ### Alias
const dialogEditAliasShown = ref(false);
function openEditAlias() {
dialogEditAliasShown.value = true;
}
function handleCloseEditAlias() {
dialogEditAliasShown.value = false;
}
function handleSaveEditAlias(alias) {
userProfileStore.changeAlias(alias);
dialogEditAliasShown.value = false;
}
const dialogShown = ref(false);
const currentComponent = ref('');
const restoreDialogShown = ref(false);
const deleteDialogShown = ref(false);
const componentsMap = {
EmailDialog: markRaw(EmailDialog),
ChangePasswordDialog: markRaw(ChangePasswordDialog),
SocialsDialog: markRaw(SocialsDialog),
ChangeSlugDialog: markRaw(ChangeSlugDialog),
ChangeNameDialog: markRaw(ChangeNameDialog),
ChangeTitleDialog: markRaw(ChangeTitleDialog),
ChangeStripeIdDialog: markRaw(ChangeStripeIdDialog),
ChangePhoneDialog: markRaw(ChangePhoneDialog),
ChangeEmailDialog: markRaw(ChangeEmailDialog),
};
const stripeButtonBusy = ref(false);
async function connectStripe() {
try {
stripeButtonBusy.value = true;
const res = await client.post('/api/stripe/connect');
window.location.href = res.data.url;
} catch (error) {
toast.error('We couldnt connect your Stripe account. Please try again.');
stripeButtonBusy.value = false;
}
}
async function removeStripe() {
try {
stripeButtonBusy.value = true;
await client.delete('/api/stripe');
creatorProfileStore.creator.isStripeAccountPresent = false;
creatorProfileStore.creator.isStripeDetailsSubmitted = false;
creatorProfileStore.creator.isStripePayoutReady = false;
creatorProfileStore.creator.isStripeChargesEnabled = false;
} catch (error) {
toast.error('We couldnt connect your Stripe account. Please try again.');
} finally {
stripeButtonBusy.value = false;
}
}
const openDialog = component => {
currentComponent.value = componentsMap[component];
dialogShown.value = true;
};
const closeDialog = () => {
currentComponent.value = null;
dialogShown.value = false;
};
function handleRestore() {
creatorProfileStore.restoreCreatorPage();
restoreDialogShown.value = false;
}
function handleDelete() {
creatorProfileStore.removeCreatorPage();
deleteDialogShown.value = false;
}
function downloadQRCode() {
try {
// Get the SVG element more specifically
const qrContainer = document.querySelector('.qr-code');
const canvasElement = qrContainer?.querySelector('canvas');
if (!canvasElement) {
console.error('QR code canvas element not found');
return;
}
const padding = 20;
const newCanvas = document.createElement('canvas');
const ctx = newCanvas.getContext('2d');
// Set canvas size to include padding
newCanvas.width = canvasElement.width + padding * 2;
newCanvas.height = canvasElement.height + padding * 2;
// Fill white background
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
// Draw the original QR code canvas with padding
ctx.drawImage(canvasElement, padding, padding);
// Convert to PNG and download
const pngUrl = newCanvas.toDataURL('image/png');
const downloadLink = document.createElement('a');
downloadLink.href = pngUrl;
downloadLink.download = `hutopy-qr-${creatorProfileStore.creator.slug}.png`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
} catch (error) {
console.error('Error in downloadQRCode:', error);
}
}
async function deconfigureStripe() {
try {
await client.post(`/api/membership/stripe-account`, {
stripeAccountId: '',
});
await creatorProfileStore.fetchCreatorProfile();
} catch (error) {
console.error('Error deconfiguring stripe:', error);
}
}
</script>
<template>
<div class="min-h-screen w-full">
<div class="m-4 flex flex-col items-center gap-4">
<div class="card">
<div class="card-title">
{{ t('personalInfo') }}
</div>
<div class="content">
<button
class="action"
@click="openEditFullname"
>
<span class="label">{{ t('fullName') }}</span>
<span class="value">{{ userProfileStore.fullname }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openEditAlias"
>
<span class="label">{{ t('alias') }}</span>
<span class="value">{{ userProfileStore.user.alias }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
</div>
<div class="content">
<button
class="action"
@click="openDialog('EmailDialog')"
>
<span class="label">{{ t('email') }}</span>
<span class="value">{{ userProfileStore.user.email }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
</div>
<div class="content">
<button
class="action"
@click="openDialog('ChangePasswordDialog')"
>
<span class="label">{{ t('changePassword') }}</span>
<span class="value"></span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
</div>
</div>
<template v-if="creatorProfileStore.hasCreator">
<div class="card">
<div class="card-title">
{{ t('creatorInfo') }}
</div>
<div class="content">
<div
class="action"
@click="openDialog('ChangeSlugDialog')"
>
<span class="label">{{ t('handle') }}</span>
<span class="value">{{ baseURL }}/@{{ creatorProfileStore.creator.slug }}</span>
<button
ref="copyButtonRef"
:class="{ success: copySuccess }"
class="copy-button"
@click.stop="copyCreatorUrl"
>
<v-icon :icon="copySuccess ? mdiCheck : mdiContentCopy" />
</button>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</div>
<!-- NAME -->
<button
class="action"
@click="openDialog('ChangeNameDialog')"
>
<span class="label">{{ t('name') }}</span>
<span class="value">{{ creatorProfileStore.creator.name }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<!-- TITLE -->
<button
class="action"
@click="openDialog('ChangeTitleDialog')"
>
<span class="label">{{ t('title') }}</span>
<span class="value">{{ creatorProfileStore.creator.title }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<!-- PHONE NUMBER -->
<button
class="action"
@click="openDialog('ChangePhoneDialog')"
>
<span class="label">{{ t('phoneNumber') }}</span>
<span
:class="{ 'not-set': !creatorProfileStore.creator.presentation?.phoneNumber }"
class="value"
>
{{ creatorProfileStore.creator.presentation?.phoneNumber || t('notSet') }}
</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<!-- EMAIL -->
<button
class="action"
@click="openDialog('ChangeEmailDialog')"
>
<span class="label">{{ t('email') }}</span>
<span
:class="{ 'not-set': !creatorProfileStore.creator.presentation?.email }"
class="value"
>
{{ creatorProfileStore.creator.presentation?.email || t('notSet') }}
</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
</div>
</div>
<div class="card">
<div class="card-title">
{{ t('payment-information') }}
</div>
<div class="content">
<div class="stripe-status">
<span class="label">{{ t('stripeStatus') }}</span>
<span
:class="stripeStatus"
class="value"
>
{{ stripeStatusText }}
</span>
<div class="stripe-actions">
<button
:class="stripeReady ? 'deconfigure-stripe-button' : 'configure-stripe-button'"
:disabled="stripeButtonBusy"
@click="() => (stripeReady ? removeStripe() : connectStripe())"
>
<v-icon
v-if="!stripeButtonBusy"
:icon="stripeReady ? mdiCreditCardOff : mdiCreditCard"
/>
<v-progress-circular
v-else
class="mr-2"
color="text-hTextOnPrimary"
indeterminate
size="20"
width="2"
/>
{{ stripeButtonText }}
</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
{{ t('socialNetworks') }}
</div>
<div class="content">
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<facebook class="social-icon"></facebook>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.facebookUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<instagram class="social-icon"></instagram>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.instagramUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<x class="social-icon"></x>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.xUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<linkedin class="social-icon"></linkedin>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.linkedInUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<tiktok class="social-icon"></tiktok>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.tikTokUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<youtube class="social-icon"></youtube>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.youtubeUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<reddit class="social-icon"></reddit>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.redditUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
<button
class="action"
@click="openDialog('SocialsDialog')"
>
<span class="label">
<web class="social-icon"></web>
</span>
<span class="value">{{ creatorProfileStore.creator.socials?.websiteUrl }}</span>
<span class="chevron">
<v-icon :icon="mdiChevronRight" />
</span>
</button>
</div>
</div>
<div class="card">
<div class="card-title">
{{ t('qrCode') }}
</div>
<div class="content">
<div class="qr-container">
<p class="qr-text">{{ t('qrCodeDescription') }}</p>
<div class="qr-code">
<QRCodeVue
v-if="creatorProfileStore.creator"
:image-settings="imageSettings"
:margin="0"
:size="200"
:value="baseURL + '/@' + creatorProfileStore.creator.slug"
foreground="#6B0065"
level="H"
render-as="canvas"
/>
</div>
<button
v-if="creatorProfileStore.creator"
class="download-button"
@click="downloadQRCode"
>
<v-icon :icon="mdiDownload" />
{{ t('downloadQRCode') }}
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
{{ t('dangerZone') }}
</div>
<div class="content">
<span class="p-2">
{{ t('dangerZoneWarning') }}
</span>
<div class="p-2 m-2 w-auto flex justify-center">
<div class="w-1/3">
<button
v-if="!creatorProfileStore.creator.isDeleted"
class="primary danger-action"
@click="deleteDialogShown = true"
>
{{ t('deleteCreatorPage') }}
</button>
<button
v-else
class="primary safe-action"
@click="restoreDialogShown = true"
>
{{ t('restoreCreatorPage') }}
</button>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<v-dialog
v-model="dialogEditFullnameShown"
persistent
>
<FullnameDialog
:firstname="userProfileStore.user.firstname"
:lastname="userProfileStore.user.lastname"
@close="handleCloseEditFullname"
@save="handleSaveEditFullname"
/>
</v-dialog>
<v-dialog
v-model="dialogEditAliasShown"
persistent
>
<alias-dialog
:alias="userProfileStore.user.alias"
@close="handleCloseEditAlias"
@save="handleSaveEditAlias"
></alias-dialog>
</v-dialog>
<v-dialog
v-model="dialogShown"
persistent
>
<component
:is="currentComponent"
:creator="creatorProfileStore.creator"
:email="userProfileStore.user.email"
@closeRequested="closeDialog"
></component>
</v-dialog>
<v-dialog v-model="restoreDialogShown">
<div class="card dialog">
<div class="card-title">{{ t('restoreCreatorPage') }}</div>
<div class="card-content">
<p>{{ t('restoreWarning') }}</p>
<div class="card-actions">
<button
class="secondary"
@click="restoreDialogShown = false"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="handleRestore"
>
{{ t('accept') }}
</button>
</div>
</div>
</div>
</v-dialog>
<v-dialog v-model="deleteDialogShown">
<div class="card dialog">
<div class="card-title">{{ t('deleteCreatorPage') }}</div>
<div class="card-content">
<p>{{ t('deleteWarning') }}</p>
<div class="card-actions">
<button
class="secondary"
@click="deleteDialogShown = false"
>
{{ t('cancel') }}
</button>
<button
class="primary danger-action"
@click="handleDelete"
>
{{ t('accept') }}
</button>
</div>
</div>
</div>
</v-dialog>
</template>
<style scoped>
.card {
@apply rounded-lg p-4 w-full;
}
.card-title {
@apply text-hOnBackground text-lg font-bold;
}
.content {
@apply flex flex-col gap-2;
}
.action {
@apply flex flex-row items-center w-full p-3 rounded-lg;
@apply hover:bg-hSurface;
@apply transition-colors duration-500;
}
.label {
@apply text-hOnBackground w-[200px] text-left;
@apply flex items-center justify-start;
}
.copy-button {
@apply ml-2 p-1 rounded-full;
@apply transition-all duration-300;
@apply relative overflow-hidden;
@apply opacity-60;
}
.copy-button:hover {
@apply opacity-100;
@apply bg-hSurface;
}
.copy-button::after {
content: '';
@apply absolute inset-0 bg-white/20;
@apply scale-0 rounded-full;
@apply transition-transform duration-300;
}
.copy-button:active::after {
@apply scale-150;
@apply opacity-0;
}
.copy-button.success {
@apply bg-green-500/20;
@apply opacity-100;
}
.value {
@apply text-hOnBackground flex-1 text-center;
@apply flex items-center justify-center;
}
.value.not-set {
@apply text-gray-400;
}
.chevron {
@apply text-hOnBackground w-[40px] text-right;
@apply flex items-center justify-end;
}
.social-icon {
@apply fill-current w-6 h-6;
@apply text-hOnBackground;
@apply mr-2;
}
.danger-action {
@apply bg-red-800 hover:bg-red-700 active:bg-red-600;
}
.safe-action {
@apply bg-green-800 hover:bg-green-700 active:bg-green-600;
}
.qr-container {
@apply flex flex-col items-center gap-4 p-4;
}
.qr-code {
@apply bg-white p-4 rounded-2xl;
}
.qr-text {
@apply text-hOnBackground text-center;
}
.download-button {
@apply flex items-center gap-2 px-4 py-2 rounded-lg;
@apply bg-hutopyPrimary text-hOnPrimary;
@apply hover:bg-hutopySecondary;
@apply transition-colors duration-300;
}
.stripe-status {
@apply flex flex-row items-center w-full p-3 rounded-lg;
@apply bg-hSurface;
@apply cursor-default;
@apply transition-colors duration-300;
}
.stripe-status .value {
@apply text-hOnBackground flex-1 text-center;
@apply flex items-center justify-center;
}
.stripe-status .value.fully_configured {
@apply text-green-500;
}
.stripe-status .value.configured {
@apply text-green-500;
}
.stripe-status .value.pending_verification {
@apply text-yellow-500;
}
.stripe-status .value.needs_more_info {
@apply text-orange-500;
}
.stripe-actions {
@apply flex items-center gap-2 ml-4;
}
.configure-stripe-button {
@apply flex items-center justify-center gap-2 px-4 py-2 rounded-lg;
@apply bg-hutopyPrimary text-hOnPrimary;
@apply hover:bg-hutopySecondary;
@apply transition-colors duration-300;
}
.deconfigure-stripe-button {
@apply flex items-center justify-center gap-2 px-4 py-2 rounded-lg;
@apply bg-red-600 text-white;
@apply hover:bg-red-700;
@apply transition-colors duration-300;
}
</style>
<i18n>
{
"en": {
"personalInfo": "Personal Information",
"fullName": "Full Name",
"alias": "Alias",
"email": "Email",
"changePassword": "Update Password",
"creatorInfo": "Creator Information",
"dangerZone": "Danger Zone",
"dangerZoneWarning": "The actions below can have significant impacts on your creator page. Please proceed with caution.",
"deleteWarning": "Are you sure you want to delete your creator page? This action cannot be undone.",
"restoreWarning": "Are you sure you want to restore your creator page? This will make your page visible again.",
"deleteCreatorPage": "Delete Creator Page",
"restoreCreatorPage": "Restore Creator Page",
"stripeAccountId": "Stripe Account ID",
"socialNetworks": "Social Networks",
"handle": "Creator Handle",
"qrCode": "QR Code",
"qrCodeDescription": "Print this QR code to share your Hutopy with the world! Perfect for business cards, social media, and promotional materials.",
"downloadQRCode": "Download QR Code",
"payment-information": "Payment Information",
"stripeStatus": "Stripe Status",
"configured": "Configured",
"notConfigured": "Not Configured",
"needsMoreInfo": "Requires More Information",
"pendingVerification": "Pending Verification",
"continueStripeSetup": "Continue Stripe Setup",
"reviewStripe": "Review Stripe",
"notSet": "Not Set",
"configureStripe": "Connect Stripe",
"phoneNumber": "Phone Number",
"title": "Title",
"removeStripe": "Remove Stripe"
},
"fr": {
"personalInfo": "Informations Personnelles",
"fullName": "Nom Complet",
"alias": "Alias",
"email": "Email",
"changePassword": "Modifier le mot de passe",
"creatorInfo": "Informations du Créateur",
"dangerZone": "Zone de Danger",
"dangerZoneWarning": "Les actions ci-dessous peuvent avoir des impacts significatifs sur votre page de créateur. Veuillez procéder avec précaution.",
"deleteWarning": "Êtes-vous sûr de vouloir supprimer votre page de créateur ? Cette action est irréversible.",
"restoreWarning": "Êtes-vous sûr de vouloir restaurer votre page de créateur ? Cela rendra votre page à nouveau visible.",
"deleteCreatorPage": "Supprimer la Page Créateur",
"restoreCreatorPage": "Restaurer la Page Créateur",
"stripeAccountId": "ID de Compte Stripe",
"socialNetworks": "Réseaux Sociaux",
"handle": "Identifiant du créateur",
"qrCode": "Code QR",
"qrCodeDescription": "Imprimez ce code QR pour partager votre Hutopy avec le monde ! Parfait pour les cartes de visite, les réseaux sociaux et les supports promotionnels.",
"downloadQRCode": "Télécharger le Code QR",
"payment-information": "Informations de Paiement",
"stripeStatus": "État de Stripe",
"configured": "Configuré",
"notConfigured": "Non Configuré",
"needsMoreInfo": "Demande plus d'informations",
"pendingVerification": "Vérification en Cours",
"continueStripeSetup": "Continuer Configuration Stripe",
"reviewStripe": "Reviser Stripe",
"notSet": "Non Défini",
"configureStripe": "Connecter Stripe",
"phoneNumber": "Numéro de téléphone",
"title": "Titre",
"removeStripe": "Retirer Stripe"
}
}
</i18n>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,200 +0,0 @@
<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 { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
creator: {
required: true,
},
});
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 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,
});
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');
} catch (error) {
console.error(error);
}
};
const cancel = () => {
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"
:placeholder="t('facebook')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<instagram class="social-icon"></instagram>
<input
v-model="instagramUrl"
:placeholder="t('instagram')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<linkedin class="social-icon"></linkedin>
<input
v-model="linkedInUrl"
:placeholder="t('linkedin')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<reddit class="social-icon"></reddit>
<input
v-model="redditUrl"
:placeholder="t('reddit')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<tiktok class="social-icon"></tiktok>
<input
v-model="tikTokUrl"
:placeholder="t('tiktok')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<web class="social-icon"></web>
<input
v-model="websiteUrl"
:placeholder="t('website')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<x class="social-icon"></x>
<input
v-model="xUrl"
:placeholder="t('x')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<youtube class="social-icon"></youtube>
<input
v-model="youtubeUrl"
:placeholder="t('youtube')"
class="input-field"
type="text"
/>
</div>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</template>
<style scoped>
.editor-line {
@apply flex flex-row gap-4;
@apply items-center;
}
.social-icon {
@apply w-8 h-8;
}
.input-field {
@apply w-full p-[10px];
@apply rounded-sm;
@apply transition duration-200;
@apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary;
@apply hover:ring-hutopyPrimary;
@apply placeholder:text-[#6D6C70];
}
</style>
<i18n>
{
"en": {
"title": "Social Media Links"
},
"fr": {
"title": "Liens des réseaux sociaux"
}
}
</i18n>

View File

@@ -1,16 +1,12 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import fs from 'fs';
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// Load environment variables based on the mode
const env = loadEnv(mode, process.cwd(), '')
return {
export default defineConfig({
plugins: [
visualizer({
filename: './dist/stats.html',
@@ -24,10 +20,6 @@ export default defineConfig(({ mode }) => {
})
],
server: {
https: {
key: fs.readFileSync('localhost-key.pem'),
cert: fs.readFileSync('localhost.pem'),
},
port: 5173, // Ensure this matches your WebStorm debug URL
open: true, // Automatically opens the browser
host: '0.0.0.0',
@@ -54,13 +46,7 @@ export default defineConfig(({ mode }) => {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
define: {
// Define a global constant __APP_ENV__ based on loaded environment variables
VITE_API_URL: JSON.stringify(env.VITE_API_URL),
VITE_STRIPE_API_KEY: JSON.stringify(env.VITE_STRIPE_API_KEY)
},
json: {
stringify: false
}
}
})