diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..85182f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +# Marketing site environment variables. +# Copy to .env.local and fill in values. Never commit .env.local. + +# ─── App URL ─────────────────────────────────────────────────────── +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_ROOT_DOMAIN= + +# ─── Auth.js ─────────────────────────────────────────────────────── +NEXTAUTH_URL=http://localhost:3000 +AUTH_SECRET= +NEXTAUTH_SECRET= + +# ─── OAuth providers ─────────────────────────────────────────────── +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= + +# ─── Database (Neon serverless Postgres) ─────────────────────────── +DATABASE_URL= + +# ─── Email (Resend) ──────────────────────────────────────────────── +RESEND_API_KEY= +EMAIL_FROM= + +# ─── AI chatbot (Groq) ───────────────────────────────────────────── +GROQ_API_KEY= + +# ─── Payments (Stripe) ───────────────────────────────────────────── +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= +NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PLAN_ID= +NEXT_PUBLIC_STRIPE_PRO_YEARLY_PLAN_ID= +NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PLAN_ID= +NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PLAN_ID= +NEXT_PUBLIC_STRIPE_ULTRA_MONTHLY_PLAN_ID= +NEXT_PUBLIC_STRIPE_ULTRA_YEARLY_PLAN_ID= + +# ─── Image CDN (ImageKit) ────────────────────────────────────────── +IMAGEKIT_PUBLIC_KEY= +IMAGEKIT_PRIVATE_KEY= +IMAGEKIT_URL_ENDPOINT= +NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT= + +# ─── GitHub (report-issue → creates issues in databayt/marketing) ── +GITHUB_PERSONAL_ACCESS_TOKEN= +GITHUB_REPO=databayt/marketing + +# ─── Optional: misc ──────────────────────────────────────────────── +TELEGRAM_BOT_TOKEN= diff --git a/.gitignore b/.gitignore index 744732e..1d8115a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index ae45db2..fac479c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,88 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Marketing — databayt.org -## Getting Started +Public landing site for [databayt](https://databayt.org). Bilingual (English / Arabic with full RTL), bills via Stripe, hosts the AI consult chatbot, and lets visitors report issues directly into GitHub. -First, run the development server: +Part of the [databayt](https://github.com/databayt) family. See sibling repos: [hogwarts](https://github.com/databayt/hogwarts), [souq](https://github.com/databayt/souq), [mkan](https://github.com/databayt/mkan), [shifa](https://github.com/databayt/shifa), [kun](https://github.com/databayt/kun). + +## Stack + +- Next.js 16.1 (App Router, Turbopack dev) +- React 19, TypeScript 5 +- Tailwind CSS 4 + shadcn/ui (Radix primitives) +- Prisma 7 + Neon serverless Postgres +- Auth.js v5 (Google + Facebook OAuth, credentials, 2FA) +- Stripe + Resend + Groq (Llama 3.1 chatbot) +- ImageKit CDN + +## Quickstart ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +pnpm install +cp .env.example .env.local # fill in values +pnpm dev # http://localhost:3000 ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +The dev server uses Turbopack and supports HMR. Routes are prefixed with the locale (`/en/...`, `/ar/...`); the proxy in `proxy.ts` handles locale detection from the `NEXT_LOCALE` cookie + `Accept-Language` header. + +## Scripts -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +| Command | What it does | +| --- | --- | +| `pnpm dev` | Dev server on :3000 | +| `pnpm build` | Production build (runs `prisma generate` first) | +| `pnpm start` | Serve the production build | +| `pnpm lint` | ESLint | + +## Architecture + +``` +src/ + app/[lang]/ # locale-segmented routes + (marketing)/ # public pages with SiteHeader/Footer + (auth)/ # login, join, reset, new-password, verification + chatbot/ # standalone chatbot page + wizard/ # multi-step onboarding wizard + components/ + marketing/ # landing-page sections + auth/ # auth flows + server actions + chatbot/ # AI chat widget + template/ # site shell (header, footer, wizard chrome) + ui/ # shadcn primitives + internationalization/ # en.json, ar.json, dictionaries.ts + lib/ + actions/ # cross-cutting server actions + use-translations.ts # client-side translation hook + env.mjs # zod-validated env schema (t3-oss) + routes.ts # public/auth route lists, default redirect +proxy.ts # Next 16 middleware-equivalent (locale detection) +prisma/schema.prisma # User, ServicePackage, Project, Payment, Inquiry, … +``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Internationalization -## Learn More +Dictionaries live at `src/components/internationalization/{en,ar}.json`. Both files mirror the same key shape — adding a key in one means adding it in the other. -To learn more about Next.js, take a look at the following resources: +Server components: `getDictionary(locale)` (`dictionaries.ts`). +Client components: `useTranslations()` returns `{ t, locale, isRTL, localeConfig }`. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +The locale-aware `Rubik` font auto-applies on RTL routes; LTR uses `GeistSans`. Use logical Tailwind utilities (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`) instead of physical ones — they flip automatically under `dir=rtl`. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Report-an-issue -## Deploy on Vercel +A floating Bug-icon button is mounted globally in `[lang]/layout.tsx`. It opens a dialog, posts to the `reportIssue` server action, and creates a GitHub issue in `databayt/marketing` with `report` label, page URL, browser, viewport, and direction metadata. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +Requires `GITHUB_PERSONAL_ACCESS_TOKEN` (repo scope) in env. Without it, the action returns an error and the toast shows the localized failure message — the UI degrades gracefully. + +## Deployment + +Vercel auto-deploys on push to `main`. Preview deploys on PR. Mirror new `.env.example` keys into Vercel project env (Production + Preview). Never set per-deploy overrides via the dashboard — `.env.local` is the source of truth locally and Vercel env mirrors it. + +## Contributing + +See `CLAUDE.md` for AI-coding-agent conventions, and follow the GitHub workflow: + +``` +issue → branch (feat|fix|chore/) → atomic commits → PR (Closes #N) → squash merge +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. -# Trigger rebuild +Conventional Commits, present tense, body explains the *why*. diff --git a/next.config.ts b/next.config.ts index 8a51cc9..68be8a2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,8 +2,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { typescript: { + // TODO: re-enable once pre-existing pricing/wizard `any` types are + // tightened (tracked separately). ignoreBuildErrors: true, }, + compiler: { + // Strip console.* from production builds, keeping warn/error. + removeConsole: + process.env.NODE_ENV === 'production' ? { exclude: ['warn', 'error'] } : false, + }, images: { remotePatterns: [ { diff --git a/src/app/[lang]/(marketing)/layout.tsx b/src/app/[lang]/(marketing)/layout.tsx index 6cc76d8..54a53ba 100644 --- a/src/app/[lang]/(marketing)/layout.tsx +++ b/src/app/[lang]/(marketing)/layout.tsx @@ -13,14 +13,7 @@ export default function SiteLayout({ const chatbotRef = useRef<{ openChat: () => void }>(null); const handleChatClick = () => { - // This will be called from the mobile header button - console.log('handleChatClick called', { chatbotRef: chatbotRef.current }); - if (chatbotRef.current?.openChat) { - console.log('Opening chat...'); - chatbotRef.current.openChat(); - } else { - console.warn('Chat ref or openChat method not available'); - } + chatbotRef.current?.openChat?.(); }; return ( diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 7885e5f..2a208ef 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -8,9 +8,16 @@ import { ThemeProvider } from "@/components/atom/theme-provider"; import { ImageKitProvider } from "@/components/ui/imagekit-provider"; import { Toaster } from "sonner"; import { getDictionary } from "@/components/internationalization/dictionaries"; -import { type Locale, localeConfig } from "@/components/internationalization/config"; -// import { SessionProvider } from "next-auth/react"; -// import { auth } from "@/auth"; +import { i18n, type Locale, localeConfig } from "@/components/internationalization/config"; +import { SessionProvider } from "next-auth/react"; +import { auth } from "@/auth"; +import { ReportIssue } from "@/components/report-issue"; + +function resolveLocale(rawLang: string): Locale { + return (i18n.locales as readonly string[]).includes(rawLang) + ? (rawLang as Locale) + : i18n.defaultLocale; +} // Configure Rubik font for Arabic const rubik = Rubik({ @@ -22,16 +29,15 @@ const rubik = Rubik({ export const viewport: Viewport = { width: 'device-width', initialScale: 1, - maximumScale: 1, - userScalable: false, }; export async function generateMetadata({ params, }: { - params: Promise<{ lang: Locale }>; + params: Promise<{ lang: string }>; }): Promise { - const { lang } = await params; + const { lang: rawLang } = await params; + const lang = resolveLocale(rawLang); const dict = await getDictionary(lang); const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://databayt.org'; @@ -78,13 +84,13 @@ export default async function LocaleLayout({ params, }: Readonly<{ children: React.ReactNode; - params: Promise<{ lang: Locale }>; + params: Promise<{ lang: string }>; }>) { - // const session = await auth(); - const { lang } = await params; + const [{ lang: rawLang }, session] = await Promise.all([params, auth()]); + const lang = resolveLocale(rawLang); const config = localeConfig[lang]; const isRTL = config.dir === 'rtl'; - + return ( - {/* */} - - - -
- - - {children} + + + +
+ + {children} +
+
- - - - {/* */} +
+
+
+
); diff --git a/src/app/[lang]/not-found.tsx b/src/app/[lang]/not-found.tsx index deffbb2..bc5e46b 100644 --- a/src/app/[lang]/not-found.tsx +++ b/src/app/[lang]/not-found.tsx @@ -1,12 +1,16 @@ import Link from 'next/link'; +import { cookies } from 'next/headers'; import { Button } from '@/components/ui/button'; import { getDictionary } from '@/components/internationalization/dictionaries'; -import type { Locale } from '@/components/internationalization/config'; +import { i18n, type Locale } from '@/components/internationalization/config'; export default async function NotFound() { - // For not-found pages, we can't reliably get locale from params - // so we'll default to English and handle this gracefully - const dict = await getDictionary('en'); + const cookieStore = await cookies(); + const cookieLocale = cookieStore.get('NEXT_LOCALE')?.value; + const locale: Locale = (i18n.locales as readonly string[]).includes(cookieLocale ?? '') + ? (cookieLocale as Locale) + : i18n.defaultLocale; + const dict = await getDictionary(locale); return (
@@ -16,11 +20,9 @@ export default async function NotFound() {

{dict.errors.notFound}

); -} \ No newline at end of file +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index e6c4153..b44a389 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1 @@ -console.log("Auth configuration:", { - NEXTAUTH_URL: process.env.NEXTAUTH_URL, - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID?.slice(0, 10) + "...", - callbackUrl: `${process.env.NEXTAUTH_URL}/api/auth/callback/google` -}); - -export { GET, POST } from "@/auth" \ No newline at end of file +export { GET, POST } from "@/auth" diff --git a/src/app/robots.ts b/src/app/robots.ts index d05c196..8bc13a7 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,13 +1,16 @@ import { MetadataRoute } from 'next'; -const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3001'; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; export default function robots(): MetadataRoute.Robots { return { - rules: { - userAgent: '*', - allow: '/', - }, + rules: [ + { + userAgent: '*', + allow: '/', + disallow: ['/api/', '/login', '/join', '/reset', '/new-password', '/new-verification', '/wizard'], + }, + ], sitemap: `${baseUrl}/sitemap.xml`, }; } \ No newline at end of file diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 53f10cb..ad1f303 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,15 +1,16 @@ import { MetadataRoute } from 'next'; import { type Locale, i18n } from '@/components/internationalization/config'; -const locales: Locale[] = i18n.locales; -const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3001'; +const locales: readonly Locale[] = i18n.locales; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; -// Define all your pages here +// Public pages indexed for search. Auth/wizard intentionally omitted. const pages = [ - '', // home page + '', // home '/about', - '/pricing', + '/pricing', '/service', + '/chatbot', ]; export default function sitemap(): MetadataRoute.Sitemap { diff --git a/src/auth.ts b/src/auth.ts index de539a0..e534810 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -45,7 +45,6 @@ export const { }, events: { async linkAccount({ user }) { - console.log("OAuth account linked:", user.email); if (user.id) { await db.user.update({ where: { id: user.id }, @@ -53,26 +52,11 @@ export const { }) } }, - async signIn({ user, account, isNewUser }) { - console.log("Sign-in event:", { - userId: user.id, - email: user.email, - provider: account?.provider, - isNewUser - }); - } }, callbacks: { async signIn({ user, account }) { - // Log sign-in attempt for debugging - console.log("Sign-in attempt:", { - userId: user.id, - provider: account?.provider, - email: user.email - }); - if (!user.id) return false - + if (account?.provider !== "credentials") return true const existingUser = await getUserById(user.id) @@ -119,7 +103,7 @@ export const { const existingAccount = await getAccountByUserId(existingUser.id) token.isOAuth = !!existingAccount - token.name = existingUser.username + token.name = existingUser.name token.email = existingUser.email token.role = existingUser.role token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled @@ -129,7 +113,6 @@ export const { }, adapter: PrismaAdapter(db), session: { strategy: "jwt" }, - // Enable debug mode temporarily to get detailed error information - debug: true, // Set to true for both dev and production to debug + debug: process.env.NODE_ENV === "development", ...authConfig, -}) \ No newline at end of file +}) diff --git a/src/components/auth/header.tsx b/src/components/auth/header.tsx index a2af99d..6734ecb 100644 --- a/src/components/auth/header.tsx +++ b/src/components/auth/header.tsx @@ -1,30 +1,16 @@ -import { Poppins } from "next/font/google"; - import { cn } from "@/lib/utils"; -const font = Poppins({ - subsets: ["latin"], - weight: ["600"], -}); - interface HeaderProps { label: string; -}; +} -export const Header = ({ - label, -}: HeaderProps) => { +export const Header = ({ label }: HeaderProps) => { return (
-

