Add TrustWin Next.js site: catalog, i18n, checkout, and quote API.

Made-with: Cursor
This commit is contained in:
“kirukib” 2026-03-22 03:22:04 +03:00
parent 0d7054e034
commit 386d3dd4cf
66 changed files with 10499 additions and 0 deletions

23
.env.example Normal file
View File

@ -0,0 +1,23 @@
# Public (exposed to browser)
NEXT_PUBLIC_CONTACT_PHONE=+251911000000
NEXT_PUBLIC_CONTACT_EMAIL=sales@example.com
# Optional — shown in footer when set
NEXT_PUBLIC_CONTACT_ADDRESS=
NEXT_PUBLIC_CONTACT_HOURS=
# Optional — footer shows icon links only for URLs you set
NEXT_PUBLIC_SOCIAL_LINKEDIN=
NEXT_PUBLIC_SOCIAL_FACEBOOK=
NEXT_PUBLIC_SOCIAL_TELEGRAM=
NEXT_PUBLIC_SOCIAL_INSTAGRAM=
# SMTP (Nodemailer)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
MAIL_FROM="TrustWin Quotes <quotes@example.com>"
OWNER_EMAIL_1=owner1@example.com
OWNER_EMAIL_2=owner2@example.com
# Or comma-separated: OWNER_EMAILS=a@example.com,b@example.com

45
.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# local quote request store
/data/requests.json
/data/uploads/
# vercel
.vercel
# typescript
*.tsbuildinfo

0
data/.gitkeep Normal file
View File

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

270
messages/am.json Normal file
View File

@ -0,0 +1,270 @@
{
"metadata": {
"title": "TrustWin — የብረት ቧንቧ፣ ቦረቶች እና የግንባታ ቁሳቁሶች"
},
"nav": {
"brand": "TrustWin",
"supplier": "TrustWin አቅራቢ",
"home": "መነሻ",
"catalog": "ካታሎግ",
"products": "ምርቶች",
"cart": "ጋሪ",
"checkout": "ዋጋ ጠይቅ",
"menu": "ሜኑ",
"openMenu": "ሜኑ ክፈት",
"closeMenu": "ሜኑ ዝጋ",
"searchPlaceholder": "ለቀጣዩ ፕሮጀክትዎ ቁሳቁሶችን ይፈልጉ",
"searchAria": "ካታሎግ ፈልግ",
"joinCta": "ዋጋ ጠይቅ"
},
"lang": {
"en": "English",
"am": "አማርኛ",
"switch": "ቋንቋ"
},
"hero": {
"eyebrow": "የግንባታ ቁሳቁሶች",
"title": "የብረት ቦረቶች፣ ቧንቧዎች እና መዋቅራዊ ብረት—በፍጥነት ዋጋ።",
"subtitle": "ዝርዝሮችን ይመልከቱ፣ ጋሪዎን ይገንቡ እና የፕሮፎርማ ጥያቄ ይላኩ። ቡድናችን ክምችት፣ ዋጋ እና ማድረስ ያረጋግጣል።",
"ctaCatalog": "ካታሎግ ይመልከቱ",
"ctaQuote": "ወደ ቼክአውት ይሂዱ",
"stackAlt": "የተከማቹ የብረት መገለጫዎች ምስል፦ ቦረት፣ ፍላት ባር፣ ቧንቧ እና ባዶ ክፍል"
},
"services": {
"title": "አገልግሎቶቻችን",
"subtitle": "ለኢንዱስትሪ ደረጃ ቁሳቁሶች፣ ሰነዶች፣ ሎጂስቲክስ እና የፕሮጀክት ድጋፍ ከአንድ ሰርተጅ።",
"cut": {
"title": "ለርዝመት መቁረጥ",
"body": "ቦረቶች፣ ፍላቶች እና ባዶ ክፍሎች ለፕሮጀክት ጊዜ አቀራረብዎ።"
},
"coating": {
"title": "ሽፋኖች እና ጨርሶች",
"body": "ጋልቫናይዚንግ፣ ፕራይመር እና ለጠንካራ አየር ሁኔታዎች የመከላከያ አማራጮች።"
},
"docs": {
"title": "የፋብሪካ ሰርተፊኬቶች",
"body": "ለመዋቅራዊ እና ለመሰረተ ልማት ጨረታዎች የክትትል ፓኬቶች።"
},
"delivery": {
"title": "ወደ ስፍራ ማድረስ",
"body": "ወደ ቦታ ወይም የስራ ቦታ ከማውረጃ እቅድ ጋር የተጣመረ መጫን።"
},
"sizing": {
"title": "የመጠን ግምቶች እና ግምገማ",
"body": "ከሰነዶችዎ ጋር የሚስማሙ የብዛት ግምት፣ ተኳሃኝ ደረጃዎች እና የመገለጫ ምርጫዎች።"
},
"bulk": {
"title": "ለኮንትራክተሮች ፕሮግራሞች",
"body": "የድግሞሽ ትዕዛዞች እና የፕሮጀክት ሎት በደረጃ መልቀቅ እና በጠቅላላ ክፍያ ሂደት።"
}
},
"faq": {
"title": "በተደጋጋሚ የሚጠየቁ ጥያቄዎች",
"subtitle": "ትዕዛዝ፣ ዋጋ ጥያቄ፣ ሰነዶች እና ማድረስ—በአጭሩ።",
"i1": {
"q": "ቼክአውት ኦንላይን ክፍያ ነው?",
"a": "አይደለም። ቼክአውት የዋጋ ጥያቄ ብቻ ይላካል። ከማንኛውም ክፍያ በፊት ዋጋ፣ ክምችት እና የክፍያ ውሎ በስልክ ወይም በኢሜይል እንረጋግጣለን።"
},
"i2": {
"q": "ምን ያህል ፈጥነው ይመልሳሉ?",
"a": "ለመደበኛ ዝርዝሮች በአንድ የስራ ቀን ውስጥ ምላሽ ለመስጠት እንሞክራለን። ትልቅ ወይም በጣም ልዩ ጥምረቶች ትንሽ ሊወስዱ ይችላሉ።"
},
"i3": {
"q": "የፋብሪካ ሰርተፊኬት ሊሰጡ ይችላሉ?",
"a": "አዎ። ለሚያሟሉ ደረጃዎች የፋብሪካ ሙከራ ሰርተፊኬቶች እና የክትትል ፓኬቶች ይገኛሉ—በጥያቄ ማስታወሻዎ ውስጥ ያስታውቁ።"
},
"i4": {
"q": "ወደ ስፍራ እንደምታደርሱ?",
"a": "አገልግሎት በሚሸፍንበት ቦታ ወደ ቦታ ወይም የስራ ቦታ ማድረስ እንጣምራለን። በቼክአውት ማስታወሻዎ ውስጥ ቦታዎን እና የማውረጃ መስፈርቶችን ያክሉ።"
},
"i5": {
"q": "አንድ እቃ ከክምችት ከጠፋ ምን ይሆናል?",
"a": "አማራጮችን እንጠቁም—ደረጃ፣ ርዝመት ወይም ሴዱል—ወይም የተሻሻለ የመራ ጊዜ። እስከምትፀድቁ ድረስ ምንም አይላክም።"
},
"i6": {
"q": "English ወይም አማርኛ?",
"a": "ሁለቱም። በሳይቱ ላይ የቋንቋ መቀያየሪያውን ይጠቀሙ፤ ቡድናችን በሚመርጡት ቋንቋ ምላሽ ሊሰጥ ይችላል።"
}
},
"how": {
"title": "እንዴት እንደሚዘዙ",
"subtitle": "ኦንላይን ክፍያ የለም—ጋሪዎ ከይፋ ጥያቄ ይሆናል።",
"step1": { "title": "ቁሳቁሶችን ይምረጡ", "body": "በምድብ ያጣሩ እና አማራጮችን ይምረጡ (ሽፋን፣ ርዝመት፣ ሴዱል)።" },
"step2": { "title": "ጋሪዎን ይገንቡ", "body": "ብዛት ያስተካክሉ፤ እስከ ቼክአውት ድረስ ሁሉም ነገር ሊስተካከል ይችላል።" },
"step3": { "title": "ፋይሎችን ያያይዙ", "body": "የማመሳከሪያ ፎቶዎችን እና የፕሮፎርማ PDF ካለዎት ያስቀምጡ።" },
"step4": { "title": "እንደምንደውስ", "body": "ባለቤቶች ኢሜይል እና በስርዓቱ ውስጥ ትኬት ከመስመር እቃዎችዎ ጋር ይቀበላሉ።" }
},
"ctaBand": {
"title": "ለተጣራ ዋጋ ዝግጁ ነዎት?",
"body": "ዝርዝርዎን ይላኩ—ክምችት፣ የመራ ጊዜ እና የክፍያ ውሎ እንመልሳለን።",
"primary": "ጥያቄ ጀምር",
"secondary": "አሁን ደውል"
},
"catalog": {
"title": "ቁሳቁሶች",
"subtitle": "የኢንዱስትሪ ካታሎግ በሚሊሜትር የግድግዳ ዝርዝር፣ የመገለጫ ስነ-ስዕሎች እና በጥያቄ ላይ የተመሠረተ ቼክአውት።",
"all": "ሁሉም ቁሳቁሶች",
"allShort": "ሁሉም",
"category": "ምድብ",
"sort": "ደርድር",
"sortNameAsc": "ስም ከአ እስከ ዘ",
"sortNameDesc": "ስም ከዘ እስከ አ",
"sortPriceAsc": "ዋጋ ከዝቅ ወደ ከፍተኛ",
"sortPriceDesc": "ዋጋ ከከፍተኛ ወደ ዝቅ",
"view": "ይመልከቱ",
"empty": "ምንም ምርቶች ከማጣሪያዎ ጋር አይጣጣሙም።",
"from": "ከ",
"filters": "ማጣሪያዎች",
"closeFilters": "ማጣሪያዎችን ዝጋ",
"filterCategory": "ምድብ",
"filterSort": "የደርድር ቅደም ተከተል",
"addToCart": "ወደ ጋሪ ጨምር",
"thicknessLabel": "ዝርዝር (ሚሜ)",
"thicknessList": "በሚሊሜትር ያሉ ዝርዝሮች",
"thicknessValue": "{value}",
"sidebarDocs": "ሰነዶች",
"sidebarDocsBody": "የፋብሪካ ሰርተፊኬቶች እና የሽፋን ዳታ ሉሆች በጥያቄ ይገኛሉ።",
"sidebarGalv": "የጋልቫናይዝድ SKU",
"sidebarGalvBody": "የሆት-ዲፕ ጋልቫናይዝድ እና ፕራይመር የተሸፈኑ መስመሮችን ለመዝለል ይቀያይሩ።",
"galvToggle": "ጋልቫናይዝድ ምርጫ"
},
"catalogLine": {
"bars": "መዋቅራዊ ብረት እና ቦረቶች",
"pipes": "ቧንቧዎች እና ባዶ ክፍሎች",
"accessories": "ግንኙነት እና ሃርድዌር"
},
"categories": {
"bars": "የብረት ቦረቶች",
"pipes": "ቧንቧ እና ባዶ",
"accessories": "መለዋወጫዎች"
},
"units": {
"unitTon": "ለቶን",
"unitMeter": "ለሜትር",
"unitSet": "ለሴት"
},
"variants": {
"coating": "ሽፋን",
"coatingMill": "እንደሚወጣ",
"coatingEpoxy": "ኢፖክሲ የተሸፈነ",
"length": "ርዝመት",
"len6": "6 ሜ",
"len12": "12 ሜ",
"schedule": "የግድግዳ ሴዱል",
"sch40": "Sch 40",
"sch80": "Sch 80",
"finish": "ጨርስ",
"finishRaw": "ጥቁር ብረት",
"finishGalv": "ጋልቫናይዝድ",
"size": "መጠን",
"m12": "M12",
"m16": "M16",
"rating": "የፕሬሽር ክፍል",
"pn10": "PN10",
"pn16": "PN16"
},
"product": {
"qty": "ብዛት",
"add": "ወደ ጋሪ ጨምር",
"options": "አማራጮች",
"details": "ዝርዝሮች",
"notFound": "ምርት አልተገኘም",
"back": "ወደ ካታሎግ ተመለስ",
"thicknessLabel": "ዝርዝር (ሚሜ)",
"thicknessValue": "{value}",
"galleryPrev": "ቀዳሚ ምስል",
"galleryNext": "ቀጣይ ምስል",
"galleryCounter": "{current} / {total}",
"galleryThumbnails": "የምስል ትንሽ ገጽታዎች",
"galleryGoTo": "ወደ ምስል {n} ይሂዱ"
},
"cart": {
"title": "ጋሪዎ",
"empty": "ጋሪዎ ባዶ ነው።",
"emptyCta": "ካታሎግ ይመልከቱ",
"lineTotal": "መስመር",
"remove": "አስወግድ",
"checkout": "ወደ ጥያቄ ይቀጥሉ",
"each": "እያንዳንዱ"
},
"checkout": {
"title": "ዋጋ ይጠይቁ",
"subtitle": "የባለቤቶችን ኢሜይል እንልካለን እና ይህን ጥያቄ በስርዓታችን ውስጥ እንይዘው እንድንደውስዎ።",
"name": "ሙሉ ስም",
"phone": "ስልክ (WhatsApp ቢሆን ይመረጣል)",
"email": "ኢሜይል",
"company": "ኩባንያ / ፕሮጀክት",
"message": "ለሽያጭ ሰፈር ማስታወሻዎች",
"referenceImages": "የማመሳከሪያ ፎቶዎች (አማራጭ)",
"referenceHelp": "እስከ 5 ምስሎች፣ እያንዳንዱ 5MB (JPEG፣ PNG፣ WebP)።",
"proforma": "ፕሮፎርማ / PDF (አማራጭ)",
"proformaHelp": "PDF ወይም ግልጽ ስካን፣ እስከ 5MB።",
"submit": "ጥያቄ ላክ",
"sending": "በመላክ ላይ…",
"successTitle": "ጥያቄ ተቀብለናል",
"successBody": "የማመሳከሪያ መለያ፡",
"emailWarn": "ኢሜይል በራስ-ሰር ሊላክ አልቻለም—ጥያቄዎ አሁንም ተቀምጧል። ከዚህ ዝርዝር እንደምንደውስዎ።",
"cartEmpty": "ዋጋ ከመጠየቅዎ በፊት ዕቃዎችን ወደ ጋሪዎ ይጨምሩ።",
"goCart": "ጋሪ ይመልከቱ",
"successCta": "ካታሎግ ይመልከቱ"
},
"footer": {
"tagline": "ለኮንትራክተሮች እና ለፋብሪኬተሮች የብረት ቦረቶች፣ ቧንቧዎች እና መዋቅራዊ መለዋወጫዎች።",
"rights": "ሁሉም መብቶች የተጠበቁ ናቸው።",
"contactTitle": "አድራሻ",
"socialTitle": "ማህበራዊ ሚዲያ",
"socialHint": "የማህበራዊ መገኛ ሊንኮች በቅርቡ እዚህ ይታያሉ።",
"social": {
"linkedin": "TrustWin በ LinkedIn",
"facebook": "TrustWin በ Facebook",
"telegram": "TrustWin በ Telegram",
"instagram": "TrustWin በ Instagram"
}
},
"sticky": {
"call": "ደውል",
"regionLabel": "ፈጣን ጥሪ"
},
"products": {
"deformed-rebar-12mm": {
"name": "Deformed rebar 12 mm",
"short": "ለመዋቅራዊ ኮንክሪት ከፍተኛ ጭንካ የተጎዳ ብረት።",
"imageAlt": "በግንባታ ስፍራ ላይ የተከማቸ የብረት ቦረት"
},
"flat-bar-50x8": {
"name": "Flat bar 50 × 8 mm",
"short": "ለመደገፍ፣ ክሌት እና ለፋብሪኬሽን የተነደፈ ፍላት።",
"imageAlt": "በወርክሾፕ ውስጥ የብረት ፍላት መጠኖች"
},
"angle-iron-l-50": {
"name": "Equal angle L50 × 50 × 5",
"short": "ለፍሬሞች፣ ለድጋፍ እና ለሊንተሎች መዋቅራዊ አንግል።",
"imageAlt": "የብረት አንግል መጠኖች"
},
"galvanized-pipe-2in": {
"name": "Galvanized pipe 2″ nominal",
"short": "ለውሃ እና መስመር ቧንቧ ስራ ቧ Fred የተዘጋጀ።",
"imageAlt": "የጋልቫናይዝድ ብረት ቧንቧዎች"
},
"black-steel-pipe-3in": {
"name": "Black steel pipe 3″ nominal",
"short": "ለፋብሪኬሽን እና ለእሳት መስመሮች የተጣመረ ሴዱል ቧንቧ።",
"imageAlt": "የተከማቹ ጥቁር ብረት ቧንቧዎች"
},
"square-hollow-80": {
"name": "Square hollow section 80 × 80 mm",
"short": "ለአምዶች፣ ለጥላሎች እና ለአርክቴክቸራል ብረት SHS።",
"imageAlt": "ባዶ መዋቅራዊ ክፍሎች"
},
"beam-connector-set": {
"name": "Beam connector set",
"short": "ለሁለተኛ ደረጃ ብረት ግንኙነቶች ቅድመ-ወፍራም ሃርድዌር ኪቶች።",
"imageAlt": "የብረት ሃርድዌር እና አገናኞች"
},
"pipe-flange-kit": {
"name": "Pipe flange kit",
"short": "ለኢንዱስትሪ ቧንቧ ስራ ከጋስኬቶች ጋር የተዛመዱ ፍላንጅ ሴቶች።",
"imageAlt": "የኢንዱስትሪ ቧንቧ ፍላንጆች"
}
}
}

