From 30808cb7ebb4989c08f7c19247d96f9fd2e21c44 Mon Sep 17 00:00:00 2001 From: Mostafa Hassan Date: Mon, 15 Dec 2025 16:25:43 +0200 Subject: [PATCH 1/7] feat: update toaster styles and simplify showToaster function --- app/assets/css/main.css | 116 ++++++++++++++++++++++++++++++---- app/components/ui/Toaster.vue | 26 +++----- app/utils/showToaster.ts | 44 ++----------- 3 files changed, 119 insertions(+), 67 deletions(-) diff --git a/app/assets/css/main.css b/app/assets/css/main.css index bd4a4f65d..789cec635 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -23,13 +23,10 @@ --dialog-backdrop: rgba(91, 112, 131, 0.4); --input-autofill: #e8f0fe; --oauth: #ffffff; - --toaster-bg-success: #dcfce7; - --toaster-text-success: #166534; - --toaster-bg-error: #fee2e2; - --toaster-text-error: #991b1b; - --toaster-bg-warning: #fef9c3; - --toaster-text-warning: #92400e; - --toaster-bg-info: #dbeafe; + --toaster-bg: #ffffff; + --toaster-text-success: #1cb356; + --toaster-text-error: #c22828; + --toaster-text-warning: #caa016; --toaster-text-info: #1e3a8a; --radius: 0.625rem; --card: oklch(1 0 0); @@ -74,14 +71,11 @@ --popover-foreground: #e7e9ea; --input-autofill: #202939; --oauth: #ffffff; - --toaster-bg-success: #064e3b; - --toaster-text-success: #86efac; - --toaster-bg-error: #7f1d1d; - --toaster-text-error: #fecaca; - --toaster-bg-warning: #78350f; + --toaster-bg: #000000; + --toaster-text-success: #18f16b; + --toaster-text-error: #ff5252; --toaster-text-warning: #fcd34d; - --toaster-bg-info: #1e3a8a; - --toaster-text-info: #bfdbfe; + --toaster-text-info: #4197ff; --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.205 0 0); @@ -199,4 +193,98 @@ outline: none !important; box-shadow: none !important; } + + [data-sonner-toast] { + position: relative; + display: flex; + align-items: center; + gap: 12px; + + padding: 14px 18px; + border-radius: 14px; + + background: color-mix(in oklch, var(--background) 92%, black 8%); + + color: var(--foreground); + font-size: 14px; + font-weight: 500; + + box-shadow: + 0 20px 25px -5px rgb(0 0 0 / 0.15), + 0 8px 10px -6px rgb(0 0 0 / 0.1); + + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + + animation: toast-slide-in 180ms cubic-bezier(0.4, 0, 0.2, 1); + } + + /* Left accent bar */ + [data-sonner-toast]::before { + content: ''; + position: absolute; + left: 0; + top: 10px; + bottom: 10px; + width: 4px; + border-radius: 999px; + background: currentColor; + opacity: 0.9; + } + + /* Toast types */ + [data-sonner-toast][data-type='success'] { + color: var(--toaster-text-success); + background: var(--toaster-bg); + box-shadow: + 0 2px 3px -2px var(--toaster-text-success), + 0 2px 3px -2px var(--toaster-text-success); + } + + [data-sonner-toast][data-type='error'] { + color: var(--toaster-text-error); + background: var(--toaster-bg); + box-shadow: + 0 2px 3px -2px var(--toaster-text-error), + 0 2px 3px -2px var(--toaster-text-error); + } + + [data-sonner-toast][data-type='warning'] { + color: var(--toaster-text-warning); + background: var(--toaster-bg); + box-shadow: + 0 2px 3px -2px var(--toaster-text-warning), + 0 2px 3px -1px var(--toaster-text-warning); + } + + [data-sonner-toast][data-type='info'] { + color: var(--toaster-text-info); + background: var(--toaster-bg); + box-shadow: + 0 2px 3px -2px var(--toaster-text-info), + 0 2px 3px -2px var(--toaster-text-info); + } + + /* Close button */ + [data-sonner-close-button] { + opacity: 0.5; + transition: opacity 120ms ease; + } + + [data-sonner-close-button]:hover { + opacity: 1; + } + + /* Animation */ + @keyframes toast-slide-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } } diff --git a/app/components/ui/Toaster.vue b/app/components/ui/Toaster.vue index 356810f30..023b96107 100644 --- a/app/components/ui/Toaster.vue +++ b/app/components/ui/Toaster.vue @@ -1,19 +1,19 @@ - diff --git a/app/utils/showToaster.ts b/app/utils/showToaster.ts index 008fc4849..ba7da9d4a 100644 --- a/app/utils/showToaster.ts +++ b/app/utils/showToaster.ts @@ -1,43 +1,13 @@ import { toast } from 'vue-sonner'; import { useNuxtApp } from '#app'; -const typeConfig = { - success: { - icon: '✔️', - bg: 'var(--toaster-bg-success)', - color: 'var(--toaster-text-success)', - }, - error: { - icon: '❌', - bg: 'var(--toaster-bg-error)', - color: 'var(--toaster-text-error)', - }, - warning: { - icon: '⚠️', - bg: 'var(--toaster-bg-warning)', - color: 'var(--toaster-text-warning)', - }, - info: { - icon: 'ℹ️', - bg: 'var(--toaster-bg-info)', - color: 'var(--toaster-text-info)', - }, -} as const; - -type ToastType = keyof typeof typeConfig; - -export function showToaster(type: ToastType, message: string, translate: boolean = false) { - const { icon, bg, color } = typeConfig[type]; +export function showToaster( + type: 'success' | 'error' | 'warning' | 'info', + message: string, + translate = false, +) { const { $i18n } = useNuxtApp(); + const text = translate ? $i18n.t(message) : message; - const translatedMessage = translate ? $i18n.t(message) : message; - - toast(`${icon} ${translatedMessage}`, { - style: { - background: bg, - color: color, - padding: '10px 14px', - borderRadius: '20px', - }, - }); + toast[type](text); } From 6e38eacc0b2e1a0707d8a9f8f048d70b08c5cf98 Mon Sep 17 00:00:00 2001 From: Mostafa Hassan Date: Mon, 15 Dec 2025 16:49:32 +0200 Subject: [PATCH 2/7] feat: simplify showToasts function and improve toaster button layout --- app/pages/playground/toaster.vue | 49 ++++++++++++-------------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/app/pages/playground/toaster.vue b/app/pages/playground/toaster.vue index 38ee18183..3e45e2ab3 100644 --- a/app/pages/playground/toaster.vue +++ b/app/pages/playground/toaster.vue @@ -1,40 +1,27 @@ From 9eb9638c78577e021d1bcea06c960017997346e5 Mon Sep 17 00:00:00 2001 From: Mostafa Hassan Date: Mon, 15 Dec 2025 16:51:55 +0200 Subject: [PATCH 3/7] test: update Toaster tests --- test/nuxt/components/ui/Toaster.spec.ts | 74 +++---------------------- 1 file changed, 8 insertions(+), 66 deletions(-) diff --git a/test/nuxt/components/ui/Toaster.spec.ts b/test/nuxt/components/ui/Toaster.spec.ts index ce275ea5c..e0bc9f5c4 100644 --- a/test/nuxt/components/ui/Toaster.spec.ts +++ b/test/nuxt/components/ui/Toaster.spec.ts @@ -1,8 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mountSuspended } from '@nuxt/test-utils/runtime'; import Toaster from '@/components/ui/Toaster.vue'; -import { showToaster } from '@/utils/showToaster'; -import { toast } from 'vue-sonner'; // Mock vue-sonner components and functions vi.mock('vue-sonner', () => ({ @@ -21,8 +19,8 @@ describe('Toaster Component', () => { it('renders with default position (bottom-right)', async () => { const wrapper = await mountSuspended(Toaster); const classes = wrapper.classes().join(' '); - expect(classes).toContain('bottom-0'); - expect(classes).toContain('right-0'); + expect(classes).toContain('bottom-4'); + expect(classes).toContain('right-4'); }); it('renders with position top-left', async () => { @@ -30,8 +28,8 @@ describe('Toaster Component', () => { props: { position: 'top-left' }, }); const classes = wrapper.classes().join(' '); - expect(classes).toContain('top-0'); - expect(classes).toContain('left-0'); + expect(classes).toContain('top-4'); + expect(classes).toContain('left-4'); }); it('renders with position top-center', async () => { @@ -39,7 +37,8 @@ describe('Toaster Component', () => { props: { position: 'top-center' }, }); const classes = wrapper.classes().join(' '); - expect(classes).toContain('top-0'); + expect(classes).toContain('top-4'); + expect(classes).toContain('left-1/2'); expect(classes).toContain('-translate-x-1/2'); }); @@ -48,65 +47,8 @@ describe('Toaster Component', () => { props: { position: 'bottom-center' }, }); const classes = wrapper.classes().join(' '); - expect(classes).toContain('bottom-0'); + expect(classes).toContain('bottom-4'); + expect(classes).toContain('left-1/2'); expect(classes).toContain('-translate-x-1/2'); }); }); - -describe('showToaster utility', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls toast with success icon and styles', () => { - showToaster('success', 'Operation successful!'); - expect(toast).toHaveBeenCalledWith( - expect.stringContaining('✔️ Operation successful!'), - expect.objectContaining({ - style: expect.objectContaining({ - background: 'var(--toaster-bg-success)', - color: 'var(--toaster-text-success)', - }), - }), - ); - }); - - it('calls toast with error icon and styles', () => { - showToaster('error', 'Something went wrong!'); - expect(toast).toHaveBeenCalledWith( - expect.stringContaining('❌ Something went wrong!'), - expect.objectContaining({ - style: expect.objectContaining({ - background: 'var(--toaster-bg-error)', - color: 'var(--toaster-text-error)', - }), - }), - ); - }); - - it('calls toast with warning icon and styles', () => { - showToaster('warning', 'Be careful!'); - expect(toast).toHaveBeenCalledWith( - expect.stringContaining('⚠️ Be careful!'), - expect.objectContaining({ - style: expect.objectContaining({ - background: 'var(--toaster-bg-warning)', - color: 'var(--toaster-text-warning)', - }), - }), - ); - }); - - it('calls toast with info icon and styles', () => { - showToaster('info', 'Information here!'); - expect(toast).toHaveBeenCalledWith( - expect.stringContaining('ℹ️ Information here!'), - expect.objectContaining({ - style: expect.objectContaining({ - background: 'var(--toaster-bg-info)', - color: 'var(--toaster-text-info)', - }), - }), - ); - }); -}); From a63ff356cee0eca94df31ccdc6dc8c7c294c7e44 Mon Sep 17 00:00:00 2001 From: Mostafa Hassan Date: Mon, 15 Dec 2025 16:55:29 +0200 Subject: [PATCH 4/7] test: add unit tests for showToaster utility --- test/nuxt/utils/showToaster.spec.ts | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/nuxt/utils/showToaster.spec.ts diff --git a/test/nuxt/utils/showToaster.spec.ts b/test/nuxt/utils/showToaster.spec.ts new file mode 100644 index 000000000..06cbc6ad3 --- /dev/null +++ b/test/nuxt/utils/showToaster.spec.ts @@ -0,0 +1,56 @@ +// tests/nuxt/utils/showToaster.spec.ts (or wherever your spec is) + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { showToaster } from '@/utils/showToaster'; +import { toast } from 'vue-sonner'; + +// Properly mock useNuxtApp using the recommended Nuxt test-utils approach +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; + +// Mock vue-sonner with the legacy typed methods (success, error, etc.) +vi.mock('vue-sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, +})); + +mockNuxtImport('useNuxtApp', () => { + return () => ({ + $i18n: { + t: vi.fn((msg: string) => `[translated: ${msg}]`), + }, + }); +}); + +describe('showToaster utility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls toast.success with raw message when translate is false', () => { + showToaster('success', 'Operation successful!'); + + expect(toast.success).toHaveBeenCalledWith('Operation successful!'); + }); + + it('calls toast.error correctly', () => { + showToaster('error', 'Something went wrong!'); + + expect(toast.error).toHaveBeenCalledWith('Something went wrong!'); + }); + + it('calls toast.warning correctly', () => { + showToaster('warning', 'Be careful!'); + + expect(toast.warning).toHaveBeenCalledWith('Be careful!'); + }); + + it('calls toast.info correctly', () => { + showToaster('info', 'Here is some info'); + + expect(toast.info).toHaveBeenCalledWith('Here is some info'); + }); +}); From 4444fe411fbc95a3534469fe6c5ce3d64a5ca747 Mon Sep 17 00:00:00 2001 From: Mostafa Hassan Date: Mon, 15 Dec 2025 19:30:00 +0200 Subject: [PATCH 5/7] fix: update showToaster calls to support translation --- app/stores/auth/password.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/stores/auth/password.ts b/app/stores/auth/password.ts index 5212d0b19..bab3b64df 100644 --- a/app/stores/auth/password.ts +++ b/app/stores/auth/password.ts @@ -58,10 +58,10 @@ export const usePasswordStore = defineStore('password', () => { const errorCode = error.data?.data?.error?.code; return [{ field: 'identifier', code: errorCode }]; } else if (error.data?.statusCode === 429) { - showToaster('error', 'toaster.checkUser.rateLimit'); + showToaster('error', 'toaster.checkUser.rateLimit', true); } } else { - showToaster('error', 'toaster.checkUser.error'); + showToaster('error', 'toaster.checkUser.error', true); } } finally { loading.value = false; @@ -82,7 +82,7 @@ export const usePasswordStore = defineStore('password', () => { const errors = error.data?.data?.error.errors; return errors; } else { - showToaster('error', 'toaster.verifyUser.error'); + showToaster('error', 'toaster.verifyUser.error', true); } } finally { loading.value = false; @@ -93,17 +93,17 @@ export const usePasswordStore = defineStore('password', () => { try { await passwordService.resendOtp(confirmationToken.value); step.value = 1; - showToaster('success', 'toaster.resendOtp.success'); + showToaster('success', 'toaster.resendOtp.success', true); } catch (error) { if (isApiError(error) && error.status === 429) { const apiError = error.data?.data; - showToaster('error', apiError?.message || 'toaster.resendOtp.rateLimit'); + showToaster('error', apiError?.message || 'toaster.resendOtp.rateLimit', true); const { retryAfter } = apiError?.error as unknown as { retryAfter: number }; if (retryAfter) { return retryAfter; } } else { - showToaster('error', 'toaster.resendOtp.error'); + showToaster('error', 'toaster.resendOtp.error', true); } } }; @@ -118,14 +118,14 @@ export const usePasswordStore = defineStore('password', () => { await passwordService.resetPassword(data); resetData(); open.value = false; - showToaster('success', 'toaster.resetPassword.success'); + showToaster('success', 'toaster.resetPassword.success', true); router.push('/home'); } catch (error) { if (isApiValidationError(error)) { const errors = error.data?.data?.error.errors; return errors; } else { - showToaster('error', 'toaster.resetPassword.error'); + showToaster('error', 'toaster.resetPassword.error', true); } } finally { loading.value = false; From 7af032b20e9b31deea6ff35394b55c8e403b208f Mon Sep 17 00:00:00 2001 From: Mostafa Hassan Date: Mon, 15 Dec 2025 19:56:30 +0200 Subject: [PATCH 6/7] test: update showToaster calls to include translation support --- test/nuxt/stores/password.spec.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/nuxt/stores/password.spec.ts b/test/nuxt/stores/password.spec.ts index f8c414041..1fa4ebf72 100644 --- a/test/nuxt/stores/password.spec.ts +++ b/test/nuxt/stores/password.spec.ts @@ -127,14 +127,14 @@ describe('Password Store', () => { passwordServiceMock.checkUser.mockRejectedValue(rateLimitError); const store = await createStore(); await store.checkUserExists({ identifier: 'test@example.com', recaptchaToken: 'recap' }); - expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.checkUser.rateLimit'); + expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.checkUser.rateLimit', true); }); it('shows toaster for generic errors', async () => { passwordServiceMock.checkUser.mockRejectedValue(new Error('Network error')); const store = await createStore(); await store.checkUserExists({ identifier: 'fail', recaptchaToken: 'recap' }); - expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.checkUser.error'); + expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.checkUser.error', true); }); }); @@ -176,7 +176,7 @@ describe('Password Store', () => { const store = await createStore(); await store.checkUserExists({ identifier: 'a', recaptchaToken: 'b' }); await store.verifyUser('wrong'); - expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.verifyUser.error'); + expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.verifyUser.error', true); }); }); @@ -188,7 +188,7 @@ describe('Password Store', () => { await store.checkUserExists({ identifier: 'a', recaptchaToken: 'b' }); await store.resendOtp(); expect(passwordServiceMock.resendOtp).toHaveBeenCalledWith('token'); - expect(showToasterMock).toHaveBeenCalledWith('success', 'toaster.resendOtp.success'); + expect(showToasterMock).toHaveBeenCalledWith('success', 'toaster.resendOtp.success', true); expect(store.step).toBe(1); }); @@ -218,7 +218,7 @@ describe('Password Store', () => { const store = await createStore(); await store.checkUserExists({ identifier: 'a', recaptchaToken: 'b' }); await store.resendOtp(); - expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.resendOtp.error'); + expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.resendOtp.error', true); }); }); @@ -231,7 +231,11 @@ describe('Password Store', () => { const store = await createStore(); await store.checkUserExists({ identifier: 'x', recaptchaToken: 'y' }); await store.resetPassword('Pass123!'); - expect(showToasterMock).toHaveBeenCalledWith('success', 'toaster.resetPassword.success'); + expect(showToasterMock).toHaveBeenCalledWith( + 'success', + 'toaster.resetPassword.success', + true, + ); expect(routerMock.push).toHaveBeenCalledWith('/home'); expect(store.step).toBe(0); expect(store.open).toBe(false); @@ -265,7 +269,7 @@ describe('Password Store', () => { const store = await createStore(); await store.checkUserExists({ identifier: 'x', recaptchaToken: 'y' }); await store.resetPassword('fail'); - expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.resetPassword.error'); + expect(showToasterMock).toHaveBeenCalledWith('error', 'toaster.resetPassword.error', true); expect(store.loading).toBe(false); }); }); From 4e442218c0f4f51b9e1108e658d378e3afc8ddf6 Mon Sep 17 00:00:00 2001 From: Ahmed Amr Date: Mon, 15 Dec 2025 22:21:39 +0200 Subject: [PATCH 7/7] style: update toaster style --- app/assets/css/main.css | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 789cec635..901e98af4 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -71,7 +71,7 @@ --popover-foreground: #e7e9ea; --input-autofill: #202939; --oauth: #ffffff; - --toaster-bg: #000000; + --toaster-bg: #111111; --toaster-text-success: #18f16b; --toaster-text-error: #ff5252; --toaster-text-warning: #fcd34d; @@ -201,9 +201,9 @@ gap: 12px; padding: 14px 18px; - border-radius: 14px; + border-radius: 8px; - background: color-mix(in oklch, var(--background) 92%, black 8%); + background: var(--toaster-bg); color: var(--foreground); font-size: 14px; @@ -217,25 +217,25 @@ -webkit-backdrop-filter: blur(10px); animation: toast-slide-in 180ms cubic-bezier(0.4, 0, 0.2, 1); + + @apply border-border border-1 shadow-lg; } /* Left accent bar */ [data-sonner-toast]::before { content: ''; position: absolute; - left: 0; - top: 10px; - bottom: 10px; + left: 4px; + top: 6px; + bottom: 6px; width: 4px; border-radius: 999px; background: currentColor; opacity: 0.9; } - /* Toast types */ [data-sonner-toast][data-type='success'] { color: var(--toaster-text-success); - background: var(--toaster-bg); box-shadow: 0 2px 3px -2px var(--toaster-text-success), 0 2px 3px -2px var(--toaster-text-success); @@ -243,7 +243,6 @@ [data-sonner-toast][data-type='error'] { color: var(--toaster-text-error); - background: var(--toaster-bg); box-shadow: 0 2px 3px -2px var(--toaster-text-error), 0 2px 3px -2px var(--toaster-text-error); @@ -251,7 +250,6 @@ [data-sonner-toast][data-type='warning'] { color: var(--toaster-text-warning); - background: var(--toaster-bg); box-shadow: 0 2px 3px -2px var(--toaster-text-warning), 0 2px 3px -1px var(--toaster-text-warning); @@ -259,7 +257,6 @@ [data-sonner-toast][data-type='info'] { color: var(--toaster-text-info); - background: var(--toaster-bg); box-shadow: 0 2px 3px -2px var(--toaster-text-info), 0 2px 3px -2px var(--toaster-text-info);