diff --git a/astro.config.mjs b/astro.config.mjs index d937ef1..2a1c436 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -17,5 +17,5 @@ export default defineConfig({ }, integrations: [react()], - adapter: cloudflare({workerEntryPoint: {path: "./src/worker.ts"}}) + adapter: cloudflare({ workerEntryPoint: { path: "./src/worker.ts" } }) }); \ No newline at end of file diff --git a/src/actions/index.ts b/src/actions/index.ts index e142468..d6bac8a 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,13 +1,10 @@ -import { login } from "./login"; -import { logout } from "./logout"; -import * as websiteActions from '../modules/website/website.actions' -import { verifyTrackingScript } from "./verify-tracking-script"; +import * as websiteActions from '@/modules/website/website.actions' +import { login , logout } from "@/modules/auth/auth.actions"; import { dashboardActions } from "@/modules/dashboard/dashboard.actions"; export const server = { login, logout, ...websiteActions, - verifyTrackingScript, dashboardActions }; \ No newline at end of file diff --git a/src/actions/logout.ts b/src/actions/logout.ts deleted file mode 100644 index a2dbf06..0000000 --- a/src/actions/logout.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineAction } from 'astro:actions'; - -export const logout = defineAction({ - handler: async (_, context) => { - // Delete the authentication cookie - context.cookies.delete('auth_token', { - path: '/', - }); - - return { success: true }; - }, -}); \ No newline at end of file diff --git a/src/actions/verify-tracking-script.ts b/src/actions/verify-tracking-script.ts deleted file mode 100644 index 85cde64..0000000 --- a/src/actions/verify-tracking-script.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ActionError, defineAction } from "astro:actions"; -import { z } from "astro:schema"; - -export const verifyTrackingScript = defineAction({ - accept: "json", - input: z.object({ - url: z.string().url(), - websiteId: z.string(), - }), - handler: async ({ url, websiteId }) => { - - const response = await fetch(url, { - headers: { - "User-Agent": "TrackFlow-Verification-Bot/1.0", - }, - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - throw new ActionError({ - code: "BAD_REQUEST", - message: `Could not reach site. Server responded with status ${response.status}.`, - }); - } - - const html = await response.text(); - - const allowedDomains = [ - "localhost:3000", - "tf.saifulalom.com", - "trackflow.saifulalom.com", - ] - - const hasScriptSrc = allowedDomains.some(domain => html.includes(`${domain}/script.js`)); - const hasWebsiteId = html.includes(`data-website-id="${websiteId}"`); - - const isVerified = hasScriptSrc && hasWebsiteId; - - if (isVerified) { - return { - success: isVerified, - id: websiteId - }; - } - - if (!hasScriptSrc) { - throw new ActionError({ - code: "NOT_FOUND", - message: "Tracking script tag could not be found. Please ensure the script tag is added to your website's .", - }); - } - - if (!hasWebsiteId) { - throw new ActionError({ - code: "BAD_REQUEST", - message: "Script detected, but the 'data-website-id' attribute is missing or incorrect for this website.", - }); - } - }, -}); \ No newline at end of file diff --git a/src/hooks/use-action.ts b/src/hooks/use-action.ts index 2d7e18d..8725c2b 100644 --- a/src/hooks/use-action.ts +++ b/src/hooks/use-action.ts @@ -1,4 +1,4 @@ -import { useState, useTransition, useRef } from "react"; +import { useState } from "react"; import { toast } from "sonner"; interface UseActionOptions { @@ -13,61 +13,41 @@ type ActionResult = { error?: { message?: string } | string | null; }; -export function useAction( - actionFn: (input: TInput) => Promise>, - options?: UseActionOptions -) { - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); +export function useAction(actionFn: (input: TInput) => Promise>, options?: UseActionOptions) { - // prevents race conditions (very important for dashboard filters) - const requestIdRef = useRef(0); + const [isLoading, setIsLoading] = useState(false); - const execute = (input: TInput) => { - setError(null); + const execute = async (input: TInput) => { + const toastId = toast.loading(options?.loadingMessage ?? "Processing your request..."); - const toastId = toast.loading( - options?.loadingMessage ?? "Processing your request..." - ); + try { + setIsLoading(true) + const result = await actionFn(input); - const requestId = ++requestIdRef.current; - - startTransition(async () => { - try { - const result = await actionFn(input); - - // ignore stale responses - if (requestId !== requestIdRef.current) return; - - if (result.error) { - const errMsg = - typeof result.error === "string" - ? result.error - : result.error.message ?? "An unexpected error occurred."; - - setError(errMsg); - options?.onError?.(errMsg); - toast.error(errMsg, { id: toastId }); - return; - } - - toast.success(options?.successMessage ?? "Success", { - id: toastId, - }); - - if (result.data) { - options?.onSuccess?.(result.data); - } - } catch (err) { + if (result.error) { const errMsg = - err instanceof Error ? err.message : "Failed to execute action."; + typeof result.error === "string" + ? result.error + : result.error.message ?? "An unexpected error occurred."; - setError(errMsg); options?.onError?.(errMsg); toast.error(errMsg, { id: toastId }); + return; + } + + toast.success(options?.successMessage ?? "Success", { id: toastId }); + + if (result.data) { + options?.onSuccess?.(result.data); } - }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : "Failed to execute action."; + options?.onError?.(errMsg); + toast.error(errMsg, { id: toastId }); + } finally { + setIsLoading(false) + } }; - return { execute, isLoading: isPending, error }; + return { execute , isLoading}; } \ No newline at end of file diff --git a/src/hooks/use-form-action.ts b/src/hooks/use-form-action.ts new file mode 100644 index 0000000..3586f75 --- /dev/null +++ b/src/hooks/use-form-action.ts @@ -0,0 +1,48 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, type UseFormReturn, type DefaultValues, type FieldValues } from "react-hook-form"; +import { useAction } from "@/hooks/use-action"; +import type { ZodType } from "zod"; + +interface UseFormActionOptions { + schema: ZodType; + defaultValues: DefaultValues; + actionFn: (input: TFieldValues) => Promise<{ data?: TData; error?: any }>; + loadingMessage?: string; + successMessage?: string; + onSuccess?: (data: TData) => void; +} + +interface UseFormActionReturn { + form: UseFormReturn; // Add 'any' context parameter to align return types + onSubmit: (data: TFieldValues) => void; +} + +export function useFormAction({ + schema, + defaultValues, + actionFn, + loadingMessage, + successMessage, + onSuccess, +}: UseFormActionOptions): UseFormActionReturn { + + const form = useForm({ + resolver: zodResolver(schema) as any, + defaultValues, + }); + + const { execute } = useAction(actionFn, { + loadingMessage, + successMessage, + onSuccess: (data) => { + form.reset(); + onSuccess?.(data); + }, + }); + + const onSubmit = (data: TFieldValues) => { + execute(data); + }; + + return { form, onSubmit }; +} \ No newline at end of file diff --git a/src/hooks/use-log-in.ts b/src/hooks/use-log-in.ts index 4b45deb..89ea51b 100644 --- a/src/hooks/use-log-in.ts +++ b/src/hooks/use-log-in.ts @@ -15,12 +15,10 @@ export default function useLogIn() { }); // Setup the action runner using your custom hook - const { execute, isLoading, error } = useAction( + const { execute } = useAction( async (data: LoginInput) => { const result = await actions.login(data); - - // Astro actions return an ActionError object on failure. - // We pass it forward or shape it to match your ActionResult type. + return { data: result.data, error: result.error ? { message: result.error.message } : null, @@ -47,10 +45,5 @@ export default function useLogIn() { execute(data); }; - return { - form, - onSubmit, - isPending: isLoading, // Maps to your hook's isLoading state - error - }; + return { form, onSubmit }; } \ No newline at end of file diff --git a/src/actions/login.ts b/src/modules/auth/auth.actions.ts similarity index 79% rename from src/actions/login.ts rename to src/modules/auth/auth.actions.ts index 992936e..159e508 100644 --- a/src/actions/login.ts +++ b/src/modules/auth/auth.actions.ts @@ -1,7 +1,6 @@ -import { loginSchema } from '@/schema/login'; import { defineAction } from 'astro:actions'; - import { SignJWT } from 'jose'; +import { loginSchema } from './auth.schema'; export const login = defineAction({ accept: 'json', @@ -36,4 +35,15 @@ export const login = defineAction({ return { success: true }; }, -}) \ No newline at end of file +}) + +export const logout = defineAction({ + handler: async (_, context) => { + // Delete the authentication cookie + context.cookies.delete('auth_token', { + path: '/', + }); + + return { success: true }; + }, +}); \ No newline at end of file diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..25575c6 --- /dev/null +++ b/src/modules/auth/auth.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'astro:schema'; + +// Single Source of Truth for Constants +export const AUTH_CONFIG = { + PASSWORD_MIN_LENGTH: 6, + PASSWORD_MAX_LENGTH: 50, +} as const; + +// Single Source of Truth for Validation Messages +export const AUTH_MESSAGES = { + EMAIL_INVALID: "Please enter a valid business email address.", + PASSWORD_TOO_SHORT: `Password must be at least ${AUTH_CONFIG.PASSWORD_MIN_LENGTH} characters long.`, + PASSWORD_TOO_LONG: `Password cannot exceed ${AUTH_CONFIG.PASSWORD_MAX_LENGTH} characters.`, +} as const; + +// User-Friendly Schema Configuration +export const loginSchema = z.object({ + email: z + .string() + .trim() + .toLowerCase() + .email({ message: AUTH_MESSAGES.EMAIL_INVALID }), + + password: z + .string() + .min(AUTH_CONFIG.PASSWORD_MIN_LENGTH, { message: AUTH_MESSAGES.PASSWORD_TOO_SHORT }) + .max(AUTH_CONFIG.PASSWORD_MAX_LENGTH, { message: AUTH_MESSAGES.PASSWORD_TOO_LONG }), +}); + +export type LoginInput = z.infer; \ No newline at end of file diff --git a/src/modules/dashboard/components/dashboard.header.tsx b/src/modules/dashboard/components/dashboard.header.tsx index eb1740c..a78e3b0 100644 --- a/src/modules/dashboard/components/dashboard.header.tsx +++ b/src/modules/dashboard/components/dashboard.header.tsx @@ -3,14 +3,14 @@ import { Button } from "@/components/ui/button"; import { useAction } from "@/hooks/use-action"; import { actions } from "astro:actions"; import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, AlertDialogDescription, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogCancel, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, } from "@/components/ui/alert-dialog"; interface DashboardHeaderProps { diff --git a/src/modules/website/components/website.manager.tsx b/src/modules/website/components/website.manager.tsx index 4d06ddc..f6da271 100644 --- a/src/modules/website/components/website.manager.tsx +++ b/src/modules/website/components/website.manager.tsx @@ -61,7 +61,7 @@ export function WebsiteManager({ initialWebsites, errorMsg }: WebsitePageProps) }); // 4. Hook for Script Verification Action - const verifyAction = useAction(actions.verifyTrackingScript, { + const verifyAction = useAction(actions.verifyWebsiteTrackingScript, { successMessage: "Tracking script verified successfully!", loadingMessage: "Verifying tracking script placement...", }); diff --git a/src/modules/website/website.actions.ts b/src/modules/website/website.actions.ts index 8ffe83b..79449e2 100644 --- a/src/modules/website/website.actions.ts +++ b/src/modules/website/website.actions.ts @@ -62,4 +62,63 @@ export const deleteSite = defineAction({ const deletedSite = await siteRepository.delete(db, input.id); return { success: true, data: deletedSite }; }, -}) \ No newline at end of file +}) + + +export const verifyWebsiteTrackingScript = defineAction({ + accept: "json", + input: z.object({ + url: z.string().url(), + websiteId: z.string(), + }), + handler: async ({ url, websiteId }) => { + + const response = await fetch(url, { + headers: { + "User-Agent": "TrackFlow-Verification-Bot/1.0", + }, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + throw new ActionError({ + code: "BAD_REQUEST", + message: `Could not reach site. Server responded with status ${response.status}.`, + }); + } + + const html = await response.text(); + + const allowedDomains = [ + "localhost:3000", + "tf.saifulalom.com", + "trackflow.saifulalom.com", + ] + + const hasScriptSrc = allowedDomains.some(domain => html.includes(`${domain}/script.js`)); + const hasWebsiteId = html.includes(`data-website-id="${websiteId}"`); + + const isVerified = hasScriptSrc && hasWebsiteId; + + if (isVerified) { + return { + success: isVerified, + id: websiteId + }; + } + + if (!hasScriptSrc) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Tracking script tag could not be found. Please ensure the script tag is added to your website's .", + }); + } + + if (!hasWebsiteId) { + throw new ActionError({ + code: "BAD_REQUEST", + message: "Script detected, but the 'data-website-id' attribute is missing or incorrect for this website.", + }); + } + }, +}); \ No newline at end of file diff --git a/src/worker.ts b/src/worker.ts index b4e9119..76f13d4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -9,6 +9,6 @@ export function createExports(manifest: SSRManifest) { async fetch(request: Request, env: Env, ctx: ExecutionContext) { return app.render(request, { locals: { runtime: { env, ctx } } }); }, - } satisfies ExportedHandler, + } }; } \ No newline at end of file