feat: localize auth and portal pages
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Typography } from "poyraz-ui/atoms";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
tr: {
|
||||||
|
title: "Bir şeyler ters gitti",
|
||||||
|
description: "Beklenmeyen bir hata oluştu. Lütfen tekrar dene.",
|
||||||
|
retry: "Tekrar dene",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Something went wrong",
|
||||||
|
description: "An unexpected error occurred. Please try again.",
|
||||||
|
retry: "Try again",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCopy() {
|
||||||
|
const language = typeof document === "undefined" ? "tr" : document.documentElement.lang;
|
||||||
|
return language?.startsWith("en") ? copy.en : copy.tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorPage({ reset }: { reset: () => void }) {
|
||||||
|
const t = getCopy();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
|
||||||
|
<section className="mx-auto max-w-md space-y-6 text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10 text-lg font-semibold text-destructive">
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Typography component="h1" variant="h1" className="text-3xl font-semibold">
|
||||||
|
{t.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography component="p" variant="muted" className="leading-6">
|
||||||
|
{t.description}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Button effect="shine" type="button" onClick={reset}>
|
||||||
|
{t.retry}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
||||||
|
import { LocaleSelectForm } from "@/components/i18n/locale-select-form";
|
||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Alert, AlertDescription } from "poyraz-ui/molecules";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ForgotPasswordPage() {
|
||||||
|
const branding = getPublicBranding();
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["auth"]).t;
|
||||||
|
const localization = new ContentTranslationService(getSqliteConnection().db).getPublicLocalizationContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageShell
|
||||||
|
branding={{
|
||||||
|
applicationName: branding.organizationName ?? branding.applicationName,
|
||||||
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
|
}}
|
||||||
|
title={t("auth.forgot.title")}
|
||||||
|
description={t("auth.forgot.description")}
|
||||||
|
marketing={{
|
||||||
|
headline: t("auth.marketing.headline"),
|
||||||
|
description: t("auth.marketing.description", { app: branding.organizationName ?? branding.applicationName }),
|
||||||
|
openSource: t("auth.marketing.openSource"),
|
||||||
|
github: t("auth.marketing.github"),
|
||||||
|
via: t("auth.marketing.via"),
|
||||||
|
builtBy: t("auth.marketing.builtBy"),
|
||||||
|
highlights: [
|
||||||
|
t("auth.highlights.clients"),
|
||||||
|
t("auth.highlights.calendar"),
|
||||||
|
t("auth.highlights.finance"),
|
||||||
|
t("auth.highlights.reports"),
|
||||||
|
] as [string, string, string, string],
|
||||||
|
}}
|
||||||
|
form={
|
||||||
|
<div className="space-y-6">
|
||||||
|
<LocaleSelectForm label={t("auth.language")} value={locale.locale} locales={localization.locales} />
|
||||||
|
<Alert variant="info" appearance="soft">
|
||||||
|
<AlertDescription>{t("auth.forgot.helper")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
secondaryAction={null}
|
||||||
|
footer={
|
||||||
|
<Link href="/login" className="block text-center text-sm font-medium text-primary hover:text-primary-hover">
|
||||||
|
{t("auth.forgot.back")}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-12
@@ -13,7 +13,10 @@ import {
|
|||||||
} from '@/server/auth/setup'
|
} from '@/server/auth/setup'
|
||||||
import { getDefaultDisplayName, parseAuthCredentials } from '@/server/auth/validation'
|
import { getDefaultDisplayName, parseAuthCredentials } from '@/server/auth/validation'
|
||||||
|
|
||||||
const genericLoginError = 'E-posta veya \u015fifre hatal\u0131.'
|
const LOGIN_ERROR_CODE = 'auth.messages.invalidCredentials'
|
||||||
|
const SETUP_UNAVAILABLE_CODE = 'auth.messages.setupUnavailable'
|
||||||
|
const SETUP_STATE_ERROR_CODE = 'auth.messages.setupStateError'
|
||||||
|
const SIGNUP_FAILED_CODE = 'auth.messages.signupFailed'
|
||||||
type SignInEmailResult = Awaited<ReturnType<typeof auth.api.signInEmail>>
|
type SignInEmailResult = Awaited<ReturnType<typeof auth.api.signInEmail>>
|
||||||
type SignUpEmailResult = Awaited<ReturnType<typeof auth.api.signUpEmail>>
|
type SignUpEmailResult = Awaited<ReturnType<typeof auth.api.signUpEmail>>
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ export async function login(formData: FormData) {
|
|||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
metadata: { reason: 'invalid_credentials' },
|
metadata: { reason: 'invalid_credentials' },
|
||||||
})
|
})
|
||||||
redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`)
|
redirect(`/login?error=true&code=${LOGIN_ERROR_CODE}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = getProfileByAuthUserId(result.user.id)
|
let profile = getProfileByAuthUserId(result.user.id)
|
||||||
@@ -52,7 +55,7 @@ export async function login(formData: FormData) {
|
|||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
metadata: { reason: 'missing_or_disabled_profile' },
|
metadata: { reason: 'missing_or_disabled_profile' },
|
||||||
})
|
})
|
||||||
redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`)
|
redirect(`/login?error=true&code=${LOGIN_ERROR_CODE}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectTarget = profile.role === 'client' ? '/portal' : '/'
|
redirectTarget = profile.role === 'client' ? '/portal' : '/'
|
||||||
@@ -65,15 +68,11 @@ export async function signup(formData: FormData) {
|
|||||||
const setupState = await getFirstFreelancerSetupState()
|
const setupState = await getFirstFreelancerSetupState()
|
||||||
|
|
||||||
if (setupState.errorMessage) {
|
if (setupState.errorMessage) {
|
||||||
redirect(`/register?error=true&message=${encodeURIComponent(setupState.errorMessage)}`)
|
redirect(`/register?error=true&code=${SETUP_STATE_ERROR_CODE}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!setupState.available) {
|
if (!setupState.available) {
|
||||||
redirect(
|
redirect(`/login?error=true&code=${SETUP_UNAVAILABLE_CODE}`)
|
||||||
`/login?error=true&message=${encodeURIComponent(
|
|
||||||
'Kay\u0131t kapal\u0131. Bu Neta kurulumunda ilk freelancer hesab\u0131 zaten olu\u015fturulmu\u015f.',
|
|
||||||
)}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = parseAuthCredentials(formData)
|
const credentials = parseAuthCredentials(formData)
|
||||||
@@ -85,10 +84,9 @@ export async function signup(formData: FormData) {
|
|||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
rememberMe: true,
|
rememberMe: true,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
failFirstFreelancerSetup(credentials.email, 'better_auth_signup_failed')
|
failFirstFreelancerSetup(credentials.email, 'better_auth_signup_failed')
|
||||||
const message = error instanceof Error ? error.message : 'Kullan\u0131c\u0131 olu\u015fturulamad\u0131.'
|
redirect(`/register?error=true&code=${SIGNUP_FAILED_CODE}`)
|
||||||
redirect(`/register?error=true&message=${encodeURIComponent(message)}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
|
|||||||
+57
-14
@@ -1,12 +1,34 @@
|
|||||||
import { login } from "@/app/login/actions";
|
import { login } from "@/app/login/actions";
|
||||||
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
||||||
import { ErrorToaster } from "@/components/error-toaster";
|
import { ErrorToaster } from "@/components/error-toaster";
|
||||||
|
import { LocaleSelectForm } from "@/components/i18n/locale-select-form";
|
||||||
import { LockKeyhole, LogIn, Mail } from "lucide-react";
|
import { LockKeyhole, LogIn, Mail } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Input, Label } from "poyraz-ui/atoms";
|
import { Input, Label } from "poyraz-ui/atoms";
|
||||||
import { Alert, AlertDescription } from "poyraz-ui/molecules";
|
import { Alert, AlertDescription } from "poyraz-ui/molecules";
|
||||||
import { SubmitButton } from "@/components/auth/submit-button";
|
import { SubmitButton } from "@/components/auth/submit-button";
|
||||||
import { getPublicBranding } from "@/server/branding/runtime";
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
|
import type { TranslationValues } from "@/lib/i18n";
|
||||||
|
|
||||||
|
function firstParam(value: string | string[] | undefined): string | null {
|
||||||
|
if (Array.isArray(value)) return value[0] ?? null;
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthMessage(
|
||||||
|
code: string | null,
|
||||||
|
fallback: string | null,
|
||||||
|
t: (key: string, values?: TranslationValues) => string,
|
||||||
|
): string | null {
|
||||||
|
if (!code) return fallback;
|
||||||
|
const key = code.startsWith("auth.") ? code : `auth.${code}`;
|
||||||
|
const message = t(key);
|
||||||
|
return message === key ? fallback : message;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function LoginPage({
|
export default async function LoginPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -14,39 +36,60 @@ export default async function LoginPage({
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const resolvedParams = await searchParams;
|
const resolvedParams = await searchParams;
|
||||||
const error = resolvedParams?.error;
|
const error = firstParam(resolvedParams?.error);
|
||||||
const message = resolvedParams?.message;
|
const code = firstParam(resolvedParams?.code);
|
||||||
|
const rawMessage = firstParam(resolvedParams?.message);
|
||||||
const branding = getPublicBranding();
|
const branding = getPublicBranding();
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["auth"]).t;
|
||||||
|
const localization = new ContentTranslationService(getSqliteConnection().db).getPublicLocalizationContext();
|
||||||
|
const message = resolveAuthMessage(code, rawMessage, t);
|
||||||
|
const marketing = {
|
||||||
|
headline: t("auth.marketing.headline"),
|
||||||
|
description: t("auth.marketing.description", { app: branding.organizationName ?? branding.applicationName }),
|
||||||
|
openSource: t("auth.marketing.openSource"),
|
||||||
|
github: t("auth.marketing.github"),
|
||||||
|
via: t("auth.marketing.via"),
|
||||||
|
builtBy: t("auth.marketing.builtBy"),
|
||||||
|
highlights: [
|
||||||
|
t("auth.highlights.clients"),
|
||||||
|
t("auth.highlights.calendar"),
|
||||||
|
t("auth.highlights.finance"),
|
||||||
|
t("auth.highlights.reports"),
|
||||||
|
] as [string, string, string, string],
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && message && <ErrorToaster message={String(message)} />}
|
{error && message ? <ErrorToaster message={message} /> : null}
|
||||||
<AuthPageShell
|
<AuthPageShell
|
||||||
branding={{
|
branding={{
|
||||||
applicationName: branding.organizationName ?? branding.applicationName,
|
applicationName: branding.organizationName ?? branding.applicationName,
|
||||||
lightLogoUrl: branding.lightLogoUrl,
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
darkLogoUrl: branding.darkLogoUrl,
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
}}
|
}}
|
||||||
title="Giriş yap"
|
title={t("auth.login.title")}
|
||||||
description="Neta çalışma alanına erişmek için hesabına giriş yap."
|
description={t("auth.login.description")}
|
||||||
|
marketing={marketing}
|
||||||
form={
|
form={
|
||||||
<form className="space-y-6">
|
<form className="space-y-6">
|
||||||
{!error && message ? (
|
{!error && message ? (
|
||||||
<Alert variant="success" appearance="soft">
|
<Alert variant="success" appearance="soft">
|
||||||
<AlertDescription>{String(message)}</AlertDescription>
|
<AlertDescription>{message}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
<LocaleSelectForm label={t("auth.language")} value={locale.locale} locales={localization.locales} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="flex items-center gap-2">
|
<Label htmlFor="email" className="flex items-center gap-2">
|
||||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
E-posta
|
{t("auth.login.email")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ornek@mail.com"
|
placeholder={t("auth.login.emailPlaceholder")}
|
||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
@@ -56,13 +99,13 @@ export default async function LoginPage({
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label htmlFor="password" className="flex items-center gap-2">
|
<Label htmlFor="password" className="flex items-center gap-2">
|
||||||
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
|
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
|
||||||
Şifre
|
{t("auth.login.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-sm font-medium text-primary transition-colors hover:text-primary-hover"
|
className="text-sm font-medium text-primary transition-colors hover:text-primary-hover"
|
||||||
>
|
>
|
||||||
Şifremi unuttum
|
{t("auth.login.forgotPassword")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -75,21 +118,21 @@ export default async function LoginPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmitButton size="lg" formAction={login} className="w-full gap-2" pendingText="Giriş yapılıyor...">
|
<SubmitButton size="lg" formAction={login} className="w-full gap-2" pendingText={t("auth.login.pending")}>
|
||||||
<LogIn className="h-4 w-4" />
|
<LogIn className="h-4 w-4" />
|
||||||
Giriş yap
|
{t("auth.login.submit")}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
secondaryAction={null}
|
secondaryAction={null}
|
||||||
footer={
|
footer={
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
İlk kurulumu yapmadın mı?{" "}
|
{t("auth.login.setupPrompt")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="font-medium text-primary transition-colors hover:text-primary-hover"
|
className="font-medium text-primary transition-colors hover:text-primary-hover"
|
||||||
>
|
>
|
||||||
Admin hesabını oluştur
|
{t("auth.login.createAdmin")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button, Typography } from "poyraz-ui/atoms";
|
||||||
|
|
||||||
|
export default async function NotFoundPage() {
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["common"]).t;
|
||||||
|
const branding = getPublicBranding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
|
||||||
|
<section className="mx-auto max-w-md space-y-6 text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-lg font-semibold text-primary">
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Typography component="h1" variant="h1" className="text-3xl font-semibold">
|
||||||
|
{t("common.notFound.title")}
|
||||||
|
</Typography>
|
||||||
|
<Typography component="p" variant="muted" className="leading-6">
|
||||||
|
{t("common.notFound.description")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Button effect="shine" asChild>
|
||||||
|
<Link href="/" aria-label={`${branding.applicationName}: ${t("common.notFound.backHome")}`}>
|
||||||
|
{t("common.notFound.backHome")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-12
@@ -1,14 +1,29 @@
|
|||||||
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
||||||
import { FolderKanban, CheckCircle2, Clock, BarChart } from "lucide-react";
|
import { FolderKanban, CheckCircle2, Clock, BarChart } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { tr } from "date-fns/locale";
|
|
||||||
import { StatCard } from "@/components/system/stat-card";
|
import { StatCard } from "@/components/system/stat-card";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content";
|
||||||
|
import { formatDate } from "@/lib/i18n/format";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
import { requirePortalBackend } from "@/server/web/portal";
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
|
||||||
export default async function PortalDashboardPage() {
|
export default async function PortalDashboardPage() {
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["portal"]).t;
|
||||||
const { actor, service } = await requirePortalBackend();
|
const { actor, service } = await requirePortalBackend();
|
||||||
const projects = service.listProjects(actor);
|
const content = new ContentTranslationService(getSqliteConnection().db);
|
||||||
|
const localization = content.getPublicLocalizationContext();
|
||||||
|
const fallbackLocale = getContentFallbackLocale(locale.locale, localization);
|
||||||
|
const projectRows = service.listProjects(actor);
|
||||||
|
const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id));
|
||||||
|
const projects = projectRows.map((project) => content.resolveEntity("project", project, {
|
||||||
|
locale: locale.locale,
|
||||||
|
fallbackLocale,
|
||||||
|
defaultLocale: locale.defaultLocale,
|
||||||
|
translations: projectTranslations.get(project.id) ?? [],
|
||||||
|
}));
|
||||||
const activeProjects = projects.filter((project) => project.status !== "completed" && project.status !== "cancelled");
|
const activeProjects = projects.filter((project) => project.status !== "completed" && project.status !== "cancelled");
|
||||||
const completedProjects = projects.filter((project) => project.status === "completed");
|
const completedProjects = projects.filter((project) => project.status === "completed");
|
||||||
const avgProgress = projects.length
|
const avgProgress = projects.length
|
||||||
@@ -19,22 +34,22 @@ export default async function PortalDashboardPage() {
|
|||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||||
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">Müşteri Paneli</h1>
|
<h1 className="text-3xl font-semibold tracking-normal text-foreground">{t("portal.dashboard.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<StatCard label="Aktif Projeler" value={String(activeProjects.length)} icon={FolderKanban} tone="blue" />
|
<StatCard label={t("portal.dashboard.activeProjects")} value={String(activeProjects.length)} icon={FolderKanban} tone="blue" />
|
||||||
<StatCard label="Tamamlanan" value={String(completedProjects.length)} icon={CheckCircle2} tone="green" />
|
<StatCard label={t("portal.dashboard.completed")} value={String(completedProjects.length)} icon={CheckCircle2} tone="green" />
|
||||||
<StatCard label="Ortalama İlerleme" value={`%${avgProgress}`} icon={BarChart} tone="amber" />
|
<StatCard label={t("portal.dashboard.averageProgress")} value={`%${avgProgress}`} icon={BarChart} tone="amber" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold">Tüm Projeleriniz</h2>
|
<h2 className="text-xl font-semibold">{t("portal.dashboard.allProjects")}</h2>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground">
|
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground">
|
||||||
Henüz size atanmış bir proje bulunmuyor.
|
{t("portal.projects.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : projects.map((project) => (
|
) : projects.map((project) => (
|
||||||
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
||||||
@@ -49,19 +64,19 @@ export default async function PortalDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge variant={project.status === "completed" ? "secondary" : "default"} className="capitalize text-[10px] px-1.5 py-0">
|
<Badge variant={project.status === "completed" ? "secondary" : "default"} className="capitalize text-[10px] px-1.5 py-0">
|
||||||
{project.status === "completed" ? "Tamamlandı" : project.status === "active" ? "Aktif" : "Beklemede"}
|
{project.status === "completed" ? t("portal.status.project.completed") : project.status === "active" ? t("portal.status.project.active") : t("portal.status.project.waiting")}
|
||||||
</Badge>
|
</Badge>
|
||||||
{project.dueDate && (
|
{project.dueDate && (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<Clock className="h-3.5 w-3.5" />
|
||||||
<span>Teslim: {format(new Date(project.dueDate), "d MMM yyyy", { locale: tr })}</span>
|
<span>{t("portal.labels.delivery")}: {formatDate(project.dueDate, locale.locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 mt-2">
|
<div className="space-y-1.5 mt-2">
|
||||||
<div className="flex items-center justify-between text-xs font-medium">
|
<div className="flex items-center justify-between text-xs font-medium">
|
||||||
<span className="text-muted-foreground">İlerleme</span>
|
<span className="text-muted-foreground">{t("portal.labels.progress")}</span>
|
||||||
<span>%{project.progress}</span>
|
<span>%{project.progress}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
import { DomainError } from "@/server/domain/errors";
|
import { DomainError } from "@/server/domain/errors";
|
||||||
|
import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
import { requirePortalBackend } from "@/server/web/portal";
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
import {
|
import {
|
||||||
PortalProjectClient,
|
PortalProjectClient,
|
||||||
@@ -11,7 +14,11 @@ import {
|
|||||||
|
|
||||||
export default async function PortalProjectPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function PortalProjectPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
const { actor, service } = await requirePortalBackend();
|
const { actor, service } = await requirePortalBackend();
|
||||||
|
const content = new ContentTranslationService(getSqliteConnection().db);
|
||||||
|
const localization = content.getPublicLocalizationContext();
|
||||||
|
const fallbackLocale = getContentFallbackLocale(locale.locale, localization);
|
||||||
let data: {
|
let data: {
|
||||||
project: PortalProjectDetail;
|
project: PortalProjectDetail;
|
||||||
sections: PortalPlanningSection[];
|
sections: PortalPlanningSection[];
|
||||||
@@ -21,32 +28,57 @@ export default async function PortalProjectPage({ params }: { params: Promise<{
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const row = service.getProject(actor, id);
|
const row = service.getProject(actor, id);
|
||||||
|
const projectTranslations = content.listEntityTranslations("project", row.id);
|
||||||
|
const projectRow = content.resolveEntity("project", row, {
|
||||||
|
locale: locale.locale,
|
||||||
|
fallbackLocale,
|
||||||
|
defaultLocale: locale.defaultLocale,
|
||||||
|
translations: projectTranslations,
|
||||||
|
});
|
||||||
const allowance = service.getRevisionAllowance(actor, id);
|
const allowance = service.getRevisionAllowance(actor, id);
|
||||||
|
const sectionRows = service.listPlanningSections(actor, id);
|
||||||
|
const sectionTranslations = content.listBatch("planning_section", sectionRows.map((section) => section.id));
|
||||||
|
const taskRows = service.listTasks(actor, id).filter((task) => task.status !== "cancelled");
|
||||||
|
const taskTranslations = content.listBatch("task", taskRows.map((task) => task.id));
|
||||||
data = {
|
data = {
|
||||||
project: {
|
project: {
|
||||||
id: row.id,
|
id: projectRow.id,
|
||||||
name: row.name,
|
name: projectRow.name,
|
||||||
description: row.description,
|
description: projectRow.description,
|
||||||
status: row.status,
|
status: projectRow.status,
|
||||||
progress: row.progress,
|
progress: projectRow.progress,
|
||||||
due_date: row.dueDate,
|
due_date: projectRow.dueDate,
|
||||||
revision_quota: allowance.remaining,
|
revision_quota: allowance.remaining,
|
||||||
can_request_revision: allowance.canRequest,
|
can_request_revision: allowance.canRequest,
|
||||||
},
|
},
|
||||||
sections: service.listPlanningSections(actor, id).map((section) => ({
|
sections: sectionRows.map((section) => {
|
||||||
id: section.id,
|
const sectionRow = content.resolveEntity("planning_section", section, {
|
||||||
title: section.title,
|
locale: locale.locale,
|
||||||
content: section.content,
|
fallbackLocale,
|
||||||
type: section.category,
|
defaultLocale: locale.defaultLocale,
|
||||||
})),
|
translations: sectionTranslations.get(section.id) ?? [],
|
||||||
tasks: service.listTasks(actor, id)
|
});
|
||||||
.filter((task) => task.status !== "cancelled")
|
return {
|
||||||
.map((task) => ({
|
id: sectionRow.id,
|
||||||
|
title: sectionRow.title,
|
||||||
|
content: sectionRow.content,
|
||||||
|
type: sectionRow.category,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
tasks: taskRows.map((task) => {
|
||||||
|
const taskRow = content.resolveEntity("task", task, {
|
||||||
|
locale: locale.locale,
|
||||||
|
fallbackLocale,
|
||||||
|
defaultLocale: locale.defaultLocale,
|
||||||
|
translations: taskTranslations.get(task.id) ?? [],
|
||||||
|
});
|
||||||
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: taskRow.title,
|
||||||
status: task.status as PortalTask["status"],
|
status: task.status as PortalTask["status"],
|
||||||
date: task.dueAt?.toISOString() ?? task.scheduledDate,
|
date: task.dueAt?.toISOString() ?? task.scheduledDate,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
revisions: service.listRevisions(actor, id).map((revision) => ({
|
revisions: service.listRevisions(actor, id).map((revision) => ({
|
||||||
id: revision.id,
|
id: revision.id,
|
||||||
description: revision.description,
|
description: revision.description,
|
||||||
@@ -65,6 +97,7 @@ export default async function PortalProjectPage({ params }: { params: Promise<{
|
|||||||
sections={data.sections}
|
sections={data.sections}
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
revisions={data.revisions}
|
revisions={data.revisions}
|
||||||
|
locale={locale.locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { tr } from "date-fns/locale";
|
|
||||||
import { Card, CardContent, Badge, Button, Textarea, Label } from "poyraz-ui/atoms";
|
import { Card, CardContent, Badge, Button, Textarea, Label } from "poyraz-ui/atoms";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "poyraz-ui/molecules";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogFooter,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
toast,
|
||||||
|
} from "poyraz-ui/molecules";
|
||||||
import { CheckCircle2, Clock, MessageSquare, Loader2, RefreshCw } from "lucide-react";
|
import { CheckCircle2, Clock, MessageSquare, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { toast } from "poyraz-ui/molecules";
|
|
||||||
import { createRevisionRequest } from "./actions";
|
import { createRevisionRequest } from "./actions";
|
||||||
|
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "poyraz-ui/molecules";
|
import { formatDate, formatDateTime } from "@/lib/i18n/format";
|
||||||
|
|
||||||
export type PortalProjectDetail = {
|
export type PortalProjectDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,150 +57,174 @@ type PortalProjectClientProps = {
|
|||||||
sections: PortalPlanningSection[];
|
sections: PortalPlanningSection[];
|
||||||
tasks: PortalTask[];
|
tasks: PortalTask[];
|
||||||
revisions: PortalRevision[];
|
revisions: PortalRevision[];
|
||||||
|
locale: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PortalProjectClient({ project, sections, tasks, revisions }: PortalProjectClientProps) {
|
export function PortalProjectClient({
|
||||||
|
project,
|
||||||
|
sections,
|
||||||
|
tasks,
|
||||||
|
revisions,
|
||||||
|
locale,
|
||||||
|
}: PortalProjectClientProps) {
|
||||||
|
const t = useTranslations();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [openRevision, setOpenRevision] = useState(false);
|
const [openRevision, setOpenRevision] = useState(false);
|
||||||
|
|
||||||
const handleRevision = async (e: React.FormEvent<HTMLFormElement>) => {
|
async function handleRevision(event: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await createRevisionRequest(project.id, formData);
|
const response = await createRevisionRequest(project.id, formData);
|
||||||
if (res.error) throw new Error(res.error);
|
if (response.error) throw new Error(response.error);
|
||||||
toast.success("Revizyon talebiniz başarıyla iletildi.");
|
toast.success(t("portal.revision.success"));
|
||||||
setOpenRevision(false);
|
setOpenRevision(false);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(error instanceof Error ? error.message : "Revizyon talebi oluşturulamadı.");
|
toast.error(error instanceof Error ? error.message : t("portal.revision.error"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const pendingRevisions = revisions.filter((revision) => revision.status === 'pending' || revision.status === 'in_progress').length;
|
const pendingRevisions = revisions.filter(
|
||||||
|
(revision) => revision.status === "pending" || revision.status === "in_progress",
|
||||||
|
).length;
|
||||||
const hasRevisionQuota = project.can_request_revision;
|
const hasRevisionQuota = project.can_request_revision;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{/* Header Info */}
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-start">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start justify-between">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold text-foreground">{project.name}</h1>
|
<h1 className="text-3xl font-bold text-foreground">{project.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 md:items-end">
|
<div className="flex flex-col gap-2 md:items-end">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Badge variant="outline" className="px-3 py-1 capitalize text-sm">{project.status}</Badge>
|
<Badge variant="outline" className="px-3 py-1 text-sm capitalize">
|
||||||
|
{projectStatusLabel(project.status, t)}
|
||||||
|
</Badge>
|
||||||
{hasRevisionQuota ? (
|
{hasRevisionQuota ? (
|
||||||
<Dialog open={openRevision} onOpenChange={setOpenRevision}>
|
<Dialog open={openRevision} onOpenChange={setOpenRevision}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="default" effect="shine" className="gap-2 shrink-0">
|
<Button variant="default" effect="shine" className="shrink-0 gap-2">
|
||||||
<RefreshCw className="h-4 w-4" /> Revizyon Talep Et
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
{t("portal.actions.requestRevision")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<form onSubmit={handleRevision}>
|
<form onSubmit={handleRevision}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Yeni Revizyon Talebi</DialogTitle>
|
<DialogTitle>{t("portal.revision.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4 space-y-4">
|
<div className="space-y-4 py-4">
|
||||||
{pendingRevisions > 0 && (
|
{pendingRevisions > 0 ? (
|
||||||
<div className="p-3 bg-amber-500/10 text-amber-600 rounded-md text-sm border border-amber-500/20">
|
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-600">
|
||||||
Şu anda sonuçlanmamış {pendingRevisions} adet revizyon talebiniz var. Yeni bir tane eklemek istediğinize emin misiniz?
|
{t("portal.revision.pendingWarning", { count: pendingRevisions })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="revision-description">Lütfen yapılmasını istediğiniz değişiklikleri detaylıca açıklayın</Label>
|
<Label htmlFor="revision-description">{t("portal.revision.descriptionLabel")}</Label>
|
||||||
<Textarea id="revision-description" name="description" required rows={5} placeholder="Şu kısmın rengi mavi olabilir mi? Ayrıca metinleri güncelleyelim..." />
|
<Textarea
|
||||||
|
id="revision-description"
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
placeholder={t("portal.revision.descriptionPlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button effect="shine" type="button" variant="secondary" onClick={() => setOpenRevision(false)}>İptal</Button>
|
<Button effect="shine" type="button" variant="secondary" onClick={() => setOpenRevision(false)}>
|
||||||
|
{t("portal.actions.cancel")}
|
||||||
|
</Button>
|
||||||
<Button variant="default" effect="shine" type="submit" disabled={isSubmitting}>
|
<Button variant="default" effect="shine" type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
Talebi Gönder
|
{t("portal.actions.sendRequest")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="default" effect="shine" disabled className="gap-2 shrink-0 opacity-50">
|
<Button variant="default" effect="shine" disabled className="shrink-0 gap-2 opacity-50">
|
||||||
<RefreshCw className="h-4 w-4" /> Revizyon Hakkı Bitti
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
{t("portal.actions.noRevisionQuota")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{project.due_date && (
|
{project.due_date ? (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span>Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })}</span>
|
<span>
|
||||||
|
{t("portal.labels.delivery")}: {formatDate(project.due_date, locale)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="w-full">
|
<Tabs defaultValue="overview" className="w-full">
|
||||||
<TabsList className="mb-6 w-full justify-start rounded-none border-b border-border bg-transparent h-auto p-0">
|
<TabsList className="mb-6 h-auto w-full justify-start rounded-none border-b border-border bg-transparent p-0">
|
||||||
<TabsTrigger value="overview" className="rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary px-6 py-3 data-[state=active]:shadow-none data-[state=active]:bg-transparent">
|
<TabsTrigger value="overview" className="rounded-none px-6 py-3 data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">
|
||||||
Genel Bakış
|
{t("portal.tabs.overview")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="plan" className="rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary px-6 py-3 data-[state=active]:shadow-none data-[state=active]:bg-transparent">
|
<TabsTrigger value="plan" className="rounded-none px-6 py-3 data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">
|
||||||
Plan & Aşamalar
|
{t("portal.tabs.plan")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="revisions" className="rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary px-6 py-3 data-[state=active]:shadow-none data-[state=active]:bg-transparent flex items-center gap-2">
|
<TabsTrigger value="revisions" className="flex items-center gap-2 rounded-none px-6 py-3 data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">
|
||||||
Revizyonlar
|
{t("portal.tabs.revisions")}
|
||||||
{pendingRevisions > 0 && (
|
{pendingRevisions > 0 ? (
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 min-w-5 h-5 flex items-center justify-center">{pendingRevisions}</Badge>
|
<Badge variant="secondary" className="flex h-5 min-w-5 items-center justify-center px-1.5 py-0">
|
||||||
)}
|
{pendingRevisions}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-0">
|
<TabsContent value="overview" className="mt-0">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-5 space-y-4">
|
<CardContent className="space-y-4 p-5">
|
||||||
<h3 className="font-semibold">İlerleme Durumu</h3>
|
<h3 className="font-semibold">{t("portal.sections.progress")}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-3xl font-bold">
|
<div className="flex items-center justify-between text-3xl font-bold">
|
||||||
<span>%{project.progress}</span>
|
<span>%{project.progress}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-3 w-full bg-secondary rounded-full overflow-hidden">
|
<div className="h-3 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
<div
|
<div className="h-full bg-primary transition-all duration-500" style={{ width: `${project.progress}%` }} />
|
||||||
className="h-full bg-primary transition-all duration-500"
|
|
||||||
style={{ width: `${project.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Projenizin anlık tamamlanma oranı.</p>
|
<p className="text-sm text-muted-foreground">{t("portal.labels.progress")}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-5 space-y-4">
|
<CardContent className="space-y-4 p-5">
|
||||||
<h3 className="font-semibold flex items-center gap-2">
|
<h3 className="flex items-center gap-2 font-semibold">
|
||||||
<CheckCircle2 className="h-4 w-4" /> Yapılan İşler
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{t("portal.sections.doneTasks")}
|
||||||
</h3>
|
</h3>
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground italic">Listelenecek görev bulunmuyor.</p>
|
<p className="text-sm italic text-muted-foreground">{t("portal.empty.tasks")}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3 max-h-60 overflow-y-auto tiny-scrollbar pr-2">
|
<ul className="tiny-scrollbar max-h-60 space-y-3 overflow-y-auto pr-2">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<li key={task.id} className="text-sm flex gap-3 p-2 rounded hover:bg-muted/30 transition-colors">
|
<li key={task.id} className="flex gap-3 rounded p-2 text-sm transition-colors hover:bg-muted/30">
|
||||||
{task.status === 'done' ? (
|
{task.status === "done" ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-muted-foreground/30 shrink-0 mt-0.5" />
|
<div className="mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 border-muted-foreground/30" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className={task.status === 'done' ? "text-muted-foreground" : "text-foreground font-medium"}>
|
<span className={task.status === "done" ? "text-muted-foreground" : "font-medium text-foreground"}>
|
||||||
{task.title}
|
{task.title}
|
||||||
</span>
|
</span>
|
||||||
{task.date && (
|
{task.date ? (
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
{format(new Date(task.date), 'd MMM yyyy', { locale: tr })}
|
{formatDate(task.date, locale)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -204,21 +237,21 @@ export function PortalProjectClient({ project, sections, tasks, revisions }: Por
|
|||||||
|
|
||||||
<TabsContent value="plan" className="mt-6">
|
<TabsContent value="plan" className="mt-6">
|
||||||
{sections.length === 0 ? (
|
{sections.length === 0 ? (
|
||||||
<div className="py-10 text-center border rounded-lg border-dashed text-muted-foreground">
|
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground">
|
||||||
Henüz bir plan yüklenmemiş.
|
{t("portal.empty.plan")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<Card key={section.id}>
|
<Card key={section.id}>
|
||||||
<CardContent className="p-5 space-y-3">
|
<CardContent className="space-y-3 p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-medium text-foreground">{section.title}</h4>
|
<h4 className="font-medium text-foreground">{section.title}</h4>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{section.type === 'milestone' ? 'Aşama' : section.type === 'deliverable' ? 'Teslimat' : 'Not'}
|
{section.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{section.content}</p>
|
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{section.content}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -227,40 +260,43 @@ export function PortalProjectClient({ project, sections, tasks, revisions }: Por
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="revisions" className="mt-6">
|
<TabsContent value="revisions" className="mt-6">
|
||||||
<div className="flex items-center justify-end mb-4">
|
<div className="mb-4 flex items-center justify-end">
|
||||||
<div className="text-sm font-medium text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-md">
|
<div className="rounded-md bg-muted/50 px-3 py-1.5 text-sm font-medium text-muted-foreground">
|
||||||
Kalan Hak: <span className="text-foreground ml-1">{project.revision_quota !== null ? project.revision_quota : 'Sınırsız'}</span>
|
{t("portal.labels.remainingQuota")}:
|
||||||
|
<span className="ml-1 text-foreground">
|
||||||
|
{project.revision_quota !== null ? project.revision_quota : t("portal.labels.unlimited")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{revisions.length === 0 ? (
|
{revisions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3 border rounded-lg border-dashed text-muted-foreground">
|
<div className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed py-12 text-center text-muted-foreground">
|
||||||
<MessageSquare className="h-8 w-8 text-muted-foreground/50" />
|
<MessageSquare className="h-8 w-8 text-muted-foreground/50" />
|
||||||
<p>Henüz bir revizyon talebi oluşturmadınız.</p>
|
<p>{t("portal.empty.revisions")}</p>
|
||||||
{hasRevisionQuota && (
|
{hasRevisionQuota ? (
|
||||||
<Button effect="shine" variant="secondary" size="sm" onClick={() => setOpenRevision(true)}>Yeni Talep Oluştur</Button>
|
<Button effect="shine" variant="secondary" size="sm" onClick={() => setOpenRevision(true)}>
|
||||||
)}
|
{t("portal.actions.newRequest")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{revisions.map((rev) => (
|
{revisions.map((revision) => (
|
||||||
<Card key={rev.id} className="transition-colors hover:border-primary/30">
|
<Card key={revision.id} className="transition-colors hover:border-primary/30">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex justify-between items-start mb-3">
|
<div className="mb-3 flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{format(new Date(rev.created_at), "d MMM yyyy, HH:mm", { locale: tr })}
|
{formatDateTime(revision.created_at, locale)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={
|
<Badge
|
||||||
rev.status === 'completed' ? 'default' :
|
variant={revision.status === "completed" ? "default" : revision.status === "rejected" ? "destructive" : "secondary"}
|
||||||
rev.status === 'rejected' ? 'destructive' : 'secondary'
|
className="capitalize"
|
||||||
} className="capitalize">
|
>
|
||||||
{rev.status === 'pending' ? 'Bekliyor' :
|
{revisionStatusLabel(revision.status, t)}
|
||||||
rev.status === 'in_progress' ? 'İşleniyor' :
|
|
||||||
rev.status === 'completed' ? 'Tamamlandı' : 'Reddedildi'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{rev.description}</p>
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">{revision.description}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -271,3 +307,16 @@ export function PortalProjectClient({ project, sections, tasks, revisions }: Por
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectStatusLabel(status: PortalProjectDetail["status"], t: ReturnType<typeof useTranslations>) {
|
||||||
|
if (status === "completed") return t("portal.status.project.completed");
|
||||||
|
if (status === "active") return t("portal.status.project.active");
|
||||||
|
return t("portal.status.project.waiting");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisionStatusLabel(status: PortalRevision["status"], t: ReturnType<typeof useTranslations>) {
|
||||||
|
if (status === "pending") return t("portal.status.revision.pending");
|
||||||
|
if (status === "in_progress") return t("portal.status.revision.inProgress");
|
||||||
|
if (status === "completed") return t("portal.status.revision.completed");
|
||||||
|
return t("portal.status.revision.rejected");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
||||||
import { FolderKanban, Clock } from "lucide-react";
|
import { FolderKanban, Clock } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { format } from "date-fns";
|
import { formatDate } from "@/lib/i18n/format";
|
||||||
import { tr } from "date-fns/locale";
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
import { requirePortalBackend } from "@/server/web/portal";
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
|
||||||
export default async function PortalProjectsPage() {
|
export default async function PortalProjectsPage() {
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["portal"]).t;
|
||||||
const { actor, service } = await requirePortalBackend();
|
const { actor, service } = await requirePortalBackend();
|
||||||
const projects = service.listProjects(actor);
|
const content = new ContentTranslationService(getSqliteConnection().db);
|
||||||
|
const localization = content.getPublicLocalizationContext();
|
||||||
|
const fallbackLocale = getContentFallbackLocale(locale.locale, localization);
|
||||||
|
const projectRows = service.listProjects(actor);
|
||||||
|
const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id));
|
||||||
|
const projects = projectRows.map((project) => content.resolveEntity("project", project, {
|
||||||
|
locale: locale.locale,
|
||||||
|
fallbackLocale,
|
||||||
|
defaultLocale: locale.defaultLocale,
|
||||||
|
translations: projectTranslations.get(project.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Projeleriniz</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">{t("portal.projects.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground flex flex-col items-center gap-3">
|
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground flex flex-col items-center gap-3">
|
||||||
<FolderKanban className="w-10 h-10 text-muted-foreground/50" />
|
<FolderKanban className="w-10 h-10 text-muted-foreground/50" />
|
||||||
Henüz size atanmış bir proje bulunmuyor.
|
{t("portal.projects.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : projects.map((project) => (
|
) : projects.map((project) => (
|
||||||
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
||||||
@@ -29,19 +44,19 @@ export default async function PortalProjectsPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<h3 className="font-semibold text-lg line-clamp-2">{project.name}</h3>
|
<h3 className="font-semibold text-lg line-clamp-2">{project.name}</h3>
|
||||||
<Badge variant={project.status === "completed" ? "secondary" : "default"} className="capitalize shrink-0">
|
<Badge variant={project.status === "completed" ? "secondary" : "default"} className="capitalize shrink-0">
|
||||||
{project.status}
|
{project.status === "completed" ? t("portal.status.project.completed") : project.status === "active" ? t("portal.status.project.active") : t("portal.status.project.waiting")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{project.dueDate && (
|
{project.dueDate && (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
<span>Son Teslim: {format(new Date(project.dueDate), "d MMM yyyy", { locale: tr })}</span>
|
<span>{t("portal.labels.deadline")}: {formatDate(project.dueDate, locale.locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between text-xs font-medium">
|
<div className="flex items-center justify-between text-xs font-medium">
|
||||||
<span>İlerleme</span>
|
<span>{t("portal.labels.progress")}</span>
|
||||||
<span>%{project.progress}</span>
|
<span>%{project.progress}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
||||||
import { Clock, MessageSquare } from "lucide-react";
|
import { Clock, MessageSquare } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { format } from "date-fns";
|
import { formatDateTime } from "@/lib/i18n/format";
|
||||||
import { tr } from "date-fns/locale";
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
import { requirePortalBackend } from "@/server/web/portal";
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
|
||||||
export default async function PortalRevisionsPage() {
|
export default async function PortalRevisionsPage() {
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["portal"]).t;
|
||||||
const { actor, service } = await requirePortalBackend();
|
const { actor, service } = await requirePortalBackend();
|
||||||
const projects = service.listProjects(actor);
|
const content = new ContentTranslationService(getSqliteConnection().db);
|
||||||
|
const localization = content.getPublicLocalizationContext();
|
||||||
|
const fallbackLocale = getContentFallbackLocale(locale.locale, localization);
|
||||||
|
const projectRows = service.listProjects(actor);
|
||||||
|
const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id));
|
||||||
|
const projects = projectRows.map((project) => content.resolveEntity("project", project, {
|
||||||
|
locale: locale.locale,
|
||||||
|
fallbackLocale,
|
||||||
|
defaultLocale: locale.defaultLocale,
|
||||||
|
translations: projectTranslations.get(project.id) ?? [],
|
||||||
|
}));
|
||||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
|
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
|
||||||
const revisions = service.listPortalRevisions(actor)
|
const revisions = service.listPortalRevisions(actor)
|
||||||
.filter((revision) => projectNames.has(revision.projectId));
|
.filter((revision) => projectNames.has(revision.projectId));
|
||||||
@@ -15,14 +30,14 @@ export default async function PortalRevisionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Revizyon Taleplerim</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">{t("portal.revisions.mine")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{revisions.length === 0 ? (
|
{revisions.length === 0 ? (
|
||||||
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground flex flex-col items-center gap-3">
|
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground flex flex-col items-center gap-3">
|
||||||
<MessageSquare className="w-10 h-10 text-muted-foreground/50" />
|
<MessageSquare className="w-10 h-10 text-muted-foreground/50" />
|
||||||
Henüz bir revizyon talebinde bulunmadınız.
|
{t("portal.revisions.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : revisions.map((revision) => (
|
) : revisions.map((revision) => (
|
||||||
<Card key={revision.id} className="h-full">
|
<Card key={revision.id} className="h-full">
|
||||||
@@ -31,17 +46,17 @@ export default async function PortalRevisionsPage() {
|
|||||||
<div className="flex items-start justify-between gap-2 border-b border-border pb-3">
|
<div className="flex items-start justify-between gap-2 border-b border-border pb-3">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{format(revision.createdAt, "d MMM yyyy, HH:mm", { locale: tr })}
|
{formatDateTime(revision.createdAt, locale.locale)}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={revision.status === "completed" ? "default" : revision.status === "rejected" ? "destructive" : "secondary"}
|
variant={revision.status === "completed" ? "default" : revision.status === "rejected" ? "destructive" : "secondary"}
|
||||||
className="capitalize shrink-0"
|
className="capitalize shrink-0"
|
||||||
>
|
>
|
||||||
{revision.status === "pending" ? "Bekliyor" : revision.status === "in_progress" ? "İşleniyor" : revision.status === "completed" ? "Tamamlandı" : "Reddedildi"}
|
{revision.status === "pending" ? t("portal.status.revision.pending") : revision.status === "in_progress" ? t("portal.status.revision.inProgress") : revision.status === "completed" ? t("portal.status.revision.completed") : t("portal.status.revision.rejected")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-medium uppercase text-muted-foreground">Proje:</span>
|
<span className="text-xs font-medium uppercase text-muted-foreground">{t("portal.labels.project")}:</span>
|
||||||
<span className="text-sm font-semibold truncate bg-muted/30 p-2 rounded-md">
|
<span className="text-sm font-semibold truncate bg-muted/30 p-2 rounded-md">
|
||||||
{projectNames.get(revision.projectId)}
|
{projectNames.get(revision.projectId)}
|
||||||
</span>
|
</span>
|
||||||
@@ -50,7 +65,7 @@ export default async function PortalRevisionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end border-t border-border pt-4">
|
<div className="flex items-center justify-end border-t border-border pt-4">
|
||||||
<Link href={`/portal/projects/${revision.projectId}`} className="text-xs text-primary font-medium hover:underline">
|
<Link href={`/portal/projects/${revision.projectId}`} className="text-xs text-primary font-medium hover:underline">
|
||||||
Projeye Git →
|
{t("portal.labels.project")} →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,28 +1,50 @@
|
|||||||
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
||||||
import { CheckCircle2, Clock, CalendarDays, KanbanSquare } from "lucide-react";
|
import { CheckCircle2, Clock, CalendarDays, KanbanSquare } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { formatDate } from "@/lib/i18n/format";
|
||||||
import { tr } from "date-fns/locale";
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
import { requirePortalBackend } from "@/server/web/portal";
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
|
||||||
export default async function PortalTasksPage() {
|
export default async function PortalTasksPage() {
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["portal"]).t;
|
||||||
const { actor, service } = await requirePortalBackend();
|
const { actor, service } = await requirePortalBackend();
|
||||||
const projects = service.listProjects(actor);
|
const content = new ContentTranslationService(getSqliteConnection().db);
|
||||||
|
const localization = content.getPublicLocalizationContext();
|
||||||
|
const fallbackLocale = getContentFallbackLocale(locale.locale, localization);
|
||||||
|
const projectRows = service.listProjects(actor);
|
||||||
|
const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id));
|
||||||
|
const projects = projectRows.map((project) => content.resolveEntity("project", project, {
|
||||||
|
locale: locale.locale,
|
||||||
|
fallbackLocale,
|
||||||
|
defaultLocale: locale.defaultLocale,
|
||||||
|
translations: projectTranslations.get(project.id) ?? [],
|
||||||
|
}));
|
||||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
|
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
|
||||||
const tasks = service.listTasks(actor)
|
const taskRows = service.listTasks(actor)
|
||||||
.filter((task) => task.projectId && projectNames.has(task.projectId) && task.status !== "cancelled")
|
.filter((task) => task.projectId && projectNames.has(task.projectId) && task.status !== "cancelled")
|
||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
const taskTranslations = content.listBatch("task", taskRows.map((task) => task.id));
|
||||||
|
const tasks = taskRows.map((task) => content.resolveEntity("task", task, {
|
||||||
|
locale: locale.locale,
|
||||||
|
fallbackLocale,
|
||||||
|
defaultLocale: locale.defaultLocale,
|
||||||
|
translations: taskTranslations.get(task.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Yapılan Görevler</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">{t("portal.tasks.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground flex flex-col items-center gap-3">
|
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground flex flex-col items-center gap-3">
|
||||||
<KanbanSquare className="w-10 h-10 text-muted-foreground/50" />
|
<KanbanSquare className="w-10 h-10 text-muted-foreground/50" />
|
||||||
Henüz sizinle paylaşılan bir görev bulunmuyor.
|
{t("portal.tasks.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : tasks.map((task) => {
|
) : tasks.map((task) => {
|
||||||
const isDone = task.status === "done";
|
const isDone = task.status === "done";
|
||||||
@@ -36,7 +58,7 @@ export default async function PortalTasksPage() {
|
|||||||
{task.title}
|
{task.title}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant={isDone ? "secondary" : "outline"} className="capitalize shrink-0">
|
<Badge variant={isDone ? "secondary" : "outline"} className="capitalize shrink-0">
|
||||||
{task.status === "todo" ? "Bekliyor" : task.status === "in_progress" ? "İşleniyor" : "Tamamlandı"}
|
{task.status === "todo" ? t("portal.status.task.todo") : task.status === "in_progress" ? t("portal.status.task.inProgress") : t("portal.status.task.done")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/30 p-2 rounded-md">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/30 p-2 rounded-md">
|
||||||
@@ -46,7 +68,7 @@ export default async function PortalTasksPage() {
|
|||||||
<div className="flex items-center justify-between text-sm text-muted-foreground border-t border-border pt-4">
|
<div className="flex items-center justify-between text-sm text-muted-foreground border-t border-border pt-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<CalendarDays className="h-4 w-4" />
|
<CalendarDays className="h-4 w-4" />
|
||||||
<span>{date ? format(new Date(date), "d MMM yyyy", { locale: tr }) : "Tarih yok"}</span>
|
<span>{date ? formatDate(date, locale.locale) : "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
{isDone ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Clock className="h-4 w-4" />}
|
{isDone ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Clock className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-18
@@ -1,6 +1,7 @@
|
|||||||
import { signup } from "@/app/login/actions";
|
import { signup } from "@/app/login/actions";
|
||||||
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
||||||
import { ErrorToaster } from "@/components/error-toaster";
|
import { ErrorToaster } from "@/components/error-toaster";
|
||||||
|
import { LocaleSelectForm } from "@/components/i18n/locale-select-form";
|
||||||
import { getFirstFreelancerSetupState } from "@/server/auth/setup";
|
import { getFirstFreelancerSetupState } from "@/server/auth/setup";
|
||||||
import { LockKeyhole, Mail, UserPlus } from "lucide-react";
|
import { LockKeyhole, Mail, UserPlus } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -8,9 +9,30 @@ import { redirect } from "next/navigation";
|
|||||||
import { Input, Label } from "poyraz-ui/atoms";
|
import { Input, Label } from "poyraz-ui/atoms";
|
||||||
import { SubmitButton } from "@/components/auth/submit-button";
|
import { SubmitButton } from "@/components/auth/submit-button";
|
||||||
import { getPublicBranding } from "@/server/branding/runtime";
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
|
import type { TranslationValues } from "@/lib/i18n";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function firstParam(value: string | string[] | undefined): string | null {
|
||||||
|
if (Array.isArray(value)) return value[0] ?? null;
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthMessage(
|
||||||
|
code: string | null,
|
||||||
|
fallback: string | null,
|
||||||
|
t: (key: string, values?: TranslationValues) => string,
|
||||||
|
): string | null {
|
||||||
|
if (!code) return fallback;
|
||||||
|
const key = code.startsWith("auth.") ? code : `auth.${code}`;
|
||||||
|
const message = t(key);
|
||||||
|
return message === key ? fallback : message;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function RegisterPage({
|
export default async function RegisterPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -19,46 +41,63 @@ export default async function RegisterPage({
|
|||||||
const setupState = await getFirstFreelancerSetupState();
|
const setupState = await getFirstFreelancerSetupState();
|
||||||
|
|
||||||
if (setupState.errorMessage) {
|
if (setupState.errorMessage) {
|
||||||
redirect(`/login?error=true&message=${encodeURIComponent(setupState.errorMessage)}`);
|
redirect("/login?error=true&code=auth.messages.setupStateError");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!setupState.available) {
|
if (!setupState.available) {
|
||||||
redirect(
|
redirect("/login?error=true&code=auth.messages.setupUnavailable");
|
||||||
`/login?error=true&message=${encodeURIComponent(
|
|
||||||
"Kayıt kapalı. Bu Neta kurulumunda ilk admin hesabı zaten oluşturulmuş.",
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedParams = await searchParams;
|
const resolvedParams = await searchParams;
|
||||||
const error = resolvedParams?.error;
|
const error = firstParam(resolvedParams?.error);
|
||||||
const message = resolvedParams?.message;
|
const code = firstParam(resolvedParams?.code);
|
||||||
|
const rawMessage = firstParam(resolvedParams?.message);
|
||||||
const branding = getPublicBranding();
|
const branding = getPublicBranding();
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["auth"]).t;
|
||||||
|
const localization = new ContentTranslationService(getSqliteConnection().db).getPublicLocalizationContext();
|
||||||
|
const message = resolveAuthMessage(code, rawMessage, t);
|
||||||
|
const marketing = {
|
||||||
|
headline: t("auth.marketing.headline"),
|
||||||
|
description: t("auth.marketing.description", { app: branding.organizationName ?? branding.applicationName }),
|
||||||
|
openSource: t("auth.marketing.openSource"),
|
||||||
|
github: t("auth.marketing.github"),
|
||||||
|
via: t("auth.marketing.via"),
|
||||||
|
builtBy: t("auth.marketing.builtBy"),
|
||||||
|
highlights: [
|
||||||
|
t("auth.highlights.clients"),
|
||||||
|
t("auth.highlights.calendar"),
|
||||||
|
t("auth.highlights.finance"),
|
||||||
|
t("auth.highlights.reports"),
|
||||||
|
] as [string, string, string, string],
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && message && <ErrorToaster message={String(message)} />}
|
{error && message ? <ErrorToaster message={message} /> : null}
|
||||||
<AuthPageShell
|
<AuthPageShell
|
||||||
branding={{
|
branding={{
|
||||||
applicationName: branding.organizationName ?? branding.applicationName,
|
applicationName: branding.organizationName ?? branding.applicationName,
|
||||||
lightLogoUrl: branding.lightLogoUrl,
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
darkLogoUrl: branding.darkLogoUrl,
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
}}
|
}}
|
||||||
title="İlk admin hesabını oluştur"
|
title={t("auth.register.firstAdminTitle")}
|
||||||
description="Bu Neta çalışma alanının ilk yönetici hesabını oluştur."
|
description={t("auth.register.description")}
|
||||||
|
marketing={marketing}
|
||||||
form={
|
form={
|
||||||
<form className="space-y-6">
|
<form className="space-y-6">
|
||||||
|
<LocaleSelectForm label={t("auth.language")} value={locale.locale} locales={localization.locales} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="flex items-center gap-2">
|
<Label htmlFor="email" className="flex items-center gap-2">
|
||||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
E-posta
|
{t("auth.login.email")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ornek@mail.com"
|
placeholder={t("auth.login.emailPlaceholder")}
|
||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
@@ -67,7 +106,7 @@ export default async function RegisterPage({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="flex items-center gap-2">
|
<Label htmlFor="password" className="flex items-center gap-2">
|
||||||
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
|
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
|
||||||
Şifre
|
{t("auth.login.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -79,21 +118,21 @@ export default async function RegisterPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmitButton size="lg" formAction={signup} className="w-full gap-2" pendingText="Oluşturuluyor...">
|
<SubmitButton size="lg" formAction={signup} className="w-full gap-2" pendingText={t("auth.register.pending")}>
|
||||||
<UserPlus className="h-4 w-4" />
|
<UserPlus className="h-4 w-4" />
|
||||||
Admin hesabını oluştur
|
{t("auth.register.submit")}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
secondaryAction={null}
|
secondaryAction={null}
|
||||||
footer={
|
footer={
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
Zaten hesabın var mı?{" "}
|
{t("auth.register.hasAccount")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="font-medium text-primary transition-colors hover:text-primary-hover"
|
className="font-medium text-primary transition-colors hover:text-primary-hover"
|
||||||
>
|
>
|
||||||
Giriş yap
|
{t("auth.login.submit")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
||||||
|
import { LocaleSelectForm } from "@/components/i18n/locale-select-form";
|
||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { ContentTranslationService } from "@/server/i18n/content";
|
||||||
|
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||||
|
import { createTranslator } from "@/server/i18n/translator";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Alert, AlertDescription } from "poyraz-ui/molecules";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ResetPasswordPage() {
|
||||||
|
const branding = getPublicBranding();
|
||||||
|
const locale = await resolveRequestLocale();
|
||||||
|
const t = createTranslator(locale.locale, ["auth"]).t;
|
||||||
|
const localization = new ContentTranslationService(getSqliteConnection().db).getPublicLocalizationContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthPageShell
|
||||||
|
branding={{
|
||||||
|
applicationName: branding.organizationName ?? branding.applicationName,
|
||||||
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
|
}}
|
||||||
|
title={t("auth.reset.title")}
|
||||||
|
description={t("auth.reset.description")}
|
||||||
|
marketing={{
|
||||||
|
headline: t("auth.marketing.headline"),
|
||||||
|
description: t("auth.marketing.description", { app: branding.organizationName ?? branding.applicationName }),
|
||||||
|
openSource: t("auth.marketing.openSource"),
|
||||||
|
github: t("auth.marketing.github"),
|
||||||
|
via: t("auth.marketing.via"),
|
||||||
|
builtBy: t("auth.marketing.builtBy"),
|
||||||
|
highlights: [
|
||||||
|
t("auth.highlights.clients"),
|
||||||
|
t("auth.highlights.calendar"),
|
||||||
|
t("auth.highlights.finance"),
|
||||||
|
t("auth.highlights.reports"),
|
||||||
|
] as [string, string, string, string],
|
||||||
|
}}
|
||||||
|
form={
|
||||||
|
<div className="space-y-6">
|
||||||
|
<LocaleSelectForm label={t("auth.language")} value={locale.locale} locales={localization.locales} />
|
||||||
|
<Alert variant="info" appearance="soft">
|
||||||
|
<AlertDescription>{t("auth.reset.description")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
secondaryAction={null}
|
||||||
|
footer={
|
||||||
|
<Link href="/login" className="block text-center text-sm font-medium text-primary hover:text-primary-hover">
|
||||||
|
{t("auth.forgot.back")}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,14 +27,18 @@ type AuthPageShellProps = {
|
|||||||
form: ReactNode;
|
form: ReactNode;
|
||||||
secondaryAction?: ReactNode;
|
secondaryAction?: ReactNode;
|
||||||
footer: ReactNode;
|
footer: ReactNode;
|
||||||
|
marketing?: {
|
||||||
|
headline: string;
|
||||||
|
description: string;
|
||||||
|
openSource: string;
|
||||||
|
github: string;
|
||||||
|
via: string;
|
||||||
|
builtBy: string;
|
||||||
|
highlights: [string, string, string, string];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const highlights = [
|
const highlightIcons = [Kanban, CalendarDays, Wallet, BarChart3];
|
||||||
{ label: "Müşteriler", icon: Kanban },
|
|
||||||
{ label: "Takvim", icon: CalendarDays },
|
|
||||||
{ label: "Finans", icon: Wallet },
|
|
||||||
{ label: "Raporlar", icon: BarChart3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AuthPageShell({
|
export function AuthPageShell({
|
||||||
branding,
|
branding,
|
||||||
@@ -43,6 +47,15 @@ export function AuthPageShell({
|
|||||||
form,
|
form,
|
||||||
secondaryAction,
|
secondaryAction,
|
||||||
footer,
|
footer,
|
||||||
|
marketing = {
|
||||||
|
headline: "Freelancer işlerini, müşterilerini ve finansını tek yerde yönet.",
|
||||||
|
description: `${branding.applicationName}, günlük operasyonunu, projelerini, side projectlerini ve temel finans durumunu sade raporlarla takip etmen için tasarlanır.`,
|
||||||
|
openSource: "Açık kaynak ve self-host edilebilir.",
|
||||||
|
github: "GitHub",
|
||||||
|
via: "üzerinden ulaşabilirsin.",
|
||||||
|
builtBy: "tarafından kodlandı.",
|
||||||
|
highlights: ["Müşteriler", "Takvim", "Finans", "Raporlar"],
|
||||||
|
},
|
||||||
}: AuthPageShellProps) {
|
}: AuthPageShellProps) {
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
|
|
||||||
@@ -87,12 +100,10 @@ export function AuthPageShell({
|
|||||||
variant="display"
|
variant="display"
|
||||||
className="max-w-2xl text-5xl font-semibold leading-[1.02] text-primary-foreground"
|
className="max-w-2xl text-5xl font-semibold leading-[1.02] text-primary-foreground"
|
||||||
>
|
>
|
||||||
Freelancer işlerini, müşterilerini ve finansını tek yerde yönet.
|
{marketing.headline}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography component="p" variant="lead" className="mt-6 max-w-xl text-lg leading-8 text-primary-foreground/78">
|
<Typography component="p" variant="lead" className="mt-6 max-w-xl text-lg leading-8 text-primary-foreground/78">
|
||||||
{branding.applicationName}, günlük operasyonunu, projelerini, side projectlerini ve
|
{marketing.description}
|
||||||
temel finans durumunu sade raporlarla takip etmen için
|
|
||||||
tasarlanır.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -102,15 +113,15 @@ export function AuthPageShell({
|
|||||||
transition={{ duration: reducedMotion ? 0 : 0.55, delay: 0.16 }}
|
transition={{ duration: reducedMotion ? 0 : 0.55, delay: 0.16 }}
|
||||||
className="mt-10 grid max-w-xl grid-cols-2 gap-3"
|
className="mt-10 grid max-w-xl grid-cols-2 gap-3"
|
||||||
>
|
>
|
||||||
{highlights.map((item) => {
|
{marketing.highlights.map((label, index) => {
|
||||||
const Icon = item.icon;
|
const Icon = highlightIcons[index] ?? Kanban;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={label}
|
||||||
className="flex items-center gap-3 rounded-sm border border-white/18 bg-white/10 px-4 py-3 text-sm font-medium backdrop-blur"
|
className="flex items-center gap-3 rounded-sm border border-white/18 bg-white/10 px-4 py-3 text-sm font-medium backdrop-blur"
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -118,15 +129,15 @@ export function AuthPageShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full relative z-10 p-10 text-sm text-primary-foreground/78">
|
<div className="w-full relative z-10 p-10 text-sm text-primary-foreground/78">
|
||||||
<span>Açık kaynak ve self-host edilebilir.</span>{" "}
|
<span>{marketing.openSource}</span>{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/poyrazavsever/neta"
|
href="https://github.com/poyrazavsever/neta"
|
||||||
className="font-semibold text-primary-foreground underline-offset-4 hover:underline"
|
className="font-semibold text-primary-foreground underline-offset-4 hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
GitHub <ArrowUpRight className="h-3.5 w-3.5 inline" />
|
{marketing.github} <ArrowUpRight className="h-3.5 w-3.5 inline" />
|
||||||
</Link>
|
</Link>
|
||||||
<span> üzerinden ulaşabilirsin. </span>
|
<span> {marketing.via} </span>
|
||||||
<Link
|
<Link
|
||||||
href="https://poyrazavsever.com"
|
href="https://poyrazavsever.com"
|
||||||
className="inline-flex items-center gap-1 font-semibold text-primary-foreground underline-offset-4 hover:underline"
|
className="inline-flex items-center gap-1 font-semibold text-primary-foreground underline-offset-4 hover:underline"
|
||||||
@@ -134,7 +145,7 @@ export function AuthPageShell({
|
|||||||
>
|
>
|
||||||
Poyraz Avsever <ArrowUpRight className="h-3.5 w-3.5 inline" />
|
Poyraz Avsever <ArrowUpRight className="h-3.5 w-3.5 inline" />
|
||||||
</Link>
|
</Link>
|
||||||
<span> tarafından kodlandı.</span>
|
<span> {marketing.builtBy}</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user