Compare commits

...

5 Commits

11 changed files with 235 additions and 5 deletions
+83 -1
View File
@@ -1,7 +1,8 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
GitBranch,
Lock,
@@ -12,6 +13,7 @@ import {
Globe,
Briefcase,
Clock,
Plus,
} from "lucide-react";
import GlassCard from "@/components/ui/GlassCard";
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
@@ -42,6 +44,7 @@ type ApproachParagraph = TextParagraph | LinkParagraph;
export default function AboutContent({ locale, dict }: AboutContentProps) {
const t = dict.aboutPage;
const [openFaq, setOpenFaq] = useState<number | null>(null);
return (
<>
@@ -152,6 +155,85 @@ export default function AboutContent({ locale, dict }: AboutContentProps) {
</div>
</section>
{/* FAQ */}
<section className="relative w-full py-20 sm:py-24">
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewportConfig}
variants={stagger(0.06)}
className="mx-auto mb-14 max-w-3xl text-center"
>
<motion.h2
variants={fadeUp}
className="font-display text-display-lg font-medium tracking-tight text-gradient-fade"
>
{t.faqTitle}
</motion.h2>
</motion.div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewportConfig}
variants={stagger(0.06)}
className="mx-auto flex max-w-3xl flex-col gap-3"
>
{t.faq.map((item, i) => {
const isOpen = openFaq === i;
return (
<motion.div key={i} variants={fadeUp}>
<GlassCard
className={`p-0 ${isOpen ? "border-purple-400/30 shadow-glow-sm" : ""}`}
hover={false}
>
<h3>
<button
type="button"
onClick={() => setOpenFaq(isOpen ? null : i)}
aria-expanded={isOpen}
aria-controls={`faq-answer-${i}`}
className="flex w-full items-center justify-between gap-4 rounded-2xl px-6 py-5 text-left transition-colors duration-300 hover:text-purple-200"
>
<span className="text-base font-medium text-text-primary sm:text-lg">
{item.question}
</span>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-purple-400/20 bg-purple-500/10 text-purple-300 transition-all duration-500 ${
isOpen ? "rotate-45 border-purple-400/50 bg-purple-500/20" : ""
}`}
>
<Plus className="h-4 w-4" strokeWidth={1.5} />
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={`faq-answer-${i}`}
key="answer"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<p className="border-t border-border/40 px-6 pb-5 pt-4 text-sm leading-relaxed text-text-secondary">
{item.answer}
</p>
</motion.div>
)}
</AnimatePresence>
</GlassCard>
</motion.div>
);
})}
</motion.div>
</div>
</section>
{/* Highlights */}
<section className="relative w-full py-20 sm:py-24">
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
+8 -2
View File
@@ -4,12 +4,15 @@ import { FaLinkedin } from "react-icons/fa6";
import { SiGitea, SiViber, SiWhatsapp } from "react-icons/si";
import type { Locale, Dictionary } from "@/lib/dictionaries";
import { LogoMark } from "@/components/ui/Logo";
import FooterPhoneLink from "@/components/layout/FooterPhoneLink";
interface FooterProps {
locale: Locale;
dict: Dictionary;
}
const PHONE_NUMBER = "+387 66 644 094";
export default function Footer({ locale, dict }: FooterProps) {
const footerLinks = {
[dict.footer.product]: [
@@ -23,7 +26,8 @@ export default function Footer({ locale, dict }: FooterProps) {
[dict.footer.connect]: [
{ label: "Source Code", href: "https://git.ksan.dev/ksan", external: true },
{ label: "Email", href: "mailto:contact@ksan.dev", external: true },
{ label: "+387 66 644 094", href: "tel:+38766644094", external: true },
// Opens the call / WhatsApp / Viber picker instead of jumping straight to tel:.
{ label: PHONE_NUMBER, href: "tel:+38766644094", phone: true },
{ label: "LinkedIn", href: "https://www.linkedin.com/in/%C4%91or%C4%91e-k%C5%A1an-886871266", external: true },
],
};
@@ -87,7 +91,9 @@ export default function Footer({ locale, dict }: FooterProps) {
<ul className="space-y-3">
{links.map((link) => (
<li key={link.label}>
{"external" in link && link.external ? (
{"phone" in link && link.phone ? (
<FooterPhoneLink number={link.label} dict={dict} />
) : "external" in link && link.external ? (
<a
href={link.href}
target={link.href.startsWith("http") ? "_blank" : undefined}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import PhoneChoiceModal from "@/components/ui/PhoneChoiceModal";
import type { Dictionary } from "@/lib/dictionaries";
interface FooterPhoneLinkProps {
number: string;
dict: Dictionary;
}
// Keeps the footer itself a server component — only the phone entry needs state.
export default function FooterPhoneLink({ number, dict }: FooterPhoneLinkProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="text-left text-sm text-text-secondary transition-colors hover:text-text-primary"
>
{number}
</button>
<PhoneChoiceModal
open={open}
number={number}
dict={dict}
onClose={() => setOpen(false)}
/>
</>
);
}
+15 -2
View File
@@ -1,8 +1,21 @@
services:
ksan-dev:
# Built and pushed by CI; deployed by tag, never by :latest, so a rollback
# is a re-run of `up -d` with an older tag instead of a rebuild.
# Normally built and pushed by CI; deployed by tag, never by :latest, so a
# rollback is a re-run of `up -d` with an older tag instead of a rebuild.
image: ${IMAGE_NAME:-git.ksan.dev/ksan/ksan.dev}:${IMAGE_TAG:-latest}
# TEMPORARY — while the Gitea workflows sit at .disabled, nothing builds or
# pushes an image, so the VPS builds from its own checkout instead:
#
# git pull && docker compose up -d --build
#
# PLANS_PAGES must be in the VPS .env for this to work; it is a build arg
# (the codes are baked in via generateStaticParams), not a runtime var.
# Remove this block when CI is re-enabled — a registry image is the only
# thing `deploy.sh` and `rollback.sh` can deploy by tag.
build:
context: .
args:
PLANS_PAGES: ${PLANS_PAGES}
container_name: ksan-dev
restart: unless-stopped
env_file:
+76
View File
@@ -238,6 +238,44 @@ export const dictionaries = {
{ label: "Languages", value: "EN / SR" }, { label: "Open to", value: "Freelance, full-time" },
{ label: "Response", value: "< 24h" },
],
faqTitle: "Frequently asked questions.",
faq: [
{
question: "Can I get a free project estimate?",
answer:
"Yes. We offer a free, no-obligation estimate for your project. Once you present your ideas, requirements, and goals, we assess the scope of the project, an approximate timeline, and the price. That way you get a clearer picture of the project before you make a decision.",
},
{
question: "How long does it take to build a website?",
answer:
"The timeline depends on the scope and complexity of the project. Smaller projects usually take 7\u201314 days, while larger and more complex ones can take longer. We define an approximate deadline before the project starts.",
},
{
question: "How much does a website cost?",
answer:
"Prices are flexible and adapted to each project. The final price depends on your requirements, the functionality, and the scope and complexity of the website. After we talk through the project, we can prepare a concrete offer.",
},
{
question: "What are the payment terms?",
answer:
"A 30% deposit is paid at the start of the project, and the remaining 70% on completion, before the final handover of the website.",
},
{
question: "Do you offer support after launch?",
answer:
"Yes. We offer ongoing support and maintenance through different support packages. Depending on the package you choose, support can include a number of changes per month, regular maintenance, content updates, or changes to individual parts of the website.",
},
{
question: "Do you offer hosting and domain registration?",
answer:
"Yes. We offer hosting for your website at an affordable price, so you can have every service you need in one place. We can also help with registering and configuring your domain, as well as the technical setup of the website on the hosting.",
},
{
question: "Do I have control over the website?",
answer:
"Yes. The website belongs to you as soon as the project is finished. You get access to and control over your website, and we are here to provide additional support and help whenever you need it.",
},
],
highlightsTitle: "What I bring.",
highlights: [
{
@@ -476,6 +514,44 @@ export const dictionaries = {
{ label: "Jezici", value: "EN / SR" }, { label: "Otvoren za", value: "Freelance, puno radno" },
{ label: "Odgovor", value: "< 24h" },
],
faqTitle: "\u010cesto postavljana pitanja.",
faq: [
{
question: "Da li mogu dobiti besplatnu procjenu projekta?",
answer:
"Da. Nudimo besplatnu procjenu va\u0161eg projekta, bez obaveze. Nakon \u0161to nam predstavite svoje ideje, zahtjeve i ciljeve, procijeni\u0107emo obim projekta, okvirno vrijeme izrade i cijenu. Na ovaj na\u010din mo\u017eete dobiti jasniju sliku o projektu prije nego \u0161to donesete odluku.",
},
{
question: "Koliko traje izrada web stranice?",
answer:
"Vrijeme izrade zavisi od obima i slo\u017eenosti projekta. Za manje projekte, izrada naj\u010de\u0161\u0107e traje 7\u201314 dana, dok za ve\u0107e i kompleksnije projekte rok mo\u017ee biti du\u017ei. Prije po\u010detka projekta defini\u0161emo okvirni rok izrade.",
},
{
question: "Koliko ko\u0161ta izrada web stranice?",
answer:
"Cijene su fleksibilne i prilago\u0111avaju se svakom projektu. Kona\u010dna cijena zavisi od va\u0161ih zahtjeva, funkcionalnosti, obima i slo\u017eenosti web stranice. Nakon razgovora o projektu mo\u017eemo pripremiti konkretnu ponudu.",
},
{
question: "Koji su uslovi pla\u0107anja?",
answer:
"Prilikom po\u010detka projekta pla\u0107a se avans od 30%, dok se preostalih 70% pla\u0107a po zavr\u0161etku projekta, prije kona\u010dne predaje web stranice.",
},
{
question: "Da li nudite podr\u0161ku nakon izrade?",
answer:
"Da. Nudimo kontinuiranu podr\u0161ku i odr\u017eavanje web stranice kroz razli\u010dite pakete podr\u0161ke. U zavisnosti od odabranog paketa, podr\u0161ka mo\u017ee uklju\u010divati nekoliko izmjena mjese\u010dno, redovno odr\u017eavanje, a\u017euriranje sadr\u017eaja ili izmjene pojedinih dijelova web stranice.",
},
{
question: "Da li nudite hosting i registraciju domena?",
answer:
"Da. Nudimo hosting za va\u0161u web stranicu po povoljnoj cijeni, kako biste sve potrebne usluge mogli imati na jednom mjestu. Mo\u017eemo vam pomo\u0107i i oko registracije i pode\u0161avanja domene, kao i tehni\u010dkog postavljanja web stranice na hosting.",
},
{
question: "Da li ja imam kontrolu nad web stranicom?",
answer:
"Da. Web stranica pripada vama odmah po zavr\u0161etku projekta. Dobijate pristup i kontrolu nad svojom web stranicom, a mi smo tu da vam pru\u017eimo dodatnu podr\u0161ku i pomo\u0107 kada vam je potrebna.",
},
],
highlightsTitle: "Šta donosim.",
highlights: [
{ title: "End-to-end vlasništvo", description: "Od ideje do gotovog proizvoda. Radite s jednom osobom koja razumije cijeli stack i sve zahtjeve projekta, bez potrebe za posrednicima ili dijeljenjem odgovornosti." },
+19
View File
@@ -3,6 +3,25 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["192.168.100.6"],
output: "standalone",
// Files in public/ are served with `Cache-Control: public, max-age=0` by
// default, because Next.js can't know whether they change. kd-tile.png is
// hot-linked by an external HTML email signature, so its URL is a fixed
// contract and its bytes never change — pin it to a long-lived immutable
// cache so mail clients and their image proxies stop re-fetching it.
// Headers are matched before the filesystem, so this applies to public/.
async headers() {
return [
{
source: "/kd-tile.png",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
];
},
};
export default nextConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB