diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff0d4cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +.next +out +.env +.env.local +.env.*.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +coverage diff --git a/data/clients.json b/data/clients.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/clients.json @@ -0,0 +1 @@ +[] diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..082262b --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + swcMinify: true +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..29ef582 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "echo \"No tests defined\"" + }, + "dependencies": { + "next": "14.1.0", + "qrcode": "^1.5.3", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@types/node": "20.11.19", + "@types/react": "18.2.47", + "@types/react-dom": "18.2.18", + "typescript": "5.3.3" + } +} diff --git a/src/lib/clients.ts b/src/lib/clients.ts new file mode 100644 index 0000000..609a4b7 --- /dev/null +++ b/src/lib/clients.ts @@ -0,0 +1,37 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import type { ClientRecord } from '@/types/client'; + +const DATA_DIRECTORY = path.join(process.cwd(), 'data'); +const CLIENTS_FILE = path.join(DATA_DIRECTORY, 'clients.json'); + +async function ensureDataFile() { + try { + await fs.access(CLIENTS_FILE); + } catch { + await fs.mkdir(DATA_DIRECTORY, { recursive: true }); + await fs.writeFile(CLIENTS_FILE, JSON.stringify([], null, 2), 'utf8'); + } +} + +export async function readClients(): Promise { + await ensureDataFile(); + const raw = await fs.readFile(CLIENTS_FILE, 'utf8'); + return JSON.parse(raw) as ClientRecord[]; +} + +export async function writeClients(clients: ClientRecord[]): Promise { + await ensureDataFile(); + await fs.writeFile(CLIENTS_FILE, JSON.stringify(clients, null, 2), 'utf8'); +} + +export async function addClient(client: ClientRecord): Promise { + const clients = await readClients(); + clients.push(client); + await writeClients(clients); +} + +export async function findClientById(id: string): Promise { + const clients = await readClients(); + return clients.find((client) => client.id === id) ?? null; +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx new file mode 100644 index 0000000..9f9e7b5 --- /dev/null +++ b/src/pages/_app.tsx @@ -0,0 +1,6 @@ +import type { AppProps } from 'next/app'; +import '@/styles/globals.css'; + +export default function App({ Component, pageProps }: AppProps) { + return ; +} diff --git a/src/pages/api/clients/[id].ts b/src/pages/api/clients/[id].ts new file mode 100644 index 0000000..80a2d8e --- /dev/null +++ b/src/pages/api/clients/[id].ts @@ -0,0 +1,28 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import QRCode from 'qrcode'; +import { findClientById } from '@/lib/clients'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ success: false, message: 'Method Not Allowed' }); + } + + const { id } = req.query; + if (typeof id !== 'string') { + return res.status(400).json({ success: false, message: 'Invalid client identifier' }); + } + + const client = await findClientById(id); + if (!client) { + return res.status(404).json({ success: false, message: 'Client not found' }); + } + + const qrCode = await QRCode.toDataURL(client.id, { + errorCorrectionLevel: 'M', + margin: 2, + scale: 6 + }); + + return res.status(200).json({ success: true, client, qrCode }); +} diff --git a/src/pages/api/clients/confirm.ts b/src/pages/api/clients/confirm.ts new file mode 100644 index 0000000..b745c98 --- /dev/null +++ b/src/pages/api/clients/confirm.ts @@ -0,0 +1,84 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import QRCode from 'qrcode'; +import { generateClientId } from '@/utils/id'; +import { addClient } from '@/lib/clients'; +import { buildConfirmationEmail } from '@/utils/email'; +import type { ClientInput, ClientRecord } from '@/types/client'; + +type ConfirmResponse = + | { + success: true; + client: ClientRecord; + qrCode: string; + confirmationUrl: string; + emailPreview: { subject: string; html: string; text: string }; + } + | { success: false; message: string }; + +function isClientInput(payload: unknown): payload is ClientInput { + if (typeof payload !== 'object' || payload === null) { + return false; + } + + const { firstName, lastName, email, gender, phone } = payload as Record; + + const isString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; + + if (!isString(firstName) || !isString(lastName) || !isString(email)) { + return false; + } + + if (gender !== 'M' && gender !== 'F') { + return false; + } + + if (phone !== undefined && typeof phone !== 'string') { + return false; + } + + return true; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ success: false, message: 'Method Not Allowed' }); + } + + const payload = req.body; + + if (!isClientInput(payload)) { + return res.status(400).json({ success: false, message: 'Invalid client payload' }); + } + + const clientId = generateClientId(payload.gender); + const client: ClientRecord = { + ...payload, + id: clientId, + createdAt: new Date().toISOString() + }; + + try { + await addClient(client); + const qrCode = await QRCode.toDataURL(clientId, { + errorCorrectionLevel: 'M', + margin: 2, + scale: 6 + }); + + const protocol = (req.headers['x-forwarded-proto'] as string | undefined) ?? 'http'; + const host = req.headers.host ?? 'localhost:3000'; + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? `${protocol}://${host}`; + const confirmationUrl = `${baseUrl.replace(/\/$/, '')}/registration/success?id=${clientId}`; + + const emailPreview = buildConfirmationEmail({ + firstName: client.firstName, + confirmationUrl + }); + + return res.status(201).json({ success: true, client, qrCode, confirmationUrl, emailPreview }); + } catch (error) { + console.error('Failed to confirm client', error); + return res.status(500).json({ success: false, message: 'Failed to confirm client' }); + } +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..6ead824 --- /dev/null +++ b/src/pages/index.tsx @@ -0,0 +1,24 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import styles from '@/styles/Home.module.css'; + +export default function Home() { + return ( + <> + + Client Registration Portal + +
+
+

مرحباً بكم في بوابة تسجيل العملاء

+

+ ابدأ عملية التسجيل أو تحقق من رسالة التأكيد للوصول إلى بطاقة النجاح الجديدة. +

+ + عرض نموذج بطاقة النجاح + +
+
+ + ); +} diff --git a/src/pages/registration/success.tsx b/src/pages/registration/success.tsx new file mode 100644 index 0000000..e307ac4 --- /dev/null +++ b/src/pages/registration/success.tsx @@ -0,0 +1,128 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import styles from '@/styles/RegistrationSuccess.module.css'; + +interface SuccessPayload { + success: boolean; + client?: { + id: string; + firstName: string; + lastName: string; + email: string; + gender: 'M' | 'F'; + phone?: string; + createdAt: string; + }; + qrCode?: string; + message?: string; +} + +export default function RegistrationSuccessPage() { + const router = useRouter(); + const { id } = router.query; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [payload, setPayload] = useState(null); + + useEffect(() => { + if (!router.isReady) { + return; + } + + if (typeof id !== 'string') { + setError('معرّف العميل غير صالح.'); + setLoading(false); + return; + } + + const controller = new AbortController(); + + fetch(`/api/clients/${id}`, { signal: controller.signal }) + .then(async (response) => { + const data: SuccessPayload = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.message ?? 'تعذّر تحميل بيانات العميل.'); + } + setPayload(data); + setError(null); + }) + .catch((err: Error) => { + setError(err.message); + setPayload(null); + }) + .finally(() => { + setLoading(false); + }); + + return () => controller.abort(); + }, [id, router.isReady]); + + return ( + <> + + نجاح التسجيل + +
+ {loading &&

جاري تحميل تفاصيل العميل...

} + {!loading && error &&

{error}

} + {!loading && !error && payload?.client && payload.qrCode && ( +
+
+
تم التفعيل بنجاح
+

مرحبا {payload.client.firstName}!

+

+ اكتملت عملية تأكيد تسجيلك. يمكنك مشاركة معرّف العميل الخاص بك أو حفظ رمز QR التالي + للوصول السريع إلى بياناتك. +

+
+ +
+
+ معرّف العميل + {payload.client.id} +
+
+ الاسم الكامل + + {payload.client.firstName} {payload.client.lastName} + +
+
+ البريد الإلكتروني + {payload.client.email} +
+
+ الجنس + {payload.client.gender === 'M' ? 'ذكر' : 'أنثى'} +
+ {payload.client.phone && ( +
+ رقم الجوال + {payload.client.phone} +
+ )} +
+ تاريخ الإنشاء + {new Date(payload.client.createdAt).toLocaleString('ar-SA')} +
+
+ +
+ رمز QR لمعرّف العميل +
+ +
+ + العودة للواجهة الرئيسية + + يمكنك حفظ هذه البطاقة أو مشاركتها مع فريق الدعم. +
+
+ )} +
+ + ); +} diff --git a/src/styles/Home.module.css b/src/styles/Home.module.css new file mode 100644 index 0000000..0b03e03 --- /dev/null +++ b/src/styles/Home.module.css @@ -0,0 +1,46 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 4rem); +} + +.hero { + max-width: 640px; + padding: 3rem; + background: rgba(255, 255, 255, 0.85); + border-radius: 24px; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.12); + text-align: center; + backdrop-filter: blur(12px); +} + +.hero h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: #111827; +} + +.hero p { + font-size: 1.1rem; + line-height: 1.8; + color: #4b5563; +} + +.cta { + margin-top: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.9rem 2.4rem; + background: linear-gradient(135deg, #2563eb, #7c3aed); + color: #fff; + border-radius: 9999px; + font-weight: 600; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.cta:hover { + transform: translateY(-2px); + box-shadow: 0 15px 30px rgba(59, 130, 246, 0.35); +} diff --git a/src/styles/RegistrationSuccess.module.css b/src/styles/RegistrationSuccess.module.css new file mode 100644 index 0000000..4c7077a --- /dev/null +++ b/src/styles/RegistrationSuccess.module.css @@ -0,0 +1,122 @@ +.page { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 4rem); + padding: 2rem; +} + +.card { + width: min(720px, 100%); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(250, 250, 255, 0.9)); + border-radius: 32px; + box-shadow: 0 40px 70px rgba(79, 70, 229, 0.15); + padding: 3rem; + display: grid; + gap: 2rem; +} + +.header { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.header h1 { + margin: 0; + font-size: 2.25rem; + color: #1f2937; +} + +.header p { + margin: 0; + color: #6b7280; + line-height: 1.6; +} + +.clientInfo { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.infoBlock { + background: rgba(255, 255, 255, 0.9); + border-radius: 20px; + padding: 1.25rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); +} + +.infoBlock span { + display: block; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9ca3af; + margin-bottom: 0.35rem; +} + +.infoBlock strong { + font-size: 1.1rem; + color: #111827; +} + +.qrWrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.qrWrapper img { + width: 180px; + height: 180px; + object-fit: contain; + border-radius: 16px; + background: rgba(255, 255, 255, 0.95); + padding: 1rem; + box-shadow: 0 20px 35px rgba(59, 130, 246, 0.25); +} + +.meta { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + color: #6b7280; + font-size: 0.9rem; +} + +.statusBadge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: rgba(16, 185, 129, 0.15); + color: #047857; + padding: 0.5rem 1rem; + border-radius: 9999px; + font-weight: 600; +} + +.backLink { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: #2563eb; + font-weight: 600; +} + +.loading, +.error { + text-align: center; + color: #4b5563; +} + +@media (max-width: 600px) { + .card { + padding: 2rem; + } + + .header h1 { + font-size: 1.8rem; + } +} diff --git a/src/styles/globals.css b/src/styles/globals.css new file mode 100644 index 0000000..bb97596 --- /dev/null +++ b/src/styles/globals.css @@ -0,0 +1,26 @@ +:root { + color-scheme: light dark; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f5f5f5; + color: #1f2933; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + min-height: 100vh; + background: linear-gradient(135deg, #f0f4ff 0%, #f9fafc 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +main { + padding: 2rem; +} diff --git a/src/types/client.ts b/src/types/client.ts new file mode 100644 index 0000000..6aab804 --- /dev/null +++ b/src/types/client.ts @@ -0,0 +1,14 @@ +export type Gender = 'M' | 'F'; + +export interface ClientInput { + firstName: string; + lastName: string; + email: string; + gender: Gender; + phone?: string; +} + +export interface ClientRecord extends ClientInput { + id: string; + createdAt: string; +} diff --git a/src/utils/email.ts b/src/utils/email.ts new file mode 100644 index 0000000..ce10aca --- /dev/null +++ b/src/utils/email.ts @@ -0,0 +1,25 @@ +interface ConfirmationEmailOptions { + firstName: string; + confirmationUrl: string; +} + +export function buildConfirmationEmail({ firstName, confirmationUrl }: ConfirmationEmailOptions): { subject: string; html: string; text: string } { + const subject = 'تأكيد تسجيل العميل'; + const text = `مرحباً ${firstName},\n\nاضغط على الرابط التالي لإكمال عملية تأكيد تسجيلك: ${confirmationUrl}`; + const html = ` +
+