270
messages/en.json Normal file
View File

@ -0,0 +1,270 @@
{
"metadata": {
"title": "TrustWin — Metal bars, pipes & construction materials"
},
"nav": {
"brand": "TrustWin",
"supplier": "TrustWin Supply",
"home": "Home",
"catalog": "Catalog",
"products": "Products",
"cart": "Cart",
"checkout": "Request quote",
"menu": "Menu",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"searchPlaceholder": "Search materials for your next project",
"searchAria": "Search catalog",
"joinCta": "Request quote"
},
"lang": {
"en": "English",
"am": "አማርኛ",
"switch": "Language"
},
"hero": {
"eyebrow": "Construction materials",
"title": "Metal bars, pipes & structural steel—quoted fast.",
"subtitle": "Browse specifications, build your cart, and send a proforma-style request. Our team confirms stock, pricing, and delivery.",
"ctaCatalog": "Browse catalog",
"ctaQuote": "Go to checkout",
"stackAlt": "Illustration of stacked steel profiles: rebar, flat bar, pipe, and hollow section"
},
"services": {
"title": "Our services",
"subtitle": "Industrial-grade materials, documentation, logistics, and project support from one desk.",
"cut": {
"title": "Cut-to-length",
"body": "Rebar, flats, and hollow sections cut to your project schedule."
},
"coating": {
"title": "Coatings & finishes",
"body": "Galvanizing, primers, and protective options for harsh environments."
},
"docs": {
"title": "Mill certificates",
"body": "Traceability packs for structural and infrastructure tenders."
},
"delivery": {
"title": "Site delivery",
"body": "Coordinated drops to yard or job site with offload planning."
},
"sizing": {
"title": "Takeoffs & estimates",
"body": "Rough quantities, compatible grades, and section choices aligned with your drawings."
},
"bulk": {
"title": "Contractor programs",
"body": "Recurring orders and project lots with staged releases and consolidated billing."
}
},
"faq": {
"title": "Frequently asked questions",
"subtitle": "Ordering, quotes, documents, and delivery—at a glance.",
"i1": {
"q": "Is checkout an online payment?",
"a": "No. Checkout only sends a quote request. We confirm price, stock, and payment terms by phone or email before any payment."
},
"i2": {
"q": "How fast do you respond?",
"a": "We aim to reply within one business day for typical lists. Large or highly custom bundles may take a bit longer."
},
"i3": {
"q": "Can you provide mill certificates?",
"a": "Yes. Mill test certificates and traceability packs are available for qualifying grades—mention it in your request notes."
},
"i4": {
"q": "Do you deliver to site?",
"a": "Where coverage allows, we coordinate delivery to yard or job site. Add your location and offload notes at checkout."
},
"i5": {
"q": "What if something is out of stock?",
"a": "We suggest alternates—grade, length, or schedule—or updated lead times. Nothing ships until you approve."
},
"i6": {
"q": "English or አማርኛ?",
"a": "Both. Use the language switcher on the site; our team can follow up in the language you prefer."
}
},
"how": {
"title": "How to order",
"subtitle": "No online payment—your cart becomes a formal request.",
"step1": { "title": "Select materials", "body": "Filter by category and choose options (coating, length, schedule)." },
"step2": { "title": "Build your cart", "body": "Adjust quantities; everything stays editable until checkout." },
"step3": { "title": "Attach files", "body": "Upload reference photos and your proforma PDF if you have one." },
"step4": { "title": "We call you", "body": "Owners receive the email and in-system ticket with your line items." }
},
"ctaBand": {
"title": "Ready for a firm quote?",
"body": "Send your list—we respond with availability, lead time, and payment terms.",
"primary": "Start request",
"secondary": "Call now"
},
"catalog": {
"title": "Materials",
"subtitle": "Industrial catalog with wall thicknesses in millimetres, schematic profiles, and request-based checkout.",
"all": "All materials",
"allShort": "All",
"category": "Category",
"sort": "Sort",
"sortNameAsc": "Name AZ",
"sortNameDesc": "Name ZA",
"sortPriceAsc": "Price lowhigh",
"sortPriceDesc": "Price highlow",
"view": "View",
"empty": "No products match your filters.",
"from": "From",
"filters": "Filters",
"closeFilters": "Close filters",
"filterCategory": "Category",
"filterSort": "Sort order",
"addToCart": "Add to cart",
"thicknessLabel": "Thickness (mm)",
"thicknessList": "Available thicknesses in millimetres",
"thicknessValue": "{value}",
"sidebarDocs": "Documentation",
"sidebarDocsBody": "Mill certificates and coating data sheets available on request.",
"sidebarGalv": "Galvanized SKUs",
"sidebarGalvBody": "Toggle to prioritize hot-dip galvanized and primer-coated lines.",
"galvToggle": "Prefer galvanized"
},
"catalogLine": {
"bars": "Structural steel & bars",
"pipes": "Pipes & hollow sections",
"accessories": "Connections & hardware"
},
"categories": {
"bars": "Metal bars",
"pipes": "Pipes & hollow",
"accessories": "Accessories"
},
"units": {
"unitTon": "per ton",
"unitMeter": "per meter",
"unitSet": "per set"
},
"variants": {
"coating": "Coating",
"coatingMill": "As-rolled",
"coatingEpoxy": "Epoxy coated",
"length": "Length",
"len6": "6 m",
"len12": "12 m",
"schedule": "Wall schedule",
"sch40": "Sch 40",
"sch80": "Sch 80",
"finish": "Finish",
"finishRaw": "Black steel",
"finishGalv": "Galvanized",
"size": "Size",
"m12": "M12",
"m16": "M16",
"rating": "Pressure class",
"pn10": "PN10",
"pn16": "PN16"
},
"product": {
"qty": "Quantity",
"add": "Add to cart",
"options": "Options",
"details": "Details",
"notFound": "Product not found",
"back": "Back to catalog",
"thicknessLabel": "Thickness (mm)",
"thicknessValue": "{value}",
"galleryPrev": "Previous image",
"galleryNext": "Next image",
"galleryCounter": "{current} / {total}",
"galleryThumbnails": "Gallery thumbnails",
"galleryGoTo": "Go to image {n}"
},
"cart": {
"title": "Your cart",
"empty": "Your cart is empty.",
"emptyCta": "Browse catalog",
"lineTotal": "Line",
"remove": "Remove",
"checkout": "Continue to request",
"each": "ea."
},
"checkout": {
"title": "Request a quote",
"subtitle": "We email owners and log this request in our system so we can call you.",
"name": "Full name",
"phone": "Phone (WhatsApp preferred)",
"email": "Email",
"company": "Company / project",
"message": "Notes for the sales desk",
"referenceImages": "Reference photos (optional)",
"referenceHelp": "Up to 5 images, 5MB each (JPEG, PNG, WebP).",
"proforma": "Proforma / PDF (optional)",
"proformaHelp": "PDF or clear scan, up to 5MB.",
"submit": "Send request",
"sending": "Sending…",
"successTitle": "Request received",
"successBody": "Reference ID:",
"emailWarn": "Email could not be sent automatically—your request is still saved. We will contact you from the details provided.",
"cartEmpty": "Add items to your cart before requesting a quote.",
"goCart": "View cart",
"successCta": "Browse catalog"
},
"footer": {
"tagline": "Metal bars, pipes, and structural accessories for contractors and fabricators.",
"rights": "All rights reserved.",
"contactTitle": "Contact",
"socialTitle": "Follow us",
"socialHint": "Social profile links will appear here soon.",
"social": {
"linkedin": "TrustWin on LinkedIn",
"facebook": "TrustWin on Facebook",
"telegram": "TrustWin on Telegram",
"instagram": "TrustWin on Instagram"
}
},
"sticky": {
"call": "Call us",
"regionLabel": "Quick call"
},
"products": {
"deformed-rebar-12mm": {
"name": "Deformed rebar 12 mm",
"short": "High-tensile reinforcement bar for structural concrete.",
"imageAlt": "Steel rebar stacked on a construction site"
},
"flat-bar-50x8": {
"name": "Flat bar 50 × 8 mm",
"short": "Hot-rolled flat for bracing, cleats, and fabrication.",
"imageAlt": "Flat steel profiles in a workshop"
},
"angle-iron-l-50": {
"name": "Equal angle L50 × 50 × 5",
"short": "Structural angle for frames, supports, and lintels.",
"imageAlt": "Steel angle profiles"
},
"galvanized-pipe-2in": {
"name": "Galvanized pipe 2″ nominal",
"short": "Thread-ready water and utility pipework.",
"imageAlt": "Galvanized steel pipes"
},
"black-steel-pipe-3in": {
"name": "Black steel pipe 3″ nominal",
"short": "Welded schedule pipe for fabrication and fire lines.",
"imageAlt": "Black steel pipes stacked"
},
"square-hollow-80": {
"name": "Square hollow section 80 × 80 mm",
"short": "SHS for columns, canopies, and architectural steel.",
"imageAlt": "Hollow structural sections"
},
"beam-connector-set": {
"name": "Beam connector set",
"short": "Pre-punched hardware kits for secondary steel connections.",
"imageAlt": "Steel hardware and connectors"
},
"pipe-flange-kit": {
"name": "Pipe flange kit",
"short": "Matched flange sets with gaskets for industrial piping.",
"imageAlt": "Industrial pipe flanges"
}
}
}

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

