Compare commits

...

11 Commits

Author SHA1 Message Date
hackerESQ a67717c2f8 Change runner to ubuntu-latest for build job 2026-03-24 19:13:34 -05:00
hackerESQ f24fd83ae1 Add missing api tests (#193) 2026-03-24 19:12:05 -05:00
hackerESQ b1a517ce71 Upgrade to Laravel 13 (#192) 2026-03-24 19:11:02 -05:00
hackerESQ d449a89349 Show only market gain on performance chart (#190)
(by default) Allow other options as a choice
2026-03-20 20:51:19 -05:00
hackerESQ a98dcd0732 fix escaping issue 2026-03-18 20:30:09 -05:00
hackerESQ eaaa218582 Clean up formatting of locale form and dropdowns (#186) 2026-03-18 20:23:51 -05:00
hackerESQ 67396b23f1 Add french translations (#185) 2026-03-18 20:23:31 -05:00
hackerESQ 01825e9108 Fix transaction table scope to my portfolios only (#184)
* Limit transactions table filters to `my portfolios` scope

* Fix scope of transactions table
2026-03-18 19:54:18 -05:00
hackerESQ d55f117565 Limit transactions table filters to my portfolios scope (#183) 2026-03-18 17:43:28 -05:00
hackerESQ 327e120a3c clean up 2026-03-18 17:40:31 -05:00
hackerESQ 5e8324551b Remove custom date picker element (#182) 2026-03-16 20:18:08 -05:00
18 changed files with 1286 additions and 1279 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ on:
jobs: jobs:
build: build:
runs-on: self-hosted # runs-on: self-hosted
runs-on: ubuntu-latest
steps: steps:
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
+1 -1
View File
@@ -26,7 +26,7 @@ class EnsureDailyChangeIsSynced
) { ) {
defer(fn () => $model->portfolio->syncDailyChanges()); defer(fn () => $model->portfolio->syncDailyChanges());
Cache::put($cacheKey, now(), now()->addMinutes(5)); Cache::put($cacheKey, true, now()->addMinutes(5));
} }
} }
+8 -1
View File
@@ -16,6 +16,7 @@ use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number; use Illuminate\Support\Number;
use Livewire\Component; use Livewire\Component;
@@ -40,7 +41,13 @@ class TransactionsTable extends Component implements HasActions, HasSchemas, Has
'SELL' => 'SELL', 'SELL' => 'SELL',
]), ]),
SelectFilter::make('portfolio') SelectFilter::make('portfolio')
->relationship('portfolio', 'title'), ->relationship('portfolio', 'title', fn (Builder $query) => $query->myPortfolios())
->query(function (Builder $query, array $data): Builder {
return $query->when(
$data['value'],
fn (Builder $query, $value) => $query->portfolio($value)
);
}),
]) ])
->deferFilters(false) ->deferFilters(false)
->query( ->query(
+4 -4
View File
@@ -22,10 +22,10 @@
"investbrainapp/frankfurter-client": "dev-main", "investbrainapp/frankfurter-client": "dev-main",
"laravel/ai": "^0.2.5", "laravel/ai": "^0.2.5",
"laravel/fortify": "^1.30.0", "laravel/fortify": "^1.30.0",
"laravel/framework": "^12.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/socialite": "^5.16", "laravel/socialite": "^5.16",
"laravel/tinker": "^2.9", "laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"livewire/livewire": "^4.0", "livewire/livewire": "^4.0",
"livewire/volt": "^1.6", "livewire/volt": "^1.6",
@@ -39,11 +39,11 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/boost": "^1.8", "laravel/boost": "^2.0",
"laravel/pint": "^1.25", "laravel/pint": "^1.25",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0", "nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0" "phpunit/phpunit": "^12.0"
}, },
"repositories": [ "repositories": [
{ {
Generated
+684 -1070
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -151,6 +151,16 @@ return [
'label' => 'English (United States)', 'label' => 'English (United States)',
'flag' => '', 'flag' => '',
], ],
[
'locale' => 'fr_CA',
'label' => 'French (Canada)',
'flag' => '',
],
[
'locale' => 'fr_FR',
'label' => 'French (France)',
'flag' => '',
],
[ [
'locale' => 'es_419', 'locale' => 'es_419',
'label' => 'Spanish (Latin America)', 'label' => 'Spanish (Latin America)',
+13
View File
@@ -107,4 +107,17 @@ return [
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
/*
|--------------------------------------------------------------------------
| Cache Serializable Classes
|--------------------------------------------------------------------------
|
| For security, unserialization of cached PHP objects is restricted. Set
| this to false to disallow all object unserialization, or list the
| specific classes your application intentionally caches as objects.
|
*/
'serializable_classes' => false,
]; ];
+6 -3
View File
@@ -2,6 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
return [ return [
@@ -77,9 +80,9 @@ return [
*/ */
'middleware' => [ 'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, 'validate_csrf_token' => PreventRequestForgery::class,
], ],
]; ];
+386
View File
@@ -0,0 +1,386 @@
{
"Unknown": "Inconnu",
"Done.": "Terminé.",
"Saved.": "Enregistré.",
"Created.": "Créé.",
"Enable": "Activer",
"Disable": "Désactiver",
"Log Out": "Se déconnecter",
"Import": "Importer",
"Export": "Exporter",
"Log in": "Se connecter",
"Register": "S'inscrire",
"Create": "Créer",
"Update": "Mettre à jour",
"Cancel": "Annuler",
"Save": "Enregistrer",
"Close": "Fermer",
"Dismiss": "Ignorer",
"or": "ou",
"and": "et",
"Yes": "Oui",
"you": "vous",
"You": "Vous",
"Nothing to show here yet": "Rien à afficher pour l'instant",
"Try again": "Réessayer",
"Hang on! You're doing that too much.": "Attendez ! Vous faites cela trop souvent.",
"Delete Account": "Supprimer le compte",
"Permanently delete your account.": "Supprimez définitivement votre compte.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Une fois votre compte supprimé, toutes ses ressources et données seront définitivement supprimées. Avant de supprimer votre compte, veuillez télécharger toutes les données ou informations que vous souhaitez conserver.",
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Êtes-vous sûr de vouloir supprimer votre compte ? Une fois votre compte supprimé, toutes ses ressources et données seront définitivement supprimées. Veuillez saisir votre mot de passe pour confirmer la suppression définitive de votre compte.",
"Two Factor Authentication": "Authentification à deux facteurs",
"Add additional security to your account using two factor authentication.": "Ajoutez une sécurité supplémentaire à votre compte grâce à l'authentification à deux facteurs.",
"Finish enabling two factor authentication.": "Terminez l'activation de l'authentification à deux facteurs.",
"You have enabled two factor authentication.": "Vous avez activé l'authentification à deux facteurs.",
"You have not enabled two factor authentication.": "Vous n'avez pas activé l'authentification à deux facteurs.",
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.": "Lorsque l'authentification à deux facteurs est activée, un jeton sécurisé et aléatoire vous sera demandé lors de la connexion. Vous pouvez récupérer ce jeton depuis l'application Google Authenticator de votre téléphone.",
"To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.": "Pour terminer l'activation de l'authentification à deux facteurs, scannez le QR code suivant avec l'application d'authentification de votre téléphone ou saisissez la clé de configuration et fournissez le code OTP généré.",
"Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.": "L'authentification à deux facteurs est maintenant activée. Scannez le QR code suivant avec l'application d'authentification de votre téléphone ou saisissez la clé de configuration.",
"Setup Key": "Clé de configuration",
"Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.": "Conservez ces codes de récupération dans un gestionnaire de mots de passe sécurisé. Ils peuvent être utilisés pour récupérer l'accès à votre compte si votre appareil d'authentification à deux facteurs est perdu.",
"Regenerate Recovery Codes": "Régénérer les codes de récupération",
"Show Recovery Codes": "Afficher les codes de récupération",
"Update Password": "Modifier le mot de passe",
"Ensure your account is using a long, random password to stay secure.": "Assurez-vous que votre compte utilise un mot de passe long et aléatoire pour rester sécurisé.",
"Current Password": "Mot de passe actuel",
"New Password": "Nouveau mot de passe",
"Token Name": "Nom du jeton",
"Permissions": "Permissions",
"Profile Information": "Informations du profil",
"Your :provider account has been connected.": "Votre compte :provider a été connecté.",
"Account already exists. Check your email to connect your :provider account.": "Un compte existe déjà. Vérifiez votre e-mail pour connecter votre compte :provider.",
"Could not login using :provider. Try again later.": "Impossible de se connecter avec :provider. Réessayez plus tard.",
"Update your account's profile information and email address.": "Mettez à jour les informations de profil et l'adresse e-mail de votre compte.",
"Photo": "Photo",
"Select A New Photo": "Sélectionner une nouvelle photo",
"Remove Photo": "Supprimer la photo",
"API Tokens": "Jetons API",
"Manage API Tokens": "Gérer les jetons API",
"Create API Token": "Créer un jeton API",
"You may delete any of your existing tokens if they are no longer needed.": "Vous pouvez supprimer tout jeton existant s'il n'est plus nécessaire.",
"Last used": "Dernière utilisation",
"Delete": "Supprimer",
"API Token": "Jeton API",
"Please copy your new API token. For your security, it won't be shown again.": "Veuillez copier votre nouveau jeton API. Pour votre sécurité, il ne sera plus affiché.",
"Copy to clipboard": "Copier dans le presse-papiers",
"Successfully copied!": "Copié avec succès !",
"API Token Permissions": "Permissions du jeton API",
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Les jetons API permettent à des services tiers de s'authentifier auprès d'Investbrain en votre nom.",
"Delete API Token": "Supprimer le jeton API",
"Are you sure you would like to delete this API token?": "Êtes-vous sûr de vouloir supprimer ce jeton API ?",
"This is a secure area of the application. Please confirm your password before continuing.": "Il s'agit d'une zone sécurisée de l'application. Veuillez confirmer votre mot de passe avant de continuer.",
"Password": "Mot de passe",
"Confirm": "Confirmer",
"Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.": "Mot de passe oublié ? Pas de problème. Indiquez-nous votre adresse e-mail et nous vous enverrons un lien de réinitialisation du mot de passe.",
"Email": "E-mail",
"Email Password Reset Link": "Envoyer le lien de réinitialisation du mot de passe",
"Remember me": "Se souvenir de moi",
"Forgot your password?": "Mot de passe oublié ?",
"Name": "Nom",
"Confirm Password": "Confirmer le mot de passe",
"I agree to the :terms_of_service and :privacy_policy": "J'accepte les :terms_of_service et la :privacy_policy",
"Terms of Service": "Conditions d'utilisation",
"Privacy Policy": "Politique de confidentialité",
"Sign up with email": "S'inscrire avec un e-mail",
"Login with": "Se connecter avec",
"Already registered?": "Déjà inscrit ?",
"Reset Password": "Réinitialiser le mot de passe",
"Please confirm access to your account by entering the authentication code provided by your authenticator application.": "Veuillez confirmer l'accès à votre compte en saisissant le code d'authentification fourni par votre application d'authentification.",
"Please confirm access to your account by entering one of your emergency recovery codes.": "Veuillez confirmer l'accès à votre compte en saisissant l'un de vos codes de récupération d'urgence.",
"Code": "Code",
"Recovery Code": "Code de récupération",
"Use a recovery code": "Utiliser un code de récupération",
"Use an authentication code": "Utiliser un code d'authentification",
"Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.": "Avant de continuer, pourriez-vous vérifier votre adresse e-mail en cliquant sur le lien que nous venons de vous envoyer ? Si vous n'avez pas reçu l'e-mail, nous vous en enverrons un autre avec plaisir.",
"A new verification link has been sent to the email address you provided in your profile settings.": "Un nouveau lien de vérification a été envoyé à l'adresse e-mail que vous avez fournie dans les paramètres de votre profil.",
"Resend Verification Email": "Renvoyer l'e-mail de vérification",
"Edit Profile": "Modifier le profil",
"Upload or recover your Investbrain portfolio and holdings.": "Téléchargez ou récupérez votre portefeuille et vos avoirs Investbrain.",
"Download all of your portfolios and transactions.": "Téléchargez tous vos portefeuilles et transactions.",
"Import / Export Data": "Importer / Exporter des données",
"Successfully imported!": "Importé avec succès !",
"Select a file": "Sélectionner un fichier",
"Download Export": "Télécharger l'export",
"Click to download import template.": "Cliquez pour télécharger le modèle d'importation.",
"Download import template.": "Télécharger le modèle d'importation.",
"Your email address is unverified.": "Votre adresse e-mail n'est pas vérifiée.",
"Click here to re-send the verification email.": "Cliquez ici pour renvoyer l'e-mail de vérification.",
"A new verification link has been sent to your email address.": "Un nouveau lien de vérification a été envoyé à votre adresse e-mail.",
"The provided password does not match your current password.": "Le mot de passe fourni ne correspond pas à votre mot de passe actuel.",
"Documentation": "Documentation",
"We're open source!": "Nous sommes open source !",
"Toggle Theme": "Changer de thème",
"Toggle Sidebar": "Afficher/masquer la barre latérale",
"Dashboard": "Tableau de bord",
"Gain/Loss": "Gain/Perte",
"Market Gain/Loss": "Gain/Perte sur le marché",
"Total Cost Basis": "Coût de base total",
"Total Sale Price": "Prix de vente total",
"Total Market Value": "Valeur marchande totale",
"Realized Gain/Loss": "Gain/Perte réalisé(e)",
"Dividends Earned": "Dividendes perçus",
"Dividends": "Dividendes",
"Holding Options": "Options de l'avoir",
"Holding options saved": "Options de l'avoir enregistrées",
"Reinvest Dividends": "Réinvestir les dividendes",
"Automatically generate buy transactions for any dividends earned": "Générer automatiquement des transactions d'achat pour les dividendes perçus",
"Split": "Division",
"Splits": "Divisions",
"No splits for :symbol yet": "Aucune division pour :symbol pour l'instant",
"Distribution Date": "Date de distribution",
"My portfolios": "Mes portefeuilles",
"Create your first portfolio!": "Créez votre premier portefeuille !",
"Wishlist": "Liste de souhaits",
"Top performers": "Meilleures performances",
"Top headlines": "Principales actualités",
"Click or press :key to search": "Cliquez ou appuyez sur :key pour rechercher",
"Click to search": "Cliquez pour rechercher",
"Search holdings, portfolios, or anything else...": "Rechercher des avoirs, des portefeuilles ou autre chose...",
"Darn! Nothing found for that search.": "Zut ! Aucun résultat pour cette recherche.",
"Portfolio": "Portefeuille",
"Portfolios": "Portefeuilles",
"Create Portfolio": "Créer un portefeuille",
"Transactions": "Transactions",
"Reporting": "Rapports",
"Manage Profile": "Gérer le profil",
"Symbol": "Symbole",
"Quantity": "Quantité",
"Quantity Owned": "Quantité détenue",
"The quantity must not be greater than the available quantity.": "La quantité ne doit pas être supérieure à la quantité disponible.",
"Average Cost Basis": "Coût de base moyen",
"Market Value": "Valeur marchande",
"52 week": "52 semaines",
"52 week low": "Plus bas sur 52 semaines",
"52 week high": "Plus haut sur 52 semaines",
"Forward PE": "PER prospectif",
"Trailing PE": "PER historique",
"Market Cap": "Capitalisation boursière",
"Book Value": "Valeur comptable",
"Dividend Yield": "Rendement du dividende",
"Last Dividend Paid": "Dernier dividende versé",
"Ex Dividend Date": "Date ex-dividende",
"No dividends for :symbol yet": "Aucun dividende pour :symbol pour l'instant",
"Number of Transactions": "Nombre de transactions",
"Last Refreshed": "Dernière mise à jour",
"Portfolio updated": "Portefeuille mis à jour",
"Portfolio created": "Portefeuille créé",
"Portfolio deleted": "Portefeuille supprimé",
"Title": "Titre",
"Notes": "Notes",
"Treat this portfolio as a \"wishlist\" (holdings will be excluded from realized gains, unrealized gains, and dividends)": "Traiter ce portefeuille comme une « liste de souhaits » (les avoirs seront exclus des gains réalisés, des gains non réalisés et des dividendes)",
"Delete Portfolio": "Supprimer le portefeuille",
"Are you sure you want to delete this portfolio? Once a portfolio is deleted, all of its holdings and other data will be permanently deleted.": "Êtes-vous sûr de vouloir supprimer ce portefeuille ? Une fois un portefeuille supprimé, tous ses avoirs et autres données seront définitivement supprimés.",
"Transaction updated": "Transaction mise à jour",
"Transaction created": "Transaction créée",
"Transaction deleted": "Transaction supprimée",
"Transaction Type": "Type de transaction",
"Transaction Date": "Date de transaction",
"Delete Transaction": "Supprimer la transaction",
"Are you sure you want to delete this transaction?": "Êtes-vous sûr de vouloir supprimer cette transaction ?",
"Cost Basis": "Coût de base",
"Sale Price": "Prix de vente",
"Market Gain": "Gain sur le marché",
"Realized Gains": "Gains réalisés",
"Performance": "Performance",
"Reset chart": "Réinitialiser le graphique",
"Choose time period": "Choisir une période",
"Manage Portfolio": "Gérer le portefeuille",
"Create Transaction": "Créer une transaction",
"Manage Transaction": "Gérer la transaction",
"Holding": "Avoir",
"Holdings": "Avoirs",
"Recent activity": "Activité récente",
"All Transactions": "Toutes les transactions",
"validation.accepted": "Ce champ doit être accepté.",
"validation.accepted_if": "Ce champ doit être accepté lorsque :other est :value.",
"validation.active_url": "Ce champ n'est pas une URL valide.",
"validation.after": "Ce champ doit être une date postérieure à :date.",
"validation.after_or_equal": "Ce champ doit être une date postérieure ou égale à :date.",
"validation.alpha": "Ce champ ne doit contenir que des lettres.",
"validation.alpha_dash": "Ce champ ne doit contenir que des lettres, des chiffres, des tirets et des underscores.",
"validation.alpha_num": "Ce champ ne doit contenir que des lettres et des chiffres.",
"validation.array": "Ce champ doit être un tableau.",
"validation.ascii": "Ce champ ne doit contenir que des caractères alphanumériques et des symboles à un seul octet.",
"validation.attached": "Ce champ est déjà attaché.",
"validation.before": "Ce champ doit être une date antérieure à :date.",
"validation.before_or_equal": "Ce champ doit être une date antérieure ou égale à :date.",
"validation.between.array": "Ce champ doit contenir entre :min et :max éléments.",
"validation.between.file": "Ce champ doit être compris entre :min et :max kilo-octets.",
"validation.between.numeric": "Ce champ doit être compris entre :min et :max.",
"validation.between.string": "Ce champ doit contenir entre :min et :max caractères.",
"validation.boolean": "Ce champ doit être vrai ou faux.",
"validation.can": "Ce champ contient une valeur non autorisée.",
"validation.confirmed": "La confirmation de ce champ ne correspond pas.",
"validation.contains": "Ce champ est absent d'une valeur requise.",
"validation.date": "Ce champ n'est pas une date valide.",
"validation.date_equals": "Ce champ doit être une date égale à :date.",
"validation.date_format": "Ce champ ne correspond pas au format :format.",
"validation.decimal": "Ce champ doit avoir :decimal décimales.",
"validation.declined": "Ce champ doit être refusé.",
"validation.declined_if": "Ce champ doit être refusé lorsque :other est :value.",
"validation.different": "Ce champ et :other doivent être différents.",
"validation.digits": "Ce champ doit contenir :digits chiffres.",
"validation.digits_between": "Ce champ doit contenir entre :min et :max chiffres.",
"validation.dimensions": "Ce champ a des dimensions d'image invalides.",
"validation.distinct": "Ce champ a une valeur en double.",
"validation.doesnt_end_with": "Ce champ ne doit pas se terminer par l'un des éléments suivants : :values.",
"validation.doesnt_start_with": "Ce champ ne doit pas commencer par l'un des éléments suivants : :values.",
"validation.email": "Ce champ doit être une adresse e-mail valide.",
"validation.ends_with": "Ce champ doit se terminer par l'un des éléments suivants : :values.",
"validation.enum": "La valeur sélectionnée ne fait pas partie des valeurs autorisées.",
"validation.exists": "La valeur sélectionnée n'existe pas.",
"validation.extensions": "Ce champ doit avoir l'une des extensions suivantes : :values.",
"validation.file": "Ce champ doit être un fichier.",
"validation.filled": "Ce champ doit avoir une valeur.",
"validation.gt.array": "Ce champ doit avoir plus de :value éléments.",
"validation.gt.file": "Ce champ doit être supérieur à :value kilo-octets.",
"validation.gt.numeric": "Ce champ doit être supérieur à :value.",
"validation.gt.string": "Ce champ doit contenir plus de :value caractères.",
"validation.gte.array": "Ce champ doit avoir :value éléments ou plus.",
"validation.gte.file": "Ce champ doit être supérieur ou égal à :value kilo-octets.",
"validation.gte.numeric": "Ce champ doit être supérieur ou égal à :value.",
"validation.gte.string": "Ce champ doit contenir :value caractères ou plus.",
"validation.hex_color": "Ce champ doit être une couleur hexadécimale valide.",
"validation.image": "Ce champ doit être une image.",
"validation.in": "La valeur sélectionnée ne fait pas partie des valeurs autorisées.",
"validation.in_array": "Ce champ n'existe pas dans :other.",
"validation.integer": "Ce champ doit être un entier.",
"validation.ip": "Ce champ doit être une adresse IP valide.",
"validation.ipv4": "Ce champ doit être une adresse IPv4 valide.",
"validation.ipv6": "Ce champ doit être une adresse IPv6 valide.",
"validation.json": "Ce champ doit être une chaîne JSON valide.",
"validation.list": "Ce champ doit être une liste.",
"validation.lowercase": "Ce champ doit être en minuscules.",
"validation.lt.array": "Ce champ doit avoir moins de :value éléments.",
"validation.lt.file": "Ce champ doit être inférieur à :value kilo-octets.",
"validation.lt.numeric": "Ce champ doit être inférieur à :value.",
"validation.lt.string": "Ce champ doit contenir moins de :value caractères.",
"validation.lte.array": "Ce champ ne doit pas avoir plus de :value éléments.",
"validation.lte.file": "Ce champ doit être inférieur ou égal à :value kilo-octets.",
"validation.lte.numeric": "Ce champ doit être inférieur ou égal à :value.",
"validation.lte.string": "Ce champ doit contenir :value caractères ou moins.",
"validation.mac_address": "Ce champ doit être une adresse MAC valide.",
"validation.max.array": "Ce champ ne doit pas avoir plus de :max éléments.",
"validation.max.file": "Ce champ ne doit pas être supérieur à :max kilo-octets.",
"validation.max.numeric": "Ce champ ne doit pas être supérieur à :max.",
"validation.max.string": "Ce champ ne doit pas contenir plus de :max caractères.",
"validation.max_digits": "Ce champ ne doit pas avoir plus de :max chiffres.",
"validation.mimes": "Ce champ doit être un fichier de type : :values.",
"validation.mimetypes": "Ce champ doit être un fichier de type : :values.",
"validation.min.array": "Ce champ doit avoir au moins :min éléments.",
"validation.min.file": "Ce champ doit faire au moins :min kilo-octets.",
"validation.min.numeric": "Ce champ doit être au moins :min.",
"validation.min.string": "Ce champ doit contenir au moins :min caractères.",
"validation.min_digits": "Ce champ doit avoir au moins :min chiffres.",
"validation.missing": "Ce champ doit être absent.",
"validation.missing_if": "Ce champ doit être absent lorsque :other est :value.",
"validation.missing_unless": "Ce champ doit être absent sauf si :other est :value.",
"validation.missing_with": "Ce champ doit être absent lorsque :values est présent.",
"validation.missing_with_all": "Ce champ doit être absent lorsque :values sont présents.",
"validation.multiple_of": "Ce champ doit être un multiple de :value.",
"validation.not_in": "La valeur sélectionnée ne doit pas être dans la liste.",
"validation.not_regex": "Le format de ce champ est invalide.",
"validation.numeric": "Ce champ doit être un nombre.",
"validation.password.letters": "Ce champ doit contenir au moins une lettre.",
"validation.password.mixed": "Ce champ doit contenir au moins une lettre majuscule et une lettre minuscule.",
"validation.password.numbers": "Ce champ doit contenir au moins un chiffre.",
"validation.password.symbols": "Ce champ doit contenir au moins un symbole.",
"validation.password.uncompromised": "Ce champ est apparu dans une fuite de données. Veuillez choisir un autre champ.",
"validation.present": "Ce champ doit être présent.",
"validation.present_if": "Ce champ doit être présent lorsque :other est :value.",
"validation.present_unless": "Ce champ doit être présent sauf si :other est :value.",
"validation.present_with": "Ce champ doit être présent lorsque :values est présent.",
"validation.present_with_all": "Ce champ doit être présent lorsque :values sont présents.",
"validation.prohibited": "Ce champ est interdit.",
"validation.prohibited_if": "Ce champ est interdit lorsque :other est :value.",
"validation.prohibited_unless": "Ce champ est interdit sauf si :other est dans :values.",
"validation.prohibits": "Ce champ interdit la présence de :other.",
"validation.regex": "Le format de ce champ est invalide.",
"validation.relatable": "Ce champ ne peut pas être associé à cette ressource.",
"validation.required": "Ce champ est obligatoire.",
"validation.required_array_keys": "Ce champ doit contenir des entrées pour : :values.",
"validation.required_if": "Ce champ est obligatoire lorsque :other est :value.",
"validation.required_if_accepted": "Ce champ est obligatoire lorsque :other est accepté.",
"validation.required_if_declined": "Ce champ est obligatoire lorsque :other est refusé.",
"validation.required_unless": "Ce champ est obligatoire sauf si :other est dans :values.",
"validation.required_with": "Ce champ est obligatoire lorsque :values est présent.",
"validation.required_with_all": "Ce champ est obligatoire lorsque :values sont présents.",
"validation.required_without": "Ce champ est obligatoire lorsque :values est absent.",
"validation.required_without_all": "Ce champ est obligatoire lorsque aucun des éléments :values n'est présent.",
"validation.same": "Ce champ et :other doivent correspondre.",
"validation.size.array": "Ce champ doit contenir :size éléments.",
"validation.size.file": "Ce champ doit faire :size kilo-octets.",
"validation.size.numeric": "Ce champ doit être :size.",
"validation.size.string": "Ce champ doit contenir :size caractères.",
"validation.starts_with": "Ce champ doit commencer par l'un des éléments suivants : :values.",
"validation.string": "Ce champ doit être une chaîne de caractères.",
"validation.timezone": "Ce champ doit être un fuseau horaire valide.",
"validation.ulid": "Ce champ doit être un ULID valide.",
"validation.unique": "Cette valeur est déjà utilisée.",
"validation.uploaded": "Ce champ n'a pas pu être téléchargé.",
"validation.uppercase": "Ce champ doit être en majuscules.",
"validation.url": "Ce champ doit être une URL valide.",
"validation.uuid": "Ce champ doit être un UUID valide.",
"passwords.reset": "Votre mot de passe a été réinitialisé.",
"passwords.sent": "Nous vous avons envoyé votre lien de réinitialisation du mot de passe par e-mail.",
"passwords.throttled": "Veuillez patienter avant de réessayer.",
"passwords.token": "Ce jeton de réinitialisation du mot de passe est invalide.",
"passwords.user": "Nous ne trouvons pas d'utilisateur avec cette adresse e-mail.",
"pagination.previous": "« Précédent",
"pagination.next": "Suivant »",
"auth.failed": "Ces identifiants ne correspondent pas à nos enregistrements.",
"auth.password": "Le mot de passe fourni est incorrect.",
"auth.throttle": "Trop de tentatives de connexion. Veuillez réessayer dans :seconds secondes.",
"Add People": "Ajouter des personnes",
"People with access": "Personnes ayant accès",
"Owner": "Propriétaire",
"Read only": "Lecture seule",
"Full access": "Accès complet",
"You do not have permission to manage transactions for this portfolio": "Vous n'avez pas la permission de gérer les transactions de ce portefeuille",
"Updated user's access permission to portfolio": "Permission d'accès de l'utilisateur au portefeuille mise à jour",
"Removed user's access to portfolio": "Accès de l'utilisateur au portefeuille supprimé",
"Shared portfolio with user": "Portefeuille partagé avec l'utilisateur",
"Share Portfolio": "Partager le portefeuille",
"Type an email address to share portfolio": "Saisissez une adresse e-mail pour partager le portefeuille",
"Grant full access": "Accorder l'accès complet",
"Allow this user to manage portfolio details and create or update transactions": "Autoriser cet utilisateur à gérer les détails du portefeuille et à créer ou mettre à jour des transactions",
"Share": "Partager",
"Remove Access": "Supprimer l'accès",
"By removing this person's access, they will no longer be able to view this portfolio. They will lose access immediately.": "En supprimant l'accès de cette personne, elle ne pourra plus consulter ce portefeuille. Elle perdra l'accès immédiatement.",
"Hey again!": "Ravi de vous revoir !",
"Before you can get started with Investbrain, let's complete your profile:": "Avant de commencer avec Investbrain, complétons votre profil :",
"Get Started": "Commencer",
"You do not have access to that portfolio.": "Vous n'avez pas accès à ce portefeuille.",
"Import starting...": "Importation en cours de démarrage...",
"Import is in progress...": "Importation en cours...",
"Importing portfolios...": "Importation des portefeuilles...",
"Preparing to import transactions...": "Préparation de l'importation des transactions...",
"Importing transactions (Batch :currentBatch of :totalBatches)...": "Importation des transactions (Lot :currentBatch sur :totalBatches)...",
"Preparing to import daily changes...": "Préparation de l'importation des variations journalières...",
"Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importation des variations journalières (Lot :currentBatch sur :totalBatches)...",
"Importing configurations...": "Importation des configurations...",
"Import completed successfully!": "Importation terminée avec succès !",
"Your import will continue in the background": "Votre importation continuera en arrière-plan",
"AI Chat": "Chat IA",
"Hi, how can I help?": "Bonjour, comment puis-je vous aider ?",
"Have a question? AI might be able to help...": "Vous avez une question ? L'IA pourrait peut-être vous aider...",
"Feel free to ask me a question!": "N'hésitez pas à me poser une question !",
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Les conseils générés par l'IA peuvent contenir des erreurs. Utilisez à vos propres risques. Consultez toujours un conseiller en investissement agréé.",
"Currency": "Devise",
"Locale Options": "Options de localisation",
"Adjust localization options for your preferred region.": "Ajustez les options de localisation pour votre région préférée.",
"Locale": "Locale",
"Display Currency": "Devise d'affichage"
}
File diff suppressed because one or more lines are too long
@@ -99,6 +99,7 @@
this.data.tooltip = { this.data.tooltip = {
enabled: true, enabled: true,
shared: false,
y: { y: {
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => { formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
const firstDataPoint = this.data.series[seriesIndex].data[0][1] const firstDataPoint = this.data.series[seriesIndex].data[0][1]
@@ -21,97 +21,11 @@
input[type="date"]::-webkit-calendar-picker-indicator { input[type="date"]::-webkit-calendar-picker-indicator {
color: transparent; color: transparent;
background: transparent; background: transparent;
display: none;
} }
</style> </style>
<div <div x-data>
x-cloak
x-data="{
datePickerOpen: false,
datePickerValue: $wire.entangle(@js($modelName)),
datePickerMonth: '',
datePickerYear: '',
datePickerDay: '',
datePickerDaysInMonth: [],
datePickerBlankDaysInMonth: [],
datePickerMonthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
datePickerDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
datePickerDayClicked(day) {
let selectedDate = new Date(this.datePickerYear, this.datePickerMonth, day);
this.datePickerDay = day;
this.datePickerValue = this.dateToValue(selectedDate);
this.datePickerIsSelectedDate(day);
this.datePickerOpen = false;
},
datePickerPreviousMonth(){
if (this.datePickerMonth == 0) {
this.datePickerYear--;
this.datePickerMonth = 12;
}
this.datePickerMonth--;
this.datePickerCalculateDays();
},
datePickerNextMonth(){
if (this.datePickerMonth == 11) {
this.datePickerMonth = 0;
this.datePickerYear++;
} else {
this.datePickerMonth++;
}
this.datePickerCalculateDays();
},
datePickerIsSelectedDate(day) {
const d = new Date(this.datePickerYear, this.datePickerMonth, day);
return this.datePickerValue === this.dateToValue(d) ? true : false;
},
datePickerIsToday(day) {
const today = new Date();
const d = new Date(this.datePickerYear, this.datePickerMonth, day);
return today.toDateString() === d.toDateString() ? true : false;
},
datePickerCalculateDays() {
let daysInMonth = new Date(this.datePickerYear, this.datePickerMonth + 1, 0).getDate();
// find where to start calendar day of week
let dayOfWeek = new Date(this.datePickerYear, this.datePickerMonth).getDay();
let blankdaysArray = [];
for (var i = 1; i <= dayOfWeek; i++) {
blankdaysArray.push(i);
}
let daysArray = [];
for (var i = 1; i <= daysInMonth; i++) {
daysArray.push(i);
}
this.datePickerBlankDaysInMonth = blankdaysArray;
this.datePickerDaysInMonth = daysArray;
},
dateToValue(d) {
d = this.parseDate(d)
let formattedDate = ('0' + d.getDate()).slice(-2);
let formattedMonthInNumber = ('0' + (parseInt(d.getMonth()) + 1)).slice(-2);
let formattedYear = d.getFullYear();
return `${formattedYear}-${formattedMonthInNumber}-${formattedDate}`;
},
parseDate(d) {
date = new Date();
let userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(Date.parse(d) + userTimezoneOffset);
}
}"
x-init="
currentDate = new Date();
if (datePickerValue) {
currentDate = parseDate(datePickerValue)
}
datePickerMonth = currentDate.getMonth();
datePickerYear = currentDate.getFullYear();
datePickerDay = currentDate.getDay();
datePickerValue = currentDate.toISOString().slice(0, 10);
datePickerCalculateDays();
"
>
{{-- STANDARD LABEL --}} {{-- STANDARD LABEL --}}
@if($label) @if($label)
<label for="{{ $id }}" class="pt-0 label label-text font-semibold"> <label for="{{ $id }}" class="pt-0 label label-text font-semibold">
@@ -126,96 +40,15 @@
@endif @endif
<div class="flex-1 relative"> <div class="flex-1 relative">
{{-- DESKTOP --}}
<div
x-ref="desktopDatePickerInput"
x-html="parseDate(datePickerValue).toLocaleDateString()"
x-on:keydown.escape="datePickerOpen=false"
@click="datePickerOpen=true"
{{ $attributes->class([
"hidden md:block py-2 input px-4 input-primary w-full peer appearance-none",
'ps-10' => ($icon),
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
'input-error' => $errors->has($errorFieldName)
]) }}
></div>
<div
x-show="datePickerOpen"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-x-2"
x-transition:enter-end="translate-x-0"
@click.away="datePickerOpen = false"
class="
p-4
mt-12
top-0
left-0
max-w-lg
w-[17rem]
absolute
z-100
bg-base-100
dark:bg-base-300
rounded-box
shadow-md
select-none
"
>
<div class="flex justify-between items-center mb-2">
<div>
<span x-text="datePickerMonthNames[datePickerMonth]" class="text-lg font-bold"></span>
<span x-text="datePickerYear" class="ml-1 text-lg font-normal text-gray-600"></span>
</div>
<div>
<button @click="datePickerPreviousMonth()" type="button" class="inline-flex p-1 rounded-full transition duration-100 ease-in-out cursor-pointer focus:outline-none focus:shadow-outline hover:bg-accent/50">
<x-ui.icon name="o-chevron-left" />
</button>
<button @click="datePickerNextMonth()" type="button" class="inline-flex p-1 rounded-full transition duration-100 ease-in-out cursor-pointer focus:outline-none focus:shadow-outline hover:bg-accent/50">
<x-ui.icon name="o-chevron-right" />
</button>
</div>
</div>
<div class="grid grid-cols-7 mb-3">
<template x-for="(day, index) in datePickerDays" :key="index">
<div class="px-0.5">
<div x-text="day" class="text-xs font-medium text-center"></div>
</div>
</template>
</div>
<div class="grid grid-cols-7">
<template x-for="blankDay in datePickerBlankDaysInMonth">
<div class="p-1 text-sm text-center border border-transparent"></div>
</template>
<template x-for="(day, dayIndex) in datePickerDaysInMonth" :key="dayIndex">
<div class="px-0.5 mb-1 aspect-square">
<div
x-text="day"
@click="datePickerDayClicked(day)"
:class="{
'border border-accent/50': datePickerIsToday(day) == true,
'hover:bg-neutral-800/70': datePickerIsToday(day) == false && datePickerIsSelectedDate(day) == false,
'text-primary-content bg-primary hover:bg-primary/50': datePickerIsSelectedDate(day) == true
}"
class="flex justify-center items-center w-7 h-7 text-sm leading-none text-center rounded-full cursor-pointer"
></div>
</div>
</template>
</div>
</div>
{{-- MOBILE/NATIVE --}}
<input <input
type="date" type="date"
x-model="datePickerValue"
placeholder="Select date" placeholder="Select date"
id="{{ $id }}" id="{{ $id }}"
x-ref="dateInput"
onfocus="this.showPicker?.()" onfocus="this.showPicker?.()"
x-ref="mobileDatePickerInput"
{{ $attributes->class([ {{ $attributes->class([
"block md:hidden input input-primary w-full peer appearance-none", "block input input-primary w-full peer appearance-none",
'ps-10' => ($icon), 'ps-10' => ($icon),
'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true, 'border border-dashed' => $attributes->has('readonly') && $attributes->get('readonly') == true,
'input-error' => $errors->has($errorFieldName) 'input-error' => $errors->has($errorFieldName)
@@ -223,23 +56,11 @@
/> />
{{-- ICON --}} {{-- ICON --}}
<div @click=" <div @click="$refs.dateInput.showPicker?.()"
if ($refs.mobileDatePickerInput?.checkVisibility()) {
$refs.mobileDatePickerInput?.showPicker()
return;
}
if(datePickerOpen) {
$refs.desktopDatePickerInput.focus();
return;
}
datePickerOpen=!datePickerOpen;
"
class="z-60 absolute top-1/2 -translate-y-1/2 end-0 p-3 cursor-pointer text-neutral-400 hover:text-neutral-500" class="z-60 absolute top-1/2 -translate-y-1/2 end-0 p-3 cursor-pointer text-neutral-400 hover:text-neutral-500"
> >
<x-ui.icon name="o-calendar" /> <x-ui.icon name="o-calendar" />
</div> </div>
</div> </div>
{{-- ERROR --}} {{-- ERROR --}}
@@ -75,26 +75,29 @@ new #[Lazy] class extends Component
foreach ($dailyChange as $data) { foreach ($dailyChange as $data) {
$date = $data->date; $date = $data->date;
$marketGainData[] = [$date, round($data->total_market_gain, 2)];
$marketValueData[] = [$date, round($data->total_market_value, 2)]; $marketValueData[] = [$date, round($data->total_market_value, 2)];
$costBasisData[] = [$date, round($data->total_cost_basis, 2)]; $costBasisData[] = [$date, round($data->total_cost_basis, 2)];
$marketGainData[] = [$date, round($data->total_market_gain, 2)];
// $dividendSeries[] = [$date, round($data->total_dividends_earned, 2)]; // $dividendSeries[] = [$date, round($data->total_dividends_earned, 2)];
// $realizedGainSeries[] = [$date, round($data->realized_gains, 2)]; // $realizedGainSeries[] = [$date, round($data->realized_gains, 2)];
} }
return [ return [
'series' => [ 'series' => [
[
'name' => __('Market Gain'),
'data' => $marketGainData,
],
[ [
'name' => __('Market Value'), 'name' => __('Market Value'),
'data' => $marketValueData, 'data' => $marketValueData,
'hidden' => true,
], ],
[ [
'name' => __('Cost Basis'), 'name' => __('Cost Basis'),
'data' => $costBasisData, 'data' => $costBasisData,
], 'hidden' => true,
[
'name' => __('Market Gain'),
'data' => $marketGainData,
], ],
// [ // [
@@ -154,7 +154,7 @@ new class extends Component
<x-slot:actions> <x-slot:actions>
@if (auth()->user()->id != $user->id) @if (auth()->user()->id != $user->id)
<x-ui.select <x-ui.select
class="select select-ghost border-none focus:outline-none focus:ring-0" class="cursor-pointer select-ghost border-none focus:outline-none focus:ring-0"
:options="[['id' => 0, 'name' => __('Read only')], ['id' => 1, 'name' => __('Full access')]]" :options="[['id' => 0, 'name' => __('Read only')], ['id' => 1, 'name' => __('Full access')]]"
wire:model.live.number="permissions.{{ $user->id }}.full_access" wire:model.live.number="permissions.{{ $user->id }}.full_access"
/> />
@@ -68,8 +68,8 @@ new class extends Component
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<x-ui.select <x-ui.select
label="{{ __('Locale') }}" :label="__('Locale')"
class="select block mt-1 w-full" class=""
:options="config('app.available_locales')" :options="config('app.available_locales')"
option-value="locale" option-value="locale"
option-label="label" option-label="label"
@@ -83,8 +83,8 @@ new class extends Component
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<x-ui.select <x-ui.select
label="{{ __('Display Currency') }}" :label="__('Display Currency')"
class="select block mt-1 w-full" class=""
:options="$currencies" :options="$currencies"
option-value="currency" option-value="currency"
option-label="label" option-label="label"
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Tests\Api;
use App\Models\MarketData;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MarketDataTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
public function test_can_get_market_data_for_symbol(): void
{
MarketData::getMarketData('AAPL');
$this->actingAs($this->user)
->getJson(route('api.market-data.show', ['symbol' => 'AAPL']))
->assertOk()
->assertJsonStructure([
'symbol',
'name',
'market_value',
'fifty_two_week_low',
'fifty_two_week_high',
'last_dividend_date',
'last_dividend_amount',
'dividend_yield',
'market_cap',
'trailing_pe',
'forward_pe',
'book_value',
'created_at',
'updated_at',
]);
}
public function test_market_data_returns_correct_symbol(): void
{
$this->actingAs($this->user)
->getJson(route('api.market-data.show', ['symbol' => 'ACME']))
->assertSuccessful()
->assertJsonFragment([
'symbol' => 'ACME',
]);
}
public function test_market_data_response_has_expected_fields(): void
{
MarketData::getMarketData('MSFT');
$this->actingAs($this->user)
->getJson(route('api.market-data.show', ['symbol' => 'MSFT']))
->assertOk()
->assertJsonPath('symbol', 'MSFT')
->assertJsonPath('market_value', 230.19);
}
public function test_cannot_access_market_data_when_unauthenticated(): void
{
$this->getJson(route('api.market-data.show', ['symbol' => 'AAPL']))->assertUnauthorized();
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
public function test_can_get_authenticated_user_profile(): void
{
$this->actingAs($this->user)
->getJson(route('api.me'))
->assertOk()
->assertJsonStructure([
'id',
'name',
'email',
'profile_photo_url',
'options' => ['display_currency', 'locale'],
'created_at',
'updated_at',
]);
}
public function test_profile_returns_correct_user_data(): void
{
$this->actingAs($this->user)
->getJson(route('api.me'))
->assertOk()
->assertJsonFragment([
'id' => $this->user->id,
'name' => $this->user->name,
'email' => $this->user->email,
]);
}
public function test_profile_returns_correct_options(): void
{
$this->actingAs($this->user)
->getJson(route('api.me'))
->assertOk()
->assertJsonPath('options.display_currency', $this->user->getCurrency())
->assertJsonPath('options.locale', $this->user->getLocale());
}
public function test_cannot_access_profile_when_unauthenticated(): void
{
$this->getJson(route('api.me'))->assertUnauthorized();
}
public function test_profile_does_not_expose_password(): void
{
$response = $this->actingAs($this->user)
->getJson(route('api.me'))
->assertOk();
$this->assertArrayNotHasKey('password', $response->json());
}
}
+1 -2
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Tests\Feature; namespace Tests;
use App\Ai\Agents\ChatWithPortfolioAgent; use App\Ai\Agents\ChatWithPortfolioAgent;
use App\Ai\Agents\ChatWithSuggestedPromptsAgent; use App\Ai\Agents\ChatWithSuggestedPromptsAgent;
@@ -14,7 +14,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Volt\Volt; use Livewire\Volt\Volt;
use Tests\TestCase;
class ChatWithTest extends TestCase class ChatWithTest extends TestCase
{ {