مرحباً ${firstName}،

+

شكراً لانضمامك إلى منصتنا. لإكمال عملية التسجيل يرجى الضغط على الزر التالي:

+

+ + تأكيد التسجيل + +

+

في حال عدم تمكنك من الضغط على الزر، انسخ الرابط التالي والصقه في متصفحك:

+

${confirmationUrl}

+

تحياتنا،
فريق خدمة العملاء

+
+ `; + + return { subject, html, text }; +} diff --git a/src/utils/id.ts b/src/utils/id.ts new file mode 100644 index 0000000..23220ec --- /dev/null +++ b/src/utils/id.ts @@ -0,0 +1,23 @@ +const GENDER_SEGMENT: Record<'M' | 'F', string> = { + M: '01', + F: '02' +}; + +/** + * يولّد معرّف عميل مطابق للنمط CIDYYMMGG#### حيث: + * - YY تمثل آخر رقمين من السنة الحالية. + * - MM تمثل الشهر الحالي مكون من خانتين. + * - GG تمثل رمز الجنس (01 للذكور، 02 للإناث). + * - #### سلسلة عشوائية من أربعة أرقام لضمان التفرد. + */ +export function generateClientId(gender: 'M' | 'F'): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const genderCode = GENDER_SEGMENT[gender]; + const random = Math.floor(Math.random() * 10000) + .toString() + .padStart(4, '0'); + + return `CID${year}${month}${genderCode}${random}`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..168869f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/utils/*": ["src/utils/*"], + "@/lib/*": ["src/lib/*"], + "@/types/*": ["src/types/*"], + "@/styles/*": ["src/styles/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}