23
next.config.ts Normal file
View File

@ -0,0 +1,23 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "images.pexels.com",
pathname: "/**",
},
],
},
};
export default withNextIntl(nextConfig);

6841
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "trustwin-site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"next": "16.2.1",
"next-intl": "^4.8.3",
"nodemailer": "^8.0.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.71.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^7.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

5
public/metal/angle.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<path fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5" stroke-linejoin="round" d="M52 148V52h36v60h60v36H52z"/>
<path d="M88 112V88h24v24H88z" fill="#f4f4f5" stroke="#57534e" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

7
public/metal/bracket.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<path fill="#e7e5e4" stroke="#1c1917" stroke-width="2" stroke-linejoin="round" d="M48 132V68h52v64H48zm52 0V88h48v44h-48z"/>
<circle cx="74" cy="100" r="5" fill="#f4f4f5" stroke="#57534e" stroke-width="1.75"/>
<circle cx="92" cy="100" r="5" fill="#f4f4f5" stroke="#57534e" stroke-width="1.75"/>
<circle cx="124" cy="108" r="5" fill="#f4f4f5" stroke="#57534e" stroke-width="1.75"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

11
public/metal/flange.svg Normal file
View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<circle cx="100" cy="100" r="60" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<circle cx="100" cy="100" r="20" fill="#f4f4f5" stroke="#57534e" stroke-width="2"/>
<circle cx="100" cy="38" r="3.5" fill="#1c1917"/>
<circle cx="146" cy="76" r="3.5" fill="#1c1917"/>
<circle cx="146" cy="124" r="3.5" fill="#1c1917"/>
<circle cx="100" cy="162" r="3.5" fill="#1c1917"/>
<circle cx="54" cy="124" r="3.5" fill="#1c1917"/>
<circle cx="54" cy="76" r="3.5" fill="#1c1917"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<rect x="32" y="82" width="136" height="36" rx="2" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<line x1="44" y1="100" x2="156" y2="100" stroke="#57534e" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 360" fill="none" aria-hidden="true">
<rect width="480" height="360" fill="#e7e5e4"/>
<rect x="24" y="24" width="432" height="312" rx="16" fill="#f4f4f5" stroke="#a8a29e" stroke-width="2"/>
<g transform="translate(48 200)">
<path stroke="#1c1917" stroke-width="2.5" stroke-linecap="round" d="M0 48h160"/>
<path stroke="#57534e" stroke-width="1.75" stroke-linecap="round" d="M8 42v12M24 40v16M40 42v12M56 40v16M72 42v12M88 40v16M104 42v12M120 40v16M136 42v12M152 40v16"/>
</g>
<rect x="220" y="228" width="140" height="28" rx="2" fill="#e7e5e4" stroke="#1c1917" stroke-width="2"/>
<line x1="232" y1="242" x2="348" y2="242" stroke="#57534e" stroke-width="1.25" stroke-linecap="round"/>
<circle cx="360" cy="120" r="52" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<circle cx="360" cy="120" r="34" fill="#f4f4f5" stroke="#57534e" stroke-width="2"/>
<rect x="72" y="72" width="100" height="100" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<rect x="92" y="92" width="60" height="60" fill="#f4f4f5" stroke="#57534e" stroke-width="2"/>
<path fill="#e7e5e4" stroke="#1c1917" stroke-width="2" stroke-linejoin="round" d="M260 72v72h72V72h-36v36h-36z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<circle cx="100" cy="100" r="56" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<circle cx="100" cy="100" r="28" fill="#f4f4f5" stroke="#57534e" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<circle cx="100" cy="100" r="54" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<circle cx="100" cy="100" r="36" fill="#f4f4f5" stroke="#57534e" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

5
public/metal/pipe.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<circle cx="100" cy="100" r="55" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<circle cx="100" cy="100" r="34" fill="#f4f4f5" stroke="#57534e" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

5
public/metal/rebar.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<path stroke="#1c1917" stroke-width="3" stroke-linecap="round" d="M28 100h144"/>
<path stroke="#57534e" stroke-width="2" stroke-linecap="round" d="M36 92v16M50 88v24M64 90v20M78 88v24M92 92v16M106 88v24M120 90v20M134 88v24M148 92v16M162 88v24"/>
</svg>

After

Width:  |  Height:  |  Size: 401 B

5
public/metal/shs.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" aria-hidden="true">
<rect width="200" height="200" fill="#f4f4f5"/>
<rect x="46" y="46" width="108" height="108" fill="#e7e5e4" stroke="#1c1917" stroke-width="2.5"/>
<rect x="70" y="70" width="60" height="60" fill="#f4f4f5" stroke="#57534e" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,24 @@
import { getTranslations } from "next-intl/server";
import { CartView } from "@/components/CartView";
import { CartCheckoutLink } from "@/components/CartCheckoutLink";
export async function generateMetadata() {
const t = await getTranslations("cart");
return { title: `${t("title")} — TrustWin` };
}
export default async function CartPage() {
const t = await getTranslations("cart");
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold tracking-tight text-stone-900">
{t("title")}
</h1>
<div className="mt-8">
<CartView />
</div>
<CartCheckoutLink />
</div>
);
}

View File

@ -0,0 +1,11 @@
import { CatalogExplorer } from "@/components/CatalogExplorer";
import { products } from "@/lib/products";
export default async function CatalogPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
return <CatalogExplorer products={products} initialSearch={q ?? ""} />;
}

View File

@ -0,0 +1,23 @@
import { getTranslations } from "next-intl/server";
import { CheckoutForm } from "@/components/CheckoutForm";
export async function generateMetadata() {
const t = await getTranslations("checkout");
return { title: `${t("title")} — TrustWin` };
}
export default async function CheckoutPage() {
const t = await getTranslations("checkout");
return (
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold tracking-tight text-stone-900">
{t("title")}
</h1>
<p className="mt-2 text-stone-600">{t("subtitle")}</p>
<div className="mt-8">
<CheckoutForm />
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import { DocumentLang } from "@/components/DocumentLang";
import { SiteChrome } from "@/components/SiteChrome";
type Props = {
children: ReactNode;
params: Promise<{ locale: string }>;
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: t("title"),
description: t("title"),
};
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
if (!routing.locales.includes(locale as "en" | "am")) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
<DocumentLang />
<SiteChrome>{children}</SiteChrome>
</NextIntlClientProvider>
);
}

View File

@ -0,0 +1,18 @@
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
export default async function NotFound() {
const t = await getTranslations("product");
return (
<div className="mx-auto flex max-w-lg flex-col items-center px-4 py-24 text-center">
<h1 className="text-2xl font-bold text-stone-900">{t("notFound")}</h1>
<Link
href="/catalog"
className="mt-6 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white hover:bg-blue-700"
>
{t("back")}
</Link>
</div>
);
}

193
src/app/[locale]/page.tsx Normal file
View File