+

{/* 🔐 Auth */}

-

- {label} -

+

{label}

); }; diff --git a/src/components/auth/join/form.tsx b/src/components/auth/join/form.tsx index 1b317a8..6f305bd 100644 --- a/src/components/auth/join/form.tsx +++ b/src/components/auth/join/form.tsx @@ -26,10 +26,13 @@ import { register } from "./action"; import { FormError } from "../error/form-error"; import { FormSuccess } from "../form-success"; import { Social } from "../social"; +import { useTranslations } from "@/lib/use-translations"; + export const RegisterForm = ({ className, ...props }: React.ComponentPropsWithoutRef<"div">) => { + const { t } = useTranslations(); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [isPending, startTransition] = useTransition(); @@ -68,7 +71,7 @@ export const RegisterForm = ({
- Or continue with + {t.auth.continueWith}
@@ -82,7 +85,7 @@ export const RegisterForm = ({ @@ -98,7 +101,7 @@ export const RegisterForm = ({ @@ -115,7 +118,7 @@ export const RegisterForm = ({ @@ -127,18 +130,18 @@ export const RegisterForm = ({ -
- Already have an account? + {t.auth.alreadyHaveAccount}
diff --git a/src/components/auth/login/form.tsx b/src/components/auth/login/form.tsx index 7508aed..a405bb8 100644 --- a/src/components/auth/login/form.tsx +++ b/src/components/auth/login/form.tsx @@ -29,15 +29,17 @@ import { login } from "./action"; import { FormError } from "../error/form-error"; import { FormSuccess } from "../form-success"; import { Social } from "../social"; +import { useTranslations } from "@/lib/use-translations"; export const LoginForm = ({ className, ...props }: React.ComponentPropsWithoutRef<"div">) => { + const { t } = useTranslations(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get("callbackUrl"); const urlError = searchParams.get("error") === "OAuthAccountNotLinked" - ? "Email already in use with different provider!" + ? t.auth.somethingWrong : ""; const [showTwoFactor, setShowTwoFactor] = useState(false); @@ -93,7 +95,7 @@ export const LoginForm = ({
- Or continue with + {t.auth.continueWith}
@@ -108,7 +110,7 @@ export const LoginForm = ({ @@ -129,7 +131,7 @@ export const LoginForm = ({ id="email" type="email" disabled={isPending} - placeholder="Email" + placeholder={t.auth.email} /> @@ -147,14 +149,14 @@ export const LoginForm = ({ id="password" type="password" disabled={isPending} - placeholder="Password" + placeholder={t.auth.password} /> - Forgot password? + {t.auth.forgotPassword} @@ -167,13 +169,13 @@ export const LoginForm = ({
- Don't have an account? + {t.auth.dontHaveAccount}
diff --git a/src/components/auth/mail.ts b/src/components/auth/mail.ts index d9ef3f6..9d53656 100644 --- a/src/components/auth/mail.ts +++ b/src/components/auth/mail.ts @@ -4,26 +4,15 @@ const resend = new Resend(process.env.RESEND_API_KEY); const domain = process.env.NEXT_PUBLIC_APP_URL; -// Debugging: Log the domain value to ensure it's correctly set -console.log("Domain used for email links:", domain); - export const sendTwoFactorTokenEmail = async (email: string, token: string) => { - // Debugging: Log the input values - console.log("Sending 2FA email to:", email); - console.log("2FA Token:", token); - try { - const response = await resend.emails.send({ - from: 'no-reply@databayt.org', + await resend.emails.send({ + from: "no-reply@databayt.org", to: email, subject: "2FA Code", html: `

Your 2FA code: ${token}

`, }); - - // Debugging: Log the response from Resend API - console.log("2FA email sent successfully, response:", response); } catch (error) { - // Debugging: Log the error if sending email fails console.error("Error sending 2FA email:", error); } }; @@ -31,21 +20,14 @@ export const sendTwoFactorTokenEmail = async (email: string, token: string) => { export const sendPasswordResetEmail = async (email: string, token: string) => { const resetLink = `${domain}/new-password?token=${token}`; - // Debugging: Log the reset link to ensure it's correctly built - console.log("Password reset link:", resetLink); - try { - const response = await resend.emails.send({ - from: 'no-reply@databayt.org', + await resend.emails.send({ + from: "no-reply@databayt.org", to: email, - subject: 'Reset your password', + subject: "Reset your password", html: `

Click here to reset password.

`, }); - - // Debugging: Log the response from Resend API - console.log("Password reset email sent successfully, response:", response); } catch (error) { - // Debugging: Log the error if sending email fails console.error("Error sending password reset email:", error); } }; @@ -53,25 +35,15 @@ export const sendPasswordResetEmail = async (email: string, token: string) => { export const sendVerificationEmail = async (email: string, token: string) => { const confirmLink = `${domain}/new-verification?token=${token}`; - // Debugging: Log the confirmation link to ensure it's correctly built - console.log("Email confirmation link:", confirmLink); - try { - const response = await resend.emails.send({ - from: 'support@databayt.org', + await resend.emails.send({ + from: "support@databayt.org", to: email, subject: "Confirm your email", html: `

Click here to confirm email.

`, - text: `Click the following link to confirm your email: ${confirmLink}` + text: `Click the following link to confirm your email: ${confirmLink}`, }); - - // Debugging: Log the response from Resend API - console.log("Verification email sent successfully, response:", response); } catch (error) { console.error("Error sending verification email:", error); - if (error instanceof Error) { - console.error("Error message:", error.message); - console.error("Error stack:", error.stack); - } } }; diff --git a/src/components/auth/password/form.tsx b/src/components/auth/password/form.tsx index 2c1a72c..243c71c 100644 --- a/src/components/auth/password/form.tsx +++ b/src/components/auth/password/form.tsx @@ -26,11 +26,13 @@ import { NewPasswordSchema } from "../validation"; import { newPassword } from "./action"; import { FormError } from "../error/form-error"; import { FormSuccess } from "../form-success"; +import { useTranslations } from "@/lib/use-translations"; export const NewPasswordForm = ({ className, ...props }: React.ComponentPropsWithoutRef<"div">) => { + const { t } = useTranslations(); const searchParams = useSearchParams(); const token = searchParams.get("token"); @@ -62,7 +64,7 @@ export const NewPasswordForm = ({
-

Enter a new password

+

{t.auth.enterNewPassword}

@@ -77,7 +79,7 @@ export const NewPasswordForm = ({ @@ -89,18 +91,18 @@ export const NewPasswordForm = ({ -
- Back to login + {t.auth.backToLogin}
diff --git a/src/components/auth/tokens.ts b/src/components/auth/tokens.ts index 608a40f..9f9aabf 100644 --- a/src/components/auth/tokens.ts +++ b/src/components/auth/tokens.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid"; import { getTwoFactorTokenByEmail } from "@/components/auth/verification/2f-token"; import { db } from "@/lib/db"; import { getPasswordResetTokenByEmail } from "@/components/auth/password/token"; -import { getVerificationTokenByEmail } from "@/components/auth/verification/verificiation-token"; +import { getVerificationTokenByEmail } from "@/components/auth/verification/verification-token"; diff --git a/src/components/auth/verification/action.ts b/src/components/auth/verification/action.ts index f9d4de0..51b56b5 100644 --- a/src/components/auth/verification/action.ts +++ b/src/components/auth/verification/action.ts @@ -1,43 +1,27 @@ "use server"; import { db } from "@/lib/db"; -import { getVerificationTokenByToken } from "./verificiation-token"; +import { getVerificationTokenByToken } from "./verification-token"; import { getUserByEmail } from "../user"; - export const newVerification = async (token: string) => { - console.log("New verification initiated. Token received:", token); - const existingToken = await getVerificationTokenByToken(token); - console.log("Token from database:", existingToken); if (!existingToken) { const existingUser = await getUserByEmail(token); if (existingUser && existingUser.emailVerified) { return { success: "Email already verified!" }; - } else { - console.error("Token does not exist in the database."); - return { error: "Token does not exist!" }; } + return { error: "Token does not exist!" }; } - console.log("Token exists in the database. Token ID:", existingToken.id); - console.log("Token exists in the database. Token email:", existingToken.email); - console.log("Token exists in the database. Token expiration:", existingToken.expires); - const hasExpired = new Date(existingToken.expires) < new Date(); - console.log("Token expiration status:", hasExpired); - if (hasExpired) { - console.error("Token has expired."); return { error: "Token has expired!" }; } const existingUser = await getUserByEmail(existingToken.email); - console.log("User associated with the token:", existingUser); - if (!existingUser) { - console.error("Email associated with the token does not exist."); return { error: "Email does not exist!" }; } @@ -47,20 +31,18 @@ export const newVerification = async (token: string) => { await db.user.update({ where: { id: existingUser.id }, - data: { + data: { emailVerified: new Date(), email: existingToken.email, - } + }, }); - console.log("User email verified and updated successfully."); + // Best-effort cleanup; failure here doesn't affect the user. setTimeout(async () => { - await db.verificationToken.delete({ - where: { id: existingToken.id } - }); - console.log("Verification token deleted successfully."); + await db.verificationToken + .delete({ where: { id: existingToken.id } }) + .catch(() => {}); }, 9000); - console.log("Verification token deleted successfully."); return { success: "Email verified!" }; -}; \ No newline at end of file +}; diff --git a/src/components/auth/verification/form.tsx b/src/components/auth/verification/form.tsx index e66ba01..e31c7f7 100644 --- a/src/components/auth/verification/form.tsx +++ b/src/components/auth/verification/form.tsx @@ -13,11 +13,13 @@ import { import { FormSuccess } from "../form-success"; import { FormError } from "../error/form-error"; import { newVerification } from "./action"; +import { useTranslations } from "@/lib/use-translations"; export const NewVerificationForm = ({ className, ...props }: React.ComponentPropsWithoutRef<"div">) => { + const { t } = useTranslations(); const [error, setError] = useState(); const [success, setSuccess] = useState(); @@ -28,7 +30,7 @@ export const NewVerificationForm = ({ if (success || error) return; if (!token) { - setError("Missing token!"); + setError(t.auth.missingToken); return; } @@ -38,9 +40,9 @@ export const NewVerificationForm = ({ setError(data.error); }) .catch(() => { - setError("Something went wrong!"); + setError(t.auth.somethingWrong); }); - }, [token, success, error]); + }, [token, success, error, t.auth.missingToken, t.auth.somethingWrong]); useEffect(() => { if (token) { @@ -52,7 +54,7 @@ export const NewVerificationForm = ({
-

Confirming your verification

+

{t.auth.confirmingVerification}

@@ -68,7 +70,7 @@ export const NewVerificationForm = ({
- Back to login + {t.auth.backToLogin}
diff --git a/src/components/auth/verification/verificiation-token.ts b/src/components/auth/verification/verification-token.ts similarity index 100% rename from src/components/auth/verification/verificiation-token.ts rename to src/components/auth/verification/verification-token.ts diff --git a/src/components/chatbot/chat-window.tsx b/src/components/chatbot/chat-window.tsx index 7c490ef..de1c296 100644 --- a/src/components/chatbot/chat-window.tsx +++ b/src/components/chatbot/chat-window.tsx @@ -8,6 +8,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { CHAT_WINDOW_POSITIONS, CHAT_WINDOW_SIZE } from './constant'; import type { ChatWindowProps } from './type'; import { SendIcon, PriceIcon, TimeIcon, ServicesIcon, InfoIcon, VoiceIcon } from './icons'; +import { useTranslations } from '@/lib/use-translations'; export const ChatWindow = memo(function ChatWindow({ isOpen, @@ -26,6 +27,9 @@ export const ChatWindow = memo(function ChatWindow({ const chatWindowRef = useRef(null); const inputRef = useRef(null); const isRTL = locale === 'ar'; + const { t } = useTranslations(); + const preset = t.chatbot.preconfigured; + const controls = t.chatbot.controls; // Auto focus input when chat opens on desktop useEffect(() => { @@ -230,44 +234,44 @@ export const ChatWindow = memo(function ChatWindow({ {isMobile ? (

- Choose a question or type your message + {preset.intro}

@@ -338,38 +342,38 @@ export const ChatWindow = memo(function ChatWindow({
@@ -407,11 +411,12 @@ export const ChatWindow = memo(function ChatWindow({ "hover:scale-110 transition-transform shrink-0 disabled:opacity-50 disabled:hover:scale-100", isMobile ? "h-12 w-12" : "h-10 w-10" )} - title="Send message" + title={controls.send} + aria-label={controls.send} > - + diff --git a/src/components/chatbot/constant.ts b/src/components/chatbot/constant.ts index 7a6de0a..76555bb 100644 --- a/src/components/chatbot/constant.ts +++ b/src/components/chatbot/constant.ts @@ -1,17 +1,18 @@ import type { ChatbotConfig, ChatbotDictionary, ChatbotTheme } from './type'; +// 'right' / 'left' map to logical end / start so RTL flips automatically. export const CHATBOT_POSITIONS = { - 'bottom-right': 'fixed bottom-1 right-1 sm:bottom-2 sm:right-2', - 'bottom-left': 'fixed bottom-1 left-1 sm:bottom-2 sm:left-2', - 'top-right': 'fixed top-1 right-1 sm:top-2 sm:right-2', - 'top-left': 'fixed top-1 left-1 sm:top-2 sm:left-2', + 'bottom-right': 'fixed bottom-1 end-1 sm:bottom-2 sm:end-2', + 'bottom-left': 'fixed bottom-1 start-1 sm:bottom-2 sm:start-2', + 'top-right': 'fixed top-1 end-1 sm:top-2 sm:end-2', + 'top-left': 'fixed top-1 start-1 sm:top-2 sm:start-2', } as const; export const CHAT_WINDOW_POSITIONS = { - 'bottom-right': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:bottom-4 sm:right-2 sm:top-auto', - 'bottom-left': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:bottom-4 sm:left-2 sm:top-auto', - 'top-right': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:top-20 sm:right-2', - 'top-left': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:top-20 sm:left-2', + 'bottom-right': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:bottom-4 sm:end-2 sm:top-auto', + 'bottom-left': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:bottom-4 sm:start-2 sm:top-auto', + 'top-right': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:top-20 sm:end-2', + 'top-left': 'fixed inset-x-4 top-1/2 -translate-y-1/2 sm:inset-x-auto sm:translate-y-0 sm:top-20 sm:start-2', } as const; export const CHAT_WINDOW_SIZE = { diff --git a/src/components/internationalization/ar.json b/src/components/internationalization/ar.json index 59249bc..0fd8c99 100644 --- a/src/components/internationalization/ar.json +++ b/src/components/internationalization/ar.json @@ -1,12 +1,35 @@ { "metadata": { - "title": "Databayt", - "description": "Automate the boring" + "title": "داتابيت", + "description": "أتمتة المهام المملة" }, "chatbot": { "title": "مساعد المحادثة", "startMessage": "ابدأ محادثة...", - "thinking": "أفكر..." + "thinking": "أفكر...", + "preconfigured": { + "intro": "اختر سؤالاً أو اكتب رسالتك", + "pricing": { + "label": "الأسعار", + "prompt": "ما هي خيارات الأسعار لديكم؟" + }, + "services": { + "label": "الخدمات", + "prompt": "ما هي الخدمات التي تقدمونها؟" + }, + "timeline": { + "label": "الجدول الزمني", + "prompt": "كم يستغرق المشروع عادةً؟" + }, + "about": { + "label": "من نحن", + "prompt": "أخبرني المزيد عن شركتكم" + } + }, + "controls": { + "send": "إرسال الرسالة", + "voice": "إدخال صوتي" + } }, "common": { "loading": "جاري التحميل...", @@ -37,17 +60,40 @@ "dark": "داكن", "system": "النظام", "platform": "منصة", - "brandName": "داتابيت" + "brandName": "داتابيت", + "reportIssue": { + "link": "الإبلاغ عن مشكلة", + "title": "الإبلاغ عن مشكلة", + "placeholder": "صف المشكلة...", + "submit": "إرسال", + "submitting": "جاري الإرسال...", + "success": "تم الإرسال. شكراً لك!", + "error": "حدث خطأ. حاول مرة أخرى." + } }, "navigation": { "menu": "القائمة", "close": "إغلاق", - "toggleMenu": "تبديل القائمة" + "toggleMenu": "تبديل القائمة", + "toggleTheme": "تبديل المظهر", + "toggleLanguage": "تبديل اللغة" }, "auth": { "signIn": "تسجيل الدخول", "signUp": "إنشاء حساب", "signOut": "تسجيل الخروج", + "continueWith": "أو تابع باستخدام", + "confirm": "تأكيد", + "login": "دخول", + "join": "انضم", + "name": "الاسم", + "twoFactorCode": "رمز المصادقة الثنائية", + "newPasswordPlaceholder": "كلمة المرور الجديدة", + "enterNewPassword": "أدخل كلمة مرور جديدة", + "confirmingVerification": "جارٍ تأكيد التحقق", + "backToLogin": "العودة لتسجيل الدخول", + "missingToken": "الرمز مفقود!", + "somethingWrong": "حدث خطأ ما", "email": "البريد الإلكتروني", "password": "كلمة المرور", "confirmPassword": "تأكيد كلمة المرور", @@ -636,7 +682,7 @@ "maybeLater": "ربما لاحقاً" }, "getStarted": { - "title": "ابدأ مع دايتابايت", + "title": "ابدأ مع داتابيت", "description": "اختر خدمة لبدء رحلتك", "services": { "web-design": { @@ -744,6 +790,25 @@ "benefit1": "احصل على توصيات الخبراء خلال 24 ساعة", "benefit2": "خارطة طريق مخصصة للمشروع والجدول الزمني", "benefit3": "الوصول المباشر إلى استشاريينا الكبار", - "submit": "احصل على مساعدة الخبراء" + "submit": "احصل على مساعدة الخبراء", + "projectTypes": { + "website": "تطوير موقع ويب", + "ecommerce": "متجر إلكتروني", + "mobile": "تطبيق جوال", + "custom": "برمجيات مخصصة", + "consulting": "استشارات تقنية" + }, + "budgets": { + "lt1000": "أقل من 1,000$", + "1000-5000": "1,000$ - 5,000$", + "5000-10000": "5,000$ - 10,000$", + "10000plus": "10,000$ وأكثر" + }, + "timelines": { + "asap": "في أقرب وقت", + "1month": "خلال شهر", + "3months": "1-3 أشهر", + "6months": "3-6 أشهر" + } } } \ No newline at end of file diff --git a/src/components/internationalization/en.json b/src/components/internationalization/en.json index aeac459..09de98d 100644 --- a/src/components/internationalization/en.json +++ b/src/components/internationalization/en.json @@ -6,7 +6,30 @@ "chatbot": { "title": "Chat Assistant", "startMessage": "Start a conversation...", - "thinking": "I'm thinking..." + "thinking": "I'm thinking...", + "preconfigured": { + "intro": "Choose a question or type your message", + "pricing": { + "label": "Pricing", + "prompt": "What are your pricing options?" + }, + "services": { + "label": "Services", + "prompt": "What services do you offer?" + }, + "timeline": { + "label": "Timeline", + "prompt": "How long does a project take?" + }, + "about": { + "label": "About Us", + "prompt": "Tell me more about your company" + } + }, + "controls": { + "send": "Send message", + "voice": "Voice input" + } }, "common": { "loading": "Loading...", @@ -37,12 +60,23 @@ "dark": "Dark", "system": "System", "platform": "Platform", - "brandName": "Databayt" + "brandName": "Databayt", + "reportIssue": { + "link": "Report an issue", + "title": "Report an issue", + "placeholder": "Describe the issue...", + "submit": "Submit", + "submitting": "Submitting...", + "success": "Submitted. Thank you!", + "error": "Something went wrong. Try again." + } }, "navigation": { "menu": "Menu", "close": "Close", - "toggleMenu": "Toggle menu" + "toggleMenu": "Toggle menu", + "toggleTheme": "Toggle theme", + "toggleLanguage": "Toggle language" }, "auth": { "signIn": "Sign In", @@ -51,15 +85,27 @@ "email": "Email", "password": "Password", "confirmPassword": "Confirm Password", - "forgotPassword": "Forgot Password?", - "resetPassword": "Reset Password", + "forgotPassword": "Forgot password?", + "resetPassword": "Reset password", "createAccount": "Create Account", "alreadyHaveAccount": "Already have an account?", "dontHaveAccount": "Don't have an account?", "enterEmail": "Enter your email", "enterPassword": "Enter your password", "welcomeBack": "Welcome back", - "createNewAccount": "Create a new account" + "createNewAccount": "Create a new account", + "continueWith": "Or continue with", + "confirm": "Confirm", + "login": "Login", + "join": "Join", + "name": "Name", + "twoFactorCode": "Two Factor Code", + "newPasswordPlaceholder": "New Password", + "enterNewPassword": "Enter a new password", + "confirmingVerification": "Confirming your verification", + "backToLogin": "Back to login", + "missingToken": "Missing token!", + "somethingWrong": "Something went wrong" }, "marketing": { "hero": { @@ -744,6 +790,25 @@ "benefit1": "Get expert recommendations within 24 hours", "benefit2": "Personalized project roadmap and timeline", "benefit3": "Direct access to our senior consultants", - "submit": "Get Expert Help" + "submit": "Get Expert Help", + "projectTypes": { + "website": "Website Development", + "ecommerce": "E-commerce Platform", + "mobile": "Mobile Application", + "custom": "Custom Software", + "consulting": "Technical Consulting" + }, + "budgets": { + "lt1000": "Less than $1,000", + "1000-5000": "$1,000 - $5,000", + "5000-10000": "$5,000 - $10,000", + "10000plus": "$10,000+" + }, + "timelines": { + "asap": "ASAP", + "1month": "Within 1 month", + "3months": "1-3 months", + "6months": "3-6 months" + } } } \ No newline at end of file diff --git a/src/components/internationalization/middleware.ts b/src/components/internationalization/middleware.ts deleted file mode 100644 index 59cd5c4..0000000 --- a/src/components/internationalization/middleware.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { match } from '@formatjs/intl-localematcher'; -import Negotiator from 'negotiator'; -import { type NextRequest, NextResponse } from 'next/server'; -import { i18n } from './config'; - -function getLocale(request: NextRequest) { - // 1. Check cookie first for user preference - const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value; - if (cookieLocale && i18n.locales.includes(cookieLocale as any)) { - return cookieLocale; - } - - // 2. Get Accept-Language header - const headers = { - 'accept-language': request.headers.get('accept-language') ?? '', - }; - - // Use negotiator to parse preferred languages - const languages = new Negotiator({ headers }).languages(); - - // Match against supported locales - return match(languages, i18n.locales, i18n.defaultLocale); -} - -export function localizationMiddleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // Check if pathname already has a locale - const pathnameHasLocale = i18n.locales.some( - (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` - ); - - // If locale exists in URL, continue - if (pathnameHasLocale) { - return NextResponse.next(); - } - - // Get best matching locale - const locale = getLocale(request); - - // Redirect to localized URL - request.nextUrl.pathname = `/${locale}${pathname}`; - const response = NextResponse.redirect(request.nextUrl); - - // Set cookie for future visits - response.cookies.set('NEXT_LOCALE', locale, { - maxAge: 365 * 24 * 60 * 60, // 1 year - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }); - - return response; -} \ No newline at end of file diff --git a/src/components/marketing/expert-modal.tsx b/src/components/marketing/expert-modal.tsx index 29ccd02..5cf1891 100644 --- a/src/components/marketing/expert-modal.tsx +++ b/src/components/marketing/expert-modal.tsx @@ -159,11 +159,11 @@ export function ExpertModal({ isOpen, onClose }: ExpertModalProps) { - Website Development - E-commerce Platform - Mobile Application - Custom Software - Technical Consulting + {t.expert.projectTypes.website} + {t.expert.projectTypes.ecommerce} + {t.expert.projectTypes.mobile} + {t.expert.projectTypes.custom} + {t.expert.projectTypes.consulting} {errors.projectType && ( @@ -179,10 +179,10 @@ export function ExpertModal({ isOpen, onClose }: ExpertModalProps) { - Less than $1,000 - $1,000 - $5,000 - $5,000 - $10,000 - $10,000+ + {t.expert.budgets.lt1000} + {t.expert.budgets["1000-5000"]} + {t.expert.budgets["5000-10000"]} + {t.expert.budgets["10000plus"]} @@ -194,10 +194,10 @@ export function ExpertModal({ isOpen, onClose }: ExpertModalProps) { - ASAP - Within 1 month - 1-3 months - 3-6 months + {t.expert.timelines.asap} + {t.expert.timelines["1month"]} + {t.expert.timelines["3months"]} + {t.expert.timelines["6months"]} diff --git a/src/components/report-issue.tsx b/src/components/report-issue.tsx new file mode 100644 index 0000000..5e6d76a --- /dev/null +++ b/src/components/report-issue.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useState, useTransition } from "react" +import { Bug } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" + +import { reportIssue } from "@/lib/actions/report-issue" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form" +import { Textarea } from "@/components/ui/textarea" +import { useTranslations } from "@/lib/use-translations" + +const ReportIssueSchema = z.object({ + description: z.string().trim().min(1).max(5000), +}) + +type ReportIssueValues = z.infer + +interface ReportIssueProps { + variant?: "text" | "icon" + className?: string +} + +function parseBrowser(ua: string): string { + const os = ua.includes("Mac OS") + ? "macOS" + : ua.includes("Windows") + ? "Windows" + : ua.includes("Android") + ? "Android" + : ua.includes("iPhone") || ua.includes("iPad") + ? "iOS" + : ua.includes("Linux") + ? "Linux" + : "Unknown" + if (ua.includes("Firefox/")) return `Firefox / ${os}` + if (ua.includes("Edg/")) return `Edge / ${os}` + if (ua.includes("Chrome/")) return `Chrome / ${os}` + if (ua.includes("Safari/")) return `Safari / ${os}` + return ua.slice(0, 50) +} + +export function ReportIssue({ variant = "text", className }: ReportIssueProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() + const { t } = useTranslations() + const dict = t.common.reportIssue + + const form = useForm({ + resolver: zodResolver(ReportIssueSchema), + defaultValues: { description: "" }, + }) + + const onSubmit = (values: ReportIssueValues) => { + startTransition(async () => { + const result = await reportIssue({ + description: values.description, + pageUrl: window.location.href, + meta: { + viewport: `${window.innerWidth}x${window.innerHeight}`, + direction: document.documentElement.dir || "ltr", + browser: parseBrowser(navigator.userAgent), + }, + }) + + if (result?.error) { + toast.error(dict.error) + return + } + + toast.success(dict.success) + form.reset() + setOpen(false) + }) + } + + return ( + { + setOpen(v) + if (!v) form.reset() + }} + > + + {variant === "icon" ? ( + + ) : ( + + )} + + + + {dict.title} + +
+ + ( + + +