diff --git a/app/assets/css/main.css b/app/assets/css/main.css index bd4a4f65d..901e98af4 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: #111111; + --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,95 @@ outline: none !important; box-shadow: none !important; } + + [data-sonner-toast] { + position: relative; + display: flex; + align-items: center; + gap: 12px; + + padding: 14px 18px; + border-radius: 8px; + + background: var(--toaster-bg); + + 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); + + @apply border-border border-1 shadow-lg; + } + + /* Left accent bar */ + [data-sonner-toast]::before { + content: ''; + position: absolute; + 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); + 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); + 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); + 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); + 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/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 @@ 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; 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); } 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)', - }), - }), - ); - }); -}); 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); }); }); 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'); + }); +});