@ -0,0 +1,193 @@
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
export default async function HomePage() {
const t = await getTranslations();
const th = await getTranslations("hero");
return (
<div>
<section className="relative overflow-hidden border-b border-neutral-200/90 bg-gradient-to-b from-white to-neutral-50">
<div className="mx-auto grid max-w-[1440px] gap-10 px-4 py-14 sm:px-6 lg:grid-cols-2 lg:items-center lg:py-20 lg:px-8">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{t("hero.eyebrow")}
</p>
<h1 className="mt-3 text-3xl font-bold tracking-tight text-stone-900 sm:text-4xl lg:text-5xl">
{t("hero.title")}
</h1>
<p className="mt-4 text-base leading-relaxed text-stone-600 sm:text-lg">
{t("hero.subtitle")}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
href="/catalog"
className="inline-flex items-center justify-center rounded-full bg-blue-600 px-5 py-3.5 text-sm font-bold text-white shadow-lg shadow-blue-600/20 hover:bg-blue-700"
>
{t("hero.ctaCatalog")}
</Link>
<Link
href="/checkout"
className="inline-flex items-center justify-center rounded-full border border-neutral-300 bg-white px-5 py-3.5 text-sm font-semibold text-neutral-800 shadow-sm hover:border-neutral-400 hover:text-neutral-950"
>
{t("hero.ctaQuote")}
</Link>
</div>
</div>
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl bg-[#e2e8f0] shadow-xl ring-1 ring-neutral-200/90">
<Image
src="/metal/hero-stack.svg"
alt={th("stackAlt")}
fill
className="object-contain p-6 sm:p-10"
priority
sizes="(max-width:1024px) 100vw, 50vw"
unoptimized
/>
</div>
</div>
</section>
<section
id="services"
className="border-y border-stone-200/80 bg-stone-50/80"
aria-labelledby="services-heading"
>
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
<h2
id="services-heading"
className="text-2xl font-bold text-stone-900 sm:text-3xl"
>
{t("services.title")}
</h2>
<p className="mt-2 max-w-2xl text-stone-600">{t("services.subtitle")}</p>
<ul className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{(
[
"cut",
"coating",
"docs",
"delivery",
"sizing",
"bulk",
] as const
).map((key) => (
<li
key={key}
className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-stone-200/80"
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-600 text-white">
<span className="text-lg font-black" aria-hidden>
</span>
</div>
<h3 className="mt-4 text-lg font-semibold text-stone-900">
{t(`services.${key}.title`)}
</h3>
<p className="mt-2 text-sm leading-relaxed text-stone-600">
{t(`services.${key}.body`)}
</p>
</li>
))}
</ul>
</div>
</section>
<section className="bg-white">
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-stone-900 sm:text-3xl">
{t("how.title")}
</h2>
<p className="mt-2 text-stone-600">{t("how.subtitle")}</p>
<ol className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{([1, 2, 3, 4] as const).map((n) => (
<li
key={n}
className="relative rounded-2xl border border-stone-200 bg-stone-50/80 p-5"
>
<span className="text-3xl font-black text-orange-500/30">
{n}
</span>
<h3 className="mt-2 font-semibold text-stone-900">
{t(`how.step${n}.title` as "how.step1.title")}
</h3>
<p className="mt-2 text-sm text-stone-600">
{t(`how.step${n}.body` as "how.step1.body")}
</p>
</li>
))}
</ol>
</div>
</section>
<section
id="faq"
className="border-t border-stone-200/80 bg-stone-50/80"
aria-labelledby="faq-heading"
>
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl">
<h2
id="faq-heading"
className="text-2xl font-bold text-stone-900 sm:text-3xl"
>
{t("faq.title")}
</h2>
<p className="mt-2 text-stone-600">{t("faq.subtitle")}</p>
<div className="mt-10 space-y-3">
{([1, 2, 3, 4, 5, 6] as const).map((n) => (
<details
key={n}
className="group rounded-2xl border border-stone-200 bg-white px-4 py-1 shadow-sm ring-stone-200/80 open:ring-2 open:ring-blue-600/15"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 py-3 text-left text-sm font-semibold text-stone-900 [&::-webkit-details-marker]:hidden">
<span>{t(`faq.i${n}.q` as "faq.i1.q")}</span>
<svg
className="faq-chevron h-5 w-5 shrink-0 text-stone-400 transition-transform duration-200"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.17l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.24 4.5a.75.75 0 0 1-1.08 0l-4.24-4.5a.75.75 0 0 1 .02-1.06Z"
clipRule="evenodd"
/>
</svg>
</summary>
<p className="border-t border-stone-100 pb-4 pt-3 text-sm leading-relaxed text-stone-600">
{t(`faq.i${n}.a` as "faq.i1.a")}
</p>
</details>
))}
</div>
</div>
</div>
</section>
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
<div className="overflow-hidden rounded-3xl bg-blue-600 px-6 py-10 text-center text-white shadow-xl shadow-blue-600/25 sm:px-12">
<h2 className="text-2xl font-bold sm:text-3xl">{t("ctaBand.title")}</h2>
<p className="mx-auto mt-3 max-w-xl text-sm text-blue-100 sm:text-base">
{t("ctaBand.body")}
</p>
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
<Link
href="/checkout"
className="inline-flex justify-center rounded-xl bg-orange-500 px-6 py-3.5 text-sm font-bold text-white hover:bg-orange-600"
>
{t("ctaBand.primary")}
</Link>
<a
href={`tel:${(process.env.NEXT_PUBLIC_CONTACT_PHONE ?? "+251911000000").replace(/\s/g, "")}`}
className="inline-flex justify-center rounded-xl border border-white/40 bg-white/10 px-6 py-3.5 text-sm font-semibold text-white backdrop-blur hover:bg-white/20"
>
{t("ctaBand.secondary")}
</a>
</div>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,110 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { ProductAddToCart } from "@/components/ProductAddToCart";
import { ProductGallery } from "@/components/ProductGallery";
import { formatThicknessMm } from "@/lib/format-thickness";
import { getProductBySlug, products } from "@/lib/products";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return products.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const p = getProductBySlug(slug);
if (!p) return { title: "TrustWin" };
const t = await getTranslations("products");
return {
title: `${t(`${slug}.name`)} — TrustWin`,
description: t(`${slug}.short`),
};
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) notFound();
const t = await getTranslations("product");
const tc = await getTranslations("catalog");
const tp = await getTranslations("products");
const tu = await getTranslations("units");
const tcl = await getTranslations("catalogLine");
const tn = await getTranslations("nav");
return (
<div className="mx-auto max-w-[1440px] px-4 py-10 sm:px-6 lg:px-8">
<Link
href="/catalog"
className="text-sm font-medium text-blue-600 hover:text-blue-700"
>
{t("back")}
</Link>
<div className="mt-8 grid gap-10 lg:grid-cols-2 lg:gap-16">
<div>
<ProductGallery slug={slug} sources={product.gallery} />
<div className="mt-4">
<p className="text-xs font-bold uppercase tracking-wider text-neutral-500">
{t("thicknessLabel")}
</p>
<div
className="mt-2 flex flex-wrap gap-2"
role="list"
aria-label={tc("thicknessList")}
>
{product.thicknessesMm.map((mm) => (
<span
key={mm}
role="listitem"
className="inline-flex rounded-full bg-neutral-100 px-3 py-1.5 text-sm font-semibold tabular-nums text-neutral-800 ring-1 ring-neutral-200/90"
>
{t("thicknessValue", {
value: formatThicknessMm(mm),
})}
</span>
))}
</div>
</div>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-neutral-500">
{tcl(product.catalogLineKey)}
</p>
<p className="mt-2 text-sm font-semibold text-neutral-900">
{tn("supplier")}
</p>
<h1 className="mt-1 text-2xl font-normal leading-tight tracking-tight text-neutral-700 sm:text-3xl lg:text-4xl">
{tp(`${slug}.name`)}
</h1>
<p className="mt-4 text-base leading-relaxed text-neutral-600">
{tp(`${slug}.short`)}
</p>
<p className="mt-6 text-xl font-semibold text-neutral-900">
{product.pricePerUnit.toFixed(2)}{" "}
<span className="text-sm font-medium text-neutral-500">
{tu(product.unitKey)}
</span>
</p>
<div className="mt-8">
<ProductAddToCart product={product} />
</div>
<div className="mt-8 rounded-2xl border border-neutral-200 bg-neutral-50/80 p-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
{t("details")}
</h2>
<p className="mt-2 text-sm leading-relaxed text-neutral-600">
{tp(`${slug}.short`)}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,239 @@
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import { checkoutPayloadSchema } from "@/lib/checkout-schema";
import { getProductBySlug } from "@/lib/products";
import { sendQuoteEmail, buildQuoteEmailBodies } from "@/lib/mail";
import { appendRequest, type RequestRecord } from "@/lib/requests";
import { rateLimitHit } from "@/lib/rate-limit";
import { sanitizeFilename } from "@/lib/sanitize-filename";
import type { CartLine } from "@/lib/checkout-schema";
const MAX_FILE = 5 * 1024 * 1024;
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
const PROFORMA_TYPES = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
function clientIp(req: Request) {
return (
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
req.headers.get("x-real-ip") ||
"unknown"
);
}
function validateCartAgainstCatalog(lines: CartLine[]): string | null {
for (const line of lines) {
const p = getProductBySlug(line.productSlug);
if (!p) return `Unknown product: ${line.productSlug}`;
for (const opt of p.variants) {
const v = line.selections[opt.key];
if (!v || !opt.values.some((x) => x.value === v)) {
return `Invalid options for ${line.productSlug}`;
}
}
}
return null;
}
function lineSummaryLabel(slug: string, selections: Record<string, string>) {
const p = getProductBySlug(slug);
const name = p?.slug ?? slug;
const sel = Object.entries(selections)
.map(([k, v]) => `${k}=${v}`)
.join(", ");
return `${name}${sel ? ` (${sel})` : ""}`;
}
export async function POST(req: Request) {
if (!rateLimitHit(clientIp(req))) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const website = String(formData.get("website") ?? "");
if (website.trim() !== "") {
return NextResponse.json({ ok: true, requestId: "ignored" });
}
const cartRaw = formData.get("cart");
if (typeof cartRaw !== "string") {
return NextResponse.json({ error: "Cart required" }, { status: 400 });
}
let cartJson: unknown;
try {
cartJson = JSON.parse(cartRaw);
} catch {
return NextResponse.json({ error: "Invalid cart JSON" }, { status: 400 });
}
const parsed = checkoutPayloadSchema.safeParse({
name: String(formData.get("name") ?? ""),
phone: String(formData.get("phone") ?? ""),
email: String(formData.get("email") ?? ""),
company: String(formData.get("company") ?? ""),
message: String(formData.get("message") ?? ""),
website: "",
cart: cartJson,
});
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 },
);
}
const cartErr = validateCartAgainstCatalog(parsed.data.cart);
if (cartErr) {
return NextResponse.json({ error: cartErr }, { status: 400 });
}
const requestId = randomUUID();
const uploadDir = path.join(process.cwd(), "data", "uploads", requestId);
const referenceImageNames: string[] = [];
const attachments: { filename: string; content: Buffer; contentType?: string }[] =
[];
try {
const refFiles = formData.getAll("referenceImages");
let refCount = 0;
for (const entry of refFiles) {
if (!(entry instanceof File) || entry.size === 0) continue;
if (refCount >= 5) break;
if (entry.size > MAX_FILE) {
return NextResponse.json(
{ error: "Each reference image must be 5MB or less" },
{ status: 400 },
);
}
const type = entry.type || "application/octet-stream";
if (!IMAGE_TYPES.has(type)) {
return NextResponse.json(
{ error: "Reference images must be JPEG, PNG, or WebP" },
{ status: 400 },
);
}
const buf = Buffer.from(await entry.arrayBuffer());
const safe = sanitizeFilename(entry.name);
const filename = `ref-${refCount + 1}-${safe}`;
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, filename), buf);
referenceImageNames.push(filename);
attachments.push({
filename,
content: buf,
contentType: type,
});
refCount += 1;
}
const proforma = formData.get("proforma");
let proformaFileName: string | null = null;
if (proforma instanceof File && proforma.size > 0) {
if (proforma.size > MAX_FILE) {
return NextResponse.json(
{ error: "Proforma must be 5MB or less" },
{ status: 400 },
);
}
const type = proforma.type || "application/octet-stream";
if (!PROFORMA_TYPES.has(type)) {
return NextResponse.json(
{ error: "Proforma must be PDF or an image scan" },
{ status: 400 },
);
}
const buf = Buffer.from(await proforma.arrayBuffer());
const safe = sanitizeFilename(proforma.name);
proformaFileName = `proforma-${safe}`;
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, proformaFileName), buf);
attachments.push({
filename: proformaFileName,
content: buf,
contentType: type,
});
}
const linesSummary = parsed.data.cart
.map(
(line, i) =>
`${i + 1}. ${lineSummaryLabel(line.productSlug, line.selections)} × ${line.quantity}`,
)
.join("\n");
const { text, html } = buildQuoteEmailBodies({
requestId,
contact: {
name: parsed.data.name,
phone: parsed.data.phone,
email: parsed.data.email,
company: parsed.data.company || undefined,
message: parsed.data.message || undefined,
},
linesSummary,
lines: parsed.data.cart,
});
let emailSent = false;
let emailError: string | undefined;
try {
await sendQuoteEmail({
requestId,
subject: `[TrustWin] Quote ${requestId}`,
textBody: text,
htmlBody: html,
attachments,
});
emailSent = true;
} catch (e) {
emailError = e instanceof Error ? e.message : "Email failed";
}
const record: RequestRecord = {
id: requestId,
createdAt: new Date().toISOString(),
contact: {
name: parsed.data.name,
phone: parsed.data.phone,
email: parsed.data.email,
company: parsed.data.company || undefined,
message: parsed.data.message || undefined,
},
lines: parsed.data.cart,
referenceImageNames,
proformaFileName,
uploadDir: referenceImageNames.length || proformaFileName ? uploadDir : null,
emailSent,
emailError,
};
await appendRequest(record);
return NextResponse.json({
ok: true,
requestId,
emailSent,
emailError: emailError ?? null,
});
} catch (e) {
console.error(e);
return NextResponse.json(
{ error: "Server error while processing request" },
{ status: 500 },
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

59
src/app/globals.css Normal file
View File

@ -0,0 +1,59 @@
@import "tailwindcss";
:root {
--background: #fafafa;
--foreground: #171717;
/* Deeper navy + burnt orange (match @theme blue/orange-600) */
--brand-blue: #163a5c;
--brand-orange: #b84a0a;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-dm-sans), ui-sans-serif, system-ui, sans-serif;
/* Navy (lower chroma + lightness than default Tailwind blue) */
--color-blue-50: oklch(96.5% 0.012 262);
--color-blue-100: oklch(90.5% 0.028 262);
--color-blue-200: oklch(83% 0.045 262);
--color-blue-300: oklch(70% 0.07 262);
--color-blue-400: oklch(56% 0.095 262);
--color-blue-500: oklch(44% 0.11 262);
--color-blue-600: oklch(36% 0.105 262);
--color-blue-700: oklch(30% 0.09 262);
--color-blue-800: oklch(25% 0.075 262);
--color-blue-900: oklch(21% 0.06 262);
--color-blue-950: oklch(16% 0.045 262);
/* Burnt orange (darker than default orange-500/400) */
--color-orange-50: oklch(97% 0.018 55);
--color-orange-100: oklch(92.5% 0.038 52);
--color-orange-200: oklch(86% 0.065 48);
--color-orange-300: oklch(74% 0.1 46);
--color-orange-400: oklch(62% 0.14 44);
--color-orange-500: oklch(52% 0.155 42);
--color-orange-600: oklch(44% 0.14 40);
--color-orange-700: oklch(37% 0.12 39);
--color-orange-800: oklch(31% 0.095 38);
--color-orange-900: oklch(26% 0.075 37);
--color-orange-950: oklch(19% 0.05 36);
}
body {
background: var(--background);
color: var(--foreground);
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
details[open] .faq-chevron {
transform: rotate(180deg);
}

19
src/app/layout.tsx Normal file
View File

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { DM_Sans } from "next/font/google";
import "./globals.css";
const dmSans = DM_Sans({
subsets: ["latin", "latin-ext"],
variable: "--font-dm-sans",
display: "swap",
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={dmSans.variable} suppressHydrationWarning>
<body className="min-h-screen bg-stone-50 font-sans text-stone-900 antialiased">
{children}
</body>
</html>
);
}

View File

@ -0,0 +1,16 @@
"use client";
import { useCartStore } from "@/store/cart";
export function CartBadge() {
const count = useCartStore((s) =>
s.items.reduce((acc, line) => acc + line.quantity, 0),
);
if (count === 0) return null;
return (
<span className="absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-orange-500 px-1 text-[10px] font-bold text-white shadow-sm">
{count > 99 ? "99+" : count}
</span>
);
}

View File

@ -0,0 +1,21 @@
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { useCartStore } from "@/store/cart";
export function CartCheckoutLink() {
const t = useTranslations("cart");
const count = useCartStore((s) => s.items.length);
if (count === 0) return null;
return (
<div className="mt-8 flex justify-end">
<Link
href="/checkout"
className="inline-flex rounded-xl bg-orange-500 px-6 py-3 text-sm font-bold text-white shadow-sm hover:bg-orange-600"
>
{t("checkout")}
</Link>
</div>
);
}

121
src/components/CartView.tsx Normal file
View File

@ -0,0 +1,121 @@
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { useCartStore } from "@/store/cart";
import type { CartLine } from "@/lib/checkout-schema";
import { getProductBySlug } from "@/lib/products";
export function CartView() {
const t = useTranslations("cart");
const tv = useTranslations("variants");
const tp = useTranslations("products");
const tu = useTranslations("units");
const items = useCartStore((s) => s.items);
const updateQuantity = useCartStore((s) => s.updateQuantity);
const removeLine = useCartStore((s) => s.removeLine);
if (items.length === 0) {
return (
<div className="mx-auto max-w-lg rounded-2xl bg-white p-10 text-center shadow-sm ring-1 ring-stone-200/80">
<p className="text-stone-600">{t("empty")}</p>
<Link
href="/catalog"
className="mt-6 inline-flex rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white hover:bg-blue-700"
>
{t("emptyCta")}
</Link>
</div>
);
}
return (
<ul className="space-y-4">
{items.map((line) => (
<CartLineRow
key={line.lineId}
line={line}
t={t}
tp={tp}
tv={tv}
tu={tu}
updateQuantity={updateQuantity}
removeLine={removeLine}
/>
))}
</ul>
);
}
function CartLineRow({
line,
t,
tp,
tv,
tu,
updateQuantity,
removeLine,
}: {
line: CartLine;
t: ReturnType<typeof useTranslations>;
tp: ReturnType<typeof useTranslations>;
tv: ReturnType<typeof useTranslations>;
tu: ReturnType<typeof useTranslations>;
updateQuantity: (id: string, q: number) => void;
removeLine: (id: string) => void;
}) {
const product = getProductBySlug(line.productSlug);
const name = product ? tp(`${product.slug}.name`) : line.productSlug;
const sub = Object.entries(line.selections)
.map(([k, val]) => {
const vDef = product?.variants.find((x) => x.key === k);
const label = vDef?.values.find((x) => x.value === val);
const optLabel = label ? tv(label.labelKey) : val;
return `${vDef ? tv(vDef.labelKey) : k}: ${optLabel}`;
})
.join(" · ");
const price = product?.pricePerUnit ?? 0;
const lineTotal = price * line.quantity;
return (
<li className="flex flex-col gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-stone-200/80 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<p className="font-semibold text-stone-900">{name}</p>
{sub ? (
<p className="mt-1 text-sm text-stone-500">{sub}</p>
) : null}
<p className="mt-2 text-sm text-stone-600">
{price.toFixed(2)}{" "}
{product ? tu(product.unitKey) : ""} {t("each")}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="sr-only" htmlFor={`q-${line.lineId}`}>
Quantity
</label>
<input
id={`q-${line.lineId}`}
type="number"
min={1}
max={9999}
value={line.quantity}
onChange={(e) =>
updateQuantity(line.lineId, Number(e.target.value) || 1)
}
className="w-20 rounded-lg border border-stone-200 px-2 py-1.5 text-sm font-semibold"
/>
<p className="text-sm font-bold text-orange-500">
{lineTotal.toFixed(2)}
</p>
<button
type="button"
onClick={() => removeLine(line.lineId)}
className="text-sm font-medium text-red-600 hover:text-red-700"
>
{t("remove")}
</button>
</div>
</li>
);
}

View File

@ -0,0 +1,369 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Link, useRouter } from "@/i18n/navigation";
import type { Product, ProductCategory } from "@/lib/products";
import { formatThicknessList, formatThicknessMm } from "@/lib/format-thickness";
import { MetalProfileVisual } from "@/components/MetalProfileVisual";
type SortKey = "name-asc" | "name-desc" | "price-asc" | "price-desc";
const categoryList: (ProductCategory | "all")[] = [
"all",
"bars",
"pipes",
"accessories",
];
function productMatchesGalv(p: Product): boolean {
if (p.slug.includes("galvanized")) return true;
return p.variants.some((v) =>
v.values.some((o) => o.value === "galv"),
);
}
export function CatalogExplorer({
products,
initialSearch = "",
}: {
products: Product[];
initialSearch?: string;
}) {
const t = useTranslations("catalog");
const tc = useTranslations("categories");
const tu = useTranslations("units");
const tp = useTranslations("products");
const tcl = useTranslations("catalogLine");
const tn = useTranslations("nav");
const router = useRouter();
const [cat, setCat] = useState<ProductCategory | "all">("all");
const [sort, setSort] = useState<SortKey>("name-asc");
const [search, setSearch] = useState(initialSearch);
const [filtersOpen, setFiltersOpen] = useState(false);
const [preferGalv, setPreferGalv] = useState(false);
useEffect(() => {
setSearch(initialSearch);
}, [initialSearch]);
const filtered = useMemo(() => {
let list =
cat === "all" ? [...products] : products.filter((p) => p.category === cat);
const q = search.trim().toLowerCase();
if (q) {
list = list.filter((p) => {
const name = tp(`${p.slug}.name`).toLowerCase();
const short = tp(`${p.slug}.short`).toLowerCase();
const listStr = formatThicknessList(p.thicknessesMm).toLowerCase();
const anyThickness = p.thicknessesMm.some((mm) => {
const f = formatThicknessMm(mm).toLowerCase();
return f.includes(q) || String(mm).includes(q);
});
return (
name.includes(q) ||
short.includes(q) ||
p.slug.toLowerCase().includes(q) ||
listStr.includes(q) ||
anyThickness
);
});
}
if (preferGalv) {
list = list.filter(productMatchesGalv);
}
list.sort((a, b) => {
const nameA = tp(`${a.slug}.name`);
const nameB = tp(`${b.slug}.name`);
if (sort === "name-asc") return nameA.localeCompare(nameB);
if (sort === "name-desc") return nameB.localeCompare(nameA);
if (sort === "price-asc") return a.pricePerUnit - b.pricePerUnit;
return b.pricePerUnit - a.pricePerUnit;
});
return list;
}, [products, cat, sort, search, preferGalv, tp]);
function submitSearch(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (search.trim()) params.set("q", search.trim());
router.push(`/catalog${params.toString() ? `?${params.toString()}` : ""}`);
}
const filterPanel = (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
{t("filterCategory")}
</p>
<ul className="mt-3 space-y-1">
{categoryList.map((c) => (
<li key={c}>
<button
type="button"
onClick={() => {
setCat(c);
setFiltersOpen(false);
}}
className={`flex w-full rounded-xl px-3 py-2 text-left text-sm font-medium transition ${
cat === c
? "bg-blue-600 text-white"
: "text-neutral-700 hover:bg-neutral-100"
}`}
>
{c === "all" ? t("all") : tc(c)}
</button>
</li>
))}
</ul>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
{t("filterSort")}
</p>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="mt-3 w-full rounded-xl border border-neutral-200 bg-white px-3 py-2.5 text-sm font-medium text-neutral-900 outline-none focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20"
>
<option value="name-asc">{t("sortNameAsc")}</option>
<option value="name-desc">{t("sortNameDesc")}</option>
<option value="price-asc">{t("sortPriceAsc")}</option>
<option value="price-desc">{t("sortPriceDesc")}</option>
</select>
</div>
<div className="rounded-2xl border border-neutral-200 bg-neutral-50/80 p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-neutral-800">
{t("galvToggle")}
</span>
<button
type="button"
role="switch"
aria-checked={preferGalv}
aria-label={t("galvToggle")}
onClick={() => setPreferGalv((v) => !v)}
className={`relative h-7 w-12 shrink-0 rounded-full transition-colors ${
preferGalv ? "bg-blue-600" : "bg-neutral-300"
}`}
>
<span
className={`absolute top-1 h-5 w-5 rounded-full bg-white shadow transition-all ${
preferGalv ? "left-[calc(100%-1.25rem-0.25rem)]" : "left-1"
}`}
/>
</button>
</div>
<p className="mt-2 text-xs leading-relaxed text-neutral-500">
{t("sidebarGalvBody")}
</p>
</div>
<div className="rounded-2xl border border-neutral-200 bg-white p-4">
<p className="text-sm font-semibold text-neutral-900">{t("sidebarDocs")}</p>
<p className="mt-2 text-xs leading-relaxed text-neutral-600">
{t("sidebarDocsBody")}
</p>
</div>
</div>
);
return (
<div className="min-h-screen bg-neutral-50">
<div className="mx-auto max-w-[1440px] px-4 py-8 sm:py-10 lg:px-8">
<header className="max-w-3xl">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl">
{t("title")}
</h1>
<p className="mt-3 text-sm leading-relaxed text-neutral-600 sm:text-base">
{t("subtitle")}
</p>
</header>
<form
onSubmit={submitSearch}
className="relative mt-6 md:hidden"
role="search"
>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={tn("searchPlaceholder")}
className="w-full rounded-full border border-neutral-200 bg-white py-3 pl-11 pr-4 text-sm text-neutral-900 shadow-sm outline-none focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20"
/>
<span className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-neutral-400">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-4.34-4.34M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z" />
</svg>
</span>
</form>
<div className="mt-8 flex flex-wrap items-center gap-3">
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-800 shadow-sm lg:hidden"
onClick={() => setFiltersOpen(true)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" d="M4 6h16M8 12h8m-6 6h4" />
</svg>
{t("filters")}
</button>
<div className="no-scrollbar flex min-w-0 flex-1 gap-2 overflow-x-auto pb-1">
{categoryList.map((c) => (
<button
key={c}
type="button"
onClick={() => setCat(c)}
className={`shrink-0 rounded-full px-4 py-2 text-sm font-semibold transition ${
cat === c
? "bg-blue-600 text-white shadow-sm"
: "bg-white text-neutral-700 ring-1 ring-neutral-200 hover:bg-neutral-100"
}`}
>
{c === "all" ? t("allShort") : tc(c)}
</button>
))}
</div>
<div className="hidden items-center gap-2 sm:flex">
<label htmlFor="catalog-sort" className="sr-only">
{t("sort")}
</label>
<select
id="catalog-sort"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded-full border border-neutral-200 bg-white py-2 pl-3 pr-8 text-sm font-medium text-neutral-900 outline-none focus:border-blue-600 focus:ring-2 focus:ring-blue-600/20"
>
<option value="name-asc">{t("sortNameAsc")}</option>
<option value="name-desc">{t("sortNameDesc")}</option>
<option value="price-asc">{t("sortPriceAsc")}</option>
<option value="price-desc">{t("sortPriceDesc")}</option>
</select>
</div>
</div>
<div className="mt-10 flex gap-10">
<aside className="hidden w-72 shrink-0 lg:block">{filterPanel}</aside>
<div className="min-w-0 flex-1">
{filtered.length === 0 ? (
<p className="py-20 text-center text-sm text-neutral-500">{t("empty")}</p>
) : (
<ul className="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{filtered.map((p) => (
<li key={p.slug}>
<article className="group flex h-full flex-col">
<Link
href={`/product/${p.slug}`}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-neutral-200/80"
>
<div className="relative aspect-square">
<MetalProfileVisual
src={p.gallery[0]}
alt={tp(`${p.slug}.imageAlt`)}
className="h-full w-full rounded-2xl"
/>
</div>
</Link>
<div className="mt-3 px-0.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-neutral-500">
{t("thicknessLabel")}
</p>
<div
className="mt-1.5 flex flex-wrap gap-1.5"
role="list"
aria-label={t("thicknessList")}
>
{p.thicknessesMm.map((mm) => (
<span
key={`${p.slug}-${mm}`}
role="listitem"
className="inline-flex rounded-full bg-neutral-100 px-2.5 py-1 text-xs font-semibold tabular-nums text-neutral-800 ring-1 ring-neutral-200/90"
>
{t("thicknessValue", {
value: formatThicknessMm(mm),
})}
</span>
))}
</div>
</div>
<p className="mt-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-500">
{tcl(p.catalogLineKey)}
</p>
<p className="mt-1 text-sm font-semibold text-neutral-900">
{tn("supplier")}
</p>
<h2 className="mt-0.5 text-sm font-normal leading-snug text-neutral-600">
<Link
href={`/product/${p.slug}`}
className="hover:text-blue-600"
>
{tp(`${p.slug}.name`)}
</Link>
</h2>
<p className="mt-3 text-sm text-neutral-500">
<span className="text-[11px] font-semibold uppercase tracking-wide">
{t("from")}
</span>{" "}
<span className="text-base font-semibold text-neutral-900">
{p.pricePerUnit.toFixed(2)}
</span>{" "}
<span className="text-neutral-500">{tu(p.unitKey)}</span>
</p>
<Link
href={`/product/${p.slug}`}
className="mt-4 w-full rounded-full border border-neutral-300 bg-white py-2.5 text-center text-sm font-semibold text-neutral-900 transition hover:border-blue-600 hover:bg-blue-600 hover:text-white"
>
{t("addToCart")}
</Link>
</article>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{filtersOpen ? (
<>
<button
type="button"
className="fixed inset-0 z-50 bg-neutral-950/40 backdrop-blur-[1px] lg:hidden"
aria-label={t("closeFilters")}
onClick={() => setFiltersOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-[60] w-[min(22rem,100vw)] overflow-y-auto border-r border-neutral-200 bg-white p-6 shadow-2xl lg:hidden">
<div className="mb-6 flex items-center justify-between">
<p className="text-lg font-semibold text-neutral-900">{t("filters")}</p>
<button
type="button"
className="rounded-full p-2 text-neutral-500 hover:bg-neutral-100"
onClick={() => setFiltersOpen(false)}
>
<span className="sr-only">{t("closeFilters")}</span>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{filterPanel}
</div>
</>
) : null}
</div>
);
}

View File

@ -0,0 +1,271 @@
"use client";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { useCartStore } from "@/store/cart";
const formSchema = z.object({
name: z.string().min(2).max(120),
phone: z.string().min(6).max(40),
email: z.string().email().max(120),
company: z.string().max(160).optional().or(z.literal("")),
message: z.string().max(2000).optional().or(z.literal("")),
});
type FormValues = z.infer<typeof formSchema>;
export function CheckoutForm() {
const t = useTranslations("checkout");
const items = useCartStore((s) => s.items);
const clear = useCartStore((s) => s.clear);
const refInput = useRef<HTMLInputElement>(null);
const proformaInput = useRef<HTMLInputElement>(null);
const [status, setStatus] = useState<
"idle" | "sending" | "success" | "error"
>("idle");
const [requestId, setRequestId] = useState<string | null>(null);
const [emailWarn, setEmailWarn] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const {
register,
trigger,
getValues,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { company: "", message: "" },
});
if (items.length === 0 && status !== "success") {
return (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-stone-200/80">
<p className="text-stone-600">{t("cartEmpty")}</p>
<Link
href="/cart"
className="mt-4 inline-flex rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white hover:bg-blue-700"
>
{t("goCart")}
</Link>
</div>
);
}
if (status === "success" && requestId) {
return (
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-emerald-200/80">
<h2 className="text-xl font-bold text-emerald-800">{t("successTitle")}</h2>
<p className="mt-2 text-sm text-stone-600">
{t("successBody")}{" "}
<span className="font-mono font-semibold text-stone-900">
{requestId}
</span>
</p>
{emailWarn ? (
<p className="mt-4 rounded-xl bg-amber-50 p-3 text-sm text-amber-900 ring-1 ring-amber-200">
{t("emailWarn")}
</p>
) : null}
<Link
href="/catalog"
className="mt-6 inline-flex rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white hover:bg-blue-700"
>
{t("successCta")}
</Link>
</div>
);
}
async function onFormSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const ok = await trigger();
if (!ok) return;
const values = getValues();
setStatus("sending");
setErrorMsg(null);
try {
const fd = new FormData();
fd.set("name", values.name);
fd.set("phone", values.phone);
fd.set("email", values.email);
fd.set("company", values.company ?? "");
fd.set("message", values.message ?? "");
fd.set("website", "");
fd.set("cart", JSON.stringify(items));
const refs = refInput.current?.files;
if (refs) {
for (let i = 0; i < refs.length; i += 1) {
fd.append("referenceImages", refs[i]);
}
}
const pro = proformaInput.current?.files?.[0];
if (pro && pro.size > 0) {
fd.set("proforma", pro);
}
const res = await fetch("/api/quote-request", {
method: "POST",
body: fd,
});
const data = (await res.json()) as {
ok?: boolean;
requestId?: string;
emailSent?: boolean;
error?: string;
};
if (!res.ok) {
setStatus("error");
setErrorMsg(data.error ?? "Request failed");
return;
}
if (data.requestId) {
setRequestId(data.requestId);
setEmailWarn(data.emailSent === false);
clear();
setStatus("success");
} else {
setStatus("error");
setErrorMsg("Invalid response");
}
} catch {
setStatus("error");
setErrorMsg("Network error");
}
}
return (
<form
onSubmit={onFormSubmit}
className="space-y-6 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-200/80 sm:p-8"
>
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
className="absolute -left-[9999px] h-0 w-0 opacity-0"
aria-hidden
/>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="text-sm font-medium text-stone-700" htmlFor="name">
{t("name")}
</label>
<input
id="name"
className="mt-1 w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/25"
{...register("name")}
/>
{errors.name ? (
<p className="mt-1 text-xs text-red-600">{errors.name.message}</p>
) : null}
</div>
<div>
<label className="text-sm font-medium text-stone-700" htmlFor="phone">
{t("phone")}
</label>
<input
id="phone"
className="mt-1 w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/25"
{...register("phone")}
/>
{errors.phone ? (
<p className="mt-1 text-xs text-red-600">{errors.phone.message}</p>
) : null}
</div>
</div>
<div>
<label className="text-sm font-medium text-stone-700" htmlFor="email">
{t("email")}
</label>
<input
id="email"
type="email"
className="mt-1 w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/25"
{...register("email")}
/>
{errors.email ? (
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
) : null}
</div>
<div>
<label className="text-sm font-medium text-stone-700" htmlFor="company">
{t("company")}
</label>
<input
id="company"
className="mt-1 w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/25"
{...register("company")}
/>
</div>
<div>
<label className="text-sm font-medium text-stone-700" htmlFor="message">
{t("message")}
</label>
<textarea
id="message"
rows={4}
className="mt-1 w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/25"
{...register("message")}
/>
</div>
<div>
<label className="text-sm font-medium text-stone-700" htmlFor="refs">
{t("referenceImages")}
</label>
<input
id="refs"
ref={refInput}
type="file"
name="referenceImages"
accept="image/jpeg,image/png,image/webp"
multiple
className="mt-1 block w-full text-sm text-stone-600 file:mr-3 file:rounded-lg file:border-0 file:bg-blue-50 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-blue-700 hover:file:bg-blue-100"
/>
<p className="mt-1 text-xs text-stone-500">{t("referenceHelp")}</p>
</div>
<div>
<label className="text-sm font-medium text-stone-700" htmlFor="proforma">
{t("proforma")}
</label>
<input
id="proforma"
ref={proformaInput}
type="file"
name="proforma"
accept="application/pdf,image/jpeg,image/png,image/webp"
className="mt-1 block w-full text-sm text-stone-600 file:mr-3 file:rounded-lg file:border-0 file:bg-orange-50 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-orange-700 hover:file:bg-orange-100"
/>
<p className="mt-1 text-xs text-stone-500">{t("proformaHelp")}</p>
</div>
{errorMsg ? (
<p className="rounded-xl bg-red-50 p-3 text-sm text-red-800 ring-1 ring-red-200">
{errorMsg}
</p>
) : null}
<button
type="submit"
disabled={status === "sending"}
className="w-full rounded-xl bg-orange-500 py-3.5 text-sm font-bold text-white shadow-sm hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto sm:px-10"
>
{status === "sending" ? t("sending") : t("submit")}
</button>
</form>
);
}

View File

@ -0,0 +1,14 @@
"use client";
import { useLocale } from "next-intl";
import { useEffect } from "react";
export function DocumentLang() {
const locale = useLocale();
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
return null;
}

137
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,137 @@
import { getTranslations } from "next-intl/server";
import {
getPublicSocialLinks,
publicContactAddress,
publicContactEmail,
publicContactHours,
publicContactPhone,
telHref,
} from "@/lib/public-contact";
function SocialGlyph({
name,
className,
}: {
name: "linkedin" | "facebook" | "telegram" | "instagram";
className?: string;
}) {
const c = className ?? "h-5 w-5";
switch (name) {
case "linkedin":
return (
<svg className={c} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
);
case "facebook":
return (
<svg className={c} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
);
case "telegram":
return (
<svg className={c} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
case "instagram":
return (
<svg className={c} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
);
default:
return null;
}
}
export async function Footer() {
const t = await getTranslations("footer");
const year = new Date().getFullYear();
const socials = getPublicSocialLinks();
return (
<footer className="mt-16 border-t border-stone-200/80 bg-white">
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6 lg:px-8">
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3 lg:gap-12">
<div>
<h2 className="text-lg font-bold tracking-tight text-blue-600">
TrustWin
</h2>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-stone-600">
{t("tagline")}
</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-500">
{t("contactTitle")}
</h3>
<ul className="mt-4 space-y-3 text-sm">
<li>
<a
href={telHref(publicContactPhone)}
className="font-medium text-stone-900 transition hover:text-blue-600"
>
{publicContactPhone}
</a>
</li>
{publicContactEmail ? (
<li>
<a
href={`mailto:${publicContactEmail}`}
className="text-stone-700 underline-offset-2 transition hover:text-blue-600 hover:underline"
>
{publicContactEmail}
</a>
</li>
) : null}
{publicContactAddress ? (
<li className="whitespace-pre-line text-stone-600">
{publicContactAddress}
</li>
) : null}
{publicContactHours ? (
<li className="text-stone-600">{publicContactHours}</li>
) : null}
</ul>
</div>
<div className="sm:col-span-2 lg:col-span-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-500">
{t("socialTitle")}
</h3>
{socials.length > 0 ? (
<ul className="mt-4 flex flex-wrap gap-3">
{socials.map(({ key, href }) => (
<li key={key}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex h-11 w-11 items-center justify-center rounded-xl bg-stone-50 text-stone-600 shadow-sm ring-1 ring-stone-200/90 transition hover:bg-blue-600 hover:text-white hover:ring-blue-600"
aria-label={t(`social.${key}`)}
>
<SocialGlyph name={key} />
</a>
</li>
))}
</ul>
) : (
<p className="mt-4 max-w-xs text-sm leading-relaxed text-stone-500">
{t("socialHint")}
</p>
)}
</div>
</div>
<div className="mt-10 border-t border-stone-200/80 pt-8">
<p className="text-center text-xs text-stone-500 sm:text-left">
© {year} TrustWin. {t("rights")}
</p>
</div>
</div>
</footer>
);
}

159
src/components/Header.tsx Normal file
View File

@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { CartBadge } from "@/components/CartBadge";
import { HeaderCatalogSearch } from "@/components/HeaderCatalogSearch";
export function Header() {
const t = useTranslations("nav");
const [open, setOpen] = useState(false);
const mobileLinks = (
<>
<Link
href="/"
className="rounded-full px-4 py-3 text-sm font-medium text-neutral-800 hover:bg-neutral-100"
onClick={() => setOpen(false)}
>
{t("home")}
</Link>
<Link
href="/catalog"
className="rounded-full px-4 py-3 text-sm font-medium text-neutral-800 hover:bg-neutral-100"
onClick={() => setOpen(false)}
>
{t("catalog")}
</Link>
<Link
href="/cart"
className="rounded-full px-4 py-3 text-sm font-medium text-neutral-800 hover:bg-neutral-100"
onClick={() => setOpen(false)}
>
{t("cart")}
</Link>
<Link
href="/checkout"
className="mx-2 mt-2 rounded-full bg-blue-600 px-4 py-3 text-center text-sm font-semibold text-white"
onClick={() => setOpen(false)}
>
{t("joinCta")}
</Link>
</>
);
return (
<header className="sticky top-0 z-40 border-b border-neutral-200/90 bg-white/95 backdrop-blur-md">
<div className="mx-auto flex max-w-[1440px] items-center gap-3 px-4 py-3 sm:gap-4 lg:px-8">
<Link
href="/"
className="flex shrink-0 items-center gap-2.5 text-neutral-900"
onClick={() => setOpen(false)}
>
<span
className="flex h-9 w-9 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-sm"
aria-hidden
>
<svg width="20" height="20" viewBox="0 0 32 32" fill="none" className="text-white">
<path
d="M6 24 16 6l10 18H6Z"
stroke="currentColor"
strokeWidth="2.2"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="hidden text-[17px] font-semibold tracking-tight sm:inline">
{t("brand")}
</span>
</Link>
<nav
className="hidden items-center gap-1 lg:flex"
aria-label="Primary"
>
<Link
href="/"
className="rounded-full px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-100 hover:text-neutral-950"
>
{t("home")}
</Link>
<Link
href="/catalog"
className="rounded-full px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-100 hover:text-neutral-950"
>
{t("products")}
</Link>
</nav>
<div className="min-w-0 flex-1">
<HeaderCatalogSearch />
</div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
<LanguageSwitcher />
<Link
href="/cart"
className="relative rounded-full p-2.5 text-neutral-600 transition hover:bg-neutral-100 hover:text-blue-600"
aria-label={t("cart")}
>
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
/>
</svg>
<CartBadge />
</Link>
<Link
href="/checkout"
className="hidden rounded-full bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700 sm:inline-flex"
>
{t("joinCta")}
</Link>
<button
type="button"
className="rounded-full p-2.5 text-neutral-800 hover:bg-neutral-100 lg:hidden"
aria-expanded={open}
aria-controls="mobile-nav"
onClick={() => setOpen((v) => !v)}
>
<span className="sr-only">{open ? t("closeMenu") : t("openMenu")}</span>
{open ? (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" d="M6 18 18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
)}
</button>
</div>
</div>
{open ? (
<div
id="mobile-nav"
className="border-t border-neutral-200 bg-white px-4 py-4 lg:hidden"
>
<nav className="flex flex-col gap-1" aria-label="Mobile">
{mobileLinks}
</nav>
</div>
) : null}
</header>
);
}

View File

@ -0,0 +1,71 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter, usePathname } from "@/i18n/navigation";
import { useSearchParams } from "next/navigation";
function HeaderCatalogSearchInner() {
const t = useTranslations("nav");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const qParam = pathname === "/catalog" ? (searchParams.get("q") ?? "") : "";
const [q, setQ] = useState(qParam);
useEffect(() => {
setQ(qParam);
}, [qParam]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = q.trim();
const params = new URLSearchParams();
if (trimmed) params.set("q", trimmed);
router.push(`/catalog${params.toString() ? `?${params.toString()}` : ""}`);
}
return (
<form
onSubmit={onSubmit}
className="relative hidden w-full max-w-xl md:block"
role="search"
>
<label htmlFor="header-catalog-search" className="sr-only">
{t("searchAria")}
</label>
<input
id="header-catalog-search"
type="search"
enterKeyHint="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("searchPlaceholder")}
className="w-full rounded-full border border-neutral-200 bg-neutral-50 py-2.5 pl-11 pr-4 text-sm text-neutral-900 shadow-inner shadow-neutral-200/40 outline-none ring-blue-600/0 transition placeholder:text-neutral-400 focus:border-blue-600 focus:bg-white focus:ring-2 focus:ring-blue-600/20"
/>
<span
className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-neutral-400"
aria-hidden
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-4.34-4.34M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z" />
</svg>
</span>
</form>
);
}
export function HeaderCatalogSearch() {
return (
<Suspense
fallback={
<div
className="hidden h-10 w-full max-w-xl rounded-full bg-neutral-100 md:block"
aria-hidden
/>
}
>
<HeaderCatalogSearchInner />
</Suspense>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import { useLocale } from "next-intl";
import { usePathname, Link } from "@/i18n/navigation";
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
return (
<div className="flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-1 py-1 shadow-sm">
<span className="hidden pl-2 text-neutral-400 sm:inline" aria-hidden>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3.6 9h16.8M3.6 15h16.8M12 3a15.3 15.3 0 0 1 4 9 15.3 15.3 0 0 1-4 9 15.3 15.3 0 0 1-4-9 15.3 15.3 0 0 1 4-9Z" />
</svg>
</span>
<div className="flex items-center rounded-full bg-neutral-100 p-0.5 text-[11px] font-semibold text-neutral-600">
<Link
href={pathname}
locale="en"
className={`rounded-full px-2 py-1 transition ${
locale === "en"
? "bg-white text-blue-600 shadow-sm"
: "hover:text-neutral-900"
}`}
aria-current={locale === "en" ? "true" : undefined}
>
EN
</Link>
<Link
href={pathname}
locale="am"
className={`rounded-full px-2 py-1 transition ${
locale === "am"
? "bg-white text-orange-500 shadow-sm"
: "hover:text-neutral-900"
}`}
aria-current={locale === "am" ? "true" : undefined}
>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import Image from "next/image";
type Props = {
src: string;
alt: string;
className?: string;
priority?: boolean;
};
/** SVG profile illustration from `public/` (technical / schematic style). */
export function MetalProfileVisual({
src,
alt,
className = "",
priority = false,
}: Props) {
return (
<div className={`relative h-full min-h-[10rem] w-full ${className}`}>
<Image
src={src}
alt={alt}
fill
className="object-contain p-5 sm:p-8"
priority={priority}
sizes="(max-width:768px) 100vw, (max-width:1200px) 50vw, 400px"
unoptimized={src.endsWith(".svg")}
/>
</div>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import type { Product } from "@/lib/products";
import { useCartStore } from "@/store/cart";
function defaultSelections(product: Product) {
const s: Record<string, string> = {};
for (const v of product.variants) {
s[v.key] = v.values[0]?.value ?? "";
}
return s;
}
export function ProductAddToCart({ product }: { product: Product }) {
const t = useTranslations("product");
const tv = useTranslations("variants");
const addItem = useCartStore((s) => s.addItem);
const [selections, setSelections] = useState<Record<string, string>>(() =>
defaultSelections(product),
);
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const canSubmit = useMemo(
() => product.variants.every((v) => selections[v.key]),
[product.variants, selections],
);
function handleAdd() {
if (!canSubmit) return;
addItem({
productSlug: product.slug,
selections: { ...selections },
quantity: qty,
});
setAdded(true);
setTimeout(() => setAdded(false), 2000);
}
return (
<div className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm">
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
{t("options")}
</h2>
<div className="mt-4 space-y-4">
{product.variants.map((v) => (
<div key={v.key}>
<label className="text-xs font-medium text-neutral-600">
{tv(v.labelKey)}
</label>
<select
className="mt-1 w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2.5 text-sm font-medium text-neutral-900 focus:border-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-600/15"
value={selections[v.key]}
onChange={(e) =>
setSelections((s) => ({ ...s, [v.key]: e.target.value }))
}
>
{v.values.map((opt) => (
<option key={opt.value} value={opt.value}>
{tv(opt.labelKey)}
</option>
))}
</select>
</div>
))}
</div>
<div className="mt-6">
<label htmlFor="qty" className="text-xs font-medium text-neutral-600">
{t("qty")}
</label>
<input
id="qty"
type="number"
min={1}
max={9999}
value={qty}
onChange={(e) => setQty(Math.max(1, Number(e.target.value) || 1))}
className="mt-2 w-full rounded-full border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold focus:border-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-600/15"
/>
<button
type="button"
onClick={handleAdd}
disabled={!canSubmit}
className="mt-4 w-full rounded-full border border-neutral-300 bg-neutral-900 py-3 text-sm font-semibold text-white transition hover:border-blue-600 hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{added ? `${t("add")}` : t("add")}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
type Props = {
slug: string;
sources: readonly string[];
};
export function ProductGallery({ slug, sources }: Props) {
const t = useTranslations("product");
const tp = useTranslations("products");
const alt = tp(`${slug}.imageAlt`);
const [index, setIndex] = useState(0);
const n = sources.length;
const touchStartX = useRef<number | null>(null);
const go = useCallback(
(delta: number) => {
setIndex((prev) => {
if (n === 0) return 0;
const cur = ((prev % n) + n) % n;
const next = cur + delta;
if (next < 0) return n - 1;
if (next >= n) return 0;
return next;
});
},
[n],
);
const setSlide = useCallback(
(i: number) => {
if (n === 0) return;
setIndex(((i % n) + n) % n);
},
[n],
);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "ArrowLeft") go(-1);
if (e.key === "ArrowRight") go(1);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [go]);
if (n === 0) {
return (
<div
className="flex aspect-square items-center justify-center rounded-2xl bg-neutral-100 text-sm text-neutral-500 ring-1 ring-neutral-200/90"
role="img"
aria-label={alt}
>
</div>
);
}
const slideIndex = ((index % n) + n) % n;
const src = sources[slideIndex]!;
const isSvg = src.endsWith(".svg");
return (
<div className="space-y-3">
<div
className="relative aspect-square overflow-hidden rounded-2xl bg-neutral-100 ring-1 ring-neutral-200/90"
onTouchStart={(e) => {
touchStartX.current = e.touches[0]?.clientX ?? null;
}}
onTouchEnd={(e) => {
const start = touchStartX.current;
touchStartX.current = null;
const end = e.changedTouches[0]?.clientX;
if (start == null || end == null) return;
const dx = end - start;
if (Math.abs(dx) < 40) return;
if (dx < 0) go(1);
else go(-1);
}}
>
<Image
src={src}
alt={alt}
fill
className={
isSvg ? "object-contain p-6 sm:p-10" : "object-cover"
}
priority={slideIndex === 0}
sizes="(max-width:1024px) 100vw, 50vw"
unoptimized={isSvg}
/>
{n > 1 ? (
<>
<button
type="button"
onClick={() => go(-1)}
className="absolute left-2 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-neutral-800 shadow-md ring-1 ring-neutral-200/80 backdrop-blur-sm transition hover:bg-white"
aria-label={t("galleryPrev")}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
onClick={() => go(1)}
className="absolute right-2 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-neutral-800 shadow-md ring-1 ring-neutral-200/80 backdrop-blur-sm transition hover:bg-white"
aria-label={t("galleryNext")}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<p className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-neutral-950/55 px-3 py-1 text-xs font-medium tabular-nums text-white backdrop-blur-sm">
{t("galleryCounter", { current: slideIndex + 1, total: n })}
</p>
</>
) : null}
</div>
{n > 1 ? (
<div
className="no-scrollbar flex gap-2 overflow-x-auto pb-1"
role="tablist"
aria-label={t("galleryThumbnails")}
>
{sources.map((thumbSrc, i) => {
const thumbSvg = thumbSrc.endsWith(".svg");
const active = i === slideIndex;
return (
<button
key={`${thumbSrc}-${i}`}
type="button"
role="tab"
aria-selected={active}
aria-label={t("galleryGoTo", { n: i + 1 })}
onClick={() => setSlide(i)}
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-2 transition sm:h-20 sm:w-20 ${
active
? "ring-blue-600 ring-offset-2"
: "ring-transparent ring-offset-0 opacity-80 hover:opacity-100"
}`}
>
<Image
src={thumbSrc}
alt=""
fill
className={
thumbSvg ? "object-contain p-2" : "object-cover"
}
sizes="80px"
unoptimized={thumbSvg}
/>
</button>
);
})}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,15 @@
import type { ReactNode } from "react";
import { Header } from "@/components/Header";
import { Footer } from "@/components/Footer";
import { StickyCallBar } from "@/components/StickyCallBar";
export async function SiteChrome({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen flex-col pb-24 sm:pb-28">
<Header />
<main className="flex-1">{children}</main>
<Footer />
<StickyCallBar />
</div>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import { useTranslations } from "next-intl";
import { publicContactPhone, telHref } from "@/lib/public-contact";
export function StickyCallBar() {
const t = useTranslations("sticky");
return (
<div
className="fixed bottom-5 right-5 z-50 flex flex-col items-end gap-3 sm:bottom-6 sm:right-6"
role="region"
aria-label={t("regionLabel")}
>
<a
href={telHref(publicContactPhone)}
className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/35 transition hover:bg-blue-700"
aria-label={t("call")}
>
<svg
className="h-6 w-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z"
/>
</svg>
</a>
</div>
);
}

5
src/i18n/navigation.ts Normal file
View File

@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

14
src/i18n/request.ts Normal file
View File

@ -0,0 +1,14 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "en" | "am")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

9
src/i18n/routing.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "am"],
defaultLocale: "en",
localePrefix: "always",
});
export type Locale = (typeof routing.locales)[number];

View File

@ -0,0 +1,23 @@
import { z } from "zod";
export const cartLineSchema = z.object({
lineId: z.string().min(1),
productSlug: z.string().min(1),
selections: z.record(z.string(), z.string()),
quantity: z.number().int().min(1).max(9999),
});
export type CartLine = z.infer<typeof cartLineSchema>;
export const checkoutPayloadSchema = z.object({
name: z.string().min(2).max(120),
phone: z.string().min(6).max(40),
email: z.string().email().max(120),
company: z.string().max(160).optional().or(z.literal("")),
message: z.string().max(2000).optional().or(z.literal("")),
website: z.string().max(200).optional().or(z.literal("")),
cart: z.array(cartLineSchema).min(1),
});
export type CheckoutPayload = z.infer<typeof checkoutPayloadSchema>;

View File

@ -0,0 +1,12 @@
/** Format wall / section thickness in mm for display (trim trailing zeros). */
export function formatThicknessMm(n: number): string {
const r = Math.round(n * 10) / 10;
if (Number.isInteger(r)) return String(r);
const s = r.toFixed(1);
return s.replace(/\.?0+$/, "");
}
/** Join formatted mm values for compact list display. */
export function formatThicknessList(mm: number[]): string {
return mm.map(formatThicknessMm).join(" · ");
}

127
src/lib/mail.ts Normal file
View File

@ -0,0 +1,127 @@
import nodemailer from "nodemailer";
import type { CartLine } from "@/lib/checkout-schema";
function getTransporter() {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT ?? "587");
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const secure =
process.env.SMTP_SECURE === "true" || String(port) === "465";
if (!host || !user || !pass) {
throw new Error("SMTP is not configured (SMTP_HOST, SMTP_USER, SMTP_PASS).");
}
return nodemailer.createTransport({
host,
port,
secure,
auth: { user, pass },
});
}
function ownerRecipients(): string[] {
const a = process.env.OWNER_EMAIL_1;
const b = process.env.OWNER_EMAIL_2;
const list = [a, b].filter(Boolean) as string[];
if (list.length === 0 && process.env.OWNER_EMAILS) {
return process.env.OWNER_EMAILS.split(",").map((s) => s.trim()).filter(Boolean);
}
return list;
}
export type MailAttachment = {
filename: string;
content: Buffer;
contentType?: string;
};
export async function sendQuoteEmail(params: {
requestId: string;
subject: string;
textBody: string;
htmlBody: string;
attachments: MailAttachment[];
}): Promise<void> {
const from = process.env.MAIL_FROM;
const to = ownerRecipients();
if (!from) throw new Error("MAIL_FROM is not set.");
if (to.length === 0) {
throw new Error("Set OWNER_EMAIL_1 and OWNER_EMAIL_2 (or OWNER_EMAILS).");
}
const transporter = getTransporter();
await transporter.sendMail({
from,
to: to.join(", "),
subject: params.subject,
text: params.textBody,
html: params.htmlBody,
attachments: params.attachments.map((a) => ({
filename: a.filename,
content: a.content,
contentType: a.contentType,
})),
});
}
export function buildQuoteEmailBodies(params: {
requestId: string;
contact: {
name: string;
phone: string;
email: string;
company?: string;
message?: string;
};
linesSummary: string;
lines: CartLine[];
}): { text: string; html: string } {
const { requestId, contact, linesSummary, lines } = params;
const text = [
`New quote request ${requestId}`,
"",
"Contact",
`Name: ${contact.name}`,
`Phone: ${contact.phone}`,
`Email: ${contact.email}`,
contact.company ? `Company: ${contact.company}` : "",
contact.message ? `Message: ${contact.message}` : "",
"",
"Line items",
linesSummary,
"",
"Raw JSON (lines)",
JSON.stringify(lines, null, 2),
]
.filter(Boolean)
.join("\n");
const html = `
<div style="font-family:system-ui,sans-serif;line-height:1.5;color:#1c1917">
<h2 style="color:#2563eb">Quote request ${requestId}</h2>
<h3>Contact</h3>
<ul>
<li><strong>Name:</strong> ${escapeHtml(contact.name)}</li>
<li><strong>Phone:</strong> ${escapeHtml(contact.phone)}</li>
<li><strong>Email:</strong> ${escapeHtml(contact.email)}</li>
${contact.company ? `<li><strong>Company:</strong> ${escapeHtml(contact.company)}</li>` : ""}
</ul>
${contact.message ? `<p><strong>Message:</strong><br/>${escapeHtml(contact.message).replace(/\n/g, "<br/>")}</p>` : ""}
<h3>Line items</h3>
<pre style="background:#f5f5f4;padding:12px;border-radius:8px;white-space:pre-wrap">${escapeHtml(linesSummary)}</pre>
<h3>Attachments</h3>
<p>Reference images and proforma (if provided) are attached to this email.</p>
</div>`;
return { text, html };
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

201
src/lib/products.ts Normal file
View File

@ -0,0 +1,201 @@
export type ProductCategory = "bars" | "pipes" | "accessories";
export type VariantOption = {
key: string;
labelKey: string;
values: { value: string; labelKey: string }[];
};
function photo(id: number) {
return `https://images.pexels.com/photos/${id}/pexels-photo-${id}.jpeg?auto=compress&cs=tinysrgb&w=1200&h=1200&fit=crop`;
}
export type Product = {
slug: string;
category: ProductCategory;
/** i18n key under `catalogLine` */
catalogLineKey: "bars" | "pipes" | "accessories";
pricePerUnit: number;
unitKey: string;
/** Wall, leg, or nominal section thicknesses in millimetres */
thicknessesMm: number[];
/**
* Detail-page gallery: schematic SVG first, then reference photos.
* Catalog cards use `gallery[0]`.
*/
gallery: readonly string[];
optionKeys: string[];
variants: VariantOption[];
};
export const products: Product[] = [
{
slug: "deformed-rebar-12mm",
category: "bars",
catalogLineKey: "bars",
pricePerUnit: 42.5,
unitKey: "unitTon",
thicknessesMm: [10, 12, 14, 16],
gallery: ["/metal/rebar.svg", photo(7688462), photo(8870245)],
optionKeys: ["coating"],
variants: [
{
key: "coating",
labelKey: "coating",
values: [
{ value: "mill", labelKey: "coatingMill" },
{ value: "epoxy", labelKey: "coatingEpoxy" },
],
},
],
},
{
slug: "flat-bar-50x8",
category: "bars",
catalogLineKey: "bars",
pricePerUnit: 18.2,
unitKey: "unitMeter",
thicknessesMm: [6, 8, 10],
gallery: ["/metal/flat-bar.svg", photo(7688475), photo(8870245)],
optionKeys: ["length"],
variants: [
{
key: "length",
labelKey: "length",
values: [
{ value: "6m", labelKey: "len6" },
{ value: "12m", labelKey: "len12" },
],
},
],
},
{
slug: "angle-iron-l-50",
category: "bars",
catalogLineKey: "bars",
pricePerUnit: 14.9,
unitKey: "unitMeter",
thicknessesMm: [4, 5, 6, 8],
gallery: ["/metal/angle.svg", photo(811187), photo(9080553)],
optionKeys: ["length"],
variants: [
{
key: "length",
labelKey: "length",
values: [
{ value: "6m", labelKey: "len6" },
{ value: "12m", labelKey: "len12" },
],
},
],
},
{
slug: "galvanized-pipe-2in",
category: "pipes",
catalogLineKey: "pipes",
pricePerUnit: 9.75,
unitKey: "unitMeter",
thicknessesMm: [2.3, 2.9, 3.6, 4.5],
gallery: ["/metal/pipe-medium.svg", photo(6476586), photo(6476589)],
optionKeys: ["schedule"],
variants: [
{
key: "schedule",
labelKey: "schedule",
values: [
{ value: "sch40", labelKey: "sch40" },
{ value: "sch80", labelKey: "sch80" },
],
},
],
},
{
slug: "black-steel-pipe-3in",
category: "pipes",
catalogLineKey: "pipes",
pricePerUnit: 12.4,
unitKey: "unitMeter",
thicknessesMm: [4, 5.5, 7.1, 8.5],
gallery: ["/metal/pipe-heavy.svg", photo(2945062), photo(7394094)],
optionKeys: ["schedule"],
variants: [
{
key: "schedule",
labelKey: "schedule",
values: [
{ value: "sch40", labelKey: "sch40" },
{ value: "sch80", labelKey: "sch80" },
],
},
],
},
{
slug: "square-hollow-80",
category: "pipes",
catalogLineKey: "pipes",
pricePerUnit: 22.1,
unitKey: "unitMeter",
thicknessesMm: [3, 4, 5, 6],
gallery: ["/metal/shs.svg", photo(7688488), photo(3856635)],
optionKeys: ["finish"],
variants: [
{
key: "finish",
labelKey: "finish",
values: [
{ value: "raw", labelKey: "finishRaw" },
{ value: "galv", labelKey: "finishGalv" },
],
},
],
},
{
slug: "beam-connector-set",
category: "accessories",
catalogLineKey: "accessories",
pricePerUnit: 34,
unitKey: "unitSet",
thicknessesMm: [8, 10, 12],
gallery: ["/metal/bracket.svg", photo(8960865), photo(7688462)],
optionKeys: ["size"],
variants: [
{
key: "size",
labelKey: "size",
values: [
{ value: "m12", labelKey: "m12" },
{ value: "m16", labelKey: "m16" },
],
},
],
},
{
slug: "pipe-flange-kit",
category: "accessories",
catalogLineKey: "accessories",
pricePerUnit: 48.6,
unitKey: "unitSet",
thicknessesMm: [12, 16, 20],
gallery: ["/metal/flange.svg", photo(7394094), photo(585419)],
optionKeys: ["rating"],
variants: [
{
key: "rating",
labelKey: "rating",
values: [
{ value: "pn10", labelKey: "pn10" },
{ value: "pn16", labelKey: "pn16" },
],
},
],
},
];
export function getProductBySlug(slug: string): Product | undefined {
return products.find((p) => p.slug === slug);
}
export function getProductsByCategory(category: ProductCategory | "all") {
if (category === "all") return products;
return products.filter((p) => p.category === category);
}

34
src/lib/public-contact.ts Normal file
View File

@ -0,0 +1,34 @@
export const publicContactPhone =
process.env.NEXT_PUBLIC_CONTACT_PHONE ?? "+251911000000";
export function telHref(phone: string): string {
const digits = phone.replace(/[^\d+]/g, "");
return digits.startsWith("+") ? `tel:${digits}` : `tel:${digits}`;
}
export const publicContactEmail =
process.env.NEXT_PUBLIC_CONTACT_EMAIL?.trim() ?? "";
export const publicContactAddress =
process.env.NEXT_PUBLIC_CONTACT_ADDRESS?.trim() ?? "";
export const publicContactHours =
process.env.NEXT_PUBLIC_CONTACT_HOURS?.trim() ?? "";
export type PublicSocialKey =
| "linkedin"
| "facebook"
| "telegram"
| "instagram";
export function getPublicSocialLinks(): { key: PublicSocialKey; href: string }[] {
const pairs: [PublicSocialKey, string | undefined][] = [
["linkedin", process.env.NEXT_PUBLIC_SOCIAL_LINKEDIN],
["facebook", process.env.NEXT_PUBLIC_SOCIAL_FACEBOOK],
["telegram", process.env.NEXT_PUBLIC_SOCIAL_TELEGRAM],
["instagram", process.env.NEXT_PUBLIC_SOCIAL_INSTAGRAM],
];
return pairs
.filter(([, href]) => href && href.trim().length > 0)
.map(([key, href]) => ({ key, href: href!.trim() }));
}

12
src/lib/rate-limit.ts Normal file
View File

@ -0,0 +1,12 @@
const windowMs = 60 * 60 * 1000;
const maxHits = 8;
const buckets = new Map<string, number[]>();
export function rateLimitHit(key: string): boolean {
const now = Date.now();
const prev = buckets.get(key) ?? [];
const fresh = prev.filter((t) => now - t < windowMs);
fresh.push(now);
buckets.set(key, fresh);
return fresh.length <= maxHits;
}

38
src/lib/requests.ts Normal file
View File

@ -0,0 +1,38 @@
import fs from "fs/promises";
import path from "path";
import type { CartLine } from "@/lib/checkout-schema";
export type RequestRecord = {
id: string;
createdAt: string;
contact: {
name: string;
phone: string;
email: string;
company?: string;
message?: string;
};
lines: CartLine[];
referenceImageNames: string[];
proformaFileName: string | null;
uploadDir: string | null;
emailSent: boolean;
emailError?: string;
};
const dataDir = path.join(process.cwd(), "data");
const requestsFile = path.join(dataDir, "requests.json");
export async function appendRequest(record: RequestRecord): Promise<void> {
await fs.mkdir(dataDir, { recursive: true });
let existing: RequestRecord[] = [];
try {
const raw = await fs.readFile(requestsFile, "utf-8");
existing = JSON.parse(raw) as RequestRecord[];
if (!Array.isArray(existing)) existing = [];
} catch {
existing = [];
}
existing.push(record);
await fs.writeFile(requestsFile, JSON.stringify(existing, null, 2), "utf-8");
}

View File

@ -0,0 +1,4 @@
export function sanitizeFilename(name: string): string {
const base = name.replace(/^.*[/\\]/, "").slice(0, 180);
return base.replace(/[^a-zA-Z0-9._-]/g, "_") || "file";
}

8
src/middleware.ts Normal file
View File

@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/", "/(en|am)/:path*", "/((?!api|_next|_vercel|.*\\..*).*)"],
};

64
src/store/cart.ts Normal file
View File

@ -0,0 +1,64 @@
"use client";
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { CartLine } from "@/lib/checkout-schema";
export type { CartLine } from "@/lib/checkout-schema";
type CartState = {
items: CartLine[];
addItem: (line: Omit<CartLine, "lineId"> & { lineId?: string }) => void;
updateQuantity: (lineId: string, quantity: number) => void;
removeLine: (lineId: string) => void;
clear: () => void;
};
function makeLineId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `line-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addItem: (line) => {
const lineId = line.lineId ?? makeLineId();
const next: CartLine = {
lineId,
productSlug: line.productSlug,
selections: line.selections,
quantity: line.quantity,
};
set({ items: [...get().items, next] });
},
updateQuantity: (lineId, quantity) => {
const q = Math.max(1, Math.min(9999, Math.floor(quantity)));
set({
items: get().items.map((i) =>
i.lineId === lineId ? { ...i, quantity: q } : i,
),
});
},
removeLine: (lineId) => {
set({ items: get().items.filter((i) => i.lineId !== lineId) });
},
clear: () => set({ items: [] }),
}),
{
name: "trustwin-cart",
storage: createJSONStorage(() => localStorage),
},
),
);
export function cartLineKey(line: CartLine) {
const sel = Object.entries(line.selections)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${v}`)
.join("|");
return `${line.productSlug}::${sel}`;
}

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}