Add TrustWin Next.js site: catalog, i18n, checkout, and quote API.
Made-with: Cursor
23
.env.example
Normal 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
|
|
@ -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
18
eslint.config.mjs
Normal 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
|
|
@ -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
|
|
@ -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 A–Z",
|
||||||
|
"sortNameDesc": "Name Z–A",
|
||||||
|
"sortPriceAsc": "Price low–high",
|
||||||
|
"sortPriceDesc": "Price high–low",
|
||||||
|
"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
|
|
@ -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
|
|
@ -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
33
package.json
Normal 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
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 |
5
public/metal/flat-bar.svg
Normal 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 |
15
public/metal/hero-stack.svg
Normal 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 |
5
public/metal/pipe-heavy.svg
Normal 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 |
5
public/metal/pipe-medium.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 |
24
src/app/[locale]/cart/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/[locale]/catalog/page.tsx
Normal 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 ?? ""} />;
|
||||||
|
}
|
||||||
23
src/app/[locale]/checkout/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/app/[locale]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/[locale]/not-found.tsx
Normal 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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/app/[locale]/product/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
src/app/api/quote-request/route.ts
Normal 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
|
After Width: | Height: | Size: 25 KiB |
59
src/app/globals.css
Normal 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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/CartBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/CartCheckoutLink.tsx
Normal 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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
369
src/components/CatalogExplorer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/components/CheckoutForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/DocumentLang.tsx
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/HeaderCatalogSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/LanguageSwitcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/MetalProfileVisual.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/ProductAddToCart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/ProductGallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/SiteChrome.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/StickyCallBar.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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];
|
||||||
23
src/lib/checkout-schema.ts
Normal 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>;
|
||||||
|
|
||||||
12
src/lib/format-thickness.ts
Normal 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
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
201
src/lib/products.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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");
|
||||||
|
}
|
||||||
4
src/lib/sanitize-filename.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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"]
|
||||||
|
}
|
||||||