[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"footer-homepage":3,"main-navigation":84,"footer-integrations":790,"footer-settings":1701,"blog-post-nav-nav":1814,"blog-post-nav-[USE LATEST FEATURED]":1836,"blog-guide-to-translate-angular-apps-with-ngx-translate-app-logic":1856},{"status":4,"header_badges":5,"footer_badges":27,"footer_secured_badges":34,"slider_badges":48},"published",[6,15,21],{"id":7,"status":4,"sort":8,"link":9,"title":10,"badgeType":11,"badgeName":12,"metadata":8,"image":13},1,null,"https:\u002F\u002Fwww.g2.com\u002Fproducts\u002Flocalazy\u002Freviews","Best meets requirements","Other","G2-winter-24-best-meets-requirments",{"id":14},"8d5dd6fd-6995-4c92-8219-a6ed5ba6acdc",{"id":16,"status":4,"sort":8,"link":9,"title":17,"badgeType":11,"badgeName":18,"metadata":8,"image":19},2,"Best support","G2-winter-24-best-support",{"id":20},"e59dd465-84d9-45c7-987d-dd4048fd6992",{"id":22,"status":4,"sort":8,"link":9,"title":23,"badgeType":11,"badgeName":24,"metadata":8,"image":25},3,"Easiest admin","G2-winter-24-easiest-admin",{"id":26},"5f12c4c8-bec0-47cf-87b4-a4502e9f1bd2",[28],{"id":29,"status":4,"sort":8,"link":9,"title":30,"badgeType":11,"badgeName":31,"metadata":8,"image":32},12,"Users love us","Users love us 25",{"id":33},"232416a5-86ba-4be6-85a5-b88802003d53",[35,42],{"id":36,"status":4,"sort":8,"link":37,"title":38,"badgeType":11,"badgeName":39,"metadata":8,"image":40},11,"https:\u002F\u002Fsprinto.com\u002Fget-iso-27001\u002F","Sprinto ISO 27001","Sprinto ISO 27001 certificate",{"id":41},"c1c6c06c-6caf-4b78-8a7e-9606c395eb61",{"id":43,"status":4,"sort":8,"link":44,"title":45,"badgeType":11,"badgeName":45,"metadata":8,"image":46},13,"https:\u002F\u002Fwww.aicpa.org\u002Fsoc4so","SOC certificate",{"id":47},"4f4bc0a6-09a0-41df-8295-8f990d96d941",[49,56,61,66,71,76,82],{"id":50,"status":4,"sort":8,"link":9,"title":51,"badgeType":11,"badgeName":52,"metadata":53,"image":54},5,"Most likely to recommend","a","",{"id":55},"da055731-3f11-4fc7-96f2-c11c0b84c831",{"id":57,"status":4,"sort":8,"link":9,"title":58,"badgeType":11,"badgeName":8,"metadata":8,"image":59},6,"EMEA High Performer",{"id":60},"586fb488-81f2-41db-a4e8-ef309e50c0e6",{"id":62,"status":4,"sort":8,"link":9,"title":63,"badgeType":11,"badgeName":8,"metadata":8,"image":64},7,"Ease of Doing Business With",{"id":65},"bfa26865-430e-463d-9886-cb56a8a8ecf6",{"id":67,"status":4,"sort":8,"link":9,"title":68,"badgeType":11,"badgeName":8,"metadata":8,"image":69},8,"High Performer",{"id":70},"2cc1ba0f-c23a-4c97-ad4a-a655c97466fe",{"id":72,"status":4,"sort":8,"link":9,"title":73,"badgeType":11,"badgeName":8,"metadata":8,"image":74},9,"Fastest Implementation",{"id":75},"6f297758-1c95-45f4-a6e9-528319897132",{"id":77,"status":4,"sort":8,"link":78,"title":79,"badgeType":11,"badgeName":8,"metadata":8,"image":80},10,"https:\u002F\u002Fwww.intercert.com\u002Fservices\u002Fgovernance-risk-compliance\u002Fiso-iec-27001","Intercert ISO 27001",{"id":81},"0521a5f1-e1f2-4da6-a2bc-dc92773f2a7b",{"id":36,"status":4,"sort":8,"link":37,"title":38,"badgeType":11,"badgeName":39,"metadata":8,"image":83},{"id":41},[85,282,519,649,782,786],{"id":16,"name":86,"url":8,"url_text":8,"status":4,"groups":87},"Solutions",[88,100,173,208],{"navigation_items_group_id":89},{"id":22,"sort":50,"status":4,"name":90,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":93,"show_all_url":8,"represent_as_tile":91,"items":94},"Featured Article",false,"top",true,[95],{"collection":96,"item":97},"blog_post_navigation_item_type",{"id":98,"status":4,"sort":8,"show_author_img":93,"blog_post_tag":99,"description":8},4,"nav",{"navigation_items_group_id":101},{"id":50,"sort":7,"status":4,"name":102,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":103,"represent_as_tile":93,"items":104},"Your Role","\u002Froles",[105,117,127,139,150,162],{"collection":106,"item":107},"relation_navigation_item_type",{"id":7,"status":4,"sort":8,"name":108,"item":109},"CMO",[110],{"collection":111,"item":112},"personas",{"id":7,"status":4,"sort":8,"slug":113,"icon":114},"chief-marketing-officers",{"id":115,"title":116},"526097d8-176f-48e5-83d3-2fc4d77061f9","La Mail Bulk",{"collection":106,"item":118},{"id":22,"status":4,"sort":8,"name":119,"item":120},"CTO",[121],{"collection":111,"item":122},{"id":98,"status":4,"sort":8,"slug":123,"icon":124},"chief-technical-officers",{"id":125,"title":126},"f808fdf0-c78b-4294-ba59-fe25f46fe825","La Mechanic",{"collection":106,"item":128},{"id":129,"status":4,"sort":8,"name":130,"item":131},20,"Product Manager",[132],{"collection":111,"item":133},{"id":134,"status":4,"sort":8,"slug":135,"icon":136},18,"product-managers",{"id":137,"title":138},"ea39586a-21fc-4b34-ba87-496028e5198f","La Worker",{"collection":106,"item":140},{"id":141,"status":4,"sort":8,"name":142,"item":143},37,"Translator",[144],{"collection":111,"item":145},{"id":129,"status":4,"sort":8,"slug":146,"icon":147},"professional-translators",{"id":148,"title":149},"07945171-d51d-4ca8-96c0-21cde9db258f","La Chat",{"collection":106,"item":151},{"id":152,"status":4,"sort":8,"name":153,"item":154},38,"Developer",[155],{"collection":111,"item":156},{"id":157,"status":4,"sort":8,"slug":158,"icon":159},30,"software-developers",{"id":160,"title":161},"1806e46d-c551-4845-bf42-4407344d229e","La Terminal",{"collection":106,"item":163},{"id":164,"status":4,"sort":8,"name":165,"item":166},27,"Localization Manager",[167],{"collection":111,"item":168},{"id":57,"status":4,"sort":8,"slug":169,"icon":170},"localization-managers",{"id":171,"title":172},"dac69bd1-bf5d-41d0-87d4-dd13e314698a","La Language",{"navigation_items_group_id":174},{"id":72,"sort":22,"status":4,"name":175,"render_show_all_url":91,"show_all_url_position":176,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":177},"Use cases","bottom",[178,184,189,194,198,203],{"collection":179,"item":180},"simple_link_navigation_item_type",{"id":181,"status":4,"sort":8,"url":182,"url_text":183,"show_favorite_icon":91},24,"https:\u002F\u002Flocalazy.com\u002Fuse-cases\u002Fsoftware-localization","Software localization",{"collection":179,"item":185},{"id":186,"status":4,"sort":8,"url":187,"url_text":188,"show_favorite_icon":91},26,"https:\u002F\u002Flocalazy.com\u002Fuse-cases\u002Fmobile-app-localization","Mobile app localization",{"collection":179,"item":190},{"id":191,"status":4,"sort":8,"url":192,"url_text":193,"show_favorite_icon":91},25,"https:\u002F\u002Flocalazy.com\u002Fuse-cases\u002Fwebsite-localization","Website localization",{"collection":179,"item":195},{"id":164,"status":4,"sort":8,"url":196,"url_text":197,"show_favorite_icon":91},"https:\u002F\u002Flocalazy.com\u002Fuse-cases\u002Fdesign-localization","Design localization",{"collection":179,"item":199},{"id":200,"status":4,"sort":8,"url":201,"url_text":202,"show_favorite_icon":91},28,"https:\u002F\u002Flocalazy.com\u002Fuse-cases\u002Fgame-localization","Game localization",{"collection":179,"item":204},{"id":205,"status":4,"sort":8,"url":206,"url_text":207,"show_favorite_icon":91},29,"https:\u002F\u002Flocalazy.com\u002Fuse-cases\u002Fhelp-and-docs-localization","Help & docs localization",{"navigation_items_group_id":209},{"id":67,"sort":16,"status":4,"name":210,"render_show_all_url":91,"show_all_url_position":176,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":211},"Industry",[212,223,234,244,255,264,275],{"collection":106,"item":213},{"id":214,"status":4,"sort":8,"name":215,"item":216},23,"Digital Agencies",[217],{"collection":111,"item":218},{"id":62,"status":4,"sort":8,"slug":219,"icon":220},"digital-agencies",{"id":221,"title":222},"547f5619-b21d-4b29-86f1-8b2200313cde","La Digi Agency",{"collection":106,"item":224},{"id":225,"status":4,"sort":8,"name":226,"item":227},39,"Startups & SaaS",[228],{"collection":111,"item":229},{"id":16,"status":4,"sort":8,"slug":230,"icon":231},"startups",{"id":232,"title":233},"da1516e2-4afc-40a2-bcbf-a8161e2d610d","La Saa S",{"collection":106,"item":235},{"id":16,"status":4,"sort":8,"name":236,"item":237},"E-commerce",[238],{"collection":111,"item":239},{"id":50,"status":4,"sort":8,"slug":240,"icon":241},"e-commerce",{"id":242,"title":243},"a3d09cd2-054f-4bb5-a0a2-d3d207db65df","La Ecommerce",{"collection":106,"item":245},{"id":246,"status":4,"sort":8,"name":247,"item":248},19,"FinTech Platforms",[249],{"collection":111,"item":250},{"id":164,"status":4,"sort":8,"slug":251,"icon":252},"fintech",{"id":253,"title":254},"147339ba-07d1-4e5e-baad-23d27e60c919","La Money",{"collection":106,"item":256},{"id":181,"status":4,"sort":8,"name":257,"item":258},"Translation Agencies",[259],{"collection":111,"item":260},{"id":181,"status":4,"sort":8,"slug":261,"icon":262},"translation-agencies",{"id":263,"title":172},"15d6fe33-2af8-4d6e-933d-5caa64394511",{"collection":106,"item":265},{"id":266,"status":4,"sort":8,"name":267,"item":268},22,"Travel & Tourism",[269],{"collection":111,"item":270},{"id":22,"status":4,"sort":8,"slug":271,"icon":272},"tourism",{"id":273,"title":274},"23a785e8-0ae5-4c4b-8452-aab1b58182a6","La Travel",{"collection":106,"item":276},{"id":191,"status":4,"sort":8,"name":277,"item":278},"Nonprofit Causes",[279],{"collection":111,"item":280},{"id":200,"status":4,"sort":8,"slug":281,"icon":8},"nonprofit",{"id":50,"name":283,"url":8,"url_text":8,"status":4,"groups":284},"Features",[285,353,410,460],{"navigation_items_group_id":286},{"id":36,"sort":57,"status":4,"name":287,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":288},"Localization",[289,298,307,315,324,328,336,345],{"collection":106,"item":290},{"id":291,"status":4,"sort":8,"name":292,"item":293},44,"Localization services",[294],{"collection":295,"item":296},"products",{"id":72,"status":4,"slug":297},"continuous-localization-team",{"collection":106,"item":299},{"id":300,"status":4,"sort":8,"name":301,"item":302},45,"Translation management",[303],{"collection":304,"item":305},"term_landing_page",{"id":205,"status":4,"slug":306},"translation-management-system",{"collection":106,"item":308},{"id":309,"status":4,"sort":8,"name":310,"item":311},46,"Context Screenshots",[312],{"collection":295,"item":313},{"id":43,"status":4,"slug":314},"context-screenshots-ocr",{"collection":106,"item":316},{"id":317,"status":4,"sort":8,"name":318,"item":319},47,"Translation Glossary",[320],{"collection":295,"item":321},{"id":322,"status":4,"slug":323},17,"glossary",{"collection":179,"item":325},{"id":98,"status":4,"sort":8,"url":326,"url_text":327,"show_favorite_icon":91},"\u002Fdocs\u002Fgeneral\u002Freviewing-translations","Quality control",{"collection":106,"item":329},{"id":330,"status":4,"sort":8,"name":331,"item":332},34,"Connected Projects",[333],{"collection":295,"item":334},{"id":7,"status":4,"slug":335},"connected-projects",{"collection":106,"item":337},{"id":338,"status":4,"sort":8,"name":339,"item":340},64,"Plural handling",[341],{"collection":295,"item":342},{"id":343,"status":4,"slug":344},32,"plurals",{"collection":106,"item":346},{"id":347,"status":4,"sort":8,"name":348,"item":349},65,"Style guides",[350],{"collection":295,"item":351},{"id":152,"status":4,"slug":352},"style-guide",{"navigation_items_group_id":354},{"id":29,"sort":62,"status":4,"name":355,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":356},"Translation",[357,365,373,377,385,393,402],{"collection":106,"item":358},{"id":359,"status":4,"sort":8,"name":360,"item":361},36,"Professional translations",[362],{"collection":304,"item":363},{"id":157,"status":4,"slug":364},"professional-translation-services",{"collection":106,"item":366},{"id":367,"status":4,"sort":8,"name":368,"item":369},48,"Crowdsourced translations",[370],{"collection":295,"item":371},{"id":67,"status":4,"slug":372},"share-tm",{"collection":179,"item":374},{"id":50,"status":4,"sort":8,"url":375,"url_text":376,"show_favorite_icon":91},"\u002Fdocs\u002Fgeneral\u002Ftranslating-strings","Translation interface",{"collection":106,"item":378},{"id":379,"status":4,"sort":8,"name":380,"item":381},55,"Machine translations",[382],{"collection":295,"item":383},{"id":50,"status":4,"slug":384},"machine-translation",{"collection":106,"item":386},{"id":387,"status":4,"sort":8,"name":388,"item":389},60,"Localazy AI translation",[390],{"collection":295,"item":391},{"id":205,"status":4,"slug":392},"localazy-ai",{"collection":106,"item":394},{"id":395,"status":4,"sort":8,"name":396,"item":397},61,"Translation Memory",[398],{"collection":295,"item":399},{"id":400,"status":4,"slug":401},35,"translation-memory",{"collection":106,"item":403},{"id":404,"status":4,"sort":8,"name":405,"item":406},63,"Code & placeholders",[407],{"collection":295,"item":408},{"id":157,"status":4,"slug":409},"code-and-placeholders",{"navigation_items_group_id":411},{"id":43,"sort":67,"status":4,"name":412,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":413},"Management",[414,418,426,435,444,452,456],{"collection":179,"item":415},{"id":67,"status":4,"sort":8,"url":416,"url_text":417,"show_favorite_icon":91},"\u002Fdocs\u002Fgeneral\u002Fdefining-user-roles","User roles",{"collection":106,"item":419},{"id":420,"status":4,"sort":8,"name":421,"item":422},51,"Duplicity Linking",[423],{"collection":295,"item":424},{"id":246,"status":4,"slug":425},"duplicity-linking",{"collection":106,"item":427},{"id":428,"status":4,"sort":8,"name":429,"item":430},52,"Language Permissions",[431],{"collection":295,"item":432},{"id":433,"status":4,"slug":434},21,"language-permissions",{"collection":106,"item":436},{"id":437,"status":438,"sort":8,"name":439,"item":440},53,"draft","Automations",[441],{"collection":295,"item":442},{"id":129,"status":4,"slug":443},"automations",{"collection":106,"item":445},{"id":446,"status":4,"sort":8,"name":447,"item":448},58,"Branching",[449],{"collection":295,"item":450},{"id":164,"status":4,"slug":451},"branching",{"collection":179,"item":453},{"id":433,"status":438,"sort":8,"url":454,"url_text":455,"show_favorite_icon":91},"https:\u002F\u002Flocalazy.com\u002Fdocs\u002Fgeneral\u002Fsso-configuration","SSO",{"collection":179,"item":457},{"id":266,"status":438,"sort":8,"url":458,"url_text":459,"show_favorite_icon":91},"https:\u002F\u002Flocalazy.com\u002Fdocs\u002Fgeneral\u002Flanguage-statistics","Reporting",{"navigation_items_group_id":461},{"id":462,"sort":72,"status":4,"name":463,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":464},14,"Development",[465,472,480,487,494,502,511],{"collection":106,"item":466},{"id":205,"status":4,"sort":8,"name":467,"item":468},"Translation API",[469],{"collection":295,"item":470},{"id":36,"status":4,"slug":471},"translation-api",{"collection":106,"item":473},{"id":474,"status":4,"sort":8,"name":475,"item":476},33,"Localazy CLI",[477],{"collection":295,"item":478},{"id":62,"status":4,"slug":479},"cli",{"collection":106,"item":481},{"id":343,"status":4,"sort":8,"name":482,"item":483},"Language CDN",[484],{"collection":295,"item":485},{"id":98,"status":4,"slug":486},"language-cdn",{"collection":106,"item":488},{"id":200,"status":4,"sort":8,"name":489,"item":490},"Releases",[491],{"collection":295,"item":492},{"id":57,"status":4,"slug":493},"releases",{"collection":106,"item":495},{"id":496,"status":438,"sort":8,"name":497,"item":498},57,"Format Conversions",[499],{"collection":295,"item":500},{"id":16,"status":4,"slug":501},"format-conversions",{"collection":106,"item":503},{"id":504,"status":4,"sort":8,"name":505,"item":506},59,"Webhooks",[507],{"collection":295,"item":508},{"id":509,"status":4,"slug":510},31,"webhooks",{"collection":106,"item":512},{"id":513,"status":4,"sort":8,"name":514,"item":515},62,"Export Aliases",[516],{"collection":295,"item":517},{"id":474,"status":4,"slug":518},"export-aliases",{"id":98,"name":520,"url":8,"url_text":8,"status":4,"groups":521},"Integrations",[522,639],{"navigation_items_group_id":523},{"id":62,"sort":77,"status":4,"name":524,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":525},"Most popular",[526],{"collection":106,"item":527},{"id":29,"status":4,"sort":8,"name":520,"item":528},[529,537,544,551,558,565,572,579,586,593,600,609,616,623,631],{"collection":530,"item":531},"platforms",{"id":57,"status":4,"sort":7,"slug":532,"name":533,"icon":534},"android","Android",{"id":535,"title":536},"ff965add-53b0-44ad-b27c-ea4ef641d384","Ic Outline Android",{"collection":530,"item":538},{"id":62,"status":4,"sort":16,"slug":539,"name":540,"icon":541},"ios","iOS",{"id":542,"title":543},"886285d7-816a-4430-b5a5-edfe307b238e","Ios",{"collection":530,"item":545},{"id":474,"status":4,"sort":22,"slug":546,"name":547,"icon":548},"react","React",{"id":549,"title":550},"26757a90-88a3-4a94-b587-9f38614ed3bd","Logos React",{"collection":530,"item":552},{"id":246,"status":4,"sort":98,"slug":553,"name":554,"icon":555},"vuejs","Vue.js",{"id":556,"title":557},"6180b41f-3249-47d3-adcd-50fe1cf11bf1","Vue",{"collection":530,"item":559},{"id":560,"status":4,"sort":50,"slug":561,"name":562,"icon":563},16,"angular","Angular",{"id":564,"title":562},"2a31a97a-48c5-4531-aa36-41c9dd0bdbef",{"collection":530,"item":566},{"id":7,"status":4,"sort":57,"slug":567,"name":568,"icon":569},"flutter","Flutter",{"id":570,"title":571},"b9f13a11-9328-4a03-86cf-b20de0685606","Logos Flutter",{"collection":530,"item":573},{"id":22,"status":4,"sort":36,"slug":574,"name":575,"icon":576},"typescript","TypeScript",{"id":577,"title":578},"8d1319b5-8a96-4199-8407-00add3418b1f","Logos Typescript Icon",{"collection":530,"item":580},{"id":509,"status":4,"sort":29,"slug":581,"name":582,"icon":583},"javascript","JavaScript",{"id":584,"title":585},"4218d0c9-7e6b-494d-9663-37ceaf93ee8c","Logos Javascript",{"collection":530,"item":587},{"id":29,"status":4,"sort":129,"slug":588,"name":589,"icon":590},"json","JSON",{"id":591,"title":592},"99ac3b7c-dba5-4693-a4f4-1f27a6d7782d","Logos Json",{"collection":530,"item":594},{"id":433,"status":4,"sort":433,"slug":595,"name":596,"icon":597},"xliff","XLIFF",{"id":598,"title":599},"ca424cc8-e8a7-4aef-8dca-ef4a4d1334fc","Format",{"collection":530,"item":601},{"id":602,"status":4,"sort":603,"slug":604,"name":605,"icon":606},72,54,"strapi","Strapi",{"id":607,"title":608},"cde7f1d0-f2e0-4243-929e-a3f8f9146e69","Strapi Logo Purple",{"collection":530,"item":610},{"id":387,"status":4,"sort":379,"slug":611,"name":612,"icon":613},"figma","Figma",{"id":614,"title":615},"813745a4-a77d-4735-93b7-e0dd731b2304","Figma Icon",{"collection":530,"item":617},{"id":420,"status":4,"sort":317,"slug":618,"name":619,"icon":620},"microsoft-excel","Microsoft Excel",{"id":621,"title":622},"7a175955-7b96-4561-939f-a0e1924faa40","Excel",{"collection":530,"item":624},{"id":625,"status":4,"sort":395,"slug":626,"name":627,"icon":628},66,"github-actions","GitHub Actions",{"id":629,"title":630},"da17554f-ac6d-443d-9bac-1f29645ec1c6","Github Icon",{"collection":530,"item":632},{"id":633,"status":4,"sort":634,"slug":635,"name":636,"icon":637},97,999,"webflow","Webflow",{"id":638,"title":636},"ff43a386-52f8-499f-b77c-ab9a30f4a77e",{"navigation_items_group_id":640},{"id":98,"sort":36,"status":4,"name":641,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":642},"Explore more",[643],{"collection":644,"item":645},"text_link_navigation_item_type",{"id":7,"status":4,"sort":8,"text":646,"url":647,"url_text":648},"Localazy is a technology-agnostic platform supporting 50+ frameworks, file formats & popular tools.","\u002Fintegrations","See all integrations",{"id":7,"name":650,"url":8,"url_text":8,"status":4,"groups":651},"Resources",[652,685,691],{"navigation_items_group_id":653},{"id":7,"sort":29,"status":4,"name":654,"render_show_all_url":91,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":8,"represent_as_tile":91,"items":655},"Helpful Links",[656,660,664,668,672,676,681],{"collection":179,"item":657},{"id":43,"status":4,"sort":8,"url":658,"url_text":659,"show_favorite_icon":93},"\u002Fblog","Localazy Blog",{"collection":179,"item":661},{"id":462,"status":4,"sort":8,"url":662,"url_text":663,"show_favorite_icon":93},"\u002Fdocs","Documentation",{"collection":179,"item":665},{"id":7,"status":4,"sort":8,"url":666,"url_text":667,"show_favorite_icon":91},"https:\u002F\u002Fdiscuss.localazy.com\u002F","Discussion Forum",{"collection":179,"item":669},{"id":29,"status":4,"sort":8,"url":670,"url_text":671,"show_favorite_icon":91},"\u002Fcase-study","Case Studies",{"collection":179,"item":673},{"id":22,"status":4,"sort":8,"url":674,"url_text":675,"show_favorite_icon":91},"\u002Fpartnership","Become a Partner",{"collection":179,"item":677},{"id":678,"status":4,"sort":8,"url":679,"url_text":680,"show_favorite_icon":91},15,"\u002Ffaq","FAQ",{"collection":179,"item":682},{"id":322,"status":4,"sort":8,"url":683,"url_text":684,"show_favorite_icon":91},"\u002Ftags\u002Frelease-update","Release Updates",{"navigation_items_group_id":686},{"id":16,"sort":462,"status":4,"name":659,"render_show_all_url":93,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":658,"represent_as_tile":91,"items":687},[688],{"collection":96,"item":689},{"id":22,"status":4,"sort":8,"show_author_img":91,"blog_post_tag":690,"description":8},"[USE LATEST FEATURED]",{"navigation_items_group_id":692},{"id":57,"sort":43,"status":4,"name":663,"render_show_all_url":93,"show_all_url_position":92,"show_favorite_icon":91,"show_all_url":662,"represent_as_tile":91,"items":693},[694,708,720,731,743,753,764,772],{"collection":106,"item":695},{"id":98,"status":4,"sort":8,"name":696,"item":697},"General",[698],{"collection":699,"item":700},"docs_section",{"id":16,"status":4,"sort":7,"slug":701,"name":696,"icon":702,"featured_icon":705},"general",{"id":703,"title":704},"423101f1-308c-42f1-8dfe-53272019e7cb","Global",{"id":706,"title":707},"0f7ca4cb-dc3b-4f62-b476-132ed5c4931f","General Doc",{"collection":106,"item":709},{"id":50,"status":4,"sort":8,"name":710,"item":711},"CLI",[712],{"collection":699,"item":713},{"id":22,"status":4,"sort":16,"slug":479,"name":710,"icon":714,"featured_icon":717},{"id":715,"title":716},"2f7a4573-8265-4d76-8d55-ce0cc79b8983","Cli",{"id":718,"title":719},"3f76ff7b-0e74-4046-bb03-4ca99c3b66d5","Doc Cat Cli",{"collection":106,"item":721},{"id":67,"status":4,"sort":8,"name":722,"item":723},"API",[724],{"collection":699,"item":725},{"id":129,"status":4,"sort":22,"slug":726,"name":722,"icon":727,"featured_icon":728},"api",{"id":715,"title":716},{"id":729,"title":730},"20866781-e69b-4e01-9456-05437487b75c","API Doc",{"collection":106,"item":732},{"id":733,"status":4,"sort":8,"name":734,"item":735},50,"CDN",[736],{"collection":699,"item":737},{"id":186,"status":4,"sort":98,"slug":738,"name":734,"icon":739,"featured_icon":740},"cdn",{"id":703,"title":704},{"id":741,"title":742},"a8ee9cb7-4e02-41f0-b595-eb518c3085b6","Doc Cat Cdn",{"collection":106,"item":744},{"id":57,"status":4,"sort":8,"name":533,"item":745},[746],{"collection":699,"item":747},{"id":7,"status":4,"sort":50,"slug":532,"name":533,"icon":748,"featured_icon":750},{"id":749,"title":533},"fb8329ae-0c0c-4fbc-bcdc-83f2eeda1039",{"id":751,"title":752},"f7c0dec8-5b42-4943-ab3a-e3665723ad6f","Doc Cat Android",{"collection":106,"item":754},{"id":62,"status":4,"sort":8,"name":540,"item":755},[756],{"collection":699,"item":757},{"id":98,"status":4,"sort":57,"slug":539,"name":540,"icon":758,"featured_icon":761},{"id":759,"title":760},"289e136c-55df-4d51-847b-1782a2308ee4","Ios Blue",{"id":762,"title":763},"2968d3f0-14b5-4427-82e7-f1ea2de17846","Doc Cat Ios",{"collection":106,"item":765},{"id":72,"status":4,"sort":8,"name":612,"item":766},[767],{"collection":699,"item":768},{"id":433,"status":4,"sort":62,"slug":611,"name":612,"icon":769,"featured_icon":770},{"id":715,"title":716},{"id":771,"title":612},"f5e66b5b-a439-47f6-af4a-6ad261de2e87",{"collection":106,"item":773},{"id":77,"status":4,"sort":8,"name":605,"item":774},[775],{"collection":699,"item":776},{"id":214,"status":4,"sort":67,"slug":604,"name":605,"icon":777,"featured_icon":779},{"id":778,"title":605},"3b4b03c7-e131-49e6-a014-c8c2c8a2751e",{"id":780,"title":781},"1dd05c76-e517-4aea-a3d8-49cfddb40056","Strapi Doc",{"id":22,"name":783,"url":784,"url_text":783,"status":4,"groups":785},"Pricing","\u002Fpricing",[],{"id":57,"name":787,"url":788,"url_text":787,"status":4,"groups":789},"Book a demo","\u002Fbook-demo",[],[791,931,1009,1115,1201,1280,1369,1460,1547,1646],{"id":474,"status":4,"created_on":792,"modified_on":793,"name":547,"slug":546,"description":794,"docs_link":795,"priority":8,"has_sdk":91,"sort":22,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":796,"show_in_spa":93,"is_file_format":91,"meta_title":8,"meta_description":8,"featured_on_web":93,"marketing_subtitle":797,"tile_subtitle":798,"tile_description":799,"is_enterprise":91,"is_popular":91,"primary_use_case":8,"hierarchy_handling":8,"best_for":8,"file_extension":8,"format_code_example":8,"format_to_extension":800,"upload_features":801,"icon":802,"meta_image":803,"primary_tag":805,"tags":821,"integration_methods":839,"recommended_methods":915,"default_integration_method":916,"faq_categories":923,"translations":929,"platforms_id":474},"2022-03-18T08:09:24.000Z","2025-02-26T13:36:08.000Z","Manage your React app translations with Localazy, a continuous localization tool. React i18n done right. ","\u002Fdocs\u002Fcli\u002Fjson-format","JSON format support","CLI support","react-i18next and more","Use Localazy to translate your React projects with your favorite i18n library.",{"json":588},[],{"id":549},{"id":804},"a97492ec-ee8d-429c-bf66-d0fe59b6e5c9",{"id":50,"status":4,"sort":16,"created_on":806,"label":807,"hidden":91,"icon":808,"translations":810},"2022-03-17T12:23:44.000Z","Web apps",{"id":809},"c9e70e4f-8136-432a-8d82-53c3501a9eb4",[811,813,816,819],{"id":50,"languages_code":812,"label":807},"xxa",{"id":300,"languages_code":814,"label":815},"es","Aplicaciones web",{"id":733,"languages_code":817,"label":818},"cs","Webové aplikace",{"id":504,"languages_code":820,"label":807},"en",[822,829],{"id":50,"status":4,"sort":16,"label":807,"hidden":91,"created_on":806,"icon":823,"translations":824},{"id":809},[825,826,827,828],{"id":50,"languages_code":812,"label":807},{"id":300,"languages_code":814,"label":815},{"id":733,"languages_code":817,"label":818},{"id":504,"languages_code":820,"label":807},{"id":67,"status":4,"sort":72,"label":830,"hidden":91,"created_on":831,"icon":8,"translations":832},"Framework","2022-03-17T12:23:45.000Z",[833,834,836,837],{"id":67,"languages_code":812,"label":830},{"id":437,"languages_code":814,"label":835},"Marco",{"id":387,"languages_code":817,"label":830},{"id":838,"languages_code":820,"label":830},67,[840,852,877,898],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":843},"Translate strings directly or upload them into your Localazy project.","\u002Fdocs\u002Fapi\u002Fintroduction",[844,845,848,850],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},40,"Utilice la API para exportar traducciones e importar contenido de\u002Fa Localazy mediante programación.",{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},"Choose between translating strings directly or uploading them into Localazy.",{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},"Pomocí rozhraní API můžete programovaně exportovat překlady a importovat obsah z\u002Fdo Localazy.",{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":858},"Create source keys online","add-keys-manually","Add source keys via the web interface online and sync them into your project later.","\u002Fdocs\u002Fgeneral\u002Fimporting-localization-files#add-new-keys-in-ui","vpn-key-outline",[859,860,863,864,867,870,871,872],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},"Cree claves fuente en línea","Añada claves fuente a través de la interfaz web en línea y sincronícelas posteriormente en su proyecto.",{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},"Vytvářejte zdrojové klíče online","Přidejte zdrojové klíče přes webové rozhraní online a synchronizujte je do svého projektu později.",{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},"Cree claves de origen en línea","Añada claves de origen a través de la interfaz web en línea y sincronícelas posteriormente en su proyecto.",{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},88,"de","Erstellen Sie Quellschlüssel online","Fügen Sie Quellschlüssel über die Weboberfläche online hinzu und synchronisieren Sie sie später mit Ihrem Projekt.",{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":884},"Direct file upload","File upload","web-upload","Upload your texts and existing translations in any format directly to start quickly.","\u002Fdocs\u002Fgeneral\u002Fimporting-localization-files","file-upload",[885,886,889,890,894],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},"Carga directa de archivos","Cargue sus textos y traducciones existentes en cualquier formato directamente para empezar rápidamente.",{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},56,"Přímé nahrávání souborů","Nahrajte přímo své texty a stávající překlady v libovolném formátu a začněte pracovat ihned.",{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},85,"Direkter Datei-Upload","Laden Sie Ihre Texte und vorhandenen Übersetzungen in einem beliebigen Format direkt hoch, um schnell loszulegen.",{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":902},"Command Line Interface","The best option for developers that want to make localization an automated part of their workflow.","\u002Fdocs\u002Fcli\u002Fthe-basics",[903,904,907,908,911],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},"Interfaz de Línea de Comandos","La mejor opción para los desarrolladores que deseen hacer de la localización una parte automatizada de su flujo de trabajo.",{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},"Rozhraní Příkazového Řádku","Nejlepší volba pro vývojáře, kteří chtějí, aby se lokalizace stala automatizovanou součástí jejich pracovních postupů.",{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},87,"Befehlszeilenschnittstelle","Die beste Option für Entwickler, die die Lokalisierung zu einem automatisierten Teil ihres Arbeitsablaufs machen möchten.",[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":917},[918,919,920,921,922],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[924],{"id":925,"status":4,"created_on":926,"sort":8,"slug":927,"label":928,"on_faq_index":8},76,"2025-02-26T12:44:29.000Z","front-end-common","Front-end Common",[930],{"languages_code":812,"name":547,"slug":546,"description":794,"support_type":796,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":798,"tile_description":799,"id":474,"platforms_id":474,"docs_link":795},{"id":246,"status":4,"created_on":792,"modified_on":932,"name":554,"slug":553,"description":933,"docs_link":795,"priority":8,"has_sdk":91,"sort":98,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":796,"show_in_spa":93,"is_file_format":91,"meta_title":8,"meta_description":8,"featured_on_web":93,"marketing_subtitle":797,"tile_subtitle":934,"tile_description":935,"is_enterprise":91,"is_popular":91,"primary_use_case":8,"hierarchy_handling":8,"best_for":8,"file_extension":8,"format_code_example":8,"format_to_extension":936,"upload_features":937,"icon":938,"meta_image":939,"primary_tag":941,"tags":948,"integration_methods":962,"recommended_methods":993,"default_integration_method":994,"faq_categories":1001,"translations":1007,"platforms_id":246},"2025-02-26T13:35:41.000Z","Online Vue.js project localization tool for your translation management. Enjoy true continuous localization with Localazy & translate your JSON files automatically.","Vue.js & JSON","Enjoy true continuous localization with Localazy & translate your Vue projects automatically.",{"json":588},[],{"id":556},{"id":940},"8171b836-d6b8-4354-8db3-34ccd384970d",{"id":50,"status":4,"sort":16,"created_on":806,"label":807,"hidden":91,"icon":942,"translations":943},{"id":809},[944,945,946,947],{"id":50,"languages_code":812,"label":807},{"id":300,"languages_code":814,"label":815},{"id":733,"languages_code":817,"label":818},{"id":504,"languages_code":820,"label":807},[949,956],{"id":50,"status":4,"sort":16,"label":807,"hidden":91,"created_on":806,"icon":950,"translations":951},{"id":809},[952,953,954,955],{"id":50,"languages_code":812,"label":807},{"id":300,"languages_code":814,"label":815},{"id":733,"languages_code":817,"label":818},{"id":504,"languages_code":820,"label":807},{"id":67,"status":4,"sort":72,"label":830,"hidden":91,"created_on":831,"icon":8,"translations":957},[958,959,960,961],{"id":67,"languages_code":812,"label":830},{"id":437,"languages_code":814,"label":835},{"id":387,"languages_code":817,"label":830},{"id":838,"languages_code":820,"label":830},[963,969,979,986],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":964},[965,966,967,968],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":970},[971,972,973,974,975,976,977,978],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":980},[981,982,983,984,985],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":987},[988,989,990,991,992],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":995},[996,997,998,999,1000],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[1002,1006],{"id":891,"status":4,"created_on":1003,"sort":8,"slug":1004,"label":1005,"on_faq_index":8},"2023-03-01T11:21:41.000Z","vue-localization","Vue.js Localization",{"id":925,"status":4,"created_on":926,"sort":8,"slug":927,"label":928,"on_faq_index":8},[1008],{"languages_code":812,"name":554,"slug":553,"description":933,"support_type":796,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":934,"tile_description":935,"id":246,"platforms_id":246,"docs_link":795},{"id":7,"status":4,"created_on":792,"modified_on":1010,"name":568,"slug":567,"description":1011,"docs_link":1012,"priority":8,"has_sdk":91,"sort":57,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1013,"show_in_spa":93,"is_file_format":91,"meta_title":8,"meta_description":8,"featured_on_web":91,"marketing_subtitle":797,"tile_subtitle":1013,"tile_description":1014,"is_enterprise":91,"is_popular":91,"primary_use_case":1015,"hierarchy_handling":1016,"best_for":1017,"file_extension":1018,"format_code_example":1019,"format_to_extension":1020,"upload_features":1022,"icon":1023,"meta_image":1024,"primary_tag":1026,"tags":1037,"integration_methods":1073,"recommended_methods":1104,"default_integration_method":1105,"faq_categories":1112,"translations":1113,"platforms_id":7},"2025-07-03T14:21:42.000Z","Learn how to use Localazy CLI for localization of Flutter app. Flutter’s ARB format is fully supported including arrays, plurals, and selected context information. No extra configuration is necessary, but you can enable certain features if you want to.","\u002Fdocs\u002Fcli\u002Fflutter-format","ARB format support","Use the Localazy CLI for localization of Flutter projects. Flutter’s ARB format is fully supported.","Supports metadata like placeholders","JSON-like key-value","Flutter apps",".arb","```\n{\n  \"localazy_message\": \"Go international, today. With Localazy.\"\n}\n```",{"arb":1021},"arb",[],{"id":570},{"id":1025},"c2674666-4371-4aa4-9a65-438c7363fafe",{"id":57,"status":4,"sort":67,"created_on":831,"label":1027,"hidden":91,"icon":1028,"translations":1030},"Mobile apps",{"id":1029},"c9c7e1f5-8b59-4016-ae0c-6fba9469cd55",[1031,1032,1034,1036],{"id":57,"languages_code":812,"label":1027},{"id":309,"languages_code":814,"label":1033},"Aplicaciones móviles",{"id":379,"languages_code":817,"label":1035},"Mobilní aplikace",{"id":347,"languages_code":820,"label":1027},[1038,1049,1056,1063],{"id":62,"status":4,"sort":246,"label":1039,"hidden":91,"created_on":831,"icon":1040,"translations":1042},"Desktop & Games",{"id":1041},"65b04533-5b4c-430c-b8a4-ecce84754200",[1043,1044,1046,1048],{"id":62,"languages_code":812,"label":1039},{"id":317,"languages_code":814,"label":1045},"Escritorio y Juegos",{"id":891,"languages_code":817,"label":1047},"Stolní počítače & Hry",{"id":625,"languages_code":820,"label":1039},{"id":50,"status":4,"sort":16,"label":807,"hidden":91,"created_on":806,"icon":1050,"translations":1051},{"id":809},[1052,1053,1054,1055],{"id":50,"languages_code":812,"label":807},{"id":300,"languages_code":814,"label":815},{"id":733,"languages_code":817,"label":818},{"id":504,"languages_code":820,"label":807},{"id":57,"status":4,"sort":67,"label":1027,"hidden":91,"created_on":831,"icon":1057,"translations":1058},{"id":1029},[1059,1060,1061,1062],{"id":57,"languages_code":812,"label":1027},{"id":309,"languages_code":814,"label":1033},{"id":379,"languages_code":817,"label":1035},{"id":347,"languages_code":820,"label":1027},{"id":157,"status":4,"sort":343,"label":1064,"hidden":93,"created_on":831,"icon":8,"translations":1065},"ARB",[1066,1067,1069,1071],{"id":157,"languages_code":812,"label":1064},{"id":1068,"languages_code":814,"label":1064},120,{"id":1070,"languages_code":817,"label":1064},125,{"id":1072,"languages_code":820,"label":1064},130,[1074,1080,1090,1097],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1075},[1076,1077,1078,1079],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1081},[1082,1083,1084,1085,1086,1087,1088,1089],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1091},[1092,1093,1094,1095,1096],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1098},[1099,1100,1101,1102,1103],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1106},[1107,1108,1109,1110,1111],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],[1114],{"languages_code":812,"name":568,"slug":567,"description":1011,"support_type":1013,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":1013,"tile_description":1014,"id":7,"platforms_id":7,"docs_link":1012},{"id":509,"status":4,"created_on":1116,"modified_on":1117,"name":582,"slug":581,"description":1118,"docs_link":1119,"priority":8,"has_sdk":91,"sort":29,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1120,"show_in_spa":93,"is_file_format":91,"meta_title":8,"meta_description":8,"featured_on_web":93,"marketing_subtitle":797,"tile_subtitle":1120,"tile_description":1121,"is_enterprise":91,"is_popular":91,"primary_use_case":8,"hierarchy_handling":8,"best_for":8,"file_extension":8,"format_code_example":8,"format_to_extension":1122,"upload_features":1124,"icon":1125,"meta_image":1126,"primary_tag":1128,"tags":1141,"integration_methods":1156,"recommended_methods":1187,"default_integration_method":1188,"faq_categories":1195,"translations":1199,"platforms_id":509},"2022-03-18T08:09:25.000Z","2024-07-17T07:43:22.000Z","Localize your app with JavaScript localizations and Localazy CLI.\nUpload JavaScript files with strings to Localazy and manage your translations easily. Download translated files back.","\u002Fdocs\u002Fcli\u002Fjavascript-format","JS file support","Translating JS files is easy as a pie with Localazy!",{"js":1123},"js",[],{"id":584},{"id":1127},"2c6699eb-1d80-4772-8268-5597dbcead8c",{"id":77,"status":4,"sort":77,"created_on":831,"label":1129,"hidden":91,"icon":1130,"translations":1132},"Programming Languages",{"id":1131},"8c5836bb-1fbe-49c5-9330-8f931838c457",[1133,1134,1136,1139],{"id":77,"languages_code":812,"label":1129},{"id":404,"languages_code":814,"label":1135},"Lenguajes de Programación",{"id":1137,"languages_code":817,"label":1138},68,"Programovací Jazyky",{"id":1140,"languages_code":820,"label":1129},70,[1142,1149],{"id":50,"status":4,"sort":16,"label":807,"hidden":91,"created_on":806,"icon":1143,"translations":1144},{"id":809},[1145,1146,1147,1148],{"id":50,"languages_code":812,"label":807},{"id":300,"languages_code":814,"label":815},{"id":733,"languages_code":817,"label":818},{"id":504,"languages_code":820,"label":807},{"id":77,"status":4,"sort":77,"label":1129,"hidden":91,"created_on":831,"icon":1150,"translations":1151},{"id":1131},[1152,1153,1154,1155],{"id":77,"languages_code":812,"label":1129},{"id":404,"languages_code":814,"label":1135},{"id":1137,"languages_code":817,"label":1138},{"id":1140,"languages_code":820,"label":1129},[1157,1163,1173,1180],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1158},[1159,1160,1161,1162],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1164},[1165,1166,1167,1168,1169,1170,1171,1172],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1174},[1175,1176,1177,1178,1179],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1181},[1182,1183,1184,1185,1186],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1189},[1190,1191,1192,1193,1194],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[1196],{"id":400,"status":4,"created_on":1197,"sort":8,"slug":1198,"label":582,"on_faq_index":91},"2022-06-10T09:49:33.000Z","javascript-i18n-localization",[1200],{"languages_code":812,"name":582,"slug":581,"description":1118,"support_type":1120,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":1120,"tile_description":1121,"id":509,"platforms_id":509,"docs_link":1119},{"id":29,"status":4,"created_on":1116,"modified_on":1202,"name":589,"slug":588,"description":1203,"docs_link":795,"priority":8,"has_sdk":91,"sort":129,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1204,"show_in_spa":93,"is_file_format":93,"meta_title":8,"meta_description":8,"featured_on_web":93,"marketing_subtitle":797,"tile_subtitle":1205,"tile_description":1206,"is_enterprise":91,"is_popular":93,"primary_use_case":1207,"hierarchy_handling":1208,"best_for":1209,"file_extension":1210,"format_code_example":1211,"format_to_extension":1212,"upload_features":1213,"icon":1214,"meta_image":1215,"primary_tag":1217,"tags":1228,"integration_methods":1236,"recommended_methods":1267,"default_integration_method":1268,"faq_categories":1275,"translations":1278,"platforms_id":29},"2025-10-14T11:22:29.000Z","Online JSON translator you will love. Comfortable JSON language files editor with powerful CLI under your fingers for continuous localization.","Advanced format support","{Objectively the best}","Translate any JSON files with Localazy, with the best in class plural and array support.","Used for structured data storage and APIs","Uses key-value pairs","Web & mobile apps, APIs",".json","```\n{\n  \"parent\": {\n    \"child\": {\n      \"another_nested_level\": \"All is supported.\"\n    }\n  }\n}\n```",{"json":588},[],{"id":591},{"id":1216},"8d614c20-667e-4bee-a64d-7d7ce9c0ee0a",{"id":98,"status":4,"sort":50,"created_on":831,"label":1218,"hidden":91,"icon":1219,"translations":1221},"File formats",{"id":1220},"ab7c8f89-dcce-45ab-9e21-8605aef289c3",[1222,1223,1225,1227],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},"Formatos de archivo",{"id":420,"languages_code":817,"label":1226},"Formáty souborů",{"id":513,"languages_code":820,"label":1218},[1229],{"id":98,"status":4,"sort":50,"label":1218,"hidden":91,"created_on":831,"icon":1230,"translations":1231},{"id":1220},[1232,1233,1234,1235],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},[1237,1243,1253,1260],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1238},[1239,1240,1241,1242],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1244},[1245,1246,1247,1248,1249,1250,1251,1252],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1254},[1255,1256,1257,1258,1259],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1261},[1262,1263,1264,1265,1266],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1269},[1270,1271,1272,1273,1274],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[1276],{"id":359,"status":4,"created_on":1277,"sort":8,"slug":588,"label":589,"on_faq_index":91},"2022-06-10T10:49:07.000Z",[1279],{"languages_code":812,"name":589,"slug":588,"description":1203,"support_type":1204,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":1205,"tile_description":1206,"id":29,"platforms_id":29,"docs_link":795},{"id":433,"status":4,"created_on":1116,"modified_on":1281,"name":596,"slug":595,"description":1282,"docs_link":1283,"priority":8,"has_sdk":91,"sort":433,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1284,"show_in_spa":91,"is_file_format":93,"meta_title":8,"meta_description":8,"featured_on_web":93,"marketing_subtitle":797,"tile_subtitle":1285,"tile_description":1286,"is_enterprise":91,"is_popular":93,"primary_use_case":1287,"hierarchy_handling":1288,"best_for":1289,"file_extension":1290,"format_code_example":1291,"format_to_extension":1292,"upload_features":1294,"icon":1295,"meta_image":1296,"primary_tag":1298,"tags":1305,"integration_methods":1327,"recommended_methods":1358,"default_integration_method":1359,"faq_categories":1366,"translations":1367,"platforms_id":433},"2025-07-03T14:28:31.000Z","XLIFF is meant for localization. Enjoy Localazy - an online XLIFF editor for your translation management. Integrate your XLIFF files with Localazy to achieve true continuous localization and translate XLIFF files on autopilot.","\u002Fdocs\u002Fcli\u002Fxliff-12-format","File format support","Meant for localization","XLIFF stands for XML Localization Interchange File Format - and Localazy is the best way to manage XLIFF files.","Used in translation pipelines","XML-based exchange format","Translation & software",".xliff","```\n\u003Ctrans-unit id=\"localazy_message\">\n    \u003Csource>Go international, today. With Localazy.\u003C\u002Fsource>\n\u003C\u002Ftrans-unit>\n```",{"xliff":1293},"xlf",[],{"id":598},{"id":1297},"889970a6-cce8-4055-b96a-9c3f292aa67f",{"id":98,"status":4,"sort":50,"created_on":831,"label":1218,"hidden":91,"icon":1299,"translations":1300},{"id":1220},[1301,1302,1303,1304],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},[1306,1313,1320],{"id":98,"status":4,"sort":50,"label":1218,"hidden":91,"created_on":831,"icon":1307,"translations":1308},{"id":1220},[1309,1310,1311,1312],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},{"id":98,"status":4,"sort":50,"label":1218,"hidden":91,"created_on":831,"icon":1314,"translations":1315},{"id":1220},[1316,1317,1318,1319],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},{"id":98,"status":4,"sort":50,"label":1218,"hidden":91,"created_on":831,"icon":1321,"translations":1322},{"id":1220},[1323,1324,1325,1326],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},[1328,1334,1344,1351],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1329},[1330,1331,1332,1333],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1335},[1336,1337,1338,1339,1340,1341,1342,1343],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1345},[1346,1347,1348,1349,1350],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1352},[1353,1354,1355,1356,1357],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1360},[1361,1362,1363,1364,1365],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],[1368],{"languages_code":812,"name":596,"slug":595,"description":1282,"support_type":1284,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":1285,"tile_description":1286,"id":433,"platforms_id":433,"docs_link":1283},{"id":129,"status":4,"created_on":1370,"modified_on":1370,"name":1371,"slug":1372,"description":1373,"docs_link":1374,"priority":8,"has_sdk":91,"sort":181,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1284,"show_in_spa":93,"is_file_format":93,"meta_title":8,"meta_description":8,"featured_on_web":91,"marketing_subtitle":797,"tile_subtitle":1375,"tile_description":1376,"is_enterprise":91,"is_popular":93,"primary_use_case":8,"hierarchy_handling":8,"best_for":8,"file_extension":8,"format_code_example":8,"format_to_extension":1377,"upload_features":1378,"icon":1379,"meta_image":1381,"primary_tag":1383,"tags":1390,"integration_methods":1418,"recommended_methods":1449,"default_integration_method":1450,"faq_categories":1457,"translations":1458,"platforms_id":129},"2026-05-21T20:49:09.000Z","PO","po","Edit .po and .pot files with your team, automate translation with AI, and ship faster.","\u002Fdocs\u002Fcli\u002Fpo-format","Seamless integration","Upload, edit and translate PO files with Localazy.",{"po":1372},[],{"id":1380},"25f317fe-0c10-4f0b-92cd-f80d1f3c6a67",{"id":1382},"490022f9-d91a-4e1a-9e79-db374ec997c2",{"id":98,"status":4,"sort":50,"created_on":831,"label":1218,"hidden":91,"icon":1384,"translations":1385},{"id":1220},[1386,1387,1388,1389],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},[1391,1398,1408],{"id":98,"status":4,"sort":50,"label":1218,"hidden":91,"created_on":831,"icon":1392,"translations":1393},{"id":1220},[1394,1395,1396,1397],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},{"id":330,"status":4,"sort":330,"label":1399,"hidden":93,"created_on":831,"icon":8,"translations":1400},"C++",[1401,1402,1404,1406],{"id":330,"languages_code":812,"label":1399},{"id":1403,"languages_code":814,"label":1399},135,{"id":1405,"languages_code":817,"label":1399},140,{"id":1407,"languages_code":820,"label":1399},144,{"id":433,"status":4,"sort":181,"label":1409,"hidden":93,"created_on":831,"icon":8,"translations":1410},"Gettext",[1411,1412,1414,1416],{"id":433,"languages_code":812,"label":1409},{"id":1413,"languages_code":814,"label":1409},101,{"id":1415,"languages_code":817,"label":1409},106,{"id":1417,"languages_code":820,"label":1409},111,[1419,1425,1435,1442],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1420},[1421,1422,1423,1424],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1426},[1427,1428,1429,1430,1431,1432,1433,1434],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1436},[1437,1438,1439,1440,1441],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1443},[1444,1445,1446,1447,1448],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1451},[1452,1453,1454,1455,1456],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],[1459],{"languages_code":812,"name":1371,"slug":1372,"description":1373,"support_type":1284,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":1375,"tile_description":1376,"id":129,"platforms_id":129,"docs_link":1374},{"id":186,"status":4,"created_on":1116,"modified_on":1461,"name":1462,"slug":1463,"description":1464,"docs_link":1465,"priority":8,"has_sdk":91,"sort":164,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1284,"show_in_spa":93,"is_file_format":93,"meta_title":8,"meta_description":8,"featured_on_web":91,"marketing_subtitle":797,"tile_subtitle":1466,"tile_description":1467,"is_enterprise":91,"is_popular":91,"primary_use_case":1468,"hierarchy_handling":1469,"best_for":1470,"file_extension":1471,"format_code_example":1472,"format_to_extension":1473,"upload_features":1474,"icon":1475,"meta_image":1476,"primary_tag":1478,"tags":1485,"integration_methods":1503,"recommended_methods":1534,"default_integration_method":1535,"faq_categories":1542,"translations":1545,"platforms_id":186},"2025-10-02T08:32:44.000Z","RESX","resx","Quickly translate and manage your RESX files with Localazy or integrate Localazy with your .NET project and enjoy a fully automated localization process. ","\u002Fdocs\u002Fcli\u002Fresx-format",".NET localization","Translate .NET projects using RESX files and Localazy.","Standard format for Windows\u002F.NET localization","Flat key-value structure",".NET applications",".resx","```\n\u003C?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003Croot>\n  \u003Cdata name=\"localazy_message\" xml:space=\"preserve\">\n    \u003Cvalue>Go international, today. With Localazy.\u003C\u002Fvalue>\n  \u003C\u002Fdata>\n\u003C\u002Froot>\n```",{"resx":1463},[],{"id":1380},{"id":1477},"1f07bf83-9363-47d6-b21b-a5121c0efaf6",{"id":98,"status":4,"sort":50,"created_on":831,"label":1218,"hidden":91,"icon":1479,"translations":1480},{"id":1220},[1481,1482,1483,1484],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},[1486,1493],{"id":98,"status":4,"sort":50,"label":1218,"hidden":91,"created_on":831,"icon":1487,"translations":1488},{"id":1220},[1489,1490,1491,1492],{"id":98,"languages_code":812,"label":1218},{"id":291,"languages_code":814,"label":1224},{"id":420,"languages_code":817,"label":1226},{"id":513,"languages_code":820,"label":1218},{"id":181,"status":4,"sort":186,"label":1494,"hidden":93,"created_on":831,"icon":8,"translations":1495},"C#",[1496,1497,1499,1501],{"id":181,"languages_code":812,"label":1494},{"id":1498,"languages_code":814,"label":1494},103,{"id":1500,"languages_code":817,"label":1494},108,{"id":1502,"languages_code":820,"label":1494},113,[1504,1510,1520,1527],{"id":98,"status":4,"sort":57,"label":722,"alternative_label":8,"slug":726,"description":841,"documentation_link":842,"loc_icon":726,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1505},[1506,1507,1508,1509],{"id":98,"languages_code":812,"label":722,"description":841,"slug":726},{"id":846,"languages_code":814,"label":722,"description":847,"slug":726},{"id":733,"languages_code":820,"label":722,"description":849,"slug":726},{"id":504,"languages_code":817,"label":722,"description":851,"slug":726},{"id":22,"status":4,"sort":50,"label":853,"alternative_label":8,"slug":854,"description":855,"documentation_link":856,"loc_icon":857,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1511},[1512,1513,1514,1515,1516,1517,1518,1519],{"id":22,"languages_code":812,"label":853,"description":855,"slug":854},{"id":225,"languages_code":814,"label":861,"description":862,"slug":8},{"id":420,"languages_code":820,"label":853,"description":855,"slug":854},{"id":387,"languages_code":817,"label":865,"description":866,"slug":8},{"id":338,"languages_code":814,"label":868,"description":869,"slug":854},{"id":347,"languages_code":820,"label":853,"description":855,"slug":854},{"id":625,"languages_code":817,"label":865,"description":866,"slug":854},{"id":873,"languages_code":874,"label":875,"description":876,"slug":8},{"id":16,"status":4,"sort":98,"label":878,"alternative_label":879,"slug":880,"description":881,"documentation_link":882,"loc_icon":883,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1521},[1522,1523,1524,1525,1526],{"id":16,"languages_code":812,"label":878,"description":881,"slug":880},{"id":152,"languages_code":814,"label":887,"description":888,"slug":8},{"id":317,"languages_code":820,"label":878,"description":881,"slug":880},{"id":891,"languages_code":817,"label":892,"description":893,"slug":8},{"id":895,"languages_code":874,"label":896,"description":897,"slug":8},{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1528},[1529,1530,1531,1532,1533],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[],{"id":7,"status":4,"sort":22,"label":899,"alternative_label":710,"slug":479,"description":900,"documentation_link":901,"loc_icon":479,"is_official_plugin":91,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1536},[1537,1538,1539,1540,1541],{"id":7,"languages_code":812,"label":899,"description":900,"slug":479},{"id":141,"languages_code":814,"label":905,"description":906,"slug":479},{"id":309,"languages_code":820,"label":899,"description":900,"slug":479},{"id":379,"languages_code":817,"label":909,"description":910,"slug":479},{"id":912,"languages_code":874,"label":913,"description":914,"slug":8},[1543],{"id":291,"status":4,"created_on":1544,"sort":8,"slug":1463,"label":1462,"on_faq_index":8},"2022-08-01T16:44:06.000Z",[1546],{"languages_code":812,"name":1462,"slug":1463,"description":1464,"support_type":1284,"meta_title":8,"meta_description":8,"marketing_subtitle":797,"tile_subtitle":1466,"tile_description":1467,"id":186,"platforms_id":186,"docs_link":1465},{"id":602,"status":4,"created_on":1548,"modified_on":1549,"name":605,"slug":604,"description":1550,"docs_link":1551,"priority":8,"has_sdk":8,"sort":603,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1552,"show_in_spa":93,"is_file_format":8,"meta_title":8,"meta_description":8,"featured_on_web":93,"marketing_subtitle":1552,"tile_subtitle":1552,"tile_description":1553,"is_enterprise":91,"is_popular":93,"primary_use_case":8,"hierarchy_handling":8,"best_for":8,"file_extension":8,"format_code_example":8,"format_to_extension":8,"upload_features":8,"icon":1554,"meta_image":1555,"primary_tag":1557,"tags":1569,"integration_methods":1613,"recommended_methods":1633,"default_integration_method":1634,"faq_categories":1641,"translations":1644,"platforms_id":602},"2022-06-16T09:08:11.000Z","2026-04-27T18:16:54.000Z","Enjoy seamless integration of Strapi & Localazy and translate your website efficiently. Download the official Localazy plugin in the Strapi Marketplace to get started!","\u002Fdocs\u002Fstrapi\u002Fstrapi-plugin-introduction","Localization Plugin","Localize your Strapi website with our official localization plugin. Easy setup and installation.",{"id":607},{"id":1556},"5995b721-a2ea-487e-ad4b-298a489bbd24",{"id":29,"status":4,"sort":36,"created_on":831,"label":1558,"hidden":91,"icon":1559,"translations":1561},"CMS",{"id":1560},"1172a655-928c-4594-9263-d8afdfd9cd79",[1562,1563,1565,1567],{"id":29,"languages_code":812,"label":1558},{"id":1564,"languages_code":814,"label":1558},73,{"id":1566,"languages_code":817,"label":1558},81,{"id":1568,"languages_code":820,"label":1558},84,[1570,1582,1594,1601],{"id":134,"status":4,"sort":322,"label":1571,"hidden":91,"created_on":831,"icon":8,"translations":1572},"Multilingual SEO",[1573,1574,1577,1580],{"id":134,"languages_code":812,"label":1571},{"id":1575,"languages_code":814,"label":1576},89,"SEO multilingüe",{"id":1578,"languages_code":817,"label":1579},94,"Vícejazyčné SEO",{"id":1581,"languages_code":820,"label":1571},99,{"id":474,"status":4,"sort":474,"label":236,"hidden":91,"created_on":831,"icon":1583,"translations":1585},{"id":1584},"e782ed3f-d954-45ca-8b17-5bf5fe95f30a",[1586,1587,1590,1592],{"id":474,"languages_code":812,"label":236},{"id":1588,"languages_code":814,"label":1589},132,"Comercio electrónico",{"id":1591,"languages_code":817,"label":236},137,{"id":1593,"languages_code":820,"label":236},142,{"id":29,"status":4,"sort":36,"label":1558,"hidden":91,"created_on":831,"icon":1595,"translations":1596},{"id":1560},[1597,1598,1599,1600],{"id":29,"languages_code":812,"label":1558},{"id":1564,"languages_code":814,"label":1558},{"id":1566,"languages_code":817,"label":1558},{"id":1568,"languages_code":820,"label":1558},{"id":16,"status":4,"sort":57,"label":1602,"hidden":91,"created_on":831,"icon":1603,"translations":1605},"Marketing tools",{"id":1604},"098fe6a4-dd2e-42d8-937e-2d8121e6e266",[1606,1607,1610,1612],{"id":16,"languages_code":812,"label":1602},{"id":1608,"languages_code":814,"label":1609},43,"Herramientas de marketing",{"id":367,"languages_code":817,"label":1611},"Marketingové nástroje",{"id":446,"languages_code":820,"label":1602},[1614],{"id":67,"status":4,"sort":8,"label":1615,"alternative_label":1616,"slug":1617,"description":1618,"documentation_link":1619,"loc_icon":1620,"is_official_plugin":93,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1621},"Official Strapi Plugin","Official Plugin","official-strapi-plugin","Our developers maintain an official Strapi localization plugin. Start in a few clicks!","\u002Fdocs\u002Fstrapi","localazy-box",[1622,1623,1626,1627,1630],{"id":67,"languages_code":812,"label":1615,"description":1618,"slug":1617},{"id":1608,"languages_code":814,"label":1624,"description":1625,"slug":8},"Complemento Oficial de Strapi","Nuestros desarrolladores mantienen un complemento oficial de localización de Strapi. ¡Empiece en unos pocos clics!",{"id":437,"languages_code":820,"label":1615,"description":1618,"slug":1617},{"id":513,"languages_code":817,"label":1628,"description":1629,"slug":8},"Oficiál Plugin Strapi","Naši vývojáři udržují a neustále vylepšují oficiální lokalizační plugin Strapi. Začněte několika kliknutími!",{"id":1575,"languages_code":874,"label":1631,"description":1632,"slug":8},"Offizielles Strapi-Plugin","Unsere Entwickler pflegen ein offizielles Strapi-Lokalisierungs-Plugin. Starten Sie mit ein paar Klicks!",[],{"id":67,"status":4,"sort":8,"label":1615,"alternative_label":1616,"slug":1617,"description":1618,"documentation_link":1619,"loc_icon":1620,"is_official_plugin":93,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1635},[1636,1637,1638,1639,1640],{"id":67,"languages_code":812,"label":1615,"description":1618,"slug":1617},{"id":1608,"languages_code":814,"label":1624,"description":1625,"slug":8},{"id":437,"languages_code":820,"label":1615,"description":1618,"slug":1617},{"id":513,"languages_code":817,"label":1628,"description":1629,"slug":8},{"id":1575,"languages_code":874,"label":1631,"description":1632,"slug":8},[1642],{"id":141,"status":4,"created_on":1643,"sort":8,"slug":604,"label":605,"on_faq_index":8},"2022-06-16T09:24:13.000Z",[1645],{"languages_code":812,"name":605,"slug":604,"description":1550,"support_type":1552,"meta_title":8,"meta_description":8,"marketing_subtitle":1552,"tile_subtitle":1552,"tile_description":1553,"id":602,"platforms_id":602,"docs_link":1551},{"id":387,"status":4,"created_on":1116,"modified_on":1647,"name":612,"slug":611,"description":8,"docs_link":1648,"priority":8,"has_sdk":91,"sort":379,"highlighted":91,"in_menu":93,"changefreq":8,"support_type":1649,"show_in_spa":93,"is_file_format":91,"meta_title":8,"meta_description":8,"featured_on_web":93,"marketing_subtitle":1650,"tile_subtitle":1651,"tile_description":1652,"is_enterprise":91,"is_popular":93,"primary_use_case":8,"hierarchy_handling":8,"best_for":8,"file_extension":8,"format_code_example":8,"format_to_extension":8,"upload_features":1653,"icon":1654,"meta_image":1655,"primary_tag":1657,"tags":1670,"integration_methods":1678,"recommended_methods":1691,"default_integration_method":1692,"faq_categories":1698,"translations":1699,"platforms_id":387},"2024-12-20T15:00:22.000Z","\u002Fdocs\u002Fintegrations\u002Fquick-start-figma","plugin","Figma plugin","Localization plugin","Quickly translate your Figma designs and seamlessly reuse the translations during the development of your project.",[],{"id":614},{"id":1656},"7573fed6-8817-4cb4-84d2-be327e6c4891",{"id":22,"status":4,"sort":62,"created_on":831,"label":1658,"hidden":91,"icon":1659,"translations":1661},"Design tools",{"id":1660},"1053d863-b690-4fbd-aadf-dbf2ba282768",[1662,1663,1666,1669],{"id":22,"languages_code":812,"label":1658},{"id":1664,"languages_code":814,"label":1665},42,"Herramientas de diseño",{"id":1667,"languages_code":817,"label":1668},49,"Nástroje pro návrh",{"id":496,"languages_code":820,"label":1658},[1671],{"id":22,"status":4,"sort":62,"label":1658,"hidden":91,"created_on":831,"icon":1672,"translations":1673},{"id":1660},[1674,1675,1676,1677],{"id":22,"languages_code":812,"label":1658},{"id":1664,"languages_code":814,"label":1665},{"id":1667,"languages_code":817,"label":1668},{"id":496,"languages_code":820,"label":1658},[1679],{"id":62,"status":4,"sort":8,"label":1616,"alternative_label":1616,"slug":1680,"description":1681,"documentation_link":662,"loc_icon":1620,"is_official_plugin":93,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1682},"official-plugin","Our developers maintain an official plugin for this integration. Start in a few clicks!",[1683,1684,1687,1688],{"id":62,"languages_code":812,"label":1616,"description":1681,"slug":1680},{"id":214,"languages_code":814,"label":1685,"description":1686,"slug":8},"Plugin Oficial","Nuestros desarrolladores mantienen un plugin oficial para esta integración. ¡Empiece con unos pocos clics!",{"id":205,"languages_code":820,"label":1616,"description":1681,"slug":1680},{"id":400,"languages_code":817,"label":1689,"description":1690,"slug":8},"Oficiální Plugin","Naši vývojáři udržují a neustále vylepšují oficiální plugin pro tuto integraci. Začněte několika kliknutími!",[],{"id":62,"status":4,"sort":8,"label":1616,"alternative_label":1616,"slug":1680,"description":1681,"documentation_link":662,"loc_icon":1620,"is_official_plugin":93,"is_universal_plugin":91,"universal_plugin_vendor":8,"translations":1693},[1694,1695,1696,1697],{"id":62,"languages_code":812,"label":1616,"description":1681,"slug":1680},{"id":214,"languages_code":814,"label":1685,"description":1686,"slug":8},{"id":205,"languages_code":820,"label":1616,"description":1681,"slug":1680},{"id":400,"languages_code":817,"label":1689,"description":1690,"slug":8},[],[1700],{"languages_code":812,"name":612,"slug":611,"description":8,"support_type":1649,"meta_title":8,"meta_description":8,"marketing_subtitle":1650,"tile_subtitle":1651,"tile_description":1652,"id":387,"platforms_id":387,"docs_link":1648},{"id":7,"status":4,"security_section_title":1702,"loved_section_title":1703,"columns":1704,"social_links":1774,"policy_links":1804},"Secured & trusted","Loved by users",[1705,1717,1732,1753,1756],{"id":7,"status":4,"sort":8,"title":1706,"column_key":1707,"links":1708},"Localazy","connect",[1709,1711,1714],{"id":36,"status":4,"sort":7,"title":787,"url":1710},"___LOCALAZY_MEETING_URL___",{"id":29,"status":4,"sort":16,"title":1712,"url":1713},"Contact","\u002Fcontact",{"id":433,"status":4,"sort":22,"title":1715,"url":1716},"About us","\u002Fabout-us",{"id":16,"status":4,"sort":8,"title":1718,"column_key":1719,"links":1720},"Programs","programs",[1721,1723,1726,1729],{"id":7,"status":4,"sort":98,"title":1722,"url":674},"Partner Program",{"id":16,"status":4,"sort":50,"title":1724,"url":1725},"Ambassador Program","\u002Fambassador",{"id":22,"status":4,"sort":57,"title":1727,"url":1728},"Startup Program","\u002Ffor\u002Fstartups",{"id":98,"status":4,"sort":62,"title":1730,"url":1731},"Nonprofits","\u002Ffor\u002Fnonprofit",{"id":22,"status":4,"sort":8,"title":1733,"column_key":1734,"links":1735},"Use Cases","use_cases",[1736,1739,1742,1744,1747,1750],{"id":50,"status":4,"sort":67,"title":1737,"url":1738},"Software Localization","\u002Fterm\u002Fsoftware-localization",{"id":57,"status":4,"sort":72,"title":1740,"url":1741},"Machine Translation Services","\u002Ffeatures\u002Fmachine-translation",{"id":62,"status":4,"sort":77,"title":467,"url":1743},"\u002Ffeatures\u002Ftranslation-api",{"id":67,"status":4,"sort":36,"title":1745,"url":1746},"Crowdsourced Translations","\u002Ffeatures\u002Fshare-tm",{"id":72,"status":4,"sort":29,"title":1748,"url":1749},"Figma i18n & localization","\u002Ffeatures\u002Ffigma-localization-plugin",{"id":77,"status":4,"sort":43,"title":1751,"url":1752},"Translation as a Service","\u002Fterm\u002Ftranslation-service",{"id":98,"status":4,"sort":8,"title":520,"column_key":1754,"links":1755},"integrations",[],{"id":50,"status":4,"sort":8,"title":650,"column_key":1757,"links":1758},"resources",[1759,1760,1763,1764,1767,1769,1770,1771],{"id":43,"status":4,"sort":462,"title":663,"url":662},{"id":462,"status":4,"sort":678,"title":1761,"url":1762},"Dictionary","\u002Fdictionary",{"id":678,"status":4,"sort":560,"title":671,"url":670},{"id":560,"status":4,"sort":322,"title":1765,"url":1766},"Discussion forum","___DISCUSS_URL___",{"id":322,"status":4,"sort":134,"title":1768,"url":658},"Localization Blog",{"id":134,"status":4,"sort":246,"title":680,"url":679},{"id":246,"status":4,"sort":129,"title":783,"url":784},{"id":129,"status":4,"sort":433,"title":1772,"url":1773},"Brand assets","https:\u002F\u002Fdrive.google.com\u002Fdrive\u002Fu\u002F1\u002Ffolders\u002F1tZqsZHBlGelz7A2FwTsiwrs4j3sd9HTk",[1775,1780,1785,1790,1795,1800],{"id":7,"status":4,"sort":8,"platform":1776,"icon":1777,"url":1778,"label":1779},"feed","rss","\u002Ffeed.xml","Localazy RSS Feed",{"id":16,"status":4,"sort":8,"platform":1781,"icon":1782,"url":1783,"label":1784},"facebook","fb-logo","https:\u002F\u002Fwww.facebook.com\u002Flocalazy\u002F","Localazy facebook",{"id":22,"status":4,"sort":8,"platform":1786,"icon":1787,"url":1788,"label":1789},"github","github-logo","https:\u002F\u002Fgithub.com\u002Flocalazy","Localazy GitHub",{"id":98,"status":4,"sort":8,"platform":1791,"icon":1792,"url":1793,"label":1794},"x","x-twitter","https:\u002F\u002Fx.com\u002Flocalazy","Localazy X",{"id":50,"status":4,"sort":8,"platform":1796,"icon":1797,"url":1798,"label":1799},"linkedin","linkedin-logo","https:\u002F\u002Fwww.linkedin.com\u002Fcompany\u002F37836599\u002F","Localazy LinkedIn",{"id":57,"status":4,"sort":8,"platform":1801,"icon":1802,"url":1766,"label":1803},"discourse","discourse-logo","Localazy Discourse",[1805,1808,1811],{"id":7,"status":4,"sort":8,"title":1806,"url":1807,"action":8},"Privacy policy","\u002Fprivacy-policy",{"id":16,"status":4,"sort":8,"title":1809,"url":1810,"action":8},"Terms & Conditions","\u002Fterms-and-conditions",{"id":22,"status":4,"sort":8,"title":1812,"url":8,"action":1813},"Cookies","cookie-consent",{"id":1815,"owner":1816,"created_by":8,"sort":8,"title":1821,"slug":1822,"modified_on":53,"created_on":1823,"pinned":91,"badge":8,"priority":1824,"main_image":1825,"status":4,"tags":1826,"dictionary":1834,"reading_time":1835,"excerpt":-1,"og_title":53,"og_description":53,"og_image":53},"69d78a237ab1270001eddabf",{"id":1817,"first_name":1818,"last_name":53,"slug":1819,"avatar":1820},"606c233dcf7b6a0001d1da93","Petr Hodný","petr-hodny","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2023\u002F12\u002Fpetr.png","Translation API: Translate your content on the fly with Localazy AI!","translation-api-translate-your-content-on-the-fly-with-localazy-ai","2026-04-24T09:27:26.000+02:00","0.7","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2026\u002F04\u002FLocalazy-AI-translation-api.png",[1827,1829,1832],{"id":387,"created_on":1828,"status":4,"label":722,"slug":726,"on_index_page":91},"2026-06-08 03:12:34",{"id":338,"created_on":1828,"status":4,"label":1830,"slug":1831,"on_index_page":93},"AI","ai",{"id":387,"created_on":1828,"status":4,"label":287,"slug":1833,"on_index_page":93},"localization",[],0,{"id":1837,"owner":1838,"created_by":8,"sort":8,"title":1843,"slug":1844,"modified_on":53,"created_on":1845,"pinned":91,"badge":8,"priority":1824,"main_image":1846,"status":4,"tags":1847,"dictionary":1855,"reading_time":1835,"excerpt":-1,"og_title":53,"og_description":53,"og_image":53},"6a145e3f8550a300014081e4",{"id":1839,"first_name":1840,"last_name":53,"slug":1841,"avatar":1842},"65647a510470910001942876","Dorota Pawlak","dorota-pawlak","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2024\u002F07\u002Fdorota.png","The great LLM translation war, pt. 2: Context beats model choice in 2026","the-great-llm-translation-war-pt-2-2026","2026-06-05T14:56:58.000+02:00","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2026\u002F06\u002Flocalazy-AI-series-11.png",[1848,1849,1852],{"id":338,"created_on":1828,"status":4,"label":1830,"slug":1831,"on_index_page":93},{"id":338,"created_on":1828,"status":4,"label":1850,"slug":1851,"on_index_page":91},"ChatGPT","chatgpt",{"id":387,"created_on":1828,"status":4,"label":1853,"slug":1854,"on_index_page":91},"Translations","translations",[],{"post":1857,"relatedPosts":1991,"banner":2051},{"id":1858,"owner":1859,"created_by":8,"sort":8,"title":1864,"slug":1865,"modified_on":1866,"created_on":1867,"pinned":93,"badge":8,"priority":1824,"main_image":1868,"status":4,"tags":1869,"dictionary":1877,"reading_time":560,"excerpt":1925,"og_title":53,"og_description":53,"og_image":53,"content":1926,"meta_title":1927,"meta_description":53,"canonical":53,"cta":1928,"fullGhostPost":1936},"690e4a1863350c0001d61794",{"id":1860,"first_name":1861,"last_name":53,"slug":1862,"avatar":1863},"6824a22d63350c0001d5a5cb","Kevine Nzapdi","kevine-nzapdi","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2025\u002F11\u002FKevine.png","Guide to translating Angular apps with ngx-translate | Part 2: App logic 📲","guide-to-translate-angular-apps-with-ngx-translate-app-logic","2025-12-02T15:59:31.000+01:00","2025-12-02T15:59:19.000+01:00","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2025\u002F11\u002Ftechnical-article-2-1.png",[1870,1872,1873],{"id":387,"created_on":1828,"status":4,"label":1871,"slug":1871,"on_index_page":93},"i18n",{"id":387,"created_on":1828,"status":4,"label":562,"slug":561,"on_index_page":91},{"id":1874,"created_on":1828,"status":4,"label":1875,"slug":1876,"on_index_page":91},6556628,"Tutorial","tutorial",[1878,1898,1906],{"id":1879,"status":4,"owner":1880,"created_on":1881,"title":1882,"excerpt":1883,"content":1884,"slug":1882,"meta_title":53,"meta_description":53,"canonical":53,"related_terms":1885},426,"a5e46ee7-1f50-4f81-ae2d-68a664c76aa2","2026-05-12T07:09:27.000Z","ngx-translate","A community-built translation library for Angular that loads and displays localized strings at runtime.","[ngx-translate](https:\u002F\u002Fngx-translate.org \"https:\u002F\u002Fngx-translate.org\") is a runtime translation library for Angular that loads localized strings from JSON files and updates the UI as soon as the user switches languages. It is used to build multilingual interfaces without rebuilding the application for every locale. It loads translation files at runtime, applies keys directly in templates, and updates visible text when the active language changes. This makes it practical for projects that handle dynamic content, frequent releases, or environments where language files need to come from an API.\n\nThe library offers a flexible structure: teams can organize translations in separate folders, load them on demand, or map them to custom file names. Interpolation, nested keys, and fallbacks are supported out of the box. However, for features such as ICU Message handling or advanced parsing, you might need to use compatible plugins. \n\nThis modular approach makes ngx-translate suitable for Angular, Ionic, hybrid apps, and setups that need more control than Angular’s built-in i18n system.\n\n### 📌 Key points to know about ngx-translate\n\n* It supports nested keys, parameters, plural forms, and fallbacks.\n* JSON-based translation files let teams add, edit, and restructure content quickly.\n* Language switching happens at runtime, so the UI updates instantly without a rebuild.\n* The architecture is modular, so teams can add plugins or write their own compiler or parser.\n* Works well with apps that need fast iteration, dynamic content, or remote translation sources.\n* The library works with custom loaders, so translations can come from the server, a CMS, or a CDN.\n\n### 🧩 How ngx-translate fits into localization workflows\n\n* Works with translation platforms that export structured JSON.\n* Makes it easy to load locale data at different stages of the app lifecycle.\n* Ideal for projects that want live updates across all supported languages.\n* Supports continuous delivery because content changes do not require a deployment.\n* Helps teams test translations, layout shifts, and parameter handling without rebuilding the app.\n\n### Notes\n\nClient-side loading gives ngx-translate a lot of flexibility, yet it also puts file size and network conditions under direct pressure. Many teams split translations by module, preload key sections, or serve files through [a CDN](https:\u002F\u002Flocalazy.com\u002Ffeatures\u002Flanguage-cdn) to keep the interface responsive.\n\n### Related sources:\n\n* GitHub: \u003Chttps:\u002F\u002Fgithub.com\u002Fngx-translate\u002Fcore>\n* npm: \u003Chttps:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002F@ngx-translate\u002Fcore>\n* Documentation: \u003Chttps:\u002F\u002Fgithub.com\u002Fngx-translate\u002Fcore#readme>",[1886,1887,1889,1892,1895],{"id":1500,"slug":588},{"id":1888,"slug":561},69,{"id":1890,"slug":1891},79,"internationalization",{"id":1893,"slug":1894},255,"icu-message-format",{"id":1896,"slug":1897},405,"string-interpolation",{"id":1888,"status":4,"owner":1899,"created_on":1900,"title":562,"excerpt":1901,"content":1902,"slug":561,"meta_title":53,"meta_description":8,"canonical":8,"related_terms":1903},"2bac48a8-b362-482f-b574-3bc71dca4c5b","2022-03-17T12:22:55.000Z","Angular is a TypeScript-based open-source platform for building mobile and desktop web applications. ","Angular enables you to build applications that range from simple, single-developer projects to enterprise-level applications.\n\nThe Angular platform consists of:\n- a framework for building scalable web applications that consist of several individual components,\n- an extensive collection of integrated libraries that support the development of application features, including routing, forms management, and client-server communication,\n- a set of tools designed to help you write code, build software, test your work, and update your applications.\n\nGoogle designed Angular as a ground-up rewrite of AngularJS. Angular has been developed and maintained by Google developers since 2016 with help from a community of individuals and corporations. \n\nYou can learn more about Angular on the [official website](https:\u002F\u002Fangular.io\u002F).\n\nRelated links:\n[How to localize Angular app with angular-i18n and Localazy](\u002Fblog\u002Flocalize-angular-app-i18n-l10n-localazy)",[1904],{"id":1905,"slug":574},74,{"id":1907,"status":438,"owner":1908,"created_on":1909,"title":1910,"excerpt":1911,"content":1912,"slug":1871,"meta_title":8,"meta_description":8,"canonical":8,"related_terms":1913},192,"ceebb166-1990-467a-a514-56e71dfcf7a4","2026-05-12T07:09:24.000Z","i18n ","i18n is an abbreviation for internationalization, the practice of preparing software to be localized. ","i18n, or internationalization, is the process of designing software so it can be easily adapted for different languages, regions, and cultures without changing the core code. The name comes from the abbreviation \"i18n,\" where \"18\" represents the number of letters between \"i\" and \"n\" in the word \"internationalization.\"\n\n### Key points about i18n: 🌍\n- **Content Separate from Code.** i18n keeps language-specific content like text and dates separate from the code, making it easier to translate and adapt.\n- **Handles Plurals and Context.** It supports multiple plural forms, gender-specific words, and cultural context changes.\n- **Cross-Platform.** i18n can be applied to software, websites, and mobile apps, ensuring smooth localization across all platforms.\n- **Prepares for Globalization.** It sets up your product to handle different languages, date formats, currencies, and writing directions like left-to-right or right-to-left.\n\ni18n simplifies the localization process, making it easier to adapt software for global audiences.",[1914,1915,1917,1919,1921,1923],{"id":16,"slug":1833},{"id":98,"slug":1916},"translation",{"id":50,"slug":1918},"globalization",{"id":57,"slug":1920},"t9n",{"id":77,"slug":1922},"g11n",{"id":22,"slug":1924},"l10n","After setting up runtime i18n, translation loading, and Tailwind styling in Part 1, it’s time to bring our Angular invoice app to life. We'll create, edit, and manage invoices while keeping everything translation-ready for full localization with Localazy later on.","\u003Cp>Now that \u003Ca href=\"https:\u002F\u002Flocalazy.com\u002Fblog\u002Fguide-to-translate-angular-apps-with-ngx-translate-foundations\">the groundwork is done\u003C\u002Fa> and i18n is configured, Tailwind is in place, and our Angular app is ready, it’s time to make things happen.\u003C\u002Fp>\u003Cp>In this part, we’ll focus on functionality. \u003Cstrong>You’ll build a complete invoice dashboard\u003C\u002Fstrong> where users can create, edit, view, and delete invoices. \u003Cstrong>We’ll also add handy features like printing, exporting, and importing\u003C\u002Fstrong>, all while keeping the app clean, reactive, and localization-friendly.\u003C\u002Fp>\u003Cp>By the end of this section, your app will display placeholders and will feel alive, interactive, and ready for multilingual support when we integrate Localazy in the final part.\u003C\u002Fp>\u003Ch2 id=\"1-step-1-build-the-dashboard\">1️⃣ Step 1: Build the dashboard \u003Ca class=\"markdownit-header-anchor\" href=\"#1-step-1-build-the-dashboard\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Cp>You’ll scaffold a dashboard screen as the app’s first view, then plug in a minimal Tailwind\u002Fi18n template.\u003C\u002Fp>\u003Ch3 id=\"1-generate-the-dashboard-component\">1. Generate the dashboard component \u003Ca class=\"markdownit-header-anchor\" href=\"#1-generate-the-dashboard-component\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Run this command from your project root:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run ng -- generate component features\u002Fdashboard --standalone\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Angular CLI scaffolds the component under \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002F\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode>dashboard.component.ts      \u002F\u002F Component logic (TypeScript)\ndashboard.component.html     \u002F\u002F Template for layout &amp; i18n text\ndashboard.component.scss     \u002F\u002F Local styling (will use Tailwind classes)\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>It also wires the component for standalone use.\u003C\u002Fp>\u003Ch3 id=\"2-implementing-the-dashboard-component\">2. Implementing the dashboard component \u003Ca class=\"markdownit-header-anchor\" href=\"#2-implementing-the-dashboard-component\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Next, let's implement the component:\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Component, ChangeDetectionStrategy } from '@angular\u002Fcore';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\n\n@Component({\n  selector: 'app-dashboard',\n  standalone: true,\n  imports: [RouterModule, TranslateModule],\n  templateUrl: '.\u002Fdashboard.component.html',\n  styleUrls: ['.\u002Fdashboard.component.scss'],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class DashboardComponent {}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;section class=\"max-w-5xl mx-auto p-6\"&gt;\n  &lt;header class=\"flex items-center gap-3 mb-6\"&gt;\n    &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'dashboard.title' | translate }}&lt;\u002Fh2&gt;\n    &lt;a routerLink=\"\u002Finvoice\u002Fnew\" class=\"btn ml-auto\"&gt;\n      {{ 'invoice.actions.create' | translate }}\n    &lt;\u002Fa&gt;\n  &lt;\u002Fheader&gt;\n\n  &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-600\"&gt;\n    {{ 'dashboard.empty' | translate }}\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fsection&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"3-invoice-edit-component\">3. Invoice Edit component \u003Ca class=\"markdownit-header-anchor\" href=\"#3-invoice-edit-component\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>We’ll now create the \u003Cstrong>Invoice Edit view\u003C\u002Fstrong>, wire up its template, add the needed i18n keys, and define \u003Ca href=\"https:\u002F\u002Fangular.dev\u002Freference\u002Fmigrations\u002Froute-lazy-loading\" rel=\"noopener noreferrer\">lazy-loaded routes\u003C\u002Fa> for a lightweight Angular app. Generate the standalone component first, then replace its files with the code below.\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Component, ChangeDetectionStrategy } from '@angular\u002Fcore';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\n\n@Component({\n  selector: 'app-invoice-edit',\n  standalone: true,\n  imports: [RouterModule, TranslateModule],\n  templateUrl: '.\u002Finvoice-edit.component.html',\n  styleUrls: ['.\u002Finvoice-edit.component.scss'],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceEditComponent {}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Like the dashboard, this component uses Angular’s standalone API, importing both the \u003Ccode>RouterModule\u003C\u002Fcode> and \u003Ccode>TranslateModule\u003C\u002Fcode> directly. The \u003Ccode>OnPush\u003C\u002Fcode> change detection keeps the UI performant as the project scales with invoice forms and API data.\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.html\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;section class=\"max-w-3xl mx-auto p-6 space-y-4\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.edit.title' | translate }}&lt;\u002Fh2&gt;\n  &lt;p class=\"text-gray-600\"&gt;{{ 'invoice.edit.placeholder' | translate }}&lt;\u002Fp&gt;\n\n  &lt;a routerLink=\"\u002F\" class=\"btn-secondary inline-flex items-center\"&gt;\n    {{ 'nav.back' | translate }}\n  &lt;\u002Fa&gt;\n&lt;\u002Fsection&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-add-translation-keys\">4. Add translation keys \u003Ca class=\"markdownit-header-anchor\" href=\"#4-add-translation-keys\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Place these in \u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002F\u003C\u002Fcode>\u003C\u002Fstrong> so \u003Cstrong>ngx-translate\u003C\u002Fstrong> (and later Localazy) can sync them.\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"title\": \"Welcome to Your Invoice &amp; Expense Tracker\",\n  },\n  \"dashboard\": {\n    \"title\": \"Dashboard\",\n    \"empty\": \"No invoices yet. Create your first invoice.\"\n  },\n  \"invoice\": {\n    \"edit\": {\n      \"title\": \"New Invoice\",\n      \"placeholder\": \"Form coming next.\"\n    },\n    \"actions\": {\n      \"create\": \"Create Invoice\"\n    }\n  },\n  \"nav\": {\n    \"back\": \"Back\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"title\": \"Bienvenue dans votre outil de suivi des factures et des dépenses\",\n  },\n  \"dashboard\": {\n    \"title\": \"Tableau de bord\",\n    \"empty\": \"Aucune facture pour le moment. Créez votre première facture.\"\n  },\n  \"invoice\": {\n    \"edit\": {\n      \"title\": \"Nouvelle facture\",\n      \"placeholder\": \"Formulaire à venir.\"\n    },\n    \"actions\": {\n      \"create\": \"Créer une facture\"\n    }\n  },\n  \"nav\": {\n    \"back\": \"Retour\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"5-define-routes-with-lazy-loading\">5. Define routes with lazy loading \u003Ca class=\"markdownit-header-anchor\" href=\"#5-define-routes-with-lazy-loading\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Create\u002Fupdate \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fapp.routes.ts\u003C\u002Fcode>\u003C\u002Fstrong> to lazy-load screens. See Angular Router docs for reference: angular.dev &gt; Guide &gt; Router.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Routes } from '@angular\u002Frouter';\n\nexport const routes: Routes = [\n  {\n    path: '',\n    pathMatch: 'full',\n    loadComponent: () =&gt;\n      import('.\u002Ffeatures\u002Fdashboard\u002Fdashboard.component').then(m =&gt; m.DashboardComponent),\n  },\n  {\n    path: 'invoice\u002Fnew',\n    loadComponent: () =&gt;\n      import('.\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component').then(m =&gt; m.InvoiceEditComponent),\n  },\n  {\n    path: 'invoice\u002F:id',\n    loadComponent: () =&gt;\n      import('.\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component').then(m =&gt; m.InvoiceEditComponent),\n  },\n  { path: '**', redirectTo: '' },\n];\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This routing setup ensures that:\u003C\u002Fp>\u003Cul>\u003Cli>\u003Ccode>\u002F\u003C\u002Fcode> loads the \u003Cstrong>Dashboard\u003C\u002Fstrong> (your main screen).\u003C\u002Fli>\u003Cli>\u003Ccode>\u002Finvoice\u002Fnew\u003C\u002Fcode> opens the \u003Cstrong>Invoice Editor\u003C\u002Fstrong> for creating a new invoice.\u003C\u002Fli>\u003Cli>\u003Ccode>\u002Finvoice\u002F:id\u003C\u002Fcode> reuses the same component for editing an existing invoice.\u003C\u002Fli>\u003Cli>Any undefined route gracefully redirects back to the Dashboard.\u003C\u002Fli>\u003C\u002Ful>\u003Ch3 id=\"6-run-and-verify\">6. Run & verify \u003Ca class=\"markdownit-header-anchor\" href=\"#6-run-and-verify\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Start your local server to check that everything works:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm start\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then open \u003Cstrong>\u003Ca href=\"http:\u002F\u002Flocalhost:4200\u002F\">http:\u002F\u002Flocalhost:4200\u003C\u002Fa>\u003C\u002Fstrong> and verify that:\u003C\u002Fp>\u003Cul>\u003Cli>The Dashboard loads with a localized title and \u003Cstrong>Create Invoice\u003C\u002Fstrong> button.\u003C\u002Fli>\u003Cli>By clicking, it opens the \u003Cstrong>Invoice Edit\u003C\u002Fstrong> screen with the \u003Cstrong>Back\u003C\u002Fstrong> button styled using \u003Ccode>.btn-secondary\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>Text is updated when switching languages, confirming that \u003Cstrong>ngx-translate\u003C\u002Fstrong> and \u003Cstrong>Tailwind\u003C\u002Fstrong> have been correctly integrated.\u003C\u002Fli>\u003C\u002Ful>\u003Ch2 id=\"2-step-2-set-up-the-invoice-models-and-store\">2️⃣ Step 2: Set up the invoice models and store \u003Ca class=\"markdownit-header-anchor\" href=\"#2-step-2-set-up-the-invoice-models-and-store\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Cp>Define the app’s data layer so the UI and i18n stay clean. We’ll model invoices and line items first, then wire a lightweight store next.\u003C\u002Fp>\u003Ch3 id=\"1-create-the-models\">1. Create the models \u003Ca class=\"markdownit-header-anchor\" href=\"#1-create-the-models\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Start by defining the core data structures that your invoice app will use: invoices, line items, and their statuses. These models form the foundation of your store and components.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fmodels\u002Finvoice.model.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export type InvoiceStatus = 'draft' | 'sent' | 'paid';\n\nexport interface LineItem {\n  id: string;\n  description: string;\n  quantity: number;\n  unitPrice: number;\n  taxRate?: number;\n  discountRate?: number;\n}\n\nexport interface Invoice {\n  id: string;\n  number: string;\n  clientName: string;\n  clientEmail?: string;\n  issueDate: string;\n  dueDate?: string;\n  currency: string;\n  items: LineItem[];\n  notes?: string;\n  status: InvoiceStatus;\n  createdAt: string;\n  updatedAt: string;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>These models define the structure of invoices and line items, ensuring consistent data handling across components and simplifying integration with the store.\u003C\u002Fp>\u003Ch3 id=\"2-create-the-signal-based-store-with-persistence\">2. Create the Signal-based store with Persistence \u003Ca class=\"markdownit-header-anchor\" href=\"#2-create-the-signal-based-store-with-persistence\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Use Angular \u003Cstrong>signals\u003C\u002Fstrong> to manage the invoice state without extra libraries. This store exposes reactive selectors (via \u003Ccode>computed\u003C\u002Fcode>), persists to \u003Ccode>localStorage\u003C\u002Fcode> with an \u003Ccode>effect\u003C\u002Fcode>, and keeps your components lean.\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, computed, effect, signal } from '@angular\u002Fcore';\nimport { Invoice, InvoiceStatus, LineItem } from '..\u002Fmodels\u002Finvoice.model';\n\nconst STORAGE_KEY = 'invoice.store.v1';\n\nfunction nowIso() { return new Date().toISOString(); }\nfunction newId()  { return (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)); }\n\nfunction calcLineTotal(li: LineItem): number {\n  const qty = Math.max(0, li.quantity || 0);\n  const price = Math.max(0, li.unitPrice || 0);\n  const preTax = qty * price;\n  const discount = li.discountRate ? preTax * (li.discountRate \u002F 100) : 0;\n  const afterDiscount = preTax - discount;\n  const tax = li.taxRate ? afterDiscount * (li.taxRate \u002F 100) : 0;\n  return +(afterDiscount + tax).toFixed(2);\n}\n\nfunction calcInvoiceTotal(inv: Invoice): number {\n  return +inv.items.reduce((sum, li) =&gt; sum + calcLineTotal(li), 0).toFixed(2);\n}\n\nfunction load(): Invoice[] {\n  try {\n    const raw = localStorage.getItem(STORAGE_KEY);\n    if (!raw) return [];\n    const parsed = JSON.parse(raw) as Invoice[];\n    \u002F\u002F basic sanity\n    return Array.isArray(parsed) ? parsed : [];\n  } catch {\n    return [];\n  }\n}\n\n@Injectable({ providedIn: 'root' })\nexport class InvoiceStore {\n  private readonly _invoices = signal&lt;Invoice[]&gt;(load());\n\n  \u002F\u002F Persist to localStorage on change\n  private readonly _persist = effect(() =&gt; {\n    const value = JSON.stringify(this._invoices());\n    localStorage.setItem(STORAGE_KEY, value);\n  });\n\n  \u002F\u002F Selectors\n  readonly invoices = this._invoices.asReadonly();\n  totalCount = computed(() =&gt; this._invoices().length);\n  totalByStatus = (status: InvoiceStatus) =&gt; computed(\n    () =&gt; this._invoices().filter(i =&gt; i.status === status).length\n  );\n  byId = (id: string) =&gt; computed(() =&gt; this._invoices().find(i =&gt; i.id === id) || null);\n  totalAmount = (id: string) =&gt; computed(() =&gt; {\n    const inv = this._invoices().find(i =&gt; i.id === id);\n    return inv ? calcInvoiceTotal(inv) : 0;\n  });\n\n  \u002F\u002F Mutations\n  createDraft(partial?: Partial&lt;Invoice&gt;): Invoice {\n    const id = newId();\n    const created = nowIso();\n    const number = this.nextNumber();\n    const invoice: Invoice = {\n      id,\n      number,\n      clientName: partial?.clientName ?? '',\n      clientEmail: partial?.clientEmail,\n      issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),\n      dueDate: partial?.dueDate,\n      currency: partial?.currency ?? 'USD',\n      items: partial?.items ?? [this.newLineItem()],\n      notes: partial?.notes,\n      status: partial?.status ?? 'draft',\n      createdAt: created,\n      updatedAt: created,\n    };\n    this._invoices.update(arr =&gt; [invoice, ...arr]);\n    return invoice;\n  }\n\n  update(id: string, changes: Partial&lt;Invoice&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; inv.id === id ? { ...inv, ...changes, updatedAt: nowIso() } : inv)\n    );\n  }\n\n  remove(id: string): void {\n    this._invoices.update(arr =&gt; arr.filter(inv =&gt; inv.id !== id));\n  }\n\n  \u002F\u002F ---- Line item helpers\n  newLineItem(): LineItem {\n    return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };\n  }\n\n  addLineItem(invoiceId: string, li?: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; inv.id === invoiceId\n        ? { ...inv, items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items], updatedAt: nowIso() }\n        : inv\n      )\n    );\n  }\n\n  updateLineItem(invoiceId: string, itemId: string, changes: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.map(it =&gt; it.id === itemId ? { ...it, ...changes } : it);\n        return { ...inv, items, updatedAt: nowIso() };\n      })\n    );\n  }\n\n  removeLineItem(invoiceId: string, itemId: string): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.filter(it =&gt; it.id !== itemId);\n        return { ...inv, items, updatedAt: nowIso() };\n      })\n    );\n  }\n\n  setStatus(id: string, status: InvoiceStatus) {\n    this.update(id, { status });\n  }\n\n  \u002F\u002F Utilities\n  private nextNumber(): string {\n    const seq = this._invoices().length + 1;\n    return `INV-${String(seq).padStart(4, '0')}`;\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This setup lets you handle invoices in real time while keeping data in sync across sessions.\u003C\u002Fp>\u003Cp>Because it’s based on Angular’s built-in reactivity, \u003Cstrong>the store stays small, fast, and ready to scale\u003C\u002Fstrong> alongside your localized UI managed with ngx-translate and Localazy.\u003C\u002Fp>\u003Ch3 id=\"3-add-i18n-keys-for-statuses\">3. Add i18n keys for statuses \u003Ca class=\"markdownit-header-anchor\" href=\"#3-add-i18n-keys-for-statuses\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Before displaying invoice statuses in the UI, define their localized labels.\u003C\u002Fp>\u003Cp>This ensures values like \u003Cstrong>Draft\u003C\u002Fstrong>, \u003Cstrong>Sent\u003C\u002Fstrong>, and \u003Cstrong>Paid\u003C\u002Fstrong> are translated dynamically in any language your app supports.\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"draft\": \"Draft\",\n    \"sent\": \"Sent\",\n    \"paid\": \"Paid\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"draft\": \"Brouillon\",\n    \"sent\": \"Envoyée\",\n    \"paid\": \"Payée\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-update-the-dashboard-component-to-read-from-the-store\">4. Update the dashboard component to read from the store \u003Ca class=\"markdownit-header-anchor\" href=\"#4-update-the-dashboard-component-to-read-from-the-store\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Now that your store is ready, \u003Cstrong>update the dashboard to pull invoice data directly from it\u003C\u002Fstrong>. This step lets you display stored invoices, totals, and statuses with live updates.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Component, ChangeDetectionStrategy, inject } from '@angular\u002Fcore';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\nimport { CurrencyPipe, DatePipe } from '@angular\u002Fcommon';\nimport { InvoiceStore } from '..\u002F..\u002Fcore\u002Fstores\u002Finvoice.store';\nimport { Invoice } from '..\u002F..\u002Fcore\u002Fmodels\u002Finvoice.model';\n\n@Component({\n  selector: 'app-dashboard',\n  standalone: true,\n  imports: [RouterModule, TranslateModule, CurrencyPipe, DatePipe],\n  templateUrl: '.\u002Fdashboard.component.html',\n  styleUrls: ['.\u002Fdashboard.component.scss'],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class DashboardComponent {\n  private readonly store = inject(InvoiceStore);\n  invoices = this.store.invoices; \u002F\u002F signal&lt;Invoice[]&gt;\n\n  \u002F\u002F demo: create a draft quickly\n  createSample() {\n    this.store.createDraft({\n      clientName: 'Acme Corp',\n      currency: 'USD',\n      items: [\n        { id: crypto.randomUUID(), description: 'Design work', quantity: 3, unitPrice: 120, taxRate: 10 },\n      ],\n      status: 'draft',\n    });\n  }\n\n  total(inv: Invoice): number {\n    \u002F\u002F Avoid recomputing across the list by using store.totalAmount(inv.id) if you prefer computed-per-id\n    return this.store.totalAmount(inv.id)();\n  }\n\n  statusClass(status: Invoice['status']): string {\n    switch (status) {\n      case 'paid':  return 'bg-green-100 text-green-700';\n      case 'sent':  return 'bg-amber-100 text-amber-700';\n      default:      return 'bg-gray-100 text-gray-700';\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now update the file \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;section class=\"max-w-5xl mx-auto p-6 space-y-4\"&gt;\n  &lt;header class=\"flex items-center gap-3\"&gt;\n    &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'dashboard.title' | translate }}&lt;\u002Fh2&gt;\n    &lt;a routerLink=\"\u002Finvoice\u002Fnew\" class=\"btn ml-auto\"&gt;\n      {{ 'invoice.actions.create' | translate }}\n    &lt;\u002Fa&gt;\n    &lt;button class=\"btn-secondary\" type=\"button\" (click)=\"createSample()\"&gt;+ Sample&lt;\u002Fbutton&gt;\n  &lt;\u002Fheader&gt;\n\n  @if (invoices().length === 0) {\n    &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-600\"&gt;\n      {{ 'dashboard.empty' | translate }}\n    &lt;\u002Fdiv&gt;\n  } @else {\n    &lt;ul class=\"space-y-3\"&gt;\n      @for (inv of invoices(); track inv.id) {\n        &lt;li class=\"rounded-xl border p-4 hover:shadow-sm transition\"&gt;\n          &lt;div class=\"flex items-center gap-3\"&gt;\n            &lt;div class=\"font-semibold\"&gt;{{ inv.number }}&lt;\u002Fdiv&gt;\n            &lt;div class=\"text-gray-600\"&gt;•&lt;\u002Fdiv&gt;\n            &lt;div class=\"text-gray-800\"&gt;{{ inv.clientName || '—' }}&lt;\u002Fdiv&gt;\n            &lt;div class=\"text-gray-500 ml-auto flex items-center gap-3\"&gt;\n              &lt;span class=\"px-2 py-1 rounded-md text-xs\" [class]=\"statusClass(inv.status)\"&gt;\n                {{ ('status.' + inv.status) | translate }}\n              &lt;\u002Fspan&gt;\n              &lt;span class=\"text-sm\"&gt;\n                {{ inv.issueDate | date:'mediumDate' }}\n              &lt;\u002Fspan&gt;\n              &lt;strong class=\"ml-2\"&gt;\n                {{ total(inv) | currency: inv.currency:'symbol-narrow' }}\n              &lt;\u002Fstrong&gt;\n            &lt;\u002Fdiv&gt;\n          &lt;\u002Fdiv&gt;\n        &lt;\u002Fli&gt;\n      }\n    &lt;\u002Ful&gt;\n  }\n&lt;\u002Fsection&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"5-update-the-root-component-and-language-switcher\">5. Update the root component & language switcher \u003Ca class=\"markdownit-header-anchor\" href=\"#5-update-the-root-component-and-language-switcher\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Wire up the app-wide language controls so every screen (Dashboard, Invoice Edit) updates instantly with \u003Cstrong>ngx-translate\u003C\u002Fstrong>. This uses Angular \u003Cstrong>standalone\u003C\u002Fstrong> components and a small service to manage runtime i18n.\u003C\u002Fp>\u003Cp>File:\u003Cstrong> \u003C\u002Fstrong>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\nimport { Component, ChangeDetectionStrategy, inject } from '@angular\u002Fcore';\nimport { RouterOutlet } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\nimport { LanguageService } from '.\u002Fcore\u002Flanguage.service';\n\n@Component({\n  selector: 'app-root',\n  standalone: true,\n  imports: [RouterOutlet, TranslateModule],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n  templateUrl: '.\u002Fapp.html',\n  styleUrl: '.\u002Fapp.scss',\n})\nexport class App {\n  private readonly lang = inject(LanguageService);\n  setLang(l: 'en' | 'fr') { this.lang.use(l); }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>In \u003Ccode>app.html\u003C\u002Fcode>, the header displays the translated title and language buttons, while \u003Ccode>&lt;router-outlet&gt;\u003C\u002Fcode> loads the active view. Each button calls \u003Ccode>setLang()\u003C\u002Fcode> to toggle between English and French.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">&lt;header class=\"flex items-center gap-3 p-4 border-b\"&gt;\n  &lt;h1 class=\"text-xl font-semibold\"&gt;{{ 'app.title' | translate }}&lt;\u002Fh1&gt;\n  &lt;div class=\"ml-auto flex items-center gap-2\"&gt;\n    &lt;button type=\"button\" class=\"btn\" (click)=\"setLang('en')\"&gt;EN&lt;\u002Fbutton&gt;\n    &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"setLang('fr')\"&gt;FR&lt;\u002Fbutton&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fheader&gt;\n\n&lt;main class=\"p-4\"&gt;\n  &lt;router-outlet&gt;&lt;\u002Frouter-outlet&gt;\n&lt;\u002Fmain&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The \u003Ccode>\u003Cstrong>LanguageService\u003C\u002Fstrong>\u003C\u002Fcode> registers available locales, remembers the last selected language, and updates the \u003Ccode>&lt;html lang&gt;\u003C\u002Fcode>attribute. This keeps translations consistent across routes and sessions.\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable } from '@angular\u002Fcore';\nimport { TranslateService } from '@ngx-translate\u002Fcore';\n\ntype Lang = 'en' | 'fr';\n\n@Injectable({ providedIn: 'root' })\nexport class LanguageService {\n  private readonly storageKey = 'lang';\n  private readonly supported: readonly Lang[] = ['en', 'fr'] as const;\n\n  constructor(private t: TranslateService) {}\n\n  init(): void {\n    this.t.addLangs([...this.supported]);\n    const saved = (localStorage.getItem(this.storageKey) as Lang | null) ?? this.matchNavigator();\n    this.use(saved);\n  }\n\n  use(lang: string): void {\n    const chosen: Lang =\n      (this.supported as readonly string[]).includes(lang as Lang) ? (lang as Lang) : 'en';\n\n    localStorage.setItem(this.storageKey, chosen);\n    document.documentElement.lang = chosen;\n    this.t.use(chosen);\n  }\n\n  private matchNavigator(): Lang {\n    const base = (navigator.language || navigator.languages?.[0] || 'en').slice(0, 2) as Lang;\n    return (this.supported as readonly string[]).includes(base) ? base : 'en';\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Together, \u003Cstrong>these three files finalize your localization flow\u003C\u002Fstrong>. When the user clicks \u003Cstrong>EN\u003C\u002Fstrong> or \u003Cstrong>FR\u003C\u002Fstrong>, the entire interface Dashboard, Invoice Edit, and all status labels updates instantly.\u003C\u002Fp>\u003Ch3 id=\"6-add-locale-aware-formatting\">6. Add locale-aware formatting \u003Ca class=\"markdownit-header-anchor\" href=\"#6-add-locale-aware-formatting\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Next, create a \u003Ccode>src\u002Fapp\u002Fcore\u002Flocale-format.service.ts\u003C\u002Fcode> file to handle \u003Ca href=\"https:\u002F\u002Flocalazy.com\u002Fblog\u002Fnumber-localization-guide-currencies-dates-measurement-units\u002F\">language-aware number and date formatting\u003C\u002Fa> across the app. This service listens to the active language from \u003Ccode>ngx-translate\u003C\u002Fcode> using Angular signals, so\u003Cstrong> all amounts and dates update automatically when users switch languages\u003C\u002Fstrong>. It exposes two helpers: \u003Ccode>currency()\u003C\u002Fcode> for localized prices and \u003Ccode>dateISO()\u003C\u002Fcode> for readable dates, both used by the dashboard and upcoming invoice views.\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal } from '@angular\u002Fcore';\nimport { TranslateService } from '@ngx-translate\u002Fcore';\n\n@Injectable({ providedIn: 'root' })\nexport class LocaleFormatService {\n  lang = signal('en');\n\n  constructor(private t: TranslateService) {\n    this.lang.set(this.t.currentLang || 'en');\n    this.t.onLangChange.subscribe(e =&gt; this.lang.set(e.lang));\n  }\n\n  currency(amount: number, currency: string): string {\n    return new Intl.NumberFormat(this.lang(), { style: 'currency', currency }).format(amount);\n  }\n\n  dateISO(isoDate: string): string {\n    return new Intl.DateTimeFormat(this.lang(), { dateStyle: 'medium' }).format(new Date(isoDate));\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now update \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode> to use this service. Import \u003Ccode>\u003Cstrong>LocaleFormatService\u003C\u002Fstrong>\u003C\u002Fcode>, inject it inside the class, and replace the currency and date pipes with the new helper methods. This ensures that totals and issue dates reformat automatically whenever the language changes.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { LocaleFormatService } from '..\u002F..\u002Fcore\u002Flocale-format.service';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Inject the service inside the class:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">private readonly fmt = inject(LocaleFormatService);\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Use \u003Ccode>this.fmt.currency()\u003C\u002Fcode> and \u003Ccode>this.fmt.dateISO()\u003C\u002Fcode> to format totals and issue dates:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { LocaleFormatService } from '..\u002F..\u002Fcore\u002Flocale-format.service';\n\nprivate readonly fmt = inject(LocaleFormatService);\n\n{{ fmt.currency(total, inv.currency) }}\n{{ fmt.dateISO(inv.issueDate) }}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"7-run-and-verify\">7. Run & verify \u003Ca class=\"markdownit-header-anchor\" href=\"#7-run-and-verify\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cpre>\u003Ccode class=\"language-bash\">npm start\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>Open \u003Cstrong>\u003Ca href=\"http:\u002F\u002Flocalhost:4200\u002F\">http:\u002F\u002Flocalhost:4200\u003C\u002Fa>\u003C\u002Fstrong>.\u003C\u002Fli>\u003Cli>Click \u003Cstrong>+ Sample\u003C\u002Fstrong> — a demo invoice appears instantly.\u003C\u002Fli>\u003Cli>All text and status labels translate dynamically through \u003Cstrong>ngx-translate\u003C\u002Fstrong>, confirming your \u003Cstrong>Angular localization\u003C\u002Fstrong> and store setup work together correctly.\u003C\u002Fli>\u003Cli>Toggle \u003Cstrong>EN\u002FFR\u003C\u002Fstrong> to confirm translations and status labels updates.\u003C\u002Fli>\u003C\u002Ful>\u003Ch2 id=\"3-step-3-refactor-the-store\">3️⃣ Step 3: Refactor the store \u003Ca class=\"markdownit-header-anchor\" href=\"#3-step-3-refactor-the-store\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Cp>To keep the store maintainable and modular, we’ll extract helper functions and logic into smaller dedicated files. \u003Cstrong>This makes it easier to reuse utilities across the app \u003C\u002Fstrong>and keeps the core store focused on state management.\u003C\u002Fp>\u003Ch3 id=\"1-create-an-id-generator\">1. Create an ID generator \u003Ca class=\"markdownit-header-anchor\" href=\"#1-create-an-id-generator\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Start by creating a small ID generator in \u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Fid.ts\u003C\u002Fcode>. This helper provides a lightweight, consistent way to \u003Cstrong>create unique identifiers throughout the app\u003C\u002Fstrong>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export const newId = (): string =&gt;\n  globalThis.crypto?.randomUUID?.() ?? 'id-' + Math.random().toString(36).slice(2);\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, open \u003Ccode>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fcode>, import the helper, and remove the inline ID logic.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { newId } from '..\u002Futils\u002Fid';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Replace every reference to the old generator with newId(). This simple change ensures that \u003Cstrong>all IDs follow a single standard\u003C\u002Fstrong>:\u003C\u002Fp>\u003Cul>\u003Cli>\u003Ccode>sanitizeItem(...)\u003C\u002Fcode>\u003C\u002Fli>\u003Cli>\u003Ccode>sanitizeInvoice(...)\u003C\u002Fcode>\u003C\u002Fli>\u003Cli>\u003Ccode>createDraft(...)\u003C\u002Fcode>\u003C\u002Fli>\u003Cli>\u003Ccode>newLineItem(...)\u003C\u002Fcode>\u003C\u002Fli>\u003C\u002Ful>\u003Ch3 id=\"2-set-up-a-shared-time-utility\">2. Set up a shared time utility \u003Ca class=\"markdownit-header-anchor\" href=\"#2-set-up-a-shared-time-utility\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Now let’s move timestamp handling into its own utility. Create \u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Ftime.ts\u003C\u002Fcode> with:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export const isoNow = (): string =&gt; new Date().toISOString();\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then remove the old local helper entirely:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F\u002F const isoNow = () =&gt; new Date().toISOString();\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, import it into the store and delete the inline version to keep date logic consistent and easy to test. \u003C\u002Fp>\u003Cp>Now extract all financial calculations into a reusable module. \u003C\u002Fp>\u003Cp>Create \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Futils\u002Fmoney.ts\u003C\u002Fstrong>\u003C\u002Fcode> and add:\u003C\u002Fp>\u003Cpre>\u003Ccode>import type { LineItem, Invoice } from '..\u002Fmodels\u002Finvoice.model';\n\n\u002F** Calculate a single line item total (after discount, plus tax). *\u002F\nexport function lineTotal(li: LineItem): number {\n  const qty = Math.max(0, li.quantity || 0);\n  const price = Math.max(0, li.unitPrice || 0);\n  const gross = qty * price;\n  const discount = li.discountRate ? gross * (li.discountRate \u002F 100) : 0;\n  const afterDiscount = gross - discount;\n  const tax = li.taxRate ? afterDiscount * (li.taxRate \u002F 100) : 0;\n  return +(afterDiscount + tax).toFixed(2);\n}\n\n\u002F** Sum all line items for an invoice. *\u002F\nexport function invoiceTotal(inv: Invoice): number {\n  return +inv.items.reduce((s, li) =&gt; s + lineTotal(li), 0).toFixed(2);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The store now relies on a shared time utility, keeping the codebase cleaner and easier to maintain.\u003C\u002Fp>\u003Ch3 id=\"3-centralize-the-stores-financial-logic\">3. Centralize the store's financial logic \u003Ca class=\"markdownit-header-anchor\" href=\"#3-centralize-the-stores-financial-logic\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Let’s \u003Cstrong>clean up the financial logic in the store \u003C\u002Fstrong>by moving all money-related calculations into their own utility file. Start by creating \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Fmoney.ts\u003C\u002Fcode>\u003C\u002Fstrong> and add the following code:\u003C\u002Fp>\u003Cpre>\u003Ccode>import type { LineItem, Invoice } from '..\u002Fmodels\u002Finvoice.model';\n\n\u002F** Calculate a single line item total (after discount, plus tax). *\u002F\nexport function lineTotal(li: LineItem): number {\n  const qty = Math.max(0, li.quantity || 0);\n  const price = Math.max(0, li.unitPrice || 0);\n  const gross = qty * price;\n  const discount = li.discountRate ? gross * (li.discountRate \u002F 100) : 0;\n  const afterDiscount = gross - discount;\n  const tax = li.taxRate ? afterDiscount * (li.taxRate \u002F 100) : 0;\n  return +(afterDiscount + tax).toFixed(2);\n}\n\n\u002F** Sum all line items for an invoice. *\u002F\nexport function invoiceTotal(inv: Invoice): number {\n  return +inv.items.reduce((s, li) =&gt; s + lineTotal(li), 0).toFixed(2);\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, open \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fcode>\u003C\u002Fstrong> and update it to use these new helpers.\u003C\u002Fp>\u003Cp>Add the following import near the top of the file:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { lineTotal, invoiceTotal } from '..\u002Futils\u002Fmoney';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then remove the old inline calculation functions entirely:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">function lineTotal(li: LineItem): number { \u002F* ... *\u002F }\nfunction invoiceTotal(inv: Invoice): number { \u002F* ... *\u002F }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This refactor keeps your store lean and focused while centralizing all currency and total calculations in a single reusable utility.\u003C\u002Fp>\u003Ch3 id=\"4-set-up-serialization\">4. Set up serialization \u003Ca class=\"markdownit-header-anchor\" href=\"#4-set-up-serialization\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fpersistence\u002Finvoice.serialization.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import type { Invoice, LineItem } from '..\u002Fmodels\u002Finvoice.model';\nimport { newId } from '..\u002Futils\u002Fid';\nimport { isoNow } from '..\u002Futils\u002Ftime';\n\nfunction num(v: unknown, fallback: number): number {\n  const n = (typeof v === 'number' || typeof v === 'string') ? Number(v) : NaN;\n  return Number.isFinite(n) ? n : fallback;\n}\n\nexport function sanitizeItem(raw: unknown): LineItem {\n  const r = (raw &amp;&amp; typeof raw === 'object') ? raw as Record&lt;string, unknown&gt; : {};\n  return {\n    id: String(r.id ?? newId()),\n    description: String(r.description ?? ''),\n    quantity: num(r.quantity, 1),\n    unitPrice: num(r.unitPrice, 0),\n    taxRate: num(r.taxRate, 0),\n    discountRate: num(r.discountRate, 0),\n  };\n}\n\nconst VALID_STATUS = new Set&lt;Invoice['status']&gt;(['draft', 'sent', 'paid']);\n\nexport function sanitizeInvoice(raw: unknown): Invoice | null {\n  if (!raw || typeof raw !== 'object') return null;\n  const r = raw as Record&lt;string, unknown&gt;;\n\n  const itemsRaw = Array.isArray(r.items) ? r.items : [];\n  const items = itemsRaw.map(sanitizeItem);\n\n  const status = VALID_STATUS.has(r.status as Invoice['status'])\n    ? (r.status as Invoice['status'])\n    : 'draft';\n\n  return {\n    id: String(r.id ?? newId()),\n    number: String(r.number ?? 'INV-XXXX'),\n    clientName: String(r.clientName ?? ''),\n    clientEmail: r.clientEmail ? String(r.clientEmail) : undefined,\n    issueDate: String(r.issueDate ?? new Date().toISOString().slice(0, 10)),\n    dueDate: r.dueDate ? String(r.dueDate) : undefined,\n    currency: String(r.currency ?? 'USD'),\n    items,\n    notes: r.notes ? String(r.notes) : undefined,\n    status,\n    createdAt: String(r.createdAt ?? isoNow()),\n    updatedAt: String(r.updatedAt ?? isoNow()),\n  };\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then update the store to use the serializer. Replace the store file with the version below. Behavior stays the same; the only change is that\u003Cstrong> parsing\u002Fvalidation is delegated to the serializers\u003C\u002Fstrong>.\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, computed, effect, signal } from '@angular\u002Fcore';\nimport { Invoice, InvoiceStatus, LineItem } from '..\u002Fmodels\u002Finvoice.model';\nimport { newId } from '..\u002Futils\u002Fid';\nimport { isoNow } from '..\u002Futils\u002Ftime';\nimport { invoiceTotal } from '..\u002Futils\u002Fmoney';\nimport { sanitizeInvoice } from '..\u002Fpersistence\u002Finvoice.serialization';\n\n\u002F\u002F nstants\nconst STORAGE_KEY = 'invoice.store.v1';\n\n\u002F\u002F load\nfunction load(): Invoice[] {\n  try {\n    const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');\n    if (!Array.isArray(raw)) return [];\n    return raw.map(sanitizeInvoice).filter(Boolean) as Invoice[];\n  } catch {\n    return [];\n  }\n}\n\n\u002F\u002F store\n@Injectable({ providedIn: 'root' })\nexport class InvoiceStore {\n  private readonly _invoices = signal&lt;Invoice[]&gt;(load());\n\n  \u002F\u002F throttle persistence to avoid excessive writes\n  private persistTimer: any = null;\n  private readonly _persist = effect(() =&gt; {\n    const snapshot = this._invoices();\n    clearTimeout(this.persistTimer);\n    this.persistTimer = setTimeout(() =&gt; {\n      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch {}\n    }, 120);\n  });\n\n  \u002F\u002F precompute totals map for O(1) lookup\n  private readonly totalsById = computed(() =&gt; {\n    const map = new Map&lt;string, number&gt;();\n    for (const inv of this._invoices()) map.set(inv.id, invoiceTotal(inv));\n    return map;\n  });\n\n  \u002F\u002F selectors\n  readonly invoices = this._invoices.asReadonly();\n  byId = (id: string) =&gt; computed(() =&gt; this._invoices().find(i =&gt; i.id === id) ?? null);\n  totalAmount = (id: string) =&gt; computed(() =&gt; this.totalsById().get(id) ?? 0);\n  totalCount = computed(() =&gt; this._invoices().length);\n  totalByStatus = (status: InvoiceStatus) =&gt;\n    computed(() =&gt; this._invoices().filter(i =&gt; i.status === status).length);\n\n  \u002F\u002F mutations \n  createDraft(partial?: Partial&lt;Invoice&gt;): Invoice {\n    const id = newId();\n    const created = isoNow();\n    const number = this.nextNumber();\n    const invoice: Invoice = {\n      id,\n      number,\n      clientName: partial?.clientName ?? '',\n      clientEmail: partial?.clientEmail,\n      issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),\n      dueDate: partial?.dueDate,\n      currency: partial?.currency ?? 'USD',\n      items: partial?.items ?? [this.newLineItem()],\n      notes: partial?.notes,\n      status: partial?.status ?? 'draft',\n      createdAt: created,\n      updatedAt: created,\n    };\n    this._invoices.update(arr =&gt; [invoice, ...arr]);\n    return invoice;\n  }\n\n  update(id: string, changes: Partial&lt;Invoice&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; (inv.id === id ? { ...inv, ...changes, updatedAt: isoNow() } : inv)),\n    );\n  }\n\n  remove(id: string): void {\n    this._invoices.update(arr =&gt; arr.filter(inv =&gt; inv.id !== id));\n  }\n\n  \u002F\u002F line items \n  newLineItem(): LineItem {\n    return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };\n  }\n\n  addLineItem(invoiceId: string, li?: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt;\n        inv.id === invoiceId\n          ? {\n              ...inv,\n              items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items],\n              updatedAt: isoNow(),\n            }\n          : inv,\n      ),\n    );\n  }\n\n  updateLineItem(invoiceId: string, itemId: string, changes: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.map(it =&gt; (it.id === itemId ? { ...it, ...changes } : it));\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  removeLineItem(invoiceId: string, itemId: string): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.filter(it =&gt; it.id !== itemId);\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  setStatus(id: string, status: InvoiceStatus): void {\n    this.update(id, { status });\n  }\n\n  \u002F\u002F utilities \n  private nextNumber(): string {\n    const seq = this._invoices().length + 1;\n    return `INV-${String(seq).padStart(4, '0')}`;\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The goal is to \u003Cstrong>keep the store “loose”\u003C\u002Fstrong>: state and mutations live in one place, while parsing\u002Fvalidation and core calculations are modular and reusable.\u003C\u002Fp>\u003Ch3 id=\"5-register-and-connect-the-new-repository\">5. Register and connect the new repository \u003Ca class=\"markdownit-header-anchor\" href=\"#5-register-and-connect-the-new-repository\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>To finish the persistence setup, register your new repository provider inside the app configuration. Thanks to this, Angular will inject the correct implementation (\u003Ccode>LocalStorageInvoiceRepository\u003C\u002Fcode>) wherever the \u003Ccode>INVOICE_REPOSITORY\u003C\u002Fcode> token is requested.\u003C\u002Fp>\u003Cp>Create \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fcore\u002Fpersistence\u002Finvoice.repository.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { InjectionToken } from '@angular\u002Fcore';\nimport type { Invoice } from '..\u002Fmodels\u002Finvoice.model';\nimport { sanitizeInvoice } from '.\u002Finvoice.serialization';\n\nexport interface InvoiceRepository {\n  load(): Invoice[];\n  save(data: Invoice[]): void;\n}\n\nexport const INVOICE_REPOSITORY = new InjectionToken&lt;InvoiceRepository&gt;('INVOICE_REPOSITORY');\n\nexport class LocalStorageInvoiceRepository implements InvoiceRepository {\n  private readonly KEY = 'invoice.store.v2';\n\n  load(): Invoice[] {\n    try {\n      const raw = localStorage.getItem(this.KEY);\n      if (!raw) return [];\n      const parsed: unknown = JSON.parse(raw);\n\n      \u002F\u002F Accept v1 (array) or v2 ({ version, data })\n      const arr = Array.isArray(parsed)\n        ? parsed\n        : (parsed &amp;&amp; typeof parsed === 'object' &amp;&amp; Array.isArray((parsed as any).data))\n          ? (parsed as any).data\n          : [];\n\n      const out: Invoice[] = [];\n      for (const it of arr) {\n        const inv = sanitizeInvoice(it);\n        if (inv) out.push(inv);\n      }\n      return out;\n    } catch {\n      return [];\n    }\n  }\n\n  save(data: Invoice[]): void {\n    try {\n      const payload = { version: 2, data };\n      localStorage.setItem(this.KEY, JSON.stringify(payload));\n    } catch {\n      \u002F\u002F ignore quota\u002Fsecurity errors\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Update \u003Ccode>src\u002Fapp\u002Fapp.config.ts\u003C\u002Fcode> by adding the provider below your existing imports and configuration:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F\u002F ...existing imports...\nimport { INVOICE_REPOSITORY, LocalStorageInvoiceRepository } from '.\u002Fcore\u002Fpersistence\u002Finvoice.repository';\n\nexport const appConfig: ApplicationConfig = {\n  providers: [\n    \u002F\u002F ...existing providers...\n    { provide: INVOICE_REPOSITORY, useClass: LocalStorageInvoiceRepository },\n  ],\n};\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This registration \u003Cstrong>connects your dependency injection system to the repository clas\u003C\u002Fstrong>s, allowing your store and future services to interact cleanly with invoice data through a unified interface, rather than hard-coding persistence logic.\u003C\u002Fp>\u003Cp>Update the store to use the repository \u003Ccode>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, computed, effect, inject, signal } from '@angular\u002Fcore';\nimport { Invoice, InvoiceStatus, LineItem } from '..\u002Fmodels\u002Finvoice.model';\nimport { newId } from '..\u002Futils\u002Fid';\nimport { isoNow } from '..\u002Futils\u002Ftime';\nimport { invoiceTotal } from '..\u002Futils\u002Fmoney';\nimport { INVOICE_REPOSITORY, InvoiceRepository } from '..\u002Fpersistence\u002Finvoice.repository';\n\n@Injectable({ providedIn: 'root' })\nexport class InvoiceStore {\n  \u002F\u002F repo abstraction (can be swapped via DI)\n  private readonly repo: InvoiceRepository = inject(INVOICE_REPOSITORY);\n\n  \u002F\u002F state\n  private readonly _invoices = signal&lt;Invoice[]&gt;(this.repo.load());\n\n  \u002F\u002F throttle persistence via repo to avoid excessive writes\n  private persistTimer: any = null;\n  private readonly _persist = effect(() =&gt; {\n    const snapshot = this._invoices();\n    clearTimeout(this.persistTimer);\n    this.persistTimer = setTimeout(() =&gt; {\n      this.repo.save(snapshot);\n    }, 120);\n  });\n\n  \u002F\u002F precompute totals map for O(1) lookup\n  private readonly totalsById = computed(() =&gt; {\n    const map = new Map&lt;string, number&gt;();\n    for (const inv of this._invoices()) map.set(inv.id, invoiceTotal(inv));\n    return map;\n  });\n\n  \u002F\u002F selectors\n  readonly invoices = this._invoices.asReadonly();\n  byId = (id: string) =&gt; computed(() =&gt; this._invoices().find(i =&gt; i.id === id) ?? null);\n  totalAmount = (id: string) =&gt; computed(() =&gt; this.totalsById().get(id) ?? 0);\n  totalCount = computed(() =&gt; this._invoices().length);\n  totalByStatus = (status: InvoiceStatus) =&gt;\n    computed(() =&gt; this._invoices().filter(i =&gt; i.status === status).length);\n\n  \u002F\u002F mutations \n  createDraft(partial?: Partial&lt;Invoice&gt;): Invoice {\n    const id = newId();\n    const created = isoNow();\n    const number = this.nextNumber();\n    const invoice: Invoice = {\n      id,\n      number,\n      clientName: partial?.clientName ?? '',\n      clientEmail: partial?.clientEmail,\n      issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),\n      dueDate: partial?.dueDate,\n      currency: partial?.currency ?? 'USD',\n      items: partial?.items ?? [this.newLineItem()],\n      notes: partial?.notes,\n      status: partial?.status ?? 'draft',\n      createdAt: created,\n      updatedAt: created,\n    };\n    this._invoices.update(arr =&gt; [invoice, ...arr]);\n    return invoice;\n  }\n\n  update(id: string, changes: Partial&lt;Invoice&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; (inv.id === id ? { ...inv, ...changes, updatedAt: isoNow() } : inv)),\n    );\n  }\n\n  remove(id: string): void {\n    this._invoices.update(arr =&gt; arr.filter(inv =&gt; inv.id !== id));\n  }\n\n  \u002F\u002F line items \n  newLineItem(): LineItem {\n    return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };\n  }\n\n  addLineItem(invoiceId: string, li?: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt;\n        inv.id === invoiceId\n          ? {\n              ...inv,\n              items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items],\n              updatedAt: isoNow(),\n            }\n          : inv,\n      ),\n    );\n  }\n\n  updateLineItem(invoiceId: string, itemId: string, changes: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.map(it =&gt; (it.id === itemId ? { ...it, ...changes } : it));\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  removeLineItem(invoiceId: string, itemId: string): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.filter(it =&gt; it.id !== itemId);\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  setStatus(id: string, status: InvoiceStatus): void {\n    this.update(id, { status });\n  }\n\n  \u002F\u002F utilities \n  private nextNumber(): string {\n    const seq = this._invoices().length + 1;\n    return `INV-${String(seq).padStart(4, '0')}`;\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then bind the repository in the app configuration:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { INVOICE_REPOSITORY, LocalStorageInvoiceRepository } from '.\u002Fcore\u002Fpersistence\u002Finvoice.repository';\n\nexport const appConfig: ApplicationConfig = {\n  providers: [\n  \n  ...\n  \n\t\u002F\u002F Repository binding    \n\t{ provide: INVOICE_REPOSITORY, useClass: LocalStorageInvoiceRepository },\n\t\n ],\n};\t\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The store now depends on an abstract \u003Ccode>InvoiceRepository\u003C\u002Fcode>, \u003Ccode>LocalStorageInvoiceRepository\u003C\u002Fcode> implements it, and the app config provides the binding so Angular’s DI injects the correct implementation everywhere. This keeps your state layer \u003Cstrong>clean, testable, and ready for future backends\u003C\u002Fstrong>.\u003C\u002Fp>\u003Ch2 id=\"4-step-4-invoice-editor-form\">4️⃣ Step 4: Invoice editor form \u003Ca class=\"markdownit-header-anchor\" href=\"#4-step-4-invoice-editor-form\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Cp>Now it's time to replace the generated TypeScript file with this implementation (strongly-typed \u003Cstrong>Reactive Forms\u003C\u002Fstrong>, Tailwind-ready, i18n-friendly, and using your shared money utilities): \u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-jsx\">import { Component, ChangeDetectionStrategy, inject } from '@angular\u002Fcore';\nimport { CommonModule } from '@angular\u002Fcommon';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\nimport {\n  ReactiveFormsModule,\n  NonNullableFormBuilder,\n  FormGroup,\n  FormControl,\n  FormArray,\n  Validators,\n} from '@angular\u002Fforms';\n\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\n\n\u002F\u002F Strongly-typed item group\ntype ItemControls = {\n  description: FormControl&lt;string&gt;;\n  quantity: FormControl&lt;number&gt;;\n  unitPrice: FormControl&lt;number&gt;;\n  taxRate: FormControl&lt;number&gt;;\n  discountRate: FormControl&lt;number&gt;;\n};\ntype ItemGroup = FormGroup&lt;ItemControls&gt;;\n\n\u002F\u002F Root form controls\ntype InvoiceFormControls = {\n  clientName: FormControl&lt;string&gt;;\n  clientEmail: FormControl&lt;string | null&gt;;\n  issueDate: FormControl&lt;string&gt;;\n  dueDate: FormControl&lt;string | null&gt;;\n  currency: FormControl&lt;'USD' | 'EUR' | 'XAF'&gt;;\n  notes: FormControl&lt;string | null&gt;;\n  items: FormArray&lt;ItemGroup&gt;;\n};\ntype InvoiceForm = FormGroup&lt;InvoiceFormControls&gt;;\n\n@Component({\n  selector: 'app-invoice-edit',\n  standalone: true,\n  imports: [CommonModule, RouterModule, TranslateModule, ReactiveFormsModule],\n  templateUrl: '.\u002Finvoice-edit.component.html',\n  styleUrl: '.\u002Finvoice-edit.component.scss',\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceEditComponent {\n  private fb = inject(NonNullableFormBuilder);\n\n  \u002F\u002F Build a new item row with typed controls\n  private createItemGroup(): ItemGroup {\n    return this.fb.group&lt;ItemControls&gt;({\n      description: this.fb.control('', { validators: [] }),\n      quantity: this.fb.control(1, { validators: [Validators.min(0)] }),\n      unitPrice: this.fb.control(0, { validators: [Validators.min(0)] }),\n      taxRate: this.fb.control(0, { validators: [Validators.min(0), Validators.max(100)] }),\n      discountRate: this.fb.control(0, { validators: [Validators.min(0), Validators.max(100)] }),\n    });\n  }\n\n  \u002F\u002F Root form with strict types (non-nullable where appropriate)\n  form: InvoiceForm = this.fb.group&lt;InvoiceFormControls&gt;({\n    clientName: this.fb.control('', { validators: [Validators.required] }),\n    clientEmail: new FormControl&lt;string | null&gt;(null, { nonNullable: false, validators: [Validators.email] }),\n    issueDate: this.fb.control(new Date().toISOString().slice(0, 10)),\n    dueDate: new FormControl&lt;string | null&gt;(null, { nonNullable: false }),\n    currency: this.fb.control&lt;'USD' | 'EUR' | 'XAF'&gt;('USD'),\n    notes: new FormControl&lt;string | null&gt;(null, { nonNullable: false }),\n    items: this.fb.array&lt;ItemGroup&gt;([this.createItemGroup()]),\n  });\n\n  \u002F\u002F items helpers \n  get items(): FormArray&lt;ItemGroup&gt; {\n    return this.form.controls.items;\n  }\n\n  addItem(): void {\n    this.items.push(this.createItemGroup());\n  }\n\n  removeItem(i: number): void {\n    this.items.removeAt(i);\n  }\n\n  \u002F\u002F totals\n  lineTotalAt(index: number): number {\n    const g = this.items.at(index).getRawValue(); \n    return lineTotal({\n      id: 'tmp',\n      description: g.description,\n      quantity: g.quantity,\n      unitPrice: g.unitPrice,\n      taxRate: g.taxRate,\n      discountRate: g.discountRate,\n    });\n  }\n\n  grandTotal(): number {\n    const v = this.form.getRawValue(); \n    return invoiceTotal({\n      id: 'tmp',\n      number: 'INV-TMP',\n      clientName: v.clientName,\n      clientEmail: v.clientEmail ?? undefined,\n      issueDate: v.issueDate,\n      dueDate: v.dueDate ?? undefined,\n      currency: v.currency,\n      items: v.items.map(it =&gt; {\n        const row = it;\n        return {\n          id: 'tmp',\n          description: row.description,\n          quantity: row.quantity,\n          unitPrice: row.unitPrice,\n          taxRate: row.taxRate,\n          discountRate: row.discountRate,\n        };\n      }),\n      notes: v.notes ?? undefined,\n      status: 'draft',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    });\n  }\n\n  \u002F\u002F actions\n  save(): void {\n    if (this.form.invalid) {\n      this.form.markAllAsTouched();\n      return;\n    }\n    \u002F\u002F For now, just demo; next micro-step will persist via InvoiceStore.\n    \u002F\u002F Typed value:\n    const value = this.form.getRawValue();\n    console.log('[invoice-edit] value', value);\n    alert('Form captured (no persistence yet). Next step: connect to store.');\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Update the \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.html\u003C\u002Fstrong>\u003C\u002Fcode>template, which lays out the client details, line items, and totals for the Invoice Editor using Tailwind utilities and ReactiveFormsModule bindings. It supports adding\u002Fremoving items, shows each row’s computed total, and displays the grand total with simple, accessible form controls.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;form class=\"max-w-4xl mx-auto p-6 space-y-6\" [formGroup]=\"form\" (ngSubmit)=\"save()\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.edit.title' | translate }}&lt;\u002Fh2&gt;\n\n  &lt;!-- Client block --&gt;\n  &lt;div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"&gt;\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientName' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"text\" formControlName=\"clientName\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientEmail' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"email\" formControlName=\"clientEmail\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.issueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"issueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.dueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"dueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.currency' | translate }}&lt;\u002Fspan&gt;\n      &lt;select formControlName=\"currency\" class=\"rounded-lg border p-2\"&gt;\n        &lt;option value=\"USD\"&gt;USD&lt;\u002Foption&gt;\n        &lt;option value=\"EUR\"&gt;EUR&lt;\u002Foption&gt;\n        &lt;option value=\"XAF\"&gt;XAF&lt;\u002Foption&gt;\n      &lt;\u002Fselect&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1 md:col-span-2\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.notes' | translate }}&lt;\u002Fspan&gt;\n      &lt;textarea formControlName=\"notes\" rows=\"3\" class=\"rounded-lg border p-2\"&gt;&lt;\u002Ftextarea&gt;\n    &lt;\u002Flabel&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Items --&gt;\n  &lt;div class=\"space-y-3\"&gt;\n    &lt;div class=\"flex items-center justify-between\"&gt;\n      &lt;h3 class=\"text-lg font-semibold\"&gt;{{ 'invoice.form.items' | translate }}&lt;\u002Fh3&gt;\n      &lt;button type=\"button\" class=\"btn\" (click)=\"addItem()\"&gt;{{ 'invoice.actions.addItem' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n\n    &lt;div formArrayName=\"items\" class=\"space-y-2\"&gt;\n      &lt;div *ngFor=\"let g of items.controls; let i = index\" [formGroupName]=\"i\"\n           class=\"grid grid-cols-12 gap-2 items-center border rounded-lg p-3\"&gt;\n        &lt;input class=\"col-span-5 rounded-lg border p-2\" type=\"text\" placeholder=\"{{ 'invoice.form.item.description' | translate }}\"\n               formControlName=\"description\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"1\"\n               formControlName=\"quantity\" placeholder=\"{{ 'invoice.form.item.quantity' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"0.01\"\n               formControlName=\"unitPrice\" placeholder=\"{{ 'invoice.form.item.unitPrice' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"taxRate\" placeholder=\"{{ 'invoice.form.item.taxRate' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"discountRate\" placeholder=\"{{ 'invoice.form.item.discountRate' | translate }}\" \u002F&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-10 text-sm text-gray-600 md:text-right\"&gt;\n          {{ 'invoice.form.item.total' | translate }}:\n          &lt;strong&gt;{{ lineTotalAt(i) }}&lt;\u002Fstrong&gt;\n        &lt;\u002Fdiv&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-2 flex justify-end\"&gt;\n          &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"removeItem(i)\"&gt;\n            {{ 'invoice.actions.removeItem' | translate }}\n          &lt;\u002Fbutton&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Totals + actions --&gt;\n  &lt;div class=\"flex items-center justify-between border-t pt-4\"&gt;\n    &lt;div class=\"text-lg\"&gt;\n      {{ 'invoice.form.total' | translate }}:\n      &lt;strong&gt;{{ grandTotal() }}&lt;\u002Fstrong&gt;\n    &lt;\u002Fdiv&gt;\n    &lt;div class=\"flex gap-2\"&gt;\n      &lt;a routerLink=\"\u002F\" class=\"btn-secondary\"&gt;{{ 'nav.back' | translate }}&lt;\u002Fa&gt;\n      &lt;button type=\"submit\" class=\"btn\"&gt;{{ 'invoice.actions.save' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fform&gt;\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then replace the \u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.html\u003C\u002Fcode>\u003Cstrong> \u003C\u002Fstrong>with the version below. It binds a typed Reactive Form, uses Tailwind for layout, supports add\u002Fremove rows, shows each row’s computed total, and displays the grand total.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;form class=\"max-w-4xl mx-auto p-6 space-y-6\" [formGroup]=\"form\" (ngSubmit)=\"save()\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.edit.title' | translate }}&lt;\u002Fh2&gt;\n\n  &lt;!-- Client block --&gt;\n  &lt;div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"&gt;\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientName' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"text\" formControlName=\"clientName\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientEmail' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"email\" formControlName=\"clientEmail\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.issueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"issueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.dueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"dueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.currency' | translate }}&lt;\u002Fspan&gt;\n      &lt;select formControlName=\"currency\" class=\"rounded-lg border p-2\"&gt;\n        &lt;option value=\"USD\"&gt;USD&lt;\u002Foption&gt;\n        &lt;option value=\"EUR\"&gt;EUR&lt;\u002Foption&gt;\n        &lt;option value=\"XAF\"&gt;XAF&lt;\u002Foption&gt;\n      &lt;\u002Fselect&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1 md:col-span-2\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.notes' | translate }}&lt;\u002Fspan&gt;\n      &lt;textarea formControlName=\"notes\" rows=\"3\" class=\"rounded-lg border p-2\"&gt;&lt;\u002Ftextarea&gt;\n    &lt;\u002Flabel&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Items --&gt;\n  &lt;div class=\"space-y-3\"&gt;\n    &lt;div class=\"flex items-center justify-between\"&gt;\n      &lt;h3 class=\"text-lg font-semibold\"&gt;{{ 'invoice.form.items' | translate }}&lt;\u002Fh3&gt;\n      &lt;button type=\"button\" class=\"btn\" (click)=\"addItem()\"&gt;{{ 'invoice.actions.addItem' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n\n    &lt;div formArrayName=\"items\" class=\"space-y-2\"&gt;\n      &lt;div *ngFor=\"let g of items.controls; let i = index\" [formGroupName]=\"i\"\n           class=\"grid grid-cols-12 gap-2 items-center border rounded-lg p-3\"&gt;\n        &lt;input class=\"col-span-5 rounded-lg border p-2\" type=\"text\"\n               placeholder=\"{{ 'invoice.form.item.description' | translate }}\"\n               formControlName=\"description\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"1\"\n               formControlName=\"quantity\" placeholder=\"{{ 'invoice.form.item.quantity' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"0.01\"\n               formControlName=\"unitPrice\" placeholder=\"{{ 'invoice.form.item.unitPrice' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"taxRate\" placeholder=\"{{ 'invoice.form.item.taxRate' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"discountRate\" placeholder=\"{{ 'invoice.form.item.discountRate' | translate }}\" \u002F&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-10 text-sm text-gray-600 md:text-right\"&gt;\n          {{ 'invoice.form.item.total' | translate }}:\n          &lt;strong&gt;{{ lineTotalAt(i) }}&lt;\u002Fstrong&gt;\n        &lt;\u002Fdiv&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-2 flex justify-end\"&gt;\n          &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"removeItem(i)\"&gt;\n            {{ 'invoice.actions.removeItem' | translate }}\n          &lt;\u002Fbutton&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Totals + actions --&gt;\n  &lt;div class=\"flex items-center justify-between border-t pt-4\"&gt;\n    &lt;div class=\"text-lg\"&gt;\n      {{ 'invoice.form.total' | translate }}:\n      &lt;strong&gt;{{ grandTotal() }}&lt;\u002Fstrong&gt;\n    &lt;\u002Fdiv&gt;\n    &lt;div class=\"flex gap-2\"&gt;\n      &lt;a routerLink=\"\u002F\" class=\"btn-secondary\"&gt;{{ 'nav.back' | translate }}&lt;\u002Fa&gt;\n      &lt;button type=\"submit\" class=\"btn\"&gt;{{ 'invoice.actions.save' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fform&gt;\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Don’t forget to update the translations:\u003C\u002Fp>\u003Cpre>\u003Ccode>{\n  \"invoice\": {\n    \"form\": {\n      \"clientName\": \"Client name\",\n      \"clientEmail\": \"Client email\",\n      \"issueDate\": \"Issue date\",\n      \"dueDate\": \"Due date\",\n      \"currency\": \"Currency\",\n      \"notes\": \"Notes\",\n      \"items\": \"Items\",\n      \"item\": {\n        \"description\": \"Description\",\n        \"quantity\": \"Qty\",\n        \"unitPrice\": \"Unit price\",\n        \"taxRate\": \"Tax %\",\n        \"discountRate\": \"Discount %\",\n        \"total\": \"Line total\"\n      },\n      \"total\": \"Total\"\n    },\n    \"actions\": {\n      \"addItem\": \"Add item\",\n      \"removeItem\": \"Remove\",\n      \"save\": \"Save\"\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cpre>\u003Ccode>```tsx\n{\n  \"invoice\": {\n    \"form\": {\n      \"clientName\": \"Nom du client\",\n      \"clientEmail\": \"Email du client\",\n      \"issueDate\": \"Date d'émission\",\n      \"dueDate\": \"Date d'échéance\",\n      \"currency\": \"Devise\",\n      \"notes\": \"Notes\",\n      \"items\": \"Articles\",\n      \"item\": {\n        \"description\": \"Description\",\n        \"quantity\": \"Qté\",\n        \"unitPrice\": \"Prix unitaire\",\n        \"taxRate\": \"TVA %\",\n        \"discountRate\": \"Remise %\",\n        \"total\": \"Total ligne\"\n      },\n      \"total\": \"Total\"\n    },\n    \"actions\": {\n      \"addItem\": \"Ajouter un article\",\n      \"removeItem\": \"Supprimer\",\n      \"save\": \"Enregistrer\"\n    }\n  }\n}\n```\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now wire the Invoice Editor to the store, create on \u003Ccode>\u002Finvoice\u002Fnew\u003C\u002Fcode>, update on \u003Ccode>\u002Finvoice\u002F:id\u003C\u002Fcode>.\u003C\u002Fp>\u003Cp>Only one file changes. The editor now loads an existing invoice (when \u003Ccode>:id\u003C\u002Fcode> is present), patches the form, and saves either a \u003Cstrong>new draft\u003C\u002Fstrong> or an \u003Cstrong>update\u003C\u002Fstrong> to the store.\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>...\n\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\nimport { InvoiceStore } from '..\u002F..\u002Fcore\u002Fstores\u002Finvoice.store';\nimport type { Invoice } from '..\u002F..\u002Fcore\u002Fmodels\u002Finvoice.model';\n\n....\n\n@Component({\n  selector: 'app-invoice-edit',\n  standalone: true,\n  imports: [CommonModule, RouterModule, TranslateModule, ReactiveFormsModule],\n  templateUrl: '.\u002Finvoice-edit.html',\n  styleUrl: '.\u002Finvoice-edit.scss',\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceEditComponent {\n  ...\n  private route = inject(ActivatedRoute);\n  private store = inject(InvoiceStore);\n\n  \u002F\u002F when editing, keep refs to id + current invoice\n  private editingId: string | null = null;\n  private current: Invoice | null = null;\n\n  ...\n\n  constructor() {\n    \u002F\u002F detect \u002Finvoice\u002Fnew vs \u002Finvoice\u002F:id and hydrate if editing\n    this.route.paramMap.subscribe(p =&gt; {\n      const id = p.get('id');\n      this.editingId = id;\n      if (id) {\n        const inv = this.store.byId(id)();\n        this.current = inv ?? null;\n        if (inv) this.setFormFromInvoice(inv);\n      } else {\n        this.current = null; \u002F\u002F creating new\n      }\n    });\n  }\n\n...\n\n  \u002F\u002F ---------- hydrate form for edit ----------\n  private setFormFromInvoice(inv: Invoice): void {\n    this.form.patchValue({\n      clientName: inv.clientName,\n      clientEmail: inv.clientEmail ?? null,\n      issueDate: inv.issueDate,\n      dueDate: inv.dueDate ?? null,\n      currency: inv.currency,\n      notes: inv.notes ?? null,\n    });\n\n    this.items.clear();\n    inv.items.forEach(it =&gt; {\n      const g = this.createItemGroup();\n      g.patchValue({\n        description: it.description,\n        quantity: it.quantity,\n        unitPrice: it.unitPrice,\n        taxRate: it.taxRate ?? 0,\n        discountRate: it.discountRate ?? 0,\n      });\n      this.items.push(g);\n    });\n  }\n\n  \u002F\u002F replace the previous save() placeholder with create\u002Fupdate via store\n  save(): void {\n    if (this.form.invalid) {\n      this.form.markAllAsTouched();\n      return;\n    }\n\n    const v = this.form.getRawValue();\n\n    \u002F\u002F rebuild items; preserve existing IDs when editing\n    const items = v.items.map((row, idx) =&gt; ({\n      id: this.current?.items[idx]?.id ?? this.store.newLineItem().id,\n      description: row.description,\n      quantity: row.quantity,\n      unitPrice: row.unitPrice,\n      taxRate: row.taxRate,\n      discountRate: row.discountRate,\n    }));\n\n    if (this.editingId) {\n      this.store.update(this.editingId, {\n        clientName: v.clientName,\n        clientEmail: v.clientEmail ?? undefined,\n        issueDate: v.issueDate,\n        dueDate: v.dueDate ?? undefined,\n        currency: v.currency,\n        notes: v.notes ?? undefined,\n        items,\n      });\n    } else {\n      this.store.createDraft({\n        clientName: v.clientName,\n        clientEmail: v.clientEmail ?? undefined,\n        issueDate: v.issueDate,\n        dueDate: v.dueDate ?? undefined,\n        currency: v.currency,\n        notes: v.notes ?? undefined,\n        items,\n        status: 'draft',\n      });\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>That’s all you need to switch between \u003Cstrong>create\u003C\u002Fstrong> (\u003Ccode>\u002Finvoice\u002Fnew\u003C\u002Fcode>) and \u003Cstrong>edit\u003C\u002Fstrong> (\u003Ccode>\u002Finvoice\u002F:id\u003C\u002Fcode>) with the store, without dumping the whole file.\u003C\u002Fp>\u003Cp>Let’s make rows editable and add inline actions.\u003C\u002Fp>\u003Ch3 id=\"1-make-invoice-number-a-link\">1. Make invoice number a link \u003Ca class=\"markdownit-header-anchor\" href=\"#1-make-invoice-number-a-link\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">- &lt;div class=\"font-semibold\"&gt;{{ inv.number }}&lt;\u002Fdiv&gt;\n+ &lt;a [routerLink]=\"['\u002Finvoice', inv.id]\" class=\"font-semibold hover:underline\"&gt;\n+   {{ inv.number }}\n+ &lt;\u002Fa&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"2-add-row-actions-edit-mark-sent-mark-paid-delete\">2. Add row actions (Edit \u002F Mark Sent \u002F Mark Paid \u002F Delete) \u003Ca class=\"markdownit-header-anchor\" href=\"#2-add-row-actions-edit-mark-sent-mark-paid-delete\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Go to\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>\u003C\u002Fstrong> inside the same \u003Ccode>@for\u003C\u002Fcode> row and append this to the \u003Cstrong>right-side info\u003C\u002Fstrong> block (after total):\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- actions --&gt;\n&lt;div class=\"flex items-center gap-2 ml-4\"&gt;\n  &lt;a [routerLink]=\"['\u002Finvoice', inv.id]\" class=\"btn-secondary\"&gt;\n    {{ 'invoice.actions.edit' | translate }}\n  &lt;\u002Fa&gt;\n\n  @if (inv.status === 'draft') {\n    &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markAsSent(inv.id)\"&gt;\n      {{ 'invoice.actions.markSent' | translate }}\n    &lt;\u002Fbutton&gt;\n  } @else if (inv.status === 'sent') {\n    &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markAsPaid(inv.id)\"&gt;\n      {{ 'invoice.actions.markPaid' | translate }}\n    &lt;\u002Fbutton&gt;\n  }\n\n  &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"remove(inv.id)\"&gt;\n    {{ 'invoice.actions.delete' | translate }}\n  &lt;\u002Fbutton&gt;\n&lt;\u002Fdiv&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"3-add-the-handlers\">3. Add the handlers \u003Ca class=\"markdownit-header-anchor\" href=\"#3-add-the-handlers\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Add these methods inside the \u003Ccode>DashboardComponent\u003C\u002Fcode> class \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.ts\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">markAsSent(id: string): void {\n  this.store.setStatus(id, 'sent');\n}\n\nmarkAsPaid(id: string): void {\n  this.store.setStatus(id, 'paid');\n}\n\nremove(id: string): void {\n  if (confirm('Delete this invoice?')) {\n    this.store.remove(id);\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-update-strings\">4. Update strings \u003Ca class=\"markdownit-header-anchor\" href=\"#4-update-strings\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"actions\": {\n      \"edit\": \"Edit\",\n      \"markSent\": \"Mark as sent\",\n      \"markPaid\": \"Mark as paid\",\n      \"delete\": \"Delete\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"actions\": {\n      \"edit\": \"Modifier\",\n      \"markSent\": \"Marquer comme envoyée\",\n      \"markPaid\": \"Marquer comme payée\",\n      \"delete\": \"Supprimer\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now \u003Cstrong>the invoice ID are clickable, rows have clear actions, and status transitions happen with one click\u003C\u002Fstrong>, all persisted via your repository-backed store. To finish off this step, run a quick check with \u003Ccode>npm start\u003C\u002Fcode> to test that the edit, language switch, invoice status and delete operations work well. \u003C\u002Fp>\u003Cp>Let’s enable PWA support cleanly in the next step.\u003C\u002Fp>\u003Ch2 id=\"5-step-5-feature-core-pwa-scaffold\">5️⃣ Step 5: Feature core-PWA scaffold \u003Ca class=\"markdownit-header-anchor\" href=\"#5-step-5-feature-core-pwa-scaffold\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Cp>We’ll enable installable, offline-first behavior and\u003Cstrong> lay the groundwork for small UX helpers\u003C\u002Fstrong> (online\u002Foffline signal and “Install app” prompt). The idea is to keep it minimal and production-ready.\u003C\u002Fp>\u003Ch3 id=\"1-install-pwa-support\">1. Install PWA support \u003Ca class=\"markdownit-header-anchor\" href=\"#1-install-pwa-support\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Run the Angular schematic via your project script:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run ng -- add @angular\u002Fpwa\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This adds \u003Ccode>@angular\u002Fservice-worker\u003C\u002Fcode>, creates \u003Ccode>ngsw-config.json\u003C\u002Fcode>, drops \u003Ccode>public\u002Fmanifest.webmanifest\u003C\u002Fcode> and icons, and wires the worker.\u003C\u002Fp>\u003Ch3 id=\"2-test-in-production-mode\">2. Test in production mode \u003Ca class=\"markdownit-header-anchor\" href=\"#2-test-in-production-mode\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Build and serve the production output so the Service Worker can activate:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run build -- --configuration=production\nnpx http-server .\u002Fdist\u002Finvoice-pwa\u002Fbrowser -p 4200 -c-1\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Open \u003Ccode>http:\u002F\u002Flocalhost:4200\u003C\u002Fcode>. In DevTools &gt; Application &gt; Service Workers, confirm \u003Ccode>ngsw-worker.js\u003C\u002Fcode> is \u003Cstrong>activated\u003C\u002Fstrong>. Toggle \u003Cstrong>Offline\u003C\u002Fstrong> in the Network tab and reload; the app should still load.\u003C\u002Fp>\u003Ch3 id=\"3-create-a-tiny-pwa-service\">3. Create a tiny PWA service \u003Ca class=\"markdownit-header-anchor\" href=\"#3-create-a-tiny-pwa-service\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>This service exposes three simple signals you can bind to the UI: \u003Cstrong>\u003Ccode>canInstall\u003C\u002Fcode> \u003C\u002Fstrong>(show an “Install app” button), \u003Ccode>\u003Cstrong>isOnline\u003C\u002Fstrong>\u003C\u002Fcode>(online\u002Foffline badge), and \u003Ccode>\u003Cstrong>isStandalone\u003C\u002Fstrong>\u003C\u002Fcode> (running as an installed PWA). It also provides an \u003Ccode>install()\u003C\u002Fcode> method that triggers the browser’s install prompt.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fpwa\u002Fpwa.service.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal, computed } from '@angular\u002Fcore';\n\ntype BeforeInstallPromptEvent = Event &amp; {\n  prompt: () =&gt; Promise&lt;void&gt;;\n  userChoice: Promise&lt;{ outcome: 'accepted' | 'dismissed'; platform: string }&gt;;\n};\n\n@Injectable({ providedIn: 'root' })\nexport class PwaService {\n  private deferred: BeforeInstallPromptEvent | null = null;\n\n  \u002F\u002F shows the “Install” button when true\n  readonly canInstall = signal(false);\n\n  \u002F\u002F are we already installed?\n  readonly isStandalone = computed(() =&gt; {\n    \u002F\u002F iOS Safari\n    const iosStandalone = (navigator as any).standalone === true;\n    \u002F\u002F All modern browsers\n    const displayModeStandalone = window.matchMedia?.('(display-mode: standalone)').matches;\n    return iosStandalone || displayModeStandalone;\n  });\n\n  constructor() {\n    \u002F\u002F Fired when the browser thinks the app is installable\n    window.addEventListener('beforeinstallprompt', (e: Event) =&gt; {\n      e.preventDefault(); \u002F\u002F don't show the mini-infobar\n      this.deferred = e as BeforeInstallPromptEvent;\n      this.canInstall.set(!this.isStandalone());\n    });\n\n    \u002F\u002F Fired after a successful install\n    window.addEventListener('appinstalled', () =&gt; {\n      this.deferred = null;\n      this.canInstall.set(false);\n    });\n  }\n\n  async promptInstall(): Promise&lt;void&gt; {\n    if (!this.deferred) return;\n    this.canInstall.set(false);\n    await this.deferred.prompt();\n    try {\n      await this.deferred.userChoice; \u002F\u002F optional: inspect outcome\n    } finally {\n      this.deferred = null;\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-update-the-component-class\">4. Update the component class \u003Ca class=\"markdownit-header-anchor\" href=\"#4-update-the-component-class\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">...\n\nimport { PwaService } from '.\u002Fcore\u002Fpwa\u002Fpwa.service';\n\n...\n\nexport class App {\n  private readonly lang = inject(LanguageService);\n  readonly pwa = inject(PwaService);\n\n  setLang(l: 'en' | 'fr') {\n    this.lang.use(l);\n  }\n\n  install() {\n    this.pwa.promptInstall();\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"5-add-the-button-to-the-template\">5. Add the button to the template \u003Ca class=\"markdownit-header-anchor\" href=\"#5-add-the-button-to-the-template\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Add the install button next to EN\u002FFR in \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.html\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;header class=\"flex items-center gap-3 p-4 border-b\"&gt;\n  &lt;h1 class=\"text-xl font-semibold\"&gt;{{ 'app.title' | translate }}&lt;\u002Fh1&gt;\n\n  &lt;div class=\"ml-auto flex items-center gap-2\"&gt;\n    &lt;!-- Install button appears only when available and not already installed --&gt;\n    @if (pwa.canInstall() &amp;&amp; !pwa.isStandalone()) {\n      &lt;button type=\"button\" class=\"btn\" (click)=\"install()\"&gt;\n        {{ 'app.install' | translate }}\n      &lt;\u002Fbutton&gt;\n    }\n    ...\n    \n  &lt;\u002Fdiv&gt;\n&lt;\u002Fheader&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add translation key on \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"install\": \"Install app\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"install\": \"Installer l’application\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now test the button. Make sure you’re on the \u003Cstrong>production build\u003C\u002Fstrong> served over HTTP(s).\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">npm run build -- --configuration=production\nnpx http-server .\u002Fdist\u002Finvoice-pwa\u002Fbrowser -p 4200 -c-1\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Col>\u003Cli>Open \u003Ccode>http:\u002F\u002Flocalhost:4200\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>You should see the \u003Cstrong>Install app\u003C\u002Fstrong> button when:\u003C\u002Fli>\u003C\u002Fol>\u003Cul>\u003Cli>Not already installed.\u003C\u002Fli>\u003Cli>Browser deems it installable (has manifest, SW active, visited at least once).\u003C\u002Fli>\u003C\u002Ful>\u003Cp>3. Click \u003Cstrong>Install app\u003C\u002Fstrong> &gt; the native install dialog appears.\u003C\u002Fp>\u003Cp>4. After installing, the button disappears.\u003C\u002Fp>\u003Ch3 id=\"6-add-connectivity-toasts-offline-and-back-online\">6. Add connectivity toasts (offline & back online) \u003Ca class=\"markdownit-header-anchor\" href=\"#6-add-connectivity-toasts-offline-and-back-online\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Here you’ll display a small banner when the app goes \u003Cstrong>offline\u003C\u002Fstrong> and a quick green flash when it comes \u003Cstrong>back online\u003C\u002Fstrong>. This improves UX by giving \u003Cstrong>clear, instant feedback about network status\u003C\u002Fstrong>. \u003C\u002Fp>\u003Cp>It only takes four lightweight parts: a network status service, a toast banner, a quick wire-up in the app shell, and two i18n entries. Follow these steps:\u003C\u002Fp>\u003Cul>\u003Cli>\u003Cstrong>Create the network service\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Tracks \u003Ccode>online\u003C\u002Fcode>\u002F\u003Ccode>offline\u003C\u002Fcode> state using signals, and flashes a short \u003Ccode>Back online\u003C\u002Fcode> message after reconnection, too.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fnetwork\u002Fnetwork.service.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal } from '@angular\u002Fcore';\n\n@Injectable({ providedIn: 'root' })\nexport class NetworkService {\n  \u002F\u002F true = online, false = offline\n  readonly online = signal&lt;boolean&gt;(navigator.onLine);\n\n  \u002F\u002F flash \"Back online\" for a few seconds after reconnection\n  readonly flashOnline = signal&lt;boolean&gt;(false);\n\n  private timer: any = null;\n\n  constructor() {\n    window.addEventListener('online', () =&gt; {\n      this.online.set(true);\n      this.flashOnline.set(true);\n      clearTimeout(this.timer);\n      this.timer = setTimeout(() =&gt; this.flashOnline.set(false), 3000);\n    });\n\n    window.addEventListener('offline', () =&gt; {\n      this.online.set(false);\n      this.flashOnline.set(false);\n      clearTimeout(this.timer);\n    });\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Expose the service in the app shell\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Expose the network status in the app shell so the template (and the offline toast) can read it.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>...\nimport { PwaService } from '.\u002Fcore\u002Fpwa\u002Fpwa.service';\nimport { NetworkService } from '.\u002Fcore\u002Fnetwork\u002Fnetwork.service';\n\n...\nexport class App {\n  ...\n  readonly net = inject(NetworkService);\n\n ...\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Add toast banners to the template\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Place the toaster exactly at the end of \u003Ccode>app.html\u003C\u002Fcode> after \u003Ccode>&lt;\u002Fmain&gt;\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- Offline \u002F Online toasts --&gt;\n@if (!net.online()) {\n  &lt;div class=\"fixed bottom-4 left-1\u002F2 -translate-x-1\u002F2 bg-red-600 text-white px-4 py-2 rounded-lg shadow\n              flex items-center gap-2 z-50\"\n       role=\"status\" aria-live=\"polite\"&gt;\n    {{ 'app.offline' | translate }}\n  &lt;\u002Fdiv&gt;\n}\n@if (net.flashOnline()) {\n  &lt;div class=\"fixed bottom-4 left-1\u002F2 -translate-x-1\u002F2 bg-green-600 text-white px-4 py-2 rounded-lg shadow\n              flex items-center gap-2 z-50\"\n       role=\"status\" aria-live=\"polite\"&gt;\n    {{ 'app.backOnline' | translate }}\n  &lt;\u002Fdiv&gt;\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Like always, add the translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"offline\": \"You’re offline. Some features may be unavailable.\",\n    \"backOnline\": \"Back online\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"offline\": \"Vous êtes hors ligne. Certaines fonctionnalités peuvent être indisponibles.\",\n    \"backOnline\": \"De retour en ligne\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now let's test:\u003C\u002Fp>\u003Cul>\u003Cli>In DevTools &gt; \u003Cstrong>Network\u003C\u002Fstrong> &gt; set throttling to \u003Cstrong>Offline\u003C\u002Fstrong> &gt; the \u003Cstrong>red\u003C\u002Fstrong> toast appears.\u003C\u002Fli>\u003Cli>If you switch back to \u003Cstrong>Online\u003C\u002Fstrong> &gt; a \u003Cstrong>green\u003C\u002Fstrong> “Back online” toast flashes for ~3s.\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Next, we’ll extend this feature core by adding new version available, \u003Cstrong>search, filter, sort, and CSV export\u002Fimport logic \u003C\u002Fstrong>to the dashboard.\u003C\u002Fp>\u003Ch3 id=\"7-new-version-available-toast\">7. New version available toast \u003Ca class=\"markdownit-header-anchor\" href=\"#7-new-version-available-toast\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Here you will show a small toast when a fresh build is ready. One tap, it reloads into the new version. Note that this runs only on production builds.\u003C\u002Fp>\u003Cul>\u003Cli>\u003Cstrong>Create the update service\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fpwa\u002Fupdate.service.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal } from '@angular\u002Fcore';\nimport { SwUpdate } from '@angular\u002Fservice-worker';\n\n@Injectable({ providedIn: 'root' })\nexport class UpdateService {\n  \u002F** Show the “Update available” toast *\u002F\n  readonly updateAvailable = signal&lt;boolean&gt;(false);\n\n  \u002F** Disable the button and show progress text while activating *\u002F\n  readonly installing = signal&lt;boolean&gt;(false);\n\n  constructor(private sw: SwUpdate) {\n    \u002F\u002F SW is only enabled in production; guard in dev\n    if (!this.sw.isEnabled) return;\n\n    \u002F\u002F Listen for version events; when a new version is ready, show the toast\n    this.sw.versionUpdates.subscribe(evt =&gt; {\n      if ((evt as any).type === 'VERSION_READY') {\n        this.updateAvailable.set(true);\n      }\n      \u002F\u002F Optional: handle failures\n      if ((evt as any).type === 'VERSION_INSTALLATION_FAILED') {\n        \u002F\u002F Could log or surface a subtle warning if you want\n      }\n    });\n\n    \u002F\u002F Also check on app focus (useful if the tab was idle)\n    window.addEventListener('focus', () =&gt; {\n      this.checkForUpdates();\n    });\n\n    \u002F\u002F Initial check shortly after boot\n    setTimeout(() =&gt; this.checkForUpdates(), 5_000);\n\n    \u002F\u002F Periodic check every 6 hours\n    setInterval(() =&gt; this.checkForUpdates(), 6 * 60 * 60 * 1000);\n  }\n\n  async checkForUpdates(): Promise&lt;void&gt; {\n    if (!this.sw.isEnabled) return;\n    try {\n      await this.sw.checkForUpdate();\n    } catch {\n      \u002F\u002F ignore network errors\n    }\n  }\n\n  \u002F** Activate the new version and reload the app *\u002F\n  async activateAndReload(): Promise&lt;void&gt; {\n    if (!this.sw.isEnabled) return;\n    this.installing.set(true);\n    try {\n      await this.sw.activateUpdate();\n    } finally {\n      \u002F\u002F Reload to load the fresh version (even if activateUpdate failed, reload is harmless)\n      document.location.reload();\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Expose it in the root component\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Inject the service and add a tiny handler for the button.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>...\n\nimport { UpdateService } from '.\u002Fcore\u002Fpwa\u002Fupdate.service';\n\n...\n\nexport class App {\n  \n  readonly upd = inject(UpdateService);\n\n \n  reloadApp() { this.upd.activateAndReload(); }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Add the update toast\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Place the code below at the very end of the template, after your offline\u002Fonline toasts.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.html\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- Update available toast --&gt;\n@if (upd.updateAvailable()) {\n  &lt;div class=\"fixed bottom-4 left-1\u002F2 -translate-x-1\u002F2 bg-blue-600 text-white px-4 py-2 rounded-lg shadow\n              flex items-center gap-3 z-50\"\n       role=\"status\" aria-live=\"polite\"&gt;\n    {{ 'app.updateAvailable' | translate }}\n    &lt;button class=\"btn ml-2\"\n            [disabled]=\"upd.installing()\"\n            (click)=\"reloadApp()\"&gt;\n      @if (upd.installing()) {\n        {{ 'app.updating' | translate }}\n      } @else {\n        {{ 'app.reload' | translate }}\n      }\n    &lt;\u002Fbutton&gt;\n  &lt;\u002Fdiv&gt;\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And don’t forget to update your translations!\u003C\u002Fp>\u003Cp>File: \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"updateAvailable\": \"A new version is available.\",\n    \"reload\": \"Reload\",\n    \"updating\": \"Updating…\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>File: \u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"updateAvailable\": \"Une nouvelle version est disponible.\",\n    \"reload\": \"Recharger\",\n    \"updating\": \"Mise à jour…\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Build, serve, and then watch the toast appear.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\"># Build v1\nnpm run build -- --configuration=production\nnpx http-server .\u002Fdist\u002Finvoice-pwa\u002Fbrowser -p 4200 -c-1\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>When the app checks for updates on focus after approximately 5s periodically, the \u003Cstrong>blue toast\u003C\u002Fstrong> will show. Click \u003Cstrong>Reload\u003C\u002Fstrong> to activate and jump to the new version.\u003C\u002Fp>\u003Cp>Up to now, the app allows you to \u003Cstrong>create, edit, and manage invoices\u003C\u002Fstrong>, but there’s no dedicated way to \u003Cstrong>view\u003C\u002Fstrong> a finalized invoice in a clean, print-friendly layout. Users need a professional, read-only page they can show clients, download as PDF, or print directly from the browser.\u003C\u002Fp>\u003Cp>\u003Cstrong>So now, you will create the invoice view\u003C\u002Fstrong> to display the selected invoice with proper formatting, localized currency and date styles, as well as optional notes.\u003C\u002Fp>\u003Ch2 id=\"generate-the-view-invoice-component\">🧾 Generate the view invoice component \u003Ca class=\"markdownit-header-anchor\" href=\"#generate-the-view-invoice-component\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Cp>In this section you will create the invoice view to see the detail of an individual invoice when a user clicks on a specific view.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run ng -- g c features\u002Finvoice-view\u002Finvoice-view --standalone --flat --skip-tests\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add its route: \u003Ccode>src\u002Fapp\u002Fapp.routes.ts\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">{\n  path: 'invoice\u002F:id\u002Fview',\n  loadComponent: () =&gt;\n    import('.\u002Ffeatures\u002Finvoice-view\u002Finvoice-view').then(m =&gt; m.InvoiceViewComponent),\n},\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Create the View component (read-only, print-ready):\u003C\u002Fp>\u003Cp>\u003Cstrong> \u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-view\u002Finvoice-view.component.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Component, ChangeDetectionStrategy, inject, OnInit, computed } from '@angular\u002Fcore';\nimport { CommonModule } from '@angular\u002Fcommon';\nimport { Router, ActivatedRoute, RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\n\nimport { InvoiceStore } from '..\u002F..\u002Fcore\u002Fstores\u002Finvoice.store';\nimport type { Invoice, LineItem } from '..\u002F..\u002Fcore\u002Fmodels\u002Finvoice.model';\nimport { LocaleFormatService } from '..\u002F..\u002Fcore\u002Flocale-format.service';\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\n\n@Component({\n  selector: 'app-invoice-view',\n  standalone: true,\n  imports: [CommonModule, RouterModule, TranslateModule],\n  templateUrl: '.\u002Finvoice-view.component.html',\n  styleUrl: '.\u002Finvoice-view.component.scss',\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceViewComponent implements OnInit {\n  private readonly route = inject(ActivatedRoute);\n  private readonly router = inject(Router);\n  private readonly store = inject(InvoiceStore);\n  private readonly fmt = inject(LocaleFormatService);\n\n  \u002F\u002F signal to the selected invoice (reactive)\n  invoice = computed&lt;Invoice | null&gt;(() =&gt; {\n    const id = this.route.snapshot.paramMap.get('id');\n    return id ? this.store.byId(id)() : null;\n  });\n\n  ngOnInit(): void {\n    if (!this.invoice()) this.router.navigateByUrl('\u002F');\n  }\n\n  \u002F\u002F formatting helpers\n  date(d: string) { return this.fmt.dateISO(d); }\n  money(amount: number, cur: string) { return this.fmt.currency(amount, cur); }\n  lineTotal(li: LineItem) { return lineTotal(li); }\n  grand(inv: Invoice) { return invoiceTotal(inv); }\n\n  print(): void { window.print(); }\n  back(): void { this.router.navigateByUrl('\u002F'); }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add this code in \u003Cstrong> \u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-view\u002Finvoice-view.component.html\u003C\u002Fcode>\u003C\u002Fstrong>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- Controls (hidden in print) --&gt;\n&lt;div class=\"no-print max-w-4xl mx-auto p-4 flex items-center gap-2\"&gt;\n  &lt;a routerLink=\"\u002F\" class=\"btn-secondary\"&gt;{{ 'nav.back' | translate }}&lt;\u002Fa&gt;\n  &lt;button class=\"btn\" type=\"button\" (click)=\"print()\"&gt;\n    {{ 'invoice.view.print' | translate }}\n  &lt;\u002Fbutton&gt;\n&lt;\u002Fdiv&gt;\n\n&lt;!-- Printable page --&gt;\n&lt;section class=\"sheet max-w-4xl mx-auto bg-white text-gray-900 shadow p-8\"&gt;\n  @if (invoice(); as inv) {\n    &lt;header class=\"flex items-start justify-between mb-8\"&gt;\n      &lt;div&gt;\n        &lt;h1 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.view.title' | translate }} {{ inv.number }}&lt;\u002Fh1&gt;\n        &lt;div class=\"text-sm text-gray-600\"&gt;\n          &lt;div&gt;{{ 'invoice.view.issueDate' | translate }}: {{ date(inv.issueDate) }}&lt;\u002Fdiv&gt;\n          @if (inv.dueDate) {\n            &lt;div&gt;{{ 'invoice.view.dueDate' | translate }}: {{ date(inv.dueDate!) }}&lt;\u002Fdiv&gt;\n          }\n          &lt;div&gt;{{ 'invoice.view.status' | translate }}: {{ ('status.' + inv.status) | translate }}&lt;\u002Fdiv&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n\n      &lt;!-- Minimal “Bill To” block --&gt;\n      &lt;div class=\"text-right\"&gt;\n        &lt;div class=\"uppercase text-xs text-gray-500\"&gt;{{ 'invoice.view.billTo' | translate }}&lt;\u002Fdiv&gt;\n        &lt;div class=\"font-medium\"&gt;{{ inv.clientName || '—' }}&lt;\u002Fdiv&gt;\n        @if (inv.clientEmail) { &lt;div class=\"text-sm text-gray-600\"&gt;{{ inv.clientEmail }}&lt;\u002Fdiv&gt; }\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fheader&gt;\n\n    &lt;!-- Items table --&gt;\n    &lt;table class=\"w-full border-collapse\"&gt;\n      &lt;thead&gt;\n        &lt;tr class=\"border-b border-gray-300 text-left\"&gt;\n          &lt;th class=\"py-2 pr-2 w-7\u002F12\"&gt;{{ 'invoice.form.item.description' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pr-2 w-1\u002F12 text-right\"&gt;{{ 'invoice.form.item.quantity' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pr-2 w-2\u002F12 text-right\"&gt;{{ 'invoice.form.item.unitPrice' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pr-2 w-1\u002F12 text-right\"&gt;{{ 'invoice.form.item.taxRate' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pl-2 w-2\u002F12 text-right\"&gt;{{ 'invoice.form.item.total' | translate }}&lt;\u002Fth&gt;\n        &lt;\u002Ftr&gt;\n      &lt;\u002Fthead&gt;\n      &lt;tbody&gt;\n        @for (it of inv.items; track it.id) {\n          &lt;tr class=\"border-b border-gray-100\"&gt;\n            &lt;td class=\"py-2 pr-2 align-top\"&gt;\n              &lt;div class=\"font-medium\"&gt;{{ it.description || '—' }}&lt;\u002Fdiv&gt;\n              @if (it.discountRate &amp;&amp; it.discountRate &gt; 0) {\n                &lt;div class=\"text-xs text-gray-600\"&gt;\n                  {{ 'invoice.view.discount' | translate }}: {{ it.discountRate }}%\n                &lt;\u002Fdiv&gt;\n              }\n            &lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pr-2 text-right align-top\"&gt;{{ it.quantity }}&lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pr-2 text-right align-top\"&gt;{{ money(it.unitPrice, inv.currency) }}&lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pr-2 text-right align-top\"&gt;{{ it.taxRate || 0 }}%&lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pl-2 text-right align-top\"&gt;\n              {{ money(lineTotal(it), inv.currency) }}\n            &lt;\u002Ftd&gt;\n          &lt;\u002Ftr&gt;\n        }\n      &lt;\u002Ftbody&gt;\n    &lt;\u002Ftable&gt;\n\n    &lt;!-- Notes + totals --&gt;\n    &lt;div class=\"flex flex-col md:flex-row gap-6 mt-6\"&gt;\n      &lt;div class=\"md:w-1\u002F2\"&gt;\n        @if (inv.notes) {\n          &lt;div class=\"uppercase text-xs text-gray-500 mb-1\"&gt;{{ 'invoice.view.notes' | translate }}&lt;\u002Fdiv&gt;\n          &lt;div class=\"whitespace-pre-line\"&gt;{{ inv.notes }}&lt;\u002Fdiv&gt;\n        }\n      &lt;\u002Fdiv&gt;\n      &lt;div class=\"md:w-1\u002F2\"&gt;\n        &lt;div class=\"flex justify-between text-lg font-medium border-t pt-4\"&gt;\n          &lt;span&gt;{{ 'invoice.form.total' | translate }}&lt;\u002Fspan&gt;\n          &lt;span&gt;{{ money(grand(inv), inv.currency) }}&lt;\u002Fspan&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fdiv&gt;\n  }\n&lt;\u002Fsection&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And add print style so the PDF looks good:\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-view\u002Finvoice-view.component.scss\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-scss\">\u002F* Hide elements with .no-print when printing *\u002F\n@media print {\n  .no-print { display: none !important; }\n  html, body { background: white !important; }\n  .sheet {\n    box-shadow: none !important;\n    margin: 0 !important;\n    width: auto !important;\n    padding: 0.5in !important; \u002F* print margins *\u002F\n  }\n}\n\n\u002F* On screen *\u002F\n.sheet { border-radius: 0.75rem; }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now, update your translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"view\": {\n      \"title\": \"Invoice\",\n      \"issueDate\": \"Issue date\",\n      \"dueDate\": \"Due date\",\n      \"status\": \"Status\",\n      \"billTo\": \"Bill to\",\n      \"notes\": \"Notes\",\n      \"print\": \"Print \u002F Save as PDF\",\n      \"discount\": \"Discount\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"view\": {\n      \"title\": \"Facture\",\n      \"issueDate\": \"Date d'émission\",\n      \"dueDate\": \"Date d'échéance\",\n      \"status\": \"Statut\",\n      \"billTo\": \"Destinataire\",\n      \"notes\": \"Notes\",\n      \"print\": \"Imprimer \u002F Enregistrer en PDF\",\n      \"discount\": \"Remise\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then add the“View” action on each row in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.ts\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">view(inv: Invoice) {\n  this.router.navigate(['\u002Finvoice', inv.id, 'view']);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Drop the button in your actions block in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.html\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;button type=\"button\" class=\"btn-secondary\" (click)=\"view(inv)\"&gt;\n  {{ 'invoice.actions.view' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And translations in \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>...\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{ \"invoice\": { \"actions\": { \"view\": \"View\" } } }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>...and \u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{ \"invoice\": { \"actions\": { \"view\": \"Voir\" } } }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Finally, run a quick test by starting the app with \u003Ccode>npm start\u003C\u002Fcode>, then open \u003Ccode>\u002Finvoice\u002F:id\u002Fview\u003C\u002Fcode> to verify that the page displays and prints cleanly. From the dashboard, click \u003Cstrong>View\u003C\u002Fstrong>. It should open the same screen where the \u003Cstrong>Print \u002F Save as PDF\u003C\u002Fstrong> action is working properly.\u003C\u002Fp>\u003Cp>Our last step today will be adding \u003Cstrong>Search \u002F Filter \u002F Sort\u003C\u002Fstrong> to the dashboard.\u003C\u002Fp>\u003Ch2 id=\"search-filter-and-sort-feature\">🔎 Search, filter and sort feature \u003Ca class=\"markdownit-header-anchor\" href=\"#search-filter-and-sort-feature\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Ch3 id=\"1-dashboard-component\">1. Dashboard component \u003Ca class=\"markdownit-header-anchor\" href=\"#1-dashboard-component\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>In your \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular\u002Fcore';\nimport { RouterModule, Router } from '@angular\u002Frouter';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Inject \u003Cstrong>Router\u003C\u002Fstrong>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">private readonly router = inject(Router);\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add \u003Cstrong>UI state signals\u003C\u002Fstrong> for search text, status, sort:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">type StatusFilter = 'all' | 'draft' | 'sent' | 'paid';\ntype SortKey = 'dateDesc' | 'dateAsc' | 'amountDesc' | 'amountAsc';\n\nreadonly query  = signal&lt;string&gt;('');\nreadonly status = signal&lt;StatusFilter&gt;('all');\nreadonly sort   = signal&lt;SortKey&gt;('dateDesc');\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Derived view:\u003C\u002Fp>\u003Cpre>\u003Ccode>readonly view = computed(() =&gt; {\n  const q = this.query().trim().toLowerCase();\n  const st = this.status();\n  const sortKey = this.sort();\n\n  let rows = this.invoices();\n\n  if (st !== 'all') rows = rows.filter(r =&gt; r.status === st);\n\n  if (q) {\n    rows = rows.filter(r =&gt;\n      r.number.toLowerCase().includes(q) ||\n      r.clientName.toLowerCase().includes(q) ||\n      (r.clientEmail?.toLowerCase().includes(q) ?? false) ||\n      (r.notes?.toLowerCase().includes(q) ?? false)\n    );\n  }\n\n  const byAmount = (r: Invoice) =&gt; invoiceTotal(r);\n  const byDate   = (r: Invoice) =&gt; r.issueDate; \u002F\u002F ISO sorts lexicographically\n\n  rows = [...rows];\n  switch (sortKey) {\n    case 'amountDesc': rows.sort((a,b) =&gt; byAmount(b) - byAmount(a)); break;\n    case 'amountAsc':  rows.sort((a,b) =&gt; byAmount(a) - byAmount(b)); break;\n    case 'dateAsc':    rows.sort((a,b) =&gt; byDate(a).localeCompare(byDate(b))); break;\n    case 'dateDesc':\n    default:           rows.sort((a,b) =&gt; byDate(b).localeCompare(byDate(a))); break;\n  }\n\n  return rows;\n});\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add row action handlers:\u003C\u002Fp>\u003Cpre>\u003Ccode>viewInvoice(inv: Invoice) { this.router.navigate(['\u002Finvoice', inv.id, 'view']); }\nedit(inv: Invoice)        { this.router.navigate(['\u002Finvoice', inv.id]); }\nmarkSent(inv: Invoice)    { if (inv.status !== 'sent') this.store.setStatus(inv.id, 'sent'); }\nmarkPaid(inv: Invoice)    { if (inv.status !== 'paid') this.store.setStatus(inv.id, 'paid'); }\nremove(inv: Invoice)      { if (confirm(`Delete ${inv.number}? This cannot be undone.`)) this.store.remove(inv.id); }\n\n\u002F\u002F top-bar inputs → signals\nonSearch(v: string)       { this.query.set(v); }\nonStatusChange(v: string) { this.status.set((v as StatusFilter) || 'all'); }\nonSortChange(v: string)   { this.sort.set((v as SortKey) || 'dateDesc'); }\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then update the dashboard template on \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.html\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;header class=\"flex flex-col gap-3 md:flex-row md:items-center\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'dashboard.title' | translate }}&lt;\u002Fh2&gt;\n\n  &lt;div class=\"md:ml-auto grid grid-cols-1 md:grid-cols-3 gap-2 items-center\"&gt;\n    &lt;!-- Search --&gt;\n    &lt;input\n      #q type=\"search\"\n      class=\"rounded-lg border p-2\"\n      [value]=\"query()\"\n      (input)=\"onSearch(q.value)\"\n      [placeholder]=\"'dashboard.filters.searchPlaceholder' | translate\" \u002F&gt;\n\n    &lt;!-- Status --&gt;\n    &lt;select\n      #statusSel class=\"rounded-lg border p-2\"\n      [value]=\"status()\"\n      (change)=\"onStatusChange(statusSel.value)\"&gt;\n      &lt;option value=\"all\"&gt;{{ 'status.all' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"draft\"&gt;{{ 'status.draft' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"sent\"&gt;{{ 'status.sent' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"paid\"&gt;{{ 'status.paid' | translate }}&lt;\u002Foption&gt;\n    &lt;\u002Fselect&gt;\n\n    &lt;!-- Sort --&gt;\n    &lt;select\n      #sortSel class=\"rounded-lg border p-2\"\n      [value]=\"sort()\"\n      (change)=\"onSortChange(sortSel.value)\"&gt;\n      &lt;option value=\"dateDesc\"&gt;{{ 'dashboard.filters.sort.dateDesc' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"dateAsc\"&gt;{{ 'dashboard.filters.sort.dateAsc' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"amountDesc\"&gt;{{ 'dashboard.filters.sort.amountDesc' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"amountAsc\"&gt;{{ 'dashboard.filters.sort.amountAsc' | translate }}&lt;\u002Foption&gt;\n    &lt;\u002Fselect&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;div class=\"flex items-center gap-2\"&gt;\n    &lt;a routerLink=\"\u002Finvoice\u002Fnew\" class=\"btn\"&gt;{{ 'invoice.actions.create' | translate }}&lt;\u002Fa&gt;\n    &lt;button class=\"btn-secondary\" type=\"button\" (click)=\"createSample()\"&gt;+ Sample&lt;\u002Fbutton&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fheader&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Insert the result count + empty states above your list:\u003C\u002Fp>\u003Cpre>\u003Ccode>@if (view().length &gt; 0) {\n  &lt;div class=\"text-sm text-gray-500\"&gt;\n    {{ 'dashboard.resultsPlural' | translate : { count: view().length } }}\n    @if (query().length) { — {{ 'dashboard.searchTerm' | translate }}: “{{ query() }}” }\n  &lt;\u002Fdiv&gt;\n}\n\n@if (invoices().length === 0) {\n  &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-600\"&gt;\n    {{ 'dashboard.empty' | translate }}\n  &lt;\u002Fdiv&gt;\n} @else if (view().length === 0) {\n  &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-700 space-y-1\"&gt;\n    &lt;div class=\"font-semibold\"&gt;{{ 'dashboard.noResultsTitle' | translate }}&lt;\u002Fdiv&gt;\n    &lt;div&gt;{{ 'dashboard.noResultsBody' | translate }}&lt;\u002Fdiv&gt;\n    @if (query().length) {\n      &lt;div class=\"text-sm text-gray-500\"&gt;\n        {{ 'dashboard.searchTerm' | translate }}: “{{ query() }}”\n      &lt;\u002Fdiv&gt;\n    }\n  &lt;\u002Fdiv&gt;\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Use the filtered list (\u003Ccode>view()\u003C\u002Fcode>) and updated actions inside your existing list:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;ul class=\"space-y-3\"&gt;\n  @for (inv of view(); track inv.id) {\n    &lt;!-- … existing row header with number\u002Fclient\u002Fdate\u002Ftotal … --&gt;\n\n    &lt;div class=\"mt-3 flex items-center gap-2\"&gt;\n      &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"viewInvoice(inv)\"&gt;\n        {{ 'invoice.actions.view' | translate }}\n      &lt;\u002Fbutton&gt;\n      &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"edit(inv)\"&gt;\n        {{ 'invoice.actions.edit' | translate }}\n      &lt;\u002Fbutton&gt;\n      @if (inv.status !== 'sent') {\n        &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markSent(inv)\"&gt;\n          {{ 'invoice.actions.markSent' | translate }}\n        &lt;\u002Fbutton&gt;\n      }\n      @if (inv.status !== 'paid') {\n        &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markPaid(inv)\"&gt;\n          {{ 'invoice.actions.markPaid' | translate }}\n        &lt;\u002Fbutton&gt;\n      }\n      &lt;buttontype=\"button\"\n        class=\"inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-400\u002F40\"\n        (click)=\"remove(inv)\"&gt;\n        {{ 'invoice.actions.delete' | translate }}\n      &lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n  }\n&lt;\u002Ful&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then update your translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"all\": \"All\",\n    \"draft\": \"Draft\",\n    \"sent\": \"Sent\",\n    \"paid\": \"Paid\"\n  },\n  \"dashboard\": {\n    \"resultsPlural\": \"{{count}} result(s)\",\n    \"noResultsTitle\": \"Invoice not found\",\n    \"noResultsBody\": \"No invoices match your search or filters.\",\n    \"searchTerm\": \"Search\",\n    \"filters\": {\n      \"searchPlaceholder\": \"Search by number, client, email, notes…\",\n      \"sort\": {\n        \"dateDesc\": \"Newest first\",\n        \"dateAsc\": \"Oldest first\",\n        \"amountDesc\": \"Amount: high → low\",\n        \"amountAsc\": \"Amount: low → high\"\n      }\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"all\": \"Tous\",\n    \"draft\": \"Brouillon\",\n    \"sent\": \"Envoyée\",\n    \"paid\": \"Payée\"\n  },\n  \"dashboard\": {\n    \"resultsPlural\": \"{{count}} résultat(s)\",\n    \"noResultsTitle\": \"Facture introuvable\",\n    \"noResultsBody\": \"Aucune facture ne correspond à votre recherche ou à vos filtres.\",\n    \"searchTerm\": \"Recherche\",\n    \"filters\": {\n      \"searchPlaceholder\": \"Rechercher par numéro, client, email, notes…\",\n      \"sort\": {\n        \"dateDesc\": \"Plus récentes d'abord\",\n        \"dateAsc\": \"Plus anciennes d'abord\",\n        \"amountDesc\": \"Montant : élevé → faible\",\n        \"amountAsc\": \"Montant : faible → élevé\"\n      }\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Try typing in the search box and observe the list updates instantly! Play with filters or sorting to see invoices reshuffle, then clear everything to get back to your cozy “Newest first” view.\u003C\u002Fp>\u003Ch3 id=\"2-add-an-export-method\">2. Add an export method \u003Ca class=\"markdownit-header-anchor\" href=\"#2-add-an-export-method\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Add the code blow inside the \u003Ccode>InvoiceStore\u003C\u002Fcode> class in \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F** ---- backup\u002Fexport ---- *\u002F\nexportJSON(pretty = true): string {\n  const payload = { version: 2, data: this._invoices() };\n  return JSON.stringify(payload, null, pretty ? 2 : 0);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add this helper in  \u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Ffiles.ts\u003C\u002Fcode>: \u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export function downloadText(\n  filename: string,\n  text: string,\n  mime = 'application\u002Fjson;charset=utf-8'\n) {\n  const blob = new Blob([text], { type: mime });\n  const url = URL.createObjectURL(blob);\n  const a = document.createElement('a');\n  a.href = url;\n  a.download = filename;\n  document.body.appendChild(a);\n  a.click();\n  document.body.removeChild(a);\n  URL.revokeObjectURL(url);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, add the import at the top and then the method inside the class in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F\u002F add import\nimport { downloadText } from '..\u002F..\u002Fcore\u002Futils\u002Ffiles';\n\n\u002F\u002F inside DashboardComponent\nexportBackup() {\n  const json = this.store.exportJSON(true);\n  const stamp = new Date().toISOString().slice(0, 19).replace(\u002F[:T]\u002Fg, '-'); \u002F\u002F YYYY-MM-DD-HH-MM-SS\n  downloadText(`invoices-backup-${stamp}.json`, json);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add now the export button in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;button class=\"btn-secondary\" type=\"button\" (click)=\"exportBackup()\"&gt;\n  {{ 'dashboard.actions.export' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"export\": \"Exporter en JSON\"\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"export\": \"Export JSON\"\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"lessbrgreater3-add-import-json\">\u003Cbr>3. Add Import JSON \u003Ca class=\"markdownit-header-anchor\" href=\"#lessbrgreater3-add-import-json\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Here, add a simple way for users to \u003Cstrong>restore saved invoices\u003C\u002Fstrong> by importing their JSON backups. They can either replace all data or merge it with existing invoices\u003C\u002Fp>\u003Cp>Add these inside the \u003Ccode>InvoiceStore\u003C\u002Fcode> class in \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F** ---- restore\u002Fimport helpers ---- *\u002F\nsetAll(list: Invoice[]): void {\n  this._invoices.set([...list]);\n}\n\nmergeAll(list: Invoice[]): void {\n  const map = new Map&lt;string, Invoice&gt;();\n  for (const inv of this._invoices()) map.set(inv.id, inv);\n  for (const inv of list) map.set(inv.id, inv); \u002F\u002F imported wins on id collision\n  this._invoices.set([...map.values()]);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The \u003Cstrong>\u003Ccode>setAll\u003C\u002Fcode> \u003C\u002Fstrong>replaces everything in one go;\u003Cstrong> \u003Ccode>mergeAll\u003C\u002Fcode>\u003C\u002Fstrong> keeps existing invoices and lets imported ones win when IDs collide.\u003C\u002Fp>\u003Cp>Add these imports at the top alongside your existing ones in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { ViewChild, ElementRef } from '@angular\u002Fcore';\nimport { sanitizeInvoice } from '..\u002F..\u002Fcore\u002Fpersistence\u002Finvoice.serialization';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add a reference to the hidden input inside the class:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">@ViewChild('importInput') importInput?: ElementRef&lt;HTMLInputElement&gt;;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Include these two small methods with your other actions:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">triggerImport(el: HTMLInputElement) {\n  el.value = ''; \u002F\u002F allow re-selecting the same file\n  el.click();\n}\n\nasync handleImport(files: FileList | null) {\n  if (!files || files.length === 0) return;\n  const file = files[0];\n\n  try {\n    const text = await file.text();\n    const parsed: unknown = JSON.parse(text);\n\n    \u002F\u002F Accept v1 (array) or v2 ({version, data: []})\n    const rawList: unknown[] =\n      Array.isArray(parsed)\n        ? parsed\n        : (parsed &amp;&amp; typeof parsed === 'object' &amp;&amp; Array.isArray((parsed as any)['data']))\n          ? (parsed as any)['data']\n          : [];\n\n    const imported = rawList\n      .map(sanitizeInvoice)\n      .filter((x): x is NonNullable&lt;ReturnType&lt;typeof sanitizeInvoice&gt;&gt; =&gt; !!x);\n\n    if (imported.length === 0) {\n      alert('Import failed: no valid invoices found in file.');\n      return;\n    }\n\n    const replace = confirm(\n      `Found ${imported.length} invoices.\\\\n\\\\nOK = Replace ALL existing invoices\\\\nCancel = Merge (imported overwrite by id)`\n    );\n\n    if (replace) {\n      this.store.setAll(imported);\n      alert('Restore complete: replaced all invoices.');\n    } else {\n      this.store.mergeAll(imported);\n      alert('Restore complete: merged invoices.');\n    }\n  } catch (e) {\n    console.error(e);\n    alert('Import failed: invalid JSON or unreadable file.');\n  } finally {\n    if (this.importInput?.nativeElement) this.importInput.nativeElement.value = '';\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Let hide file input for import button in the header actions (next to Create \u002F + Sample \u002F Export) of the \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;!-- Hidden file input for import --&gt;\n&lt;input\n  #importInput\n  type=\"file\"\n  accept=\"application\u002Fjson\"\n  class=\"hidden\"\n  (change)=\"handleImport(importInput.files)\" \u002F&gt;\n\n&lt;button class=\"btn-secondary\" type=\"button\" (click)=\"triggerImport(importInput)\"&gt;\n  {{ 'dashboard.actions.import' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add the import text on \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"import\": \"Import JSON\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And on \u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"import\": \"Importer JSON\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Open the app and run a quick test to make sure the import works.\u003C\u002Fp>\u003Cp>Let’s round out backups with a clean \u003Cstrong>CSV export.\u003C\u002Fstrong> No need to use libraries: just two neat files you can open in Excel\u002FSheets. \u003Cstrong>One for invoices, one for line items\u003C\u002Fstrong>.\u003C\u002Fp>\u003Ch3 id=\"4-export-as-csv\">4. Export as CSV \u003Ca class=\"markdownit-header-anchor\" href=\"#4-export-as-csv\">🔗\u003C\u002Fa>\u003C\u002Fh3>\u003Cp>Let’s make it easy for users to work with their data outside the app. \u003C\u002Fp>\u003Cp>With a single click, they’ll be able to \u003Cstrong>export all invoices and line items as CSV files\u003C\u002Fstrong>, perfect for quick reviews, reports, or even Excel and Google Sheets.\u003C\u002Fp>\u003Cp>Create a CSV helper first:\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Futils\u002Fcsv.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">function csvEscape(value: unknown): string {\n  const s = value === undefined || value === null ? '' : String(value);\n  if (\u002F[\",\\\\r\\\\n]\u002F.test(s)) return `\"${s.replace(\u002F\"\u002Fg, '\"\"')}\"`;\n  return s;\n}\n\n\u002F** Build a CSV string from an array of plain objects using the provided column order. *\u002F\nexport function toCsv(columns: string[], rows: Array&lt;Record&lt;string, unknown&gt;&gt;): string {\n  const header = columns.join(',');\n  const data = rows.map(r =&gt; columns.map(c =&gt; csvEscape(r[c])).join(','));\n  return [header, ...data].join('\\\\r\\\\n');\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This tiny helper \u003Cstrong>takes any list of objects and turns it into a clean, spreadsheet-ready CSV file\u003C\u002Fstrong>. It automatically escapes commas and quotes so everything opens correctly in Excel or Sheets.\u003C\u002Fp>\u003Cp>Add these imports at the top in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { toCsv } from '..\u002F..\u002Fcore\u002Futils\u002Fcsv';\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then, inside your \u003Ccode>DashboardComponent\u003C\u002Fcode>, add this method:\u003C\u002Fp>\u003Cpre>\u003Ccode>\u002F\u002F ---------- export CSV ----------\nexportCsv() {\n  const list = this.invoices();\n\n  \u002F\u002F Invoices CSV (one row per invoice; totals are raw numbers in base currency)\n  const invCols = [\n    'id','number','clientName','clientEmail','issueDate','dueDate',\n    'currency','status','itemsCount','total','createdAt','updatedAt'\n  ];\n  const invRows = list.map(inv =&gt; ({\n    id: inv.id,\n    number: inv.number,\n    clientName: inv.clientName,\n    clientEmail: inv.clientEmail ?? '',\n    issueDate: inv.issueDate,\n    dueDate: inv.dueDate ?? '',\n    currency: inv.currency,\n    status: inv.status,\n    itemsCount: inv.items.length,\n    total: invoiceTotal(inv),        \u002F\u002F numeric, not localized\n    createdAt: inv.createdAt,\n    updatedAt: inv.updatedAt,\n  }));\n  const invoicesCsv = toCsv(invCols, invRows);\n\n  \u002F\u002F Items CSV (one row per line item; includes invoiceId)\n  const itemCols = [\n    'invoiceId','itemId','description','quantity','unitPrice','taxRate','discountRate','lineTotal'\n  ];\n  const itemRows = list.flatMap(inv =&gt;\n    inv.items.map(it =&gt; ({\n      invoiceId: inv.id,\n      itemId: it.id,\n      description: it.description,\n      quantity: it.quantity,\n      unitPrice: it.unitPrice,\n      taxRate: it.taxRate ?? 0,\n      discountRate: it.discountRate ?? 0,\n      lineTotal: lineTotal(it),      \u002F\u002F numeric, not localized\n    }))\n  );\n  const itemsCsv = toCsv(itemCols, itemRows);\n\n  const stamp = new Date().toISOString().slice(0, 19).replace(\u002F[:T]\u002Fg, '-');\n  downloadText(`invoices-${stamp}.csv`, invoicesCsv, 'text\u002Fcsv;charset=utf-8');\n  downloadText(`invoice_items-${stamp}.csv`, itemsCsv, 'text\u002Fcsv;charset=utf-8');\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This exports \u003Cstrong>two CSV files\u003C\u002Fstrong>: one summarizing each invoice and another listing all individual line items. Keeping the numbers raw makes them easier to analyze or chart later in any spreadsheet.\u003C\u002Fp>\u003Cp>Let add the UI button in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>  in the header actions (next to Create \u002F + Sample \u002F Export JSON \u002F Import JSON):\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;button class=\"btn-secondary\" type=\"button\" (click)=\"exportCsv()\"&gt;\n  {{ 'dashboard.actions.exportCsv' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Update your translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"exportCsv\": \"Export CSV\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"exportCsv\": \"Exporter CSV\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Open the app, hit \u003Cstrong>Export CSV\u003C\u002Fstrong>, and voilà! \u003Cstrong>Two neat files land on your computer \u003C\u002Fstrong>(\u003Ccode>invoices-YYYY-MM-DD-HH-MM-SS.csv\u003C\u002Fcode>and \u003Ccode>invoice_items-YYYY-MM-DD-HH-MM-SS.csv\u003C\u002Fcode>). Drop them into Excel or Google Sheets and play around: sort, filter, or build quick reports in seconds.\u003C\u002Fp>\u003Ch2 id=\"whats-next\">➡️ What's next? \u003Ca class=\"markdownit-header-anchor\" href=\"#whats-next\">🔗\u003C\u002Fa>\u003C\u002Fh2>\u003Cp>This wraps up the second part of your journey where we built the foundation of the app and set up translation services to make it bilingual and user-friendly. In the next part, \u003Cstrong>we’ll take things further by integrating the Angular app with Localazy\u003C\u002Fstrong>, unlocking the real advantages of localization: faster translations, easier updates, and a smoother multilingual experience for every user.\u003C\u002Fp>","Guide to translating Angular apps with ngx-translate, #2",{"id":43,"status":4,"created_on":1929,"modified_on":1930,"icon":1931,"header":1932,"description":1933,"button_label":1934,"link":1935},"2022-03-17T12:23:03.000Z","2026-05-25T10:38:47.000Z","member","Why developers love Localazy 💖","From mobile apps to SaaS platforms. See how product teams handle translations without slowing down development.\n","See case studies","case-study",{"slug":1865,"id":1858,"uuid":1937,"title":1864,"html":1938,"comment_id":1858,"feature_image":1868,"featured":93,"visibility":1939,"email_recipient_filter":1940,"created_at":1941,"updated_at":1866,"published_at":1867,"custom_excerpt":1925,"codeinjection_head":8,"codeinjection_foot":8,"custom_template":8,"canonical_url":8,"authors":1942,"tags":1948,"primary_author":1988,"primary_tag":1989,"url":1990,"excerpt":1925,"reading_time":317,"access":93,"send_email_when_published":91,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":1927,"meta_description":8,"email_subject":8,"frontmatter":8,"dictionary":1877,"cta":1928,"plainTags":1869},"fbe15991-2946-489f-bcca-84a3129f8fb0","\u003Cp>Now that \u003Ca href=\"https:\u002F\u002Flocalazy.com\u002Fblog\u002Fguide-to-translate-angular-apps-with-ngx-translate-foundations\">the groundwork is done\u003C\u002Fa> and i18n is configured, Tailwind is in place, and our Angular app is ready, it’s time to make things happen.\u003C\u002Fp>\u003Cp>In this part, we’ll focus on functionality. \u003Cstrong>You’ll build a complete invoice dashboard\u003C\u002Fstrong> where users can create, edit, view, and delete invoices. \u003Cstrong>We’ll also add handy features like printing, exporting, and importing\u003C\u002Fstrong>, all while keeping the app clean, reactive, and localization-friendly.\u003C\u002Fp>\u003Cp>By the end of this section, your app will display placeholders and will feel alive, interactive, and ready for multilingual support when we integrate Localazy in the final part.\u003C\u002Fp>\u003Ch2 id=\"1%EF%B8%8F%E2%83%A3-step-1-build-the-dashboard\">1️⃣ Step 1: Build the dashboard\u003C\u002Fh2>\u003Cp>You’ll scaffold a dashboard screen as the app’s first view, then plug in a minimal Tailwind\u002Fi18n template.\u003C\u002Fp>\u003Ch3 id=\"1-generate-the-dashboard-component\">1. Generate the dashboard component\u003C\u002Fh3>\u003Cp>Run this command from your project root:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run ng -- generate component features\u002Fdashboard --standalone\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Angular CLI scaffolds the component under \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002F\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode>dashboard.component.ts      \u002F\u002F Component logic (TypeScript)\ndashboard.component.html     \u002F\u002F Template for layout &amp; i18n text\ndashboard.component.scss     \u002F\u002F Local styling (will use Tailwind classes)\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>It also wires the component for standalone use.\u003C\u002Fp>\u003Ch3 id=\"2-implementing-the-dashboard-component\">2. Implementing the dashboard component\u003C\u002Fh3>\u003Cp>Next, let's implement the component:\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Component, ChangeDetectionStrategy } from '@angular\u002Fcore';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\n\n@Component({\n  selector: 'app-dashboard',\n  standalone: true,\n  imports: [RouterModule, TranslateModule],\n  templateUrl: '.\u002Fdashboard.component.html',\n  styleUrls: ['.\u002Fdashboard.component.scss'],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class DashboardComponent {}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;section class=\"max-w-5xl mx-auto p-6\"&gt;\n  &lt;header class=\"flex items-center gap-3 mb-6\"&gt;\n    &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'dashboard.title' | translate }}&lt;\u002Fh2&gt;\n    &lt;a routerLink=\"\u002Finvoice\u002Fnew\" class=\"btn ml-auto\"&gt;\n      {{ 'invoice.actions.create' | translate }}\n    &lt;\u002Fa&gt;\n  &lt;\u002Fheader&gt;\n\n  &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-600\"&gt;\n    {{ 'dashboard.empty' | translate }}\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fsection&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"3-invoice-edit-component\">3. Invoice Edit component\u003C\u002Fh3>\u003Cp>We’ll now create the \u003Cstrong>Invoice Edit view\u003C\u002Fstrong>, wire up its template, add the needed i18n keys, and define \u003Ca href=\"https:\u002F\u002Fangular.dev\u002Freference\u002Fmigrations\u002Froute-lazy-loading\" rel=\"noopener noreferrer\">lazy-loaded routes\u003C\u002Fa> for a lightweight Angular app. Generate the standalone component first, then replace its files with the code below.\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Component, ChangeDetectionStrategy } from '@angular\u002Fcore';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\n\n@Component({\n  selector: 'app-invoice-edit',\n  standalone: true,\n  imports: [RouterModule, TranslateModule],\n  templateUrl: '.\u002Finvoice-edit.component.html',\n  styleUrls: ['.\u002Finvoice-edit.component.scss'],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceEditComponent {}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Like the dashboard, this component uses Angular’s standalone API, importing both the \u003Ccode>RouterModule\u003C\u002Fcode> and \u003Ccode>TranslateModule\u003C\u002Fcode> directly. The \u003Ccode>OnPush\u003C\u002Fcode> change detection keeps the UI performant as the project scales with invoice forms and API data.\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.html\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;section class=\"max-w-3xl mx-auto p-6 space-y-4\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.edit.title' | translate }}&lt;\u002Fh2&gt;\n  &lt;p class=\"text-gray-600\"&gt;{{ 'invoice.edit.placeholder' | translate }}&lt;\u002Fp&gt;\n\n  &lt;a routerLink=\"\u002F\" class=\"btn-secondary inline-flex items-center\"&gt;\n    {{ 'nav.back' | translate }}\n  &lt;\u002Fa&gt;\n&lt;\u002Fsection&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-add-translation-keys\">4. Add translation keys\u003C\u002Fh3>\u003Cp>Place these in \u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002F\u003C\u002Fcode>\u003C\u002Fstrong> so \u003Cstrong>ngx-translate\u003C\u002Fstrong> (and later Localazy) can sync them.\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"title\": \"Welcome to Your Invoice &amp; Expense Tracker\",\n  },\n  \"dashboard\": {\n    \"title\": \"Dashboard\",\n    \"empty\": \"No invoices yet. Create your first invoice.\"\n  },\n  \"invoice\": {\n    \"edit\": {\n      \"title\": \"New Invoice\",\n      \"placeholder\": \"Form coming next.\"\n    },\n    \"actions\": {\n      \"create\": \"Create Invoice\"\n    }\n  },\n  \"nav\": {\n    \"back\": \"Back\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"title\": \"Bienvenue dans votre outil de suivi des factures et des dépenses\",\n  },\n  \"dashboard\": {\n    \"title\": \"Tableau de bord\",\n    \"empty\": \"Aucune facture pour le moment. Créez votre première facture.\"\n  },\n  \"invoice\": {\n    \"edit\": {\n      \"title\": \"Nouvelle facture\",\n      \"placeholder\": \"Formulaire à venir.\"\n    },\n    \"actions\": {\n      \"create\": \"Créer une facture\"\n    }\n  },\n  \"nav\": {\n    \"back\": \"Retour\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"5-define-routes-with-lazy-loading\">5. Define routes with lazy loading\u003C\u002Fh3>\u003Cp>Create\u002Fupdate \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fapp.routes.ts\u003C\u002Fcode>\u003C\u002Fstrong> to lazy-load screens. See Angular Router docs for reference: angular.dev &gt; Guide &gt; Router.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Routes } from '@angular\u002Frouter';\n\nexport const routes: Routes = [\n  {\n    path: '',\n    pathMatch: 'full',\n    loadComponent: () =&gt;\n      import('.\u002Ffeatures\u002Fdashboard\u002Fdashboard.component').then(m =&gt; m.DashboardComponent),\n  },\n  {\n    path: 'invoice\u002Fnew',\n    loadComponent: () =&gt;\n      import('.\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component').then(m =&gt; m.InvoiceEditComponent),\n  },\n  {\n    path: 'invoice\u002F:id',\n    loadComponent: () =&gt;\n      import('.\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component').then(m =&gt; m.InvoiceEditComponent),\n  },\n  { path: '**', redirectTo: '' },\n];\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This routing setup ensures that:\u003C\u002Fp>\u003Cul>\u003Cli>\u003Ccode>\u002F\u003C\u002Fcode> loads the \u003Cstrong>Dashboard\u003C\u002Fstrong> (your main screen).\u003C\u002Fli>\u003Cli>\u003Ccode>\u002Finvoice\u002Fnew\u003C\u002Fcode> opens the \u003Cstrong>Invoice Editor\u003C\u002Fstrong> for creating a new invoice.\u003C\u002Fli>\u003Cli>\u003Ccode>\u002Finvoice\u002F:id\u003C\u002Fcode> reuses the same component for editing an existing invoice.\u003C\u002Fli>\u003Cli>Any undefined route gracefully redirects back to the Dashboard.\u003C\u002Fli>\u003C\u002Ful>\u003Ch3 id=\"6-run-verify\">6. Run &amp; verify\u003C\u002Fh3>\u003Cp>Start your local server to check that everything works:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm start\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then open \u003Cstrong>\u003Ca href=\"http:\u002F\u002Flocalhost:4200\u002F\">http:\u002F\u002Flocalhost:4200\u003C\u002Fa>\u003C\u002Fstrong> and verify that:\u003C\u002Fp>\u003Cul>\u003Cli>The Dashboard loads with a localized title and \u003Cstrong>Create Invoice\u003C\u002Fstrong> button.\u003C\u002Fli>\u003Cli>By clicking, it opens the \u003Cstrong>Invoice Edit\u003C\u002Fstrong> screen with the \u003Cstrong>Back\u003C\u002Fstrong> button styled using \u003Ccode>.btn-secondary\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>Text is updated when switching languages, confirming that \u003Cstrong>ngx-translate\u003C\u002Fstrong> and \u003Cstrong>Tailwind\u003C\u002Fstrong> have been correctly integrated.\u003C\u002Fli>\u003C\u002Ful>\u003Ch2 id=\"2%EF%B8%8F%E2%83%A3-step-2-set-up-the-invoice-models-and-store\">2️⃣ Step 2: Set up the invoice models and store\u003C\u002Fh2>\u003Cp>Define the app’s data layer so the UI and i18n stay clean. We’ll model invoices and line items first, then wire a lightweight store next.\u003C\u002Fp>\u003Ch3 id=\"1-create-the-models\">1. Create the models\u003C\u002Fh3>\u003Cp>Start by defining the core data structures that your invoice app will use: invoices, line items, and their statuses. These models form the foundation of your store and components.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fmodels\u002Finvoice.model.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export type InvoiceStatus = 'draft' | 'sent' | 'paid';\n\nexport interface LineItem {\n  id: string;\n  description: string;\n  quantity: number;\n  unitPrice: number;\n  taxRate?: number;\n  discountRate?: number;\n}\n\nexport interface Invoice {\n  id: string;\n  number: string;\n  clientName: string;\n  clientEmail?: string;\n  issueDate: string;\n  dueDate?: string;\n  currency: string;\n  items: LineItem[];\n  notes?: string;\n  status: InvoiceStatus;\n  createdAt: string;\n  updatedAt: string;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>These models define the structure of invoices and line items, ensuring consistent data handling across components and simplifying integration with the store.\u003C\u002Fp>\u003Ch3 id=\"2-create-the-signal-based-store-with-persistence\">2. Create the Signal-based store with Persistence\u003C\u002Fh3>\u003Cp>Use Angular \u003Cstrong>signals\u003C\u002Fstrong> to manage the invoice state without extra libraries. This store exposes reactive selectors (via \u003Ccode>computed\u003C\u002Fcode>), persists to \u003Ccode>localStorage\u003C\u002Fcode> with an \u003Ccode>effect\u003C\u002Fcode>, and keeps your components lean.\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, computed, effect, signal } from '@angular\u002Fcore';\nimport { Invoice, InvoiceStatus, LineItem } from '..\u002Fmodels\u002Finvoice.model';\n\nconst STORAGE_KEY = 'invoice.store.v1';\n\nfunction nowIso() { return new Date().toISOString(); }\nfunction newId()  { return (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)); }\n\nfunction calcLineTotal(li: LineItem): number {\n  const qty = Math.max(0, li.quantity || 0);\n  const price = Math.max(0, li.unitPrice || 0);\n  const preTax = qty * price;\n  const discount = li.discountRate ? preTax * (li.discountRate \u002F 100) : 0;\n  const afterDiscount = preTax - discount;\n  const tax = li.taxRate ? afterDiscount * (li.taxRate \u002F 100) : 0;\n  return +(afterDiscount + tax).toFixed(2);\n}\n\nfunction calcInvoiceTotal(inv: Invoice): number {\n  return +inv.items.reduce((sum, li) =&gt; sum + calcLineTotal(li), 0).toFixed(2);\n}\n\nfunction load(): Invoice[] {\n  try {\n    const raw = localStorage.getItem(STORAGE_KEY);\n    if (!raw) return [];\n    const parsed = JSON.parse(raw) as Invoice[];\n    \u002F\u002F basic sanity\n    return Array.isArray(parsed) ? parsed : [];\n  } catch {\n    return [];\n  }\n}\n\n@Injectable({ providedIn: 'root' })\nexport class InvoiceStore {\n  private readonly _invoices = signal&lt;Invoice[]&gt;(load());\n\n  \u002F\u002F Persist to localStorage on change\n  private readonly _persist = effect(() =&gt; {\n    const value = JSON.stringify(this._invoices());\n    localStorage.setItem(STORAGE_KEY, value);\n  });\n\n  \u002F\u002F Selectors\n  readonly invoices = this._invoices.asReadonly();\n  totalCount = computed(() =&gt; this._invoices().length);\n  totalByStatus = (status: InvoiceStatus) =&gt; computed(\n    () =&gt; this._invoices().filter(i =&gt; i.status === status).length\n  );\n  byId = (id: string) =&gt; computed(() =&gt; this._invoices().find(i =&gt; i.id === id) || null);\n  totalAmount = (id: string) =&gt; computed(() =&gt; {\n    const inv = this._invoices().find(i =&gt; i.id === id);\n    return inv ? calcInvoiceTotal(inv) : 0;\n  });\n\n  \u002F\u002F Mutations\n  createDraft(partial?: Partial&lt;Invoice&gt;): Invoice {\n    const id = newId();\n    const created = nowIso();\n    const number = this.nextNumber();\n    const invoice: Invoice = {\n      id,\n      number,\n      clientName: partial?.clientName ?? '',\n      clientEmail: partial?.clientEmail,\n      issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),\n      dueDate: partial?.dueDate,\n      currency: partial?.currency ?? 'USD',\n      items: partial?.items ?? [this.newLineItem()],\n      notes: partial?.notes,\n      status: partial?.status ?? 'draft',\n      createdAt: created,\n      updatedAt: created,\n    };\n    this._invoices.update(arr =&gt; [invoice, ...arr]);\n    return invoice;\n  }\n\n  update(id: string, changes: Partial&lt;Invoice&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; inv.id === id ? { ...inv, ...changes, updatedAt: nowIso() } : inv)\n    );\n  }\n\n  remove(id: string): void {\n    this._invoices.update(arr =&gt; arr.filter(inv =&gt; inv.id !== id));\n  }\n\n  \u002F\u002F ---- Line item helpers\n  newLineItem(): LineItem {\n    return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };\n  }\n\n  addLineItem(invoiceId: string, li?: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; inv.id === invoiceId\n        ? { ...inv, items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items], updatedAt: nowIso() }\n        : inv\n      )\n    );\n  }\n\n  updateLineItem(invoiceId: string, itemId: string, changes: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.map(it =&gt; it.id === itemId ? { ...it, ...changes } : it);\n        return { ...inv, items, updatedAt: nowIso() };\n      })\n    );\n  }\n\n  removeLineItem(invoiceId: string, itemId: string): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.filter(it =&gt; it.id !== itemId);\n        return { ...inv, items, updatedAt: nowIso() };\n      })\n    );\n  }\n\n  setStatus(id: string, status: InvoiceStatus) {\n    this.update(id, { status });\n  }\n\n  \u002F\u002F Utilities\n  private nextNumber(): string {\n    const seq = this._invoices().length + 1;\n    return `INV-${String(seq).padStart(4, '0')}`;\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This setup lets you handle invoices in real time while keeping data in sync across sessions.\u003C\u002Fp>\u003Cp>Because it’s based on Angular’s built-in reactivity, \u003Cstrong>the store stays small, fast, and ready to scale\u003C\u002Fstrong> alongside your localized UI managed with ngx-translate and Localazy.\u003C\u002Fp>\u003Ch3 id=\"3-add-i18n-keys-for-statuses\">3. Add i18n keys for statuses\u003C\u002Fh3>\u003Cp>Before displaying invoice statuses in the UI, define their localized labels.\u003C\u002Fp>\u003Cp>This ensures values like \u003Cstrong>Draft\u003C\u002Fstrong>, \u003Cstrong>Sent\u003C\u002Fstrong>, and \u003Cstrong>Paid\u003C\u002Fstrong> are translated dynamically in any language your app supports.\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"draft\": \"Draft\",\n    \"sent\": \"Sent\",\n    \"paid\": \"Paid\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"draft\": \"Brouillon\",\n    \"sent\": \"Envoyée\",\n    \"paid\": \"Payée\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-update-the-dashboard-component-to-read-from-the-store\">4. Update the dashboard component to read from the store\u003C\u002Fh3>\u003Cp>Now that your store is ready, \u003Cstrong>update the dashboard to pull invoice data directly from it\u003C\u002Fstrong>. This step lets you display stored invoices, totals, and statuses with live updates.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Component, ChangeDetectionStrategy, inject } from '@angular\u002Fcore';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\nimport { CurrencyPipe, DatePipe } from '@angular\u002Fcommon';\nimport { InvoiceStore } from '..\u002F..\u002Fcore\u002Fstores\u002Finvoice.store';\nimport { Invoice } from '..\u002F..\u002Fcore\u002Fmodels\u002Finvoice.model';\n\n@Component({\n  selector: 'app-dashboard',\n  standalone: true,\n  imports: [RouterModule, TranslateModule, CurrencyPipe, DatePipe],\n  templateUrl: '.\u002Fdashboard.component.html',\n  styleUrls: ['.\u002Fdashboard.component.scss'],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class DashboardComponent {\n  private readonly store = inject(InvoiceStore);\n  invoices = this.store.invoices; \u002F\u002F signal&lt;Invoice[]&gt;\n\n  \u002F\u002F demo: create a draft quickly\n  createSample() {\n    this.store.createDraft({\n      clientName: 'Acme Corp',\n      currency: 'USD',\n      items: [\n        { id: crypto.randomUUID(), description: 'Design work', quantity: 3, unitPrice: 120, taxRate: 10 },\n      ],\n      status: 'draft',\n    });\n  }\n\n  total(inv: Invoice): number {\n    \u002F\u002F Avoid recomputing across the list by using store.totalAmount(inv.id) if you prefer computed-per-id\n    return this.store.totalAmount(inv.id)();\n  }\n\n  statusClass(status: Invoice['status']): string {\n    switch (status) {\n      case 'paid':  return 'bg-green-100 text-green-700';\n      case 'sent':  return 'bg-amber-100 text-amber-700';\n      default:      return 'bg-gray-100 text-gray-700';\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now update the file \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;section class=\"max-w-5xl mx-auto p-6 space-y-4\"&gt;\n  &lt;header class=\"flex items-center gap-3\"&gt;\n    &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'dashboard.title' | translate }}&lt;\u002Fh2&gt;\n    &lt;a routerLink=\"\u002Finvoice\u002Fnew\" class=\"btn ml-auto\"&gt;\n      {{ 'invoice.actions.create' | translate }}\n    &lt;\u002Fa&gt;\n    &lt;button class=\"btn-secondary\" type=\"button\" (click)=\"createSample()\"&gt;+ Sample&lt;\u002Fbutton&gt;\n  &lt;\u002Fheader&gt;\n\n  @if (invoices().length === 0) {\n    &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-600\"&gt;\n      {{ 'dashboard.empty' | translate }}\n    &lt;\u002Fdiv&gt;\n  } @else {\n    &lt;ul class=\"space-y-3\"&gt;\n      @for (inv of invoices(); track inv.id) {\n        &lt;li class=\"rounded-xl border p-4 hover:shadow-sm transition\"&gt;\n          &lt;div class=\"flex items-center gap-3\"&gt;\n            &lt;div class=\"font-semibold\"&gt;{{ inv.number }}&lt;\u002Fdiv&gt;\n            &lt;div class=\"text-gray-600\"&gt;•&lt;\u002Fdiv&gt;\n            &lt;div class=\"text-gray-800\"&gt;{{ inv.clientName || '—' }}&lt;\u002Fdiv&gt;\n            &lt;div class=\"text-gray-500 ml-auto flex items-center gap-3\"&gt;\n              &lt;span class=\"px-2 py-1 rounded-md text-xs\" [class]=\"statusClass(inv.status)\"&gt;\n                {{ ('status.' + inv.status) | translate }}\n              &lt;\u002Fspan&gt;\n              &lt;span class=\"text-sm\"&gt;\n                {{ inv.issueDate | date:'mediumDate' }}\n              &lt;\u002Fspan&gt;\n              &lt;strong class=\"ml-2\"&gt;\n                {{ total(inv) | currency: inv.currency:'symbol-narrow' }}\n              &lt;\u002Fstrong&gt;\n            &lt;\u002Fdiv&gt;\n          &lt;\u002Fdiv&gt;\n        &lt;\u002Fli&gt;\n      }\n    &lt;\u002Ful&gt;\n  }\n&lt;\u002Fsection&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"5-update-the-root-component-language-switcher\">5. Update the root component &amp; language switcher\u003C\u002Fh3>\u003Cp>Wire up the app-wide language controls so every screen (Dashboard, Invoice Edit) updates instantly with \u003Cstrong>ngx-translate\u003C\u002Fstrong>. This uses Angular \u003Cstrong>standalone\u003C\u002Fstrong> components and a small service to manage runtime i18n.\u003C\u002Fp>\u003Cp>File:\u003Cstrong> \u003C\u002Fstrong>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\nimport { Component, ChangeDetectionStrategy, inject } from '@angular\u002Fcore';\nimport { RouterOutlet } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\nimport { LanguageService } from '.\u002Fcore\u002Flanguage.service';\n\n@Component({\n  selector: 'app-root',\n  standalone: true,\n  imports: [RouterOutlet, TranslateModule],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n  templateUrl: '.\u002Fapp.html',\n  styleUrl: '.\u002Fapp.scss',\n})\nexport class App {\n  private readonly lang = inject(LanguageService);\n  setLang(l: 'en' | 'fr') { this.lang.use(l); }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>In \u003Ccode>app.html\u003C\u002Fcode>, the header displays the translated title and language buttons, while \u003Ccode>&lt;router-outlet&gt;\u003C\u002Fcode> loads the active view. Each button calls \u003Ccode>setLang()\u003C\u002Fcode> to toggle between English and French.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">&lt;header class=\"flex items-center gap-3 p-4 border-b\"&gt;\n  &lt;h1 class=\"text-xl font-semibold\"&gt;{{ 'app.title' | translate }}&lt;\u002Fh1&gt;\n  &lt;div class=\"ml-auto flex items-center gap-2\"&gt;\n    &lt;button type=\"button\" class=\"btn\" (click)=\"setLang('en')\"&gt;EN&lt;\u002Fbutton&gt;\n    &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"setLang('fr')\"&gt;FR&lt;\u002Fbutton&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fheader&gt;\n\n&lt;main class=\"p-4\"&gt;\n  &lt;router-outlet&gt;&lt;\u002Frouter-outlet&gt;\n&lt;\u002Fmain&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The \u003Ccode>\u003Cstrong>LanguageService\u003C\u002Fstrong>\u003C\u002Fcode> registers available locales, remembers the last selected language, and updates the \u003Ccode>&lt;html lang&gt;\u003C\u002Fcode>attribute. This keeps translations consistent across routes and sessions.\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable } from '@angular\u002Fcore';\nimport { TranslateService } from '@ngx-translate\u002Fcore';\n\ntype Lang = 'en' | 'fr';\n\n@Injectable({ providedIn: 'root' })\nexport class LanguageService {\n  private readonly storageKey = 'lang';\n  private readonly supported: readonly Lang[] = ['en', 'fr'] as const;\n\n  constructor(private t: TranslateService) {}\n\n  init(): void {\n    this.t.addLangs([...this.supported]);\n    const saved = (localStorage.getItem(this.storageKey) as Lang | null) ?? this.matchNavigator();\n    this.use(saved);\n  }\n\n  use(lang: string): void {\n    const chosen: Lang =\n      (this.supported as readonly string[]).includes(lang as Lang) ? (lang as Lang) : 'en';\n\n    localStorage.setItem(this.storageKey, chosen);\n    document.documentElement.lang = chosen;\n    this.t.use(chosen);\n  }\n\n  private matchNavigator(): Lang {\n    const base = (navigator.language || navigator.languages?.[0] || 'en').slice(0, 2) as Lang;\n    return (this.supported as readonly string[]).includes(base) ? base : 'en';\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Together, \u003Cstrong>these three files finalize your localization flow\u003C\u002Fstrong>. When the user clicks \u003Cstrong>EN\u003C\u002Fstrong> or \u003Cstrong>FR\u003C\u002Fstrong>, the entire interface Dashboard, Invoice Edit, and all status labels updates instantly.\u003C\u002Fp>\u003Ch3 id=\"6-add-locale-aware-formatting\">6. Add locale-aware formatting\u003C\u002Fh3>\u003Cp>Next, create a \u003Ccode>src\u002Fapp\u002Fcore\u002Flocale-format.service.ts\u003C\u002Fcode> file to handle \u003Ca href=\"https:\u002F\u002Flocalazy.com\u002Fblog\u002Fnumber-localization-guide-currencies-dates-measurement-units\u002F\">language-aware number and date formatting\u003C\u002Fa> across the app. This service listens to the active language from \u003Ccode>ngx-translate\u003C\u002Fcode> using Angular signals, so\u003Cstrong> all amounts and dates update automatically when users switch languages\u003C\u002Fstrong>. It exposes two helpers: \u003Ccode>currency()\u003C\u002Fcode> for localized prices and \u003Ccode>dateISO()\u003C\u002Fcode> for readable dates, both used by the dashboard and upcoming invoice views.\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal } from '@angular\u002Fcore';\nimport { TranslateService } from '@ngx-translate\u002Fcore';\n\n@Injectable({ providedIn: 'root' })\nexport class LocaleFormatService {\n  lang = signal('en');\n\n  constructor(private t: TranslateService) {\n    this.lang.set(this.t.currentLang || 'en');\n    this.t.onLangChange.subscribe(e =&gt; this.lang.set(e.lang));\n  }\n\n  currency(amount: number, currency: string): string {\n    return new Intl.NumberFormat(this.lang(), { style: 'currency', currency }).format(amount);\n  }\n\n  dateISO(isoDate: string): string {\n    return new Intl.DateTimeFormat(this.lang(), { dateStyle: 'medium' }).format(new Date(isoDate));\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now update \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode> to use this service. Import \u003Ccode>\u003Cstrong>LocaleFormatService\u003C\u002Fstrong>\u003C\u002Fcode>, inject it inside the class, and replace the currency and date pipes with the new helper methods. This ensures that totals and issue dates reformat automatically whenever the language changes.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { LocaleFormatService } from '..\u002F..\u002Fcore\u002Flocale-format.service';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Inject the service inside the class:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">private readonly fmt = inject(LocaleFormatService);\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Use \u003Ccode>this.fmt.currency()\u003C\u002Fcode> and \u003Ccode>this.fmt.dateISO()\u003C\u002Fcode> to format totals and issue dates:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { LocaleFormatService } from '..\u002F..\u002Fcore\u002Flocale-format.service';\n\nprivate readonly fmt = inject(LocaleFormatService);\n\n{{ fmt.currency(total, inv.currency) }}\n{{ fmt.dateISO(inv.issueDate) }}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"7-run-verify\">7. Run &amp; verify\u003C\u002Fh3>\u003Cpre>\u003Ccode class=\"language-bash\">npm start\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>Open \u003Cstrong>\u003Ca href=\"http:\u002F\u002Flocalhost:4200\u002F\">http:\u002F\u002Flocalhost:4200\u003C\u002Fa>\u003C\u002Fstrong>.\u003C\u002Fli>\u003Cli>Click \u003Cstrong>+ Sample\u003C\u002Fstrong> — a demo invoice appears instantly.\u003C\u002Fli>\u003Cli>All text and status labels translate dynamically through \u003Cstrong>ngx-translate\u003C\u002Fstrong>, confirming your \u003Cstrong>Angular localization\u003C\u002Fstrong> and store setup work together correctly.\u003C\u002Fli>\u003Cli>Toggle \u003Cstrong>EN\u002FFR\u003C\u002Fstrong> to confirm translations and status labels updates.\u003C\u002Fli>\u003C\u002Ful>\u003Ch2 id=\"3%EF%B8%8F%E2%83%A3-step-3-refactor-the-store\">3️⃣ Step 3: Refactor the store\u003C\u002Fh2>\u003Cp>To keep the store maintainable and modular, we’ll extract helper functions and logic into smaller dedicated files. \u003Cstrong>This makes it easier to reuse utilities across the app \u003C\u002Fstrong>and keeps the core store focused on state management.\u003C\u002Fp>\u003Ch3 id=\"1-create-an-id-generator\">1. Create an ID generator\u003C\u002Fh3>\u003Cp>Start by creating a small ID generator in \u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Fid.ts\u003C\u002Fcode>. This helper provides a lightweight, consistent way to \u003Cstrong>create unique identifiers throughout the app\u003C\u002Fstrong>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export const newId = (): string =&gt;\n  globalThis.crypto?.randomUUID?.() ?? 'id-' + Math.random().toString(36).slice(2);\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, open \u003Ccode>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fcode>, import the helper, and remove the inline ID logic.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { newId } from '..\u002Futils\u002Fid';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Replace every reference to the old generator with newId(). This simple change ensures that \u003Cstrong>all IDs follow a single standard\u003C\u002Fstrong>:\u003C\u002Fp>\u003Cul>\u003Cli>\u003Ccode>sanitizeItem(...)\u003C\u002Fcode>\u003C\u002Fli>\u003Cli>\u003Ccode>sanitizeInvoice(...)\u003C\u002Fcode>\u003C\u002Fli>\u003Cli>\u003Ccode>createDraft(...)\u003C\u002Fcode>\u003C\u002Fli>\u003Cli>\u003Ccode>newLineItem(...)\u003C\u002Fcode>\u003C\u002Fli>\u003C\u002Ful>\u003Ch3 id=\"2-set-up-a-shared-time-utility\">2. Set up a shared time utility\u003C\u002Fh3>\u003Cp>Now let’s move timestamp handling into its own utility. Create \u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Ftime.ts\u003C\u002Fcode> with:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export const isoNow = (): string =&gt; new Date().toISOString();\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then remove the old local helper entirely:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F\u002F const isoNow = () =&gt; new Date().toISOString();\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, import it into the store and delete the inline version to keep date logic consistent and easy to test. \u003C\u002Fp>\u003Cp>Now extract all financial calculations into a reusable module. \u003C\u002Fp>\u003Cp>Create \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Futils\u002Fmoney.ts\u003C\u002Fstrong>\u003C\u002Fcode> and add:\u003C\u002Fp>\u003Cpre>\u003Ccode>import type { LineItem, Invoice } from '..\u002Fmodels\u002Finvoice.model';\n\n\u002F** Calculate a single line item total (after discount, plus tax). *\u002F\nexport function lineTotal(li: LineItem): number {\n  const qty = Math.max(0, li.quantity || 0);\n  const price = Math.max(0, li.unitPrice || 0);\n  const gross = qty * price;\n  const discount = li.discountRate ? gross * (li.discountRate \u002F 100) : 0;\n  const afterDiscount = gross - discount;\n  const tax = li.taxRate ? afterDiscount * (li.taxRate \u002F 100) : 0;\n  return +(afterDiscount + tax).toFixed(2);\n}\n\n\u002F** Sum all line items for an invoice. *\u002F\nexport function invoiceTotal(inv: Invoice): number {\n  return +inv.items.reduce((s, li) =&gt; s + lineTotal(li), 0).toFixed(2);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The store now relies on a shared time utility, keeping the codebase cleaner and easier to maintain.\u003C\u002Fp>\u003Ch3 id=\"3-centralize-the-stores-financial-logic\">3. Centralize the store's financial logic\u003C\u002Fh3>\u003Cp>Let’s \u003Cstrong>clean up the financial logic in the store \u003C\u002Fstrong>by moving all money-related calculations into their own utility file. Start by creating \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Fmoney.ts\u003C\u002Fcode>\u003C\u002Fstrong> and add the following code:\u003C\u002Fp>\u003Cpre>\u003Ccode>import type { LineItem, Invoice } from '..\u002Fmodels\u002Finvoice.model';\n\n\u002F** Calculate a single line item total (after discount, plus tax). *\u002F\nexport function lineTotal(li: LineItem): number {\n  const qty = Math.max(0, li.quantity || 0);\n  const price = Math.max(0, li.unitPrice || 0);\n  const gross = qty * price;\n  const discount = li.discountRate ? gross * (li.discountRate \u002F 100) : 0;\n  const afterDiscount = gross - discount;\n  const tax = li.taxRate ? afterDiscount * (li.taxRate \u002F 100) : 0;\n  return +(afterDiscount + tax).toFixed(2);\n}\n\n\u002F** Sum all line items for an invoice. *\u002F\nexport function invoiceTotal(inv: Invoice): number {\n  return +inv.items.reduce((s, li) =&gt; s + lineTotal(li), 0).toFixed(2);\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, open \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fcode>\u003C\u002Fstrong> and update it to use these new helpers.\u003C\u002Fp>\u003Cp>Add the following import near the top of the file:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { lineTotal, invoiceTotal } from '..\u002Futils\u002Fmoney';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then remove the old inline calculation functions entirely:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">function lineTotal(li: LineItem): number { \u002F* ... *\u002F }\nfunction invoiceTotal(inv: Invoice): number { \u002F* ... *\u002F }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This refactor keeps your store lean and focused while centralizing all currency and total calculations in a single reusable utility.\u003C\u002Fp>\u003Ch3 id=\"4-set-up-serialization\">4. Set up serialization\u003C\u002Fh3>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fpersistence\u002Finvoice.serialization.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import type { Invoice, LineItem } from '..\u002Fmodels\u002Finvoice.model';\nimport { newId } from '..\u002Futils\u002Fid';\nimport { isoNow } from '..\u002Futils\u002Ftime';\n\nfunction num(v: unknown, fallback: number): number {\n  const n = (typeof v === 'number' || typeof v === 'string') ? Number(v) : NaN;\n  return Number.isFinite(n) ? n : fallback;\n}\n\nexport function sanitizeItem(raw: unknown): LineItem {\n  const r = (raw &amp;&amp; typeof raw === 'object') ? raw as Record&lt;string, unknown&gt; : {};\n  return {\n    id: String(r.id ?? newId()),\n    description: String(r.description ?? ''),\n    quantity: num(r.quantity, 1),\n    unitPrice: num(r.unitPrice, 0),\n    taxRate: num(r.taxRate, 0),\n    discountRate: num(r.discountRate, 0),\n  };\n}\n\nconst VALID_STATUS = new Set&lt;Invoice['status']&gt;(['draft', 'sent', 'paid']);\n\nexport function sanitizeInvoice(raw: unknown): Invoice | null {\n  if (!raw || typeof raw !== 'object') return null;\n  const r = raw as Record&lt;string, unknown&gt;;\n\n  const itemsRaw = Array.isArray(r.items) ? r.items : [];\n  const items = itemsRaw.map(sanitizeItem);\n\n  const status = VALID_STATUS.has(r.status as Invoice['status'])\n    ? (r.status as Invoice['status'])\n    : 'draft';\n\n  return {\n    id: String(r.id ?? newId()),\n    number: String(r.number ?? 'INV-XXXX'),\n    clientName: String(r.clientName ?? ''),\n    clientEmail: r.clientEmail ? String(r.clientEmail) : undefined,\n    issueDate: String(r.issueDate ?? new Date().toISOString().slice(0, 10)),\n    dueDate: r.dueDate ? String(r.dueDate) : undefined,\n    currency: String(r.currency ?? 'USD'),\n    items,\n    notes: r.notes ? String(r.notes) : undefined,\n    status,\n    createdAt: String(r.createdAt ?? isoNow()),\n    updatedAt: String(r.updatedAt ?? isoNow()),\n  };\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then update the store to use the serializer. Replace the store file with the version below. Behavior stays the same; the only change is that\u003Cstrong> parsing\u002Fvalidation is delegated to the serializers\u003C\u002Fstrong>.\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, computed, effect, signal } from '@angular\u002Fcore';\nimport { Invoice, InvoiceStatus, LineItem } from '..\u002Fmodels\u002Finvoice.model';\nimport { newId } from '..\u002Futils\u002Fid';\nimport { isoNow } from '..\u002Futils\u002Ftime';\nimport { invoiceTotal } from '..\u002Futils\u002Fmoney';\nimport { sanitizeInvoice } from '..\u002Fpersistence\u002Finvoice.serialization';\n\n\u002F\u002F nstants\nconst STORAGE_KEY = 'invoice.store.v1';\n\n\u002F\u002F load\nfunction load(): Invoice[] {\n  try {\n    const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');\n    if (!Array.isArray(raw)) return [];\n    return raw.map(sanitizeInvoice).filter(Boolean) as Invoice[];\n  } catch {\n    return [];\n  }\n}\n\n\u002F\u002F store\n@Injectable({ providedIn: 'root' })\nexport class InvoiceStore {\n  private readonly _invoices = signal&lt;Invoice[]&gt;(load());\n\n  \u002F\u002F throttle persistence to avoid excessive writes\n  private persistTimer: any = null;\n  private readonly _persist = effect(() =&gt; {\n    const snapshot = this._invoices();\n    clearTimeout(this.persistTimer);\n    this.persistTimer = setTimeout(() =&gt; {\n      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch {}\n    }, 120);\n  });\n\n  \u002F\u002F precompute totals map for O(1) lookup\n  private readonly totalsById = computed(() =&gt; {\n    const map = new Map&lt;string, number&gt;();\n    for (const inv of this._invoices()) map.set(inv.id, invoiceTotal(inv));\n    return map;\n  });\n\n  \u002F\u002F selectors\n  readonly invoices = this._invoices.asReadonly();\n  byId = (id: string) =&gt; computed(() =&gt; this._invoices().find(i =&gt; i.id === id) ?? null);\n  totalAmount = (id: string) =&gt; computed(() =&gt; this.totalsById().get(id) ?? 0);\n  totalCount = computed(() =&gt; this._invoices().length);\n  totalByStatus = (status: InvoiceStatus) =&gt;\n    computed(() =&gt; this._invoices().filter(i =&gt; i.status === status).length);\n\n  \u002F\u002F mutations \n  createDraft(partial?: Partial&lt;Invoice&gt;): Invoice {\n    const id = newId();\n    const created = isoNow();\n    const number = this.nextNumber();\n    const invoice: Invoice = {\n      id,\n      number,\n      clientName: partial?.clientName ?? '',\n      clientEmail: partial?.clientEmail,\n      issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),\n      dueDate: partial?.dueDate,\n      currency: partial?.currency ?? 'USD',\n      items: partial?.items ?? [this.newLineItem()],\n      notes: partial?.notes,\n      status: partial?.status ?? 'draft',\n      createdAt: created,\n      updatedAt: created,\n    };\n    this._invoices.update(arr =&gt; [invoice, ...arr]);\n    return invoice;\n  }\n\n  update(id: string, changes: Partial&lt;Invoice&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; (inv.id === id ? { ...inv, ...changes, updatedAt: isoNow() } : inv)),\n    );\n  }\n\n  remove(id: string): void {\n    this._invoices.update(arr =&gt; arr.filter(inv =&gt; inv.id !== id));\n  }\n\n  \u002F\u002F line items \n  newLineItem(): LineItem {\n    return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };\n  }\n\n  addLineItem(invoiceId: string, li?: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt;\n        inv.id === invoiceId\n          ? {\n              ...inv,\n              items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items],\n              updatedAt: isoNow(),\n            }\n          : inv,\n      ),\n    );\n  }\n\n  updateLineItem(invoiceId: string, itemId: string, changes: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.map(it =&gt; (it.id === itemId ? { ...it, ...changes } : it));\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  removeLineItem(invoiceId: string, itemId: string): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.filter(it =&gt; it.id !== itemId);\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  setStatus(id: string, status: InvoiceStatus): void {\n    this.update(id, { status });\n  }\n\n  \u002F\u002F utilities \n  private nextNumber(): string {\n    const seq = this._invoices().length + 1;\n    return `INV-${String(seq).padStart(4, '0')}`;\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The goal is to \u003Cstrong>keep the store “loose”\u003C\u002Fstrong>: state and mutations live in one place, while parsing\u002Fvalidation and core calculations are modular and reusable.\u003C\u002Fp>\u003Ch3 id=\"5-register-and-connect-the-new-repository\">5. Register and connect the new repository\u003C\u002Fh3>\u003Cp>To finish the persistence setup, register your new repository provider inside the app configuration. Thanks to this, Angular will inject the correct implementation (\u003Ccode>LocalStorageInvoiceRepository\u003C\u002Fcode>) wherever the \u003Ccode>INVOICE_REPOSITORY\u003C\u002Fcode> token is requested.\u003C\u002Fp>\u003Cp>Create \u003Cstrong>\u003Ccode>src\u002Fapp\u002Fcore\u002Fpersistence\u002Finvoice.repository.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { InjectionToken } from '@angular\u002Fcore';\nimport type { Invoice } from '..\u002Fmodels\u002Finvoice.model';\nimport { sanitizeInvoice } from '.\u002Finvoice.serialization';\n\nexport interface InvoiceRepository {\n  load(): Invoice[];\n  save(data: Invoice[]): void;\n}\n\nexport const INVOICE_REPOSITORY = new InjectionToken&lt;InvoiceRepository&gt;('INVOICE_REPOSITORY');\n\nexport class LocalStorageInvoiceRepository implements InvoiceRepository {\n  private readonly KEY = 'invoice.store.v2';\n\n  load(): Invoice[] {\n    try {\n      const raw = localStorage.getItem(this.KEY);\n      if (!raw) return [];\n      const parsed: unknown = JSON.parse(raw);\n\n      \u002F\u002F Accept v1 (array) or v2 ({ version, data })\n      const arr = Array.isArray(parsed)\n        ? parsed\n        : (parsed &amp;&amp; typeof parsed === 'object' &amp;&amp; Array.isArray((parsed as any).data))\n          ? (parsed as any).data\n          : [];\n\n      const out: Invoice[] = [];\n      for (const it of arr) {\n        const inv = sanitizeInvoice(it);\n        if (inv) out.push(inv);\n      }\n      return out;\n    } catch {\n      return [];\n    }\n  }\n\n  save(data: Invoice[]): void {\n    try {\n      const payload = { version: 2, data };\n      localStorage.setItem(this.KEY, JSON.stringify(payload));\n    } catch {\n      \u002F\u002F ignore quota\u002Fsecurity errors\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Update \u003Ccode>src\u002Fapp\u002Fapp.config.ts\u003C\u002Fcode> by adding the provider below your existing imports and configuration:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F\u002F ...existing imports...\nimport { INVOICE_REPOSITORY, LocalStorageInvoiceRepository } from '.\u002Fcore\u002Fpersistence\u002Finvoice.repository';\n\nexport const appConfig: ApplicationConfig = {\n  providers: [\n    \u002F\u002F ...existing providers...\n    { provide: INVOICE_REPOSITORY, useClass: LocalStorageInvoiceRepository },\n  ],\n};\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This registration \u003Cstrong>connects your dependency injection system to the repository clas\u003C\u002Fstrong>s, allowing your store and future services to interact cleanly with invoice data through a unified interface, rather than hard-coding persistence logic.\u003C\u002Fp>\u003Cp>Update the store to use the repository \u003Ccode>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, computed, effect, inject, signal } from '@angular\u002Fcore';\nimport { Invoice, InvoiceStatus, LineItem } from '..\u002Fmodels\u002Finvoice.model';\nimport { newId } from '..\u002Futils\u002Fid';\nimport { isoNow } from '..\u002Futils\u002Ftime';\nimport { invoiceTotal } from '..\u002Futils\u002Fmoney';\nimport { INVOICE_REPOSITORY, InvoiceRepository } from '..\u002Fpersistence\u002Finvoice.repository';\n\n@Injectable({ providedIn: 'root' })\nexport class InvoiceStore {\n  \u002F\u002F repo abstraction (can be swapped via DI)\n  private readonly repo: InvoiceRepository = inject(INVOICE_REPOSITORY);\n\n  \u002F\u002F state\n  private readonly _invoices = signal&lt;Invoice[]&gt;(this.repo.load());\n\n  \u002F\u002F throttle persistence via repo to avoid excessive writes\n  private persistTimer: any = null;\n  private readonly _persist = effect(() =&gt; {\n    const snapshot = this._invoices();\n    clearTimeout(this.persistTimer);\n    this.persistTimer = setTimeout(() =&gt; {\n      this.repo.save(snapshot);\n    }, 120);\n  });\n\n  \u002F\u002F precompute totals map for O(1) lookup\n  private readonly totalsById = computed(() =&gt; {\n    const map = new Map&lt;string, number&gt;();\n    for (const inv of this._invoices()) map.set(inv.id, invoiceTotal(inv));\n    return map;\n  });\n\n  \u002F\u002F selectors\n  readonly invoices = this._invoices.asReadonly();\n  byId = (id: string) =&gt; computed(() =&gt; this._invoices().find(i =&gt; i.id === id) ?? null);\n  totalAmount = (id: string) =&gt; computed(() =&gt; this.totalsById().get(id) ?? 0);\n  totalCount = computed(() =&gt; this._invoices().length);\n  totalByStatus = (status: InvoiceStatus) =&gt;\n    computed(() =&gt; this._invoices().filter(i =&gt; i.status === status).length);\n\n  \u002F\u002F mutations \n  createDraft(partial?: Partial&lt;Invoice&gt;): Invoice {\n    const id = newId();\n    const created = isoNow();\n    const number = this.nextNumber();\n    const invoice: Invoice = {\n      id,\n      number,\n      clientName: partial?.clientName ?? '',\n      clientEmail: partial?.clientEmail,\n      issueDate: partial?.issueDate ?? new Date().toISOString().slice(0, 10),\n      dueDate: partial?.dueDate,\n      currency: partial?.currency ?? 'USD',\n      items: partial?.items ?? [this.newLineItem()],\n      notes: partial?.notes,\n      status: partial?.status ?? 'draft',\n      createdAt: created,\n      updatedAt: created,\n    };\n    this._invoices.update(arr =&gt; [invoice, ...arr]);\n    return invoice;\n  }\n\n  update(id: string, changes: Partial&lt;Invoice&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; (inv.id === id ? { ...inv, ...changes, updatedAt: isoNow() } : inv)),\n    );\n  }\n\n  remove(id: string): void {\n    this._invoices.update(arr =&gt; arr.filter(inv =&gt; inv.id !== id));\n  }\n\n  \u002F\u002F line items \n  newLineItem(): LineItem {\n    return { id: newId(), description: '', quantity: 1, unitPrice: 0, taxRate: 0, discountRate: 0 };\n  }\n\n  addLineItem(invoiceId: string, li?: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt;\n        inv.id === invoiceId\n          ? {\n              ...inv,\n              items: [{ ...this.newLineItem(), ...li, id: newId() }, ...inv.items],\n              updatedAt: isoNow(),\n            }\n          : inv,\n      ),\n    );\n  }\n\n  updateLineItem(invoiceId: string, itemId: string, changes: Partial&lt;LineItem&gt;): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.map(it =&gt; (it.id === itemId ? { ...it, ...changes } : it));\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  removeLineItem(invoiceId: string, itemId: string): void {\n    this._invoices.update(arr =&gt;\n      arr.map(inv =&gt; {\n        if (inv.id !== invoiceId) return inv;\n        const items = inv.items.filter(it =&gt; it.id !== itemId);\n        return { ...inv, items, updatedAt: isoNow() };\n      }),\n    );\n  }\n\n  setStatus(id: string, status: InvoiceStatus): void {\n    this.update(id, { status });\n  }\n\n  \u002F\u002F utilities \n  private nextNumber(): string {\n    const seq = this._invoices().length + 1;\n    return `INV-${String(seq).padStart(4, '0')}`;\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then bind the repository in the app configuration:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { INVOICE_REPOSITORY, LocalStorageInvoiceRepository } from '.\u002Fcore\u002Fpersistence\u002Finvoice.repository';\n\nexport const appConfig: ApplicationConfig = {\n  providers: [\n  \n  ...\n  \n\t\u002F\u002F Repository binding    \n\t{ provide: INVOICE_REPOSITORY, useClass: LocalStorageInvoiceRepository },\n\t\n ],\n};\t\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The store now depends on an abstract \u003Ccode>InvoiceRepository\u003C\u002Fcode>, \u003Ccode>LocalStorageInvoiceRepository\u003C\u002Fcode> implements it, and the app config provides the binding so Angular’s DI injects the correct implementation everywhere. This keeps your state layer \u003Cstrong>clean, testable, and ready for future backends\u003C\u002Fstrong>.\u003C\u002Fp>\u003Ch2 id=\"4%EF%B8%8F%E2%83%A3-step-4-invoice-editor-form\">4️⃣ Step 4: Invoice editor form\u003C\u002Fh2>\u003Cp>Now it's time to replace the generated TypeScript file with this implementation (strongly-typed \u003Cstrong>Reactive Forms\u003C\u002Fstrong>, Tailwind-ready, i18n-friendly, and using your shared money utilities): \u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-jsx\">import { Component, ChangeDetectionStrategy, inject } from '@angular\u002Fcore';\nimport { CommonModule } from '@angular\u002Fcommon';\nimport { RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\nimport {\n  ReactiveFormsModule,\n  NonNullableFormBuilder,\n  FormGroup,\n  FormControl,\n  FormArray,\n  Validators,\n} from '@angular\u002Fforms';\n\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\n\n\u002F\u002F Strongly-typed item group\ntype ItemControls = {\n  description: FormControl&lt;string&gt;;\n  quantity: FormControl&lt;number&gt;;\n  unitPrice: FormControl&lt;number&gt;;\n  taxRate: FormControl&lt;number&gt;;\n  discountRate: FormControl&lt;number&gt;;\n};\ntype ItemGroup = FormGroup&lt;ItemControls&gt;;\n\n\u002F\u002F Root form controls\ntype InvoiceFormControls = {\n  clientName: FormControl&lt;string&gt;;\n  clientEmail: FormControl&lt;string | null&gt;;\n  issueDate: FormControl&lt;string&gt;;\n  dueDate: FormControl&lt;string | null&gt;;\n  currency: FormControl&lt;'USD' | 'EUR' | 'XAF'&gt;;\n  notes: FormControl&lt;string | null&gt;;\n  items: FormArray&lt;ItemGroup&gt;;\n};\ntype InvoiceForm = FormGroup&lt;InvoiceFormControls&gt;;\n\n@Component({\n  selector: 'app-invoice-edit',\n  standalone: true,\n  imports: [CommonModule, RouterModule, TranslateModule, ReactiveFormsModule],\n  templateUrl: '.\u002Finvoice-edit.component.html',\n  styleUrl: '.\u002Finvoice-edit.component.scss',\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceEditComponent {\n  private fb = inject(NonNullableFormBuilder);\n\n  \u002F\u002F Build a new item row with typed controls\n  private createItemGroup(): ItemGroup {\n    return this.fb.group&lt;ItemControls&gt;({\n      description: this.fb.control('', { validators: [] }),\n      quantity: this.fb.control(1, { validators: [Validators.min(0)] }),\n      unitPrice: this.fb.control(0, { validators: [Validators.min(0)] }),\n      taxRate: this.fb.control(0, { validators: [Validators.min(0), Validators.max(100)] }),\n      discountRate: this.fb.control(0, { validators: [Validators.min(0), Validators.max(100)] }),\n    });\n  }\n\n  \u002F\u002F Root form with strict types (non-nullable where appropriate)\n  form: InvoiceForm = this.fb.group&lt;InvoiceFormControls&gt;({\n    clientName: this.fb.control('', { validators: [Validators.required] }),\n    clientEmail: new FormControl&lt;string | null&gt;(null, { nonNullable: false, validators: [Validators.email] }),\n    issueDate: this.fb.control(new Date().toISOString().slice(0, 10)),\n    dueDate: new FormControl&lt;string | null&gt;(null, { nonNullable: false }),\n    currency: this.fb.control&lt;'USD' | 'EUR' | 'XAF'&gt;('USD'),\n    notes: new FormControl&lt;string | null&gt;(null, { nonNullable: false }),\n    items: this.fb.array&lt;ItemGroup&gt;([this.createItemGroup()]),\n  });\n\n  \u002F\u002F items helpers \n  get items(): FormArray&lt;ItemGroup&gt; {\n    return this.form.controls.items;\n  }\n\n  addItem(): void {\n    this.items.push(this.createItemGroup());\n  }\n\n  removeItem(i: number): void {\n    this.items.removeAt(i);\n  }\n\n  \u002F\u002F totals\n  lineTotalAt(index: number): number {\n    const g = this.items.at(index).getRawValue(); \n    return lineTotal({\n      id: 'tmp',\n      description: g.description,\n      quantity: g.quantity,\n      unitPrice: g.unitPrice,\n      taxRate: g.taxRate,\n      discountRate: g.discountRate,\n    });\n  }\n\n  grandTotal(): number {\n    const v = this.form.getRawValue(); \n    return invoiceTotal({\n      id: 'tmp',\n      number: 'INV-TMP',\n      clientName: v.clientName,\n      clientEmail: v.clientEmail ?? undefined,\n      issueDate: v.issueDate,\n      dueDate: v.dueDate ?? undefined,\n      currency: v.currency,\n      items: v.items.map(it =&gt; {\n        const row = it;\n        return {\n          id: 'tmp',\n          description: row.description,\n          quantity: row.quantity,\n          unitPrice: row.unitPrice,\n          taxRate: row.taxRate,\n          discountRate: row.discountRate,\n        };\n      }),\n      notes: v.notes ?? undefined,\n      status: 'draft',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    });\n  }\n\n  \u002F\u002F actions\n  save(): void {\n    if (this.form.invalid) {\n      this.form.markAllAsTouched();\n      return;\n    }\n    \u002F\u002F For now, just demo; next micro-step will persist via InvoiceStore.\n    \u002F\u002F Typed value:\n    const value = this.form.getRawValue();\n    console.log('[invoice-edit] value', value);\n    alert('Form captured (no persistence yet). Next step: connect to store.');\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Update the \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.html\u003C\u002Fstrong>\u003C\u002Fcode>template, which lays out the client details, line items, and totals for the Invoice Editor using Tailwind utilities and ReactiveFormsModule bindings. It supports adding\u002Fremoving items, shows each row’s computed total, and displays the grand total with simple, accessible form controls.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;form class=\"max-w-4xl mx-auto p-6 space-y-6\" [formGroup]=\"form\" (ngSubmit)=\"save()\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.edit.title' | translate }}&lt;\u002Fh2&gt;\n\n  &lt;!-- Client block --&gt;\n  &lt;div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"&gt;\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientName' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"text\" formControlName=\"clientName\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientEmail' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"email\" formControlName=\"clientEmail\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.issueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"issueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.dueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"dueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.currency' | translate }}&lt;\u002Fspan&gt;\n      &lt;select formControlName=\"currency\" class=\"rounded-lg border p-2\"&gt;\n        &lt;option value=\"USD\"&gt;USD&lt;\u002Foption&gt;\n        &lt;option value=\"EUR\"&gt;EUR&lt;\u002Foption&gt;\n        &lt;option value=\"XAF\"&gt;XAF&lt;\u002Foption&gt;\n      &lt;\u002Fselect&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1 md:col-span-2\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.notes' | translate }}&lt;\u002Fspan&gt;\n      &lt;textarea formControlName=\"notes\" rows=\"3\" class=\"rounded-lg border p-2\"&gt;&lt;\u002Ftextarea&gt;\n    &lt;\u002Flabel&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Items --&gt;\n  &lt;div class=\"space-y-3\"&gt;\n    &lt;div class=\"flex items-center justify-between\"&gt;\n      &lt;h3 class=\"text-lg font-semibold\"&gt;{{ 'invoice.form.items' | translate }}&lt;\u002Fh3&gt;\n      &lt;button type=\"button\" class=\"btn\" (click)=\"addItem()\"&gt;{{ 'invoice.actions.addItem' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n\n    &lt;div formArrayName=\"items\" class=\"space-y-2\"&gt;\n      &lt;div *ngFor=\"let g of items.controls; let i = index\" [formGroupName]=\"i\"\n           class=\"grid grid-cols-12 gap-2 items-center border rounded-lg p-3\"&gt;\n        &lt;input class=\"col-span-5 rounded-lg border p-2\" type=\"text\" placeholder=\"{{ 'invoice.form.item.description' | translate }}\"\n               formControlName=\"description\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"1\"\n               formControlName=\"quantity\" placeholder=\"{{ 'invoice.form.item.quantity' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"0.01\"\n               formControlName=\"unitPrice\" placeholder=\"{{ 'invoice.form.item.unitPrice' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"taxRate\" placeholder=\"{{ 'invoice.form.item.taxRate' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"discountRate\" placeholder=\"{{ 'invoice.form.item.discountRate' | translate }}\" \u002F&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-10 text-sm text-gray-600 md:text-right\"&gt;\n          {{ 'invoice.form.item.total' | translate }}:\n          &lt;strong&gt;{{ lineTotalAt(i) }}&lt;\u002Fstrong&gt;\n        &lt;\u002Fdiv&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-2 flex justify-end\"&gt;\n          &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"removeItem(i)\"&gt;\n            {{ 'invoice.actions.removeItem' | translate }}\n          &lt;\u002Fbutton&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Totals + actions --&gt;\n  &lt;div class=\"flex items-center justify-between border-t pt-4\"&gt;\n    &lt;div class=\"text-lg\"&gt;\n      {{ 'invoice.form.total' | translate }}:\n      &lt;strong&gt;{{ grandTotal() }}&lt;\u002Fstrong&gt;\n    &lt;\u002Fdiv&gt;\n    &lt;div class=\"flex gap-2\"&gt;\n      &lt;a routerLink=\"\u002F\" class=\"btn-secondary\"&gt;{{ 'nav.back' | translate }}&lt;\u002Fa&gt;\n      &lt;button type=\"submit\" class=\"btn\"&gt;{{ 'invoice.actions.save' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fform&gt;\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then replace the \u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.html\u003C\u002Fcode>\u003Cstrong> \u003C\u002Fstrong>with the version below. It binds a typed Reactive Form, uses Tailwind for layout, supports add\u002Fremove rows, shows each row’s computed total, and displays the grand total.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;form class=\"max-w-4xl mx-auto p-6 space-y-6\" [formGroup]=\"form\" (ngSubmit)=\"save()\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.edit.title' | translate }}&lt;\u002Fh2&gt;\n\n  &lt;!-- Client block --&gt;\n  &lt;div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"&gt;\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientName' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"text\" formControlName=\"clientName\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.clientEmail' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"email\" formControlName=\"clientEmail\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.issueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"issueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.dueDate' | translate }}&lt;\u002Fspan&gt;\n      &lt;input type=\"date\" formControlName=\"dueDate\" class=\"rounded-lg border p-2\" \u002F&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.currency' | translate }}&lt;\u002Fspan&gt;\n      &lt;select formControlName=\"currency\" class=\"rounded-lg border p-2\"&gt;\n        &lt;option value=\"USD\"&gt;USD&lt;\u002Foption&gt;\n        &lt;option value=\"EUR\"&gt;EUR&lt;\u002Foption&gt;\n        &lt;option value=\"XAF\"&gt;XAF&lt;\u002Foption&gt;\n      &lt;\u002Fselect&gt;\n    &lt;\u002Flabel&gt;\n\n    &lt;label class=\"flex flex-col gap-1 md:col-span-2\"&gt;\n      &lt;span class=\"text-sm text-gray-600\"&gt;{{ 'invoice.form.notes' | translate }}&lt;\u002Fspan&gt;\n      &lt;textarea formControlName=\"notes\" rows=\"3\" class=\"rounded-lg border p-2\"&gt;&lt;\u002Ftextarea&gt;\n    &lt;\u002Flabel&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Items --&gt;\n  &lt;div class=\"space-y-3\"&gt;\n    &lt;div class=\"flex items-center justify-between\"&gt;\n      &lt;h3 class=\"text-lg font-semibold\"&gt;{{ 'invoice.form.items' | translate }}&lt;\u002Fh3&gt;\n      &lt;button type=\"button\" class=\"btn\" (click)=\"addItem()\"&gt;{{ 'invoice.actions.addItem' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n\n    &lt;div formArrayName=\"items\" class=\"space-y-2\"&gt;\n      &lt;div *ngFor=\"let g of items.controls; let i = index\" [formGroupName]=\"i\"\n           class=\"grid grid-cols-12 gap-2 items-center border rounded-lg p-3\"&gt;\n        &lt;input class=\"col-span-5 rounded-lg border p-2\" type=\"text\"\n               placeholder=\"{{ 'invoice.form.item.description' | translate }}\"\n               formControlName=\"description\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"1\"\n               formControlName=\"quantity\" placeholder=\"{{ 'invoice.form.item.quantity' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-2 rounded-lg border p-2\" type=\"number\" min=\"0\" step=\"0.01\"\n               formControlName=\"unitPrice\" placeholder=\"{{ 'invoice.form.item.unitPrice' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"taxRate\" placeholder=\"{{ 'invoice.form.item.taxRate' | translate }}\" \u002F&gt;\n\n        &lt;input class=\"col-span-1 rounded-lg border p-2\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\"\n               formControlName=\"discountRate\" placeholder=\"{{ 'invoice.form.item.discountRate' | translate }}\" \u002F&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-10 text-sm text-gray-600 md:text-right\"&gt;\n          {{ 'invoice.form.item.total' | translate }}:\n          &lt;strong&gt;{{ lineTotalAt(i) }}&lt;\u002Fstrong&gt;\n        &lt;\u002Fdiv&gt;\n\n        &lt;div class=\"col-span-12 md:col-span-2 flex justify-end\"&gt;\n          &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"removeItem(i)\"&gt;\n            {{ 'invoice.actions.removeItem' | translate }}\n          &lt;\u002Fbutton&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;!-- Totals + actions --&gt;\n  &lt;div class=\"flex items-center justify-between border-t pt-4\"&gt;\n    &lt;div class=\"text-lg\"&gt;\n      {{ 'invoice.form.total' | translate }}:\n      &lt;strong&gt;{{ grandTotal() }}&lt;\u002Fstrong&gt;\n    &lt;\u002Fdiv&gt;\n    &lt;div class=\"flex gap-2\"&gt;\n      &lt;a routerLink=\"\u002F\" class=\"btn-secondary\"&gt;{{ 'nav.back' | translate }}&lt;\u002Fa&gt;\n      &lt;button type=\"submit\" class=\"btn\"&gt;{{ 'invoice.actions.save' | translate }}&lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fform&gt;\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Don’t forget to update the translations:\u003C\u002Fp>\u003Cpre>\u003Ccode>{\n  \"invoice\": {\n    \"form\": {\n      \"clientName\": \"Client name\",\n      \"clientEmail\": \"Client email\",\n      \"issueDate\": \"Issue date\",\n      \"dueDate\": \"Due date\",\n      \"currency\": \"Currency\",\n      \"notes\": \"Notes\",\n      \"items\": \"Items\",\n      \"item\": {\n        \"description\": \"Description\",\n        \"quantity\": \"Qty\",\n        \"unitPrice\": \"Unit price\",\n        \"taxRate\": \"Tax %\",\n        \"discountRate\": \"Discount %\",\n        \"total\": \"Line total\"\n      },\n      \"total\": \"Total\"\n    },\n    \"actions\": {\n      \"addItem\": \"Add item\",\n      \"removeItem\": \"Remove\",\n      \"save\": \"Save\"\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cpre>\u003Ccode>```tsx\n{\n  \"invoice\": {\n    \"form\": {\n      \"clientName\": \"Nom du client\",\n      \"clientEmail\": \"Email du client\",\n      \"issueDate\": \"Date d'émission\",\n      \"dueDate\": \"Date d'échéance\",\n      \"currency\": \"Devise\",\n      \"notes\": \"Notes\",\n      \"items\": \"Articles\",\n      \"item\": {\n        \"description\": \"Description\",\n        \"quantity\": \"Qté\",\n        \"unitPrice\": \"Prix unitaire\",\n        \"taxRate\": \"TVA %\",\n        \"discountRate\": \"Remise %\",\n        \"total\": \"Total ligne\"\n      },\n      \"total\": \"Total\"\n    },\n    \"actions\": {\n      \"addItem\": \"Ajouter un article\",\n      \"removeItem\": \"Supprimer\",\n      \"save\": \"Enregistrer\"\n    }\n  }\n}\n```\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now wire the Invoice Editor to the store, create on \u003Ccode>\u002Finvoice\u002Fnew\u003C\u002Fcode>, update on \u003Ccode>\u002Finvoice\u002F:id\u003C\u002Fcode>.\u003C\u002Fp>\u003Cp>Only one file changes. The editor now loads an existing invoice (when \u003Ccode>:id\u003C\u002Fcode> is present), patches the form, and saves either a \u003Cstrong>new draft\u003C\u002Fstrong> or an \u003Cstrong>update\u003C\u002Fstrong> to the store.\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Finvoice-edit\u002Finvoice-edit.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>...\n\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\nimport { InvoiceStore } from '..\u002F..\u002Fcore\u002Fstores\u002Finvoice.store';\nimport type { Invoice } from '..\u002F..\u002Fcore\u002Fmodels\u002Finvoice.model';\n\n....\n\n@Component({\n  selector: 'app-invoice-edit',\n  standalone: true,\n  imports: [CommonModule, RouterModule, TranslateModule, ReactiveFormsModule],\n  templateUrl: '.\u002Finvoice-edit.html',\n  styleUrl: '.\u002Finvoice-edit.scss',\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceEditComponent {\n  ...\n  private route = inject(ActivatedRoute);\n  private store = inject(InvoiceStore);\n\n  \u002F\u002F when editing, keep refs to id + current invoice\n  private editingId: string | null = null;\n  private current: Invoice | null = null;\n\n  ...\n\n  constructor() {\n    \u002F\u002F detect \u002Finvoice\u002Fnew vs \u002Finvoice\u002F:id and hydrate if editing\n    this.route.paramMap.subscribe(p =&gt; {\n      const id = p.get('id');\n      this.editingId = id;\n      if (id) {\n        const inv = this.store.byId(id)();\n        this.current = inv ?? null;\n        if (inv) this.setFormFromInvoice(inv);\n      } else {\n        this.current = null; \u002F\u002F creating new\n      }\n    });\n  }\n\n...\n\n  \u002F\u002F ---------- hydrate form for edit ----------\n  private setFormFromInvoice(inv: Invoice): void {\n    this.form.patchValue({\n      clientName: inv.clientName,\n      clientEmail: inv.clientEmail ?? null,\n      issueDate: inv.issueDate,\n      dueDate: inv.dueDate ?? null,\n      currency: inv.currency,\n      notes: inv.notes ?? null,\n    });\n\n    this.items.clear();\n    inv.items.forEach(it =&gt; {\n      const g = this.createItemGroup();\n      g.patchValue({\n        description: it.description,\n        quantity: it.quantity,\n        unitPrice: it.unitPrice,\n        taxRate: it.taxRate ?? 0,\n        discountRate: it.discountRate ?? 0,\n      });\n      this.items.push(g);\n    });\n  }\n\n  \u002F\u002F replace the previous save() placeholder with create\u002Fupdate via store\n  save(): void {\n    if (this.form.invalid) {\n      this.form.markAllAsTouched();\n      return;\n    }\n\n    const v = this.form.getRawValue();\n\n    \u002F\u002F rebuild items; preserve existing IDs when editing\n    const items = v.items.map((row, idx) =&gt; ({\n      id: this.current?.items[idx]?.id ?? this.store.newLineItem().id,\n      description: row.description,\n      quantity: row.quantity,\n      unitPrice: row.unitPrice,\n      taxRate: row.taxRate,\n      discountRate: row.discountRate,\n    }));\n\n    if (this.editingId) {\n      this.store.update(this.editingId, {\n        clientName: v.clientName,\n        clientEmail: v.clientEmail ?? undefined,\n        issueDate: v.issueDate,\n        dueDate: v.dueDate ?? undefined,\n        currency: v.currency,\n        notes: v.notes ?? undefined,\n        items,\n      });\n    } else {\n      this.store.createDraft({\n        clientName: v.clientName,\n        clientEmail: v.clientEmail ?? undefined,\n        issueDate: v.issueDate,\n        dueDate: v.dueDate ?? undefined,\n        currency: v.currency,\n        notes: v.notes ?? undefined,\n        items,\n        status: 'draft',\n      });\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>That’s all you need to switch between \u003Cstrong>create\u003C\u002Fstrong> (\u003Ccode>\u002Finvoice\u002Fnew\u003C\u002Fcode>) and \u003Cstrong>edit\u003C\u002Fstrong> (\u003Ccode>\u002Finvoice\u002F:id\u003C\u002Fcode>) with the store, without dumping the whole file.\u003C\u002Fp>\u003Cp>Let’s make rows editable and add inline actions.\u003C\u002Fp>\u003Ch3 id=\"1-make-invoice-number-a-link\">1. Make invoice number a link\u003C\u002Fh3>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">- &lt;div class=\"font-semibold\"&gt;{{ inv.number }}&lt;\u002Fdiv&gt;\n+ &lt;a [routerLink]=\"['\u002Finvoice', inv.id]\" class=\"font-semibold hover:underline\"&gt;\n+   {{ inv.number }}\n+ &lt;\u002Fa&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"2-add-row-actions-edit-mark-sent-mark-paid-delete\">2. Add row actions (Edit \u002F Mark Sent \u002F Mark Paid \u002F Delete)\u003C\u002Fh3>\u003Cp>Go to\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>\u003C\u002Fstrong> inside the same \u003Ccode>@for\u003C\u002Fcode> row and append this to the \u003Cstrong>right-side info\u003C\u002Fstrong> block (after total):\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- actions --&gt;\n&lt;div class=\"flex items-center gap-2 ml-4\"&gt;\n  &lt;a [routerLink]=\"['\u002Finvoice', inv.id]\" class=\"btn-secondary\"&gt;\n    {{ 'invoice.actions.edit' | translate }}\n  &lt;\u002Fa&gt;\n\n  @if (inv.status === 'draft') {\n    &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markAsSent(inv.id)\"&gt;\n      {{ 'invoice.actions.markSent' | translate }}\n    &lt;\u002Fbutton&gt;\n  } @else if (inv.status === 'sent') {\n    &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markAsPaid(inv.id)\"&gt;\n      {{ 'invoice.actions.markPaid' | translate }}\n    &lt;\u002Fbutton&gt;\n  }\n\n  &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"remove(inv.id)\"&gt;\n    {{ 'invoice.actions.delete' | translate }}\n  &lt;\u002Fbutton&gt;\n&lt;\u002Fdiv&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"3-add-the-handlers\">3. Add the handlers\u003C\u002Fh3>\u003Cp>Add these methods inside the \u003Ccode>DashboardComponent\u003C\u002Fcode> class \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.ts\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">markAsSent(id: string): void {\n  this.store.setStatus(id, 'sent');\n}\n\nmarkAsPaid(id: string): void {\n  this.store.setStatus(id, 'paid');\n}\n\nremove(id: string): void {\n  if (confirm('Delete this invoice?')) {\n    this.store.remove(id);\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-update-strings\">4. Update strings\u003C\u002Fh3>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"actions\": {\n      \"edit\": \"Edit\",\n      \"markSent\": \"Mark as sent\",\n      \"markPaid\": \"Mark as paid\",\n      \"delete\": \"Delete\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Cstrong>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"actions\": {\n      \"edit\": \"Modifier\",\n      \"markSent\": \"Marquer comme envoyée\",\n      \"markPaid\": \"Marquer comme payée\",\n      \"delete\": \"Supprimer\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now \u003Cstrong>the invoice ID are clickable, rows have clear actions, and status transitions happen with one click\u003C\u002Fstrong>, all persisted via your repository-backed store. To finish off this step, run a quick check with \u003Ccode>npm start\u003C\u002Fcode> to test that the edit, language switch, invoice status and delete operations work well. \u003C\u002Fp>\u003Cp>Let’s enable PWA support cleanly in the next step.\u003C\u002Fp>\u003Ch2 id=\"5%EF%B8%8F%E2%83%A3-step-5-feature-core-pwa-scaffold\">5️⃣ Step 5: Feature core-PWA scaffold\u003C\u002Fh2>\u003Cp>We’ll enable installable, offline-first behavior and\u003Cstrong> lay the groundwork for small UX helpers\u003C\u002Fstrong> (online\u002Foffline signal and “Install app” prompt). The idea is to keep it minimal and production-ready.\u003C\u002Fp>\u003Ch3 id=\"1-install-pwa-support\">1. Install PWA support\u003C\u002Fh3>\u003Cp>Run the Angular schematic via your project script:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run ng -- add @angular\u002Fpwa\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This adds \u003Ccode>@angular\u002Fservice-worker\u003C\u002Fcode>, creates \u003Ccode>ngsw-config.json\u003C\u002Fcode>, drops \u003Ccode>public\u002Fmanifest.webmanifest\u003C\u002Fcode> and icons, and wires the worker.\u003C\u002Fp>\u003Ch3 id=\"2-test-in-production-mode\">2. Test in production mode\u003C\u002Fh3>\u003Cp>Build and serve the production output so the Service Worker can activate:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run build -- --configuration=production\nnpx http-server .\u002Fdist\u002Finvoice-pwa\u002Fbrowser -p 4200 -c-1\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Open \u003Ccode>http:\u002F\u002Flocalhost:4200\u003C\u002Fcode>. In DevTools &gt; Application &gt; Service Workers, confirm \u003Ccode>ngsw-worker.js\u003C\u002Fcode> is \u003Cstrong>activated\u003C\u002Fstrong>. Toggle \u003Cstrong>Offline\u003C\u002Fstrong> in the Network tab and reload; the app should still load.\u003C\u002Fp>\u003Ch3 id=\"3-create-a-tiny-pwa-service\">3. Create a tiny PWA service\u003C\u002Fh3>\u003Cp>This service exposes three simple signals you can bind to the UI: \u003Cstrong>\u003Ccode>canInstall\u003C\u002Fcode> \u003C\u002Fstrong>(show an “Install app” button), \u003Ccode>\u003Cstrong>isOnline\u003C\u002Fstrong>\u003C\u002Fcode>(online\u002Foffline badge), and \u003Ccode>\u003Cstrong>isStandalone\u003C\u002Fstrong>\u003C\u002Fcode> (running as an installed PWA). It also provides an \u003Ccode>install()\u003C\u002Fcode> method that triggers the browser’s install prompt.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fpwa\u002Fpwa.service.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal, computed } from '@angular\u002Fcore';\n\ntype BeforeInstallPromptEvent = Event &amp; {\n  prompt: () =&gt; Promise&lt;void&gt;;\n  userChoice: Promise&lt;{ outcome: 'accepted' | 'dismissed'; platform: string }&gt;;\n};\n\n@Injectable({ providedIn: 'root' })\nexport class PwaService {\n  private deferred: BeforeInstallPromptEvent | null = null;\n\n  \u002F\u002F shows the “Install” button when true\n  readonly canInstall = signal(false);\n\n  \u002F\u002F are we already installed?\n  readonly isStandalone = computed(() =&gt; {\n    \u002F\u002F iOS Safari\n    const iosStandalone = (navigator as any).standalone === true;\n    \u002F\u002F All modern browsers\n    const displayModeStandalone = window.matchMedia?.('(display-mode: standalone)').matches;\n    return iosStandalone || displayModeStandalone;\n  });\n\n  constructor() {\n    \u002F\u002F Fired when the browser thinks the app is installable\n    window.addEventListener('beforeinstallprompt', (e: Event) =&gt; {\n      e.preventDefault(); \u002F\u002F don't show the mini-infobar\n      this.deferred = e as BeforeInstallPromptEvent;\n      this.canInstall.set(!this.isStandalone());\n    });\n\n    \u002F\u002F Fired after a successful install\n    window.addEventListener('appinstalled', () =&gt; {\n      this.deferred = null;\n      this.canInstall.set(false);\n    });\n  }\n\n  async promptInstall(): Promise&lt;void&gt; {\n    if (!this.deferred) return;\n    this.canInstall.set(false);\n    await this.deferred.prompt();\n    try {\n      await this.deferred.userChoice; \u002F\u002F optional: inspect outcome\n    } finally {\n      this.deferred = null;\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"4-update-the-component-class\">4. Update the component class\u003C\u002Fh3>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">...\n\nimport { PwaService } from '.\u002Fcore\u002Fpwa\u002Fpwa.service';\n\n...\n\nexport class App {\n  private readonly lang = inject(LanguageService);\n  readonly pwa = inject(PwaService);\n\n  setLang(l: 'en' | 'fr') {\n    this.lang.use(l);\n  }\n\n  install() {\n    this.pwa.promptInstall();\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"5-add-the-button-to-the-template\">5. Add the button to the template\u003C\u002Fh3>\u003Cp>Add the install button next to EN\u002FFR in \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.html\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;header class=\"flex items-center gap-3 p-4 border-b\"&gt;\n  &lt;h1 class=\"text-xl font-semibold\"&gt;{{ 'app.title' | translate }}&lt;\u002Fh1&gt;\n\n  &lt;div class=\"ml-auto flex items-center gap-2\"&gt;\n    &lt;!-- Install button appears only when available and not already installed --&gt;\n    @if (pwa.canInstall() &amp;&amp; !pwa.isStandalone()) {\n      &lt;button type=\"button\" class=\"btn\" (click)=\"install()\"&gt;\n        {{ 'app.install' | translate }}\n      &lt;\u002Fbutton&gt;\n    }\n    ...\n    \n  &lt;\u002Fdiv&gt;\n&lt;\u002Fheader&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add translation key on \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"install\": \"Install app\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"install\": \"Installer l’application\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now test the button. Make sure you’re on the \u003Cstrong>production build\u003C\u002Fstrong> served over HTTP(s).\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">npm run build -- --configuration=production\nnpx http-server .\u002Fdist\u002Finvoice-pwa\u002Fbrowser -p 4200 -c-1\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Col>\u003Cli>Open \u003Ccode>http:\u002F\u002Flocalhost:4200\u003C\u002Fcode>.\u003C\u002Fli>\u003Cli>You should see the \u003Cstrong>Install app\u003C\u002Fstrong> button when:\u003C\u002Fli>\u003C\u002Fol>\u003Cul>\u003Cli>Not already installed.\u003C\u002Fli>\u003Cli>Browser deems it installable (has manifest, SW active, visited at least once).\u003C\u002Fli>\u003C\u002Ful>\u003Cp>3. Click \u003Cstrong>Install app\u003C\u002Fstrong> &gt; the native install dialog appears.\u003C\u002Fp>\u003Cp>4. After installing, the button disappears.\u003C\u002Fp>\u003Ch3 id=\"6-add-connectivity-toasts-offline-back-online\">6. Add connectivity toasts (offline &amp; back online)\u003C\u002Fh3>\u003Cp>Here you’ll display a small banner when the app goes \u003Cstrong>offline\u003C\u002Fstrong> and a quick green flash when it comes \u003Cstrong>back online\u003C\u002Fstrong>. This improves UX by giving \u003Cstrong>clear, instant feedback about network status\u003C\u002Fstrong>. \u003C\u002Fp>\u003Cp>It only takes four lightweight parts: a network status service, a toast banner, a quick wire-up in the app shell, and two i18n entries. Follow these steps:\u003C\u002Fp>\u003Cul>\u003Cli>\u003Cstrong>Create the network service\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Tracks \u003Ccode>online\u003C\u002Fcode>\u002F\u003Ccode>offline\u003C\u002Fcode> state using signals, and flashes a short \u003Ccode>Back online\u003C\u002Fcode> message after reconnection, too.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fnetwork\u002Fnetwork.service.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal } from '@angular\u002Fcore';\n\n@Injectable({ providedIn: 'root' })\nexport class NetworkService {\n  \u002F\u002F true = online, false = offline\n  readonly online = signal&lt;boolean&gt;(navigator.onLine);\n\n  \u002F\u002F flash \"Back online\" for a few seconds after reconnection\n  readonly flashOnline = signal&lt;boolean&gt;(false);\n\n  private timer: any = null;\n\n  constructor() {\n    window.addEventListener('online', () =&gt; {\n      this.online.set(true);\n      this.flashOnline.set(true);\n      clearTimeout(this.timer);\n      this.timer = setTimeout(() =&gt; this.flashOnline.set(false), 3000);\n    });\n\n    window.addEventListener('offline', () =&gt; {\n      this.online.set(false);\n      this.flashOnline.set(false);\n      clearTimeout(this.timer);\n    });\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Expose the service in the app shell\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Expose the network status in the app shell so the template (and the offline toast) can read it.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>...\nimport { PwaService } from '.\u002Fcore\u002Fpwa\u002Fpwa.service';\nimport { NetworkService } from '.\u002Fcore\u002Fnetwork\u002Fnetwork.service';\n\n...\nexport class App {\n  ...\n  readonly net = inject(NetworkService);\n\n ...\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Add toast banners to the template\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Place the toaster exactly at the end of \u003Ccode>app.html\u003C\u002Fcode> after \u003Ccode>&lt;\u002Fmain&gt;\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- Offline \u002F Online toasts --&gt;\n@if (!net.online()) {\n  &lt;div class=\"fixed bottom-4 left-1\u002F2 -translate-x-1\u002F2 bg-red-600 text-white px-4 py-2 rounded-lg shadow\n              flex items-center gap-2 z-50\"\n       role=\"status\" aria-live=\"polite\"&gt;\n    {{ 'app.offline' | translate }}\n  &lt;\u002Fdiv&gt;\n}\n@if (net.flashOnline()) {\n  &lt;div class=\"fixed bottom-4 left-1\u002F2 -translate-x-1\u002F2 bg-green-600 text-white px-4 py-2 rounded-lg shadow\n              flex items-center gap-2 z-50\"\n       role=\"status\" aria-live=\"polite\"&gt;\n    {{ 'app.backOnline' | translate }}\n  &lt;\u002Fdiv&gt;\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Like always, add the translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"offline\": \"You’re offline. Some features may be unavailable.\",\n    \"backOnline\": \"Back online\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"offline\": \"Vous êtes hors ligne. Certaines fonctionnalités peuvent être indisponibles.\",\n    \"backOnline\": \"De retour en ligne\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now let's test:\u003C\u002Fp>\u003Cul>\u003Cli>In DevTools &gt; \u003Cstrong>Network\u003C\u002Fstrong> &gt; set throttling to \u003Cstrong>Offline\u003C\u002Fstrong> &gt; the \u003Cstrong>red\u003C\u002Fstrong> toast appears.\u003C\u002Fli>\u003Cli>If you switch back to \u003Cstrong>Online\u003C\u002Fstrong> &gt; a \u003Cstrong>green\u003C\u002Fstrong> “Back online” toast flashes for ~3s.\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Next, we’ll extend this feature core by adding new version available, \u003Cstrong>search, filter, sort, and CSV export\u002Fimport logic \u003C\u002Fstrong>to the dashboard.\u003C\u002Fp>\u003Ch3 id=\"7-new-version-available-toast\">7. New version available toast\u003C\u002Fh3>\u003Cp>Here you will show a small toast when a fresh build is ready. One tap, it reloads into the new version. Note that this runs only on production builds.\u003C\u002Fp>\u003Cul>\u003Cli>\u003Cstrong>Create the update service\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fpwa\u002Fupdate.service.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Injectable, signal } from '@angular\u002Fcore';\nimport { SwUpdate } from '@angular\u002Fservice-worker';\n\n@Injectable({ providedIn: 'root' })\nexport class UpdateService {\n  \u002F** Show the “Update available” toast *\u002F\n  readonly updateAvailable = signal&lt;boolean&gt;(false);\n\n  \u002F** Disable the button and show progress text while activating *\u002F\n  readonly installing = signal&lt;boolean&gt;(false);\n\n  constructor(private sw: SwUpdate) {\n    \u002F\u002F SW is only enabled in production; guard in dev\n    if (!this.sw.isEnabled) return;\n\n    \u002F\u002F Listen for version events; when a new version is ready, show the toast\n    this.sw.versionUpdates.subscribe(evt =&gt; {\n      if ((evt as any).type === 'VERSION_READY') {\n        this.updateAvailable.set(true);\n      }\n      \u002F\u002F Optional: handle failures\n      if ((evt as any).type === 'VERSION_INSTALLATION_FAILED') {\n        \u002F\u002F Could log or surface a subtle warning if you want\n      }\n    });\n\n    \u002F\u002F Also check on app focus (useful if the tab was idle)\n    window.addEventListener('focus', () =&gt; {\n      this.checkForUpdates();\n    });\n\n    \u002F\u002F Initial check shortly after boot\n    setTimeout(() =&gt; this.checkForUpdates(), 5_000);\n\n    \u002F\u002F Periodic check every 6 hours\n    setInterval(() =&gt; this.checkForUpdates(), 6 * 60 * 60 * 1000);\n  }\n\n  async checkForUpdates(): Promise&lt;void&gt; {\n    if (!this.sw.isEnabled) return;\n    try {\n      await this.sw.checkForUpdate();\n    } catch {\n      \u002F\u002F ignore network errors\n    }\n  }\n\n  \u002F** Activate the new version and reload the app *\u002F\n  async activateAndReload(): Promise&lt;void&gt; {\n    if (!this.sw.isEnabled) return;\n    this.installing.set(true);\n    try {\n      await this.sw.activateUpdate();\n    } finally {\n      \u002F\u002F Reload to load the fresh version (even if activateUpdate failed, reload is harmless)\n      document.location.reload();\n    }\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Expose it in the root component\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Inject the service and add a tiny handler for the button.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>...\n\nimport { UpdateService } from '.\u002Fcore\u002Fpwa\u002Fupdate.service';\n\n...\n\nexport class App {\n  \n  readonly upd = inject(UpdateService);\n\n \n  reloadApp() { this.upd.activateAndReload(); }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cul>\u003Cli>\u003Cstrong>Add the update toast\u003C\u002Fstrong>\u003C\u002Fli>\u003C\u002Ful>\u003Cp>Place the code below at the very end of the template, after your offline\u002Fonline toasts.\u003C\u002Fp>\u003Cp>File: \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fapp.html\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- Update available toast --&gt;\n@if (upd.updateAvailable()) {\n  &lt;div class=\"fixed bottom-4 left-1\u002F2 -translate-x-1\u002F2 bg-blue-600 text-white px-4 py-2 rounded-lg shadow\n              flex items-center gap-3 z-50\"\n       role=\"status\" aria-live=\"polite\"&gt;\n    {{ 'app.updateAvailable' | translate }}\n    &lt;button class=\"btn ml-2\"\n            [disabled]=\"upd.installing()\"\n            (click)=\"reloadApp()\"&gt;\n      @if (upd.installing()) {\n        {{ 'app.updating' | translate }}\n      } @else {\n        {{ 'app.reload' | translate }}\n      }\n    &lt;\u002Fbutton&gt;\n  &lt;\u002Fdiv&gt;\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And don’t forget to update your translations!\u003C\u002Fp>\u003Cp>File: \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"updateAvailable\": \"A new version is available.\",\n    \"reload\": \"Reload\",\n    \"updating\": \"Updating…\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>File: \u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"app\": {\n    \"updateAvailable\": \"Une nouvelle version est disponible.\",\n    \"reload\": \"Recharger\",\n    \"updating\": \"Mise à jour…\"\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Build, serve, and then watch the toast appear.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\"># Build v1\nnpm run build -- --configuration=production\nnpx http-server .\u002Fdist\u002Finvoice-pwa\u002Fbrowser -p 4200 -c-1\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>When the app checks for updates on focus after approximately 5s periodically, the \u003Cstrong>blue toast\u003C\u002Fstrong> will show. Click \u003Cstrong>Reload\u003C\u002Fstrong> to activate and jump to the new version.\u003C\u002Fp>\u003Cp>Up to now, the app allows you to \u003Cstrong>create, edit, and manage invoices\u003C\u002Fstrong>, but there’s no dedicated way to \u003Cstrong>view\u003C\u002Fstrong> a finalized invoice in a clean, print-friendly layout. Users need a professional, read-only page they can show clients, download as PDF, or print directly from the browser.\u003C\u002Fp>\u003Cp>\u003Cstrong>So now, you will create the invoice view\u003C\u002Fstrong> to display the selected invoice with proper formatting, localized currency and date styles, as well as optional notes.\u003C\u002Fp>\u003Ch2 id=\"%F0%9F%A7%BE-generate-the-view-invoice-component\">🧾 Generate the view invoice component\u003C\u002Fh2>\u003Cp>In this section you will create the invoice view to see the detail of an individual invoice when a user clicks on a specific view.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-bash\">npm run ng -- g c features\u002Finvoice-view\u002Finvoice-view --standalone --flat --skip-tests\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add its route: \u003Ccode>src\u002Fapp\u002Fapp.routes.ts\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">{\n  path: 'invoice\u002F:id\u002Fview',\n  loadComponent: () =&gt;\n    import('.\u002Ffeatures\u002Finvoice-view\u002Finvoice-view').then(m =&gt; m.InvoiceViewComponent),\n},\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Create the View component (read-only, print-ready):\u003C\u002Fp>\u003Cp>\u003Cstrong> \u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-view\u002Finvoice-view.component.ts\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode>import { Component, ChangeDetectionStrategy, inject, OnInit, computed } from '@angular\u002Fcore';\nimport { CommonModule } from '@angular\u002Fcommon';\nimport { Router, ActivatedRoute, RouterModule } from '@angular\u002Frouter';\nimport { TranslateModule } from '@ngx-translate\u002Fcore';\n\nimport { InvoiceStore } from '..\u002F..\u002Fcore\u002Fstores\u002Finvoice.store';\nimport type { Invoice, LineItem } from '..\u002F..\u002Fcore\u002Fmodels\u002Finvoice.model';\nimport { LocaleFormatService } from '..\u002F..\u002Fcore\u002Flocale-format.service';\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\n\n@Component({\n  selector: 'app-invoice-view',\n  standalone: true,\n  imports: [CommonModule, RouterModule, TranslateModule],\n  templateUrl: '.\u002Finvoice-view.component.html',\n  styleUrl: '.\u002Finvoice-view.component.scss',\n  changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class InvoiceViewComponent implements OnInit {\n  private readonly route = inject(ActivatedRoute);\n  private readonly router = inject(Router);\n  private readonly store = inject(InvoiceStore);\n  private readonly fmt = inject(LocaleFormatService);\n\n  \u002F\u002F signal to the selected invoice (reactive)\n  invoice = computed&lt;Invoice | null&gt;(() =&gt; {\n    const id = this.route.snapshot.paramMap.get('id');\n    return id ? this.store.byId(id)() : null;\n  });\n\n  ngOnInit(): void {\n    if (!this.invoice()) this.router.navigateByUrl('\u002F');\n  }\n\n  \u002F\u002F formatting helpers\n  date(d: string) { return this.fmt.dateISO(d); }\n  money(amount: number, cur: string) { return this.fmt.currency(amount, cur); }\n  lineTotal(li: LineItem) { return lineTotal(li); }\n  grand(inv: Invoice) { return invoiceTotal(inv); }\n\n  print(): void { window.print(); }\n  back(): void { this.router.navigateByUrl('\u002F'); }\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add this code in \u003Cstrong> \u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-view\u002Finvoice-view.component.html\u003C\u002Fcode>\u003C\u002Fstrong>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;!-- Controls (hidden in print) --&gt;\n&lt;div class=\"no-print max-w-4xl mx-auto p-4 flex items-center gap-2\"&gt;\n  &lt;a routerLink=\"\u002F\" class=\"btn-secondary\"&gt;{{ 'nav.back' | translate }}&lt;\u002Fa&gt;\n  &lt;button class=\"btn\" type=\"button\" (click)=\"print()\"&gt;\n    {{ 'invoice.view.print' | translate }}\n  &lt;\u002Fbutton&gt;\n&lt;\u002Fdiv&gt;\n\n&lt;!-- Printable page --&gt;\n&lt;section class=\"sheet max-w-4xl mx-auto bg-white text-gray-900 shadow p-8\"&gt;\n  @if (invoice(); as inv) {\n    &lt;header class=\"flex items-start justify-between mb-8\"&gt;\n      &lt;div&gt;\n        &lt;h1 class=\"text-2xl font-semibold\"&gt;{{ 'invoice.view.title' | translate }} {{ inv.number }}&lt;\u002Fh1&gt;\n        &lt;div class=\"text-sm text-gray-600\"&gt;\n          &lt;div&gt;{{ 'invoice.view.issueDate' | translate }}: {{ date(inv.issueDate) }}&lt;\u002Fdiv&gt;\n          @if (inv.dueDate) {\n            &lt;div&gt;{{ 'invoice.view.dueDate' | translate }}: {{ date(inv.dueDate!) }}&lt;\u002Fdiv&gt;\n          }\n          &lt;div&gt;{{ 'invoice.view.status' | translate }}: {{ ('status.' + inv.status) | translate }}&lt;\u002Fdiv&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n\n      &lt;!-- Minimal “Bill To” block --&gt;\n      &lt;div class=\"text-right\"&gt;\n        &lt;div class=\"uppercase text-xs text-gray-500\"&gt;{{ 'invoice.view.billTo' | translate }}&lt;\u002Fdiv&gt;\n        &lt;div class=\"font-medium\"&gt;{{ inv.clientName || '—' }}&lt;\u002Fdiv&gt;\n        @if (inv.clientEmail) { &lt;div class=\"text-sm text-gray-600\"&gt;{{ inv.clientEmail }}&lt;\u002Fdiv&gt; }\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fheader&gt;\n\n    &lt;!-- Items table --&gt;\n    &lt;table class=\"w-full border-collapse\"&gt;\n      &lt;thead&gt;\n        &lt;tr class=\"border-b border-gray-300 text-left\"&gt;\n          &lt;th class=\"py-2 pr-2 w-7\u002F12\"&gt;{{ 'invoice.form.item.description' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pr-2 w-1\u002F12 text-right\"&gt;{{ 'invoice.form.item.quantity' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pr-2 w-2\u002F12 text-right\"&gt;{{ 'invoice.form.item.unitPrice' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pr-2 w-1\u002F12 text-right\"&gt;{{ 'invoice.form.item.taxRate' | translate }}&lt;\u002Fth&gt;\n          &lt;th class=\"py-2 pl-2 w-2\u002F12 text-right\"&gt;{{ 'invoice.form.item.total' | translate }}&lt;\u002Fth&gt;\n        &lt;\u002Ftr&gt;\n      &lt;\u002Fthead&gt;\n      &lt;tbody&gt;\n        @for (it of inv.items; track it.id) {\n          &lt;tr class=\"border-b border-gray-100\"&gt;\n            &lt;td class=\"py-2 pr-2 align-top\"&gt;\n              &lt;div class=\"font-medium\"&gt;{{ it.description || '—' }}&lt;\u002Fdiv&gt;\n              @if (it.discountRate &amp;&amp; it.discountRate &gt; 0) {\n                &lt;div class=\"text-xs text-gray-600\"&gt;\n                  {{ 'invoice.view.discount' | translate }}: {{ it.discountRate }}%\n                &lt;\u002Fdiv&gt;\n              }\n            &lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pr-2 text-right align-top\"&gt;{{ it.quantity }}&lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pr-2 text-right align-top\"&gt;{{ money(it.unitPrice, inv.currency) }}&lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pr-2 text-right align-top\"&gt;{{ it.taxRate || 0 }}%&lt;\u002Ftd&gt;\n            &lt;td class=\"py-2 pl-2 text-right align-top\"&gt;\n              {{ money(lineTotal(it), inv.currency) }}\n            &lt;\u002Ftd&gt;\n          &lt;\u002Ftr&gt;\n        }\n      &lt;\u002Ftbody&gt;\n    &lt;\u002Ftable&gt;\n\n    &lt;!-- Notes + totals --&gt;\n    &lt;div class=\"flex flex-col md:flex-row gap-6 mt-6\"&gt;\n      &lt;div class=\"md:w-1\u002F2\"&gt;\n        @if (inv.notes) {\n          &lt;div class=\"uppercase text-xs text-gray-500 mb-1\"&gt;{{ 'invoice.view.notes' | translate }}&lt;\u002Fdiv&gt;\n          &lt;div class=\"whitespace-pre-line\"&gt;{{ inv.notes }}&lt;\u002Fdiv&gt;\n        }\n      &lt;\u002Fdiv&gt;\n      &lt;div class=\"md:w-1\u002F2\"&gt;\n        &lt;div class=\"flex justify-between text-lg font-medium border-t pt-4\"&gt;\n          &lt;span&gt;{{ 'invoice.form.total' | translate }}&lt;\u002Fspan&gt;\n          &lt;span&gt;{{ money(grand(inv), inv.currency) }}&lt;\u002Fspan&gt;\n        &lt;\u002Fdiv&gt;\n      &lt;\u002Fdiv&gt;\n    &lt;\u002Fdiv&gt;\n  }\n&lt;\u002Fsection&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And add print style so the PDF looks good:\u003C\u002Fp>\u003Cp>\u003Cstrong>\u003Ccode>src\u002Fapp\u002Ffeatures\u002Finvoice-view\u002Finvoice-view.component.scss\u003C\u002Fcode>\u003C\u002Fstrong>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-scss\">\u002F* Hide elements with .no-print when printing *\u002F\n@media print {\n  .no-print { display: none !important; }\n  html, body { background: white !important; }\n  .sheet {\n    box-shadow: none !important;\n    margin: 0 !important;\n    width: auto !important;\n    padding: 0.5in !important; \u002F* print margins *\u002F\n  }\n}\n\n\u002F* On screen *\u002F\n.sheet { border-radius: 0.75rem; }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Now, update your translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"view\": {\n      \"title\": \"Invoice\",\n      \"issueDate\": \"Issue date\",\n      \"dueDate\": \"Due date\",\n      \"status\": \"Status\",\n      \"billTo\": \"Bill to\",\n      \"notes\": \"Notes\",\n      \"print\": \"Print \u002F Save as PDF\",\n      \"discount\": \"Discount\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"invoice\": {\n    \"view\": {\n      \"title\": \"Facture\",\n      \"issueDate\": \"Date d'émission\",\n      \"dueDate\": \"Date d'échéance\",\n      \"status\": \"Statut\",\n      \"billTo\": \"Destinataire\",\n      \"notes\": \"Notes\",\n      \"print\": \"Imprimer \u002F Enregistrer en PDF\",\n      \"discount\": \"Remise\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then add the“View” action on each row in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.ts\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">view(inv: Invoice) {\n  this.router.navigate(['\u002Finvoice', inv.id, 'view']);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Drop the button in your actions block in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.html\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;button type=\"button\" class=\"btn-secondary\" (click)=\"view(inv)\"&gt;\n  {{ 'invoice.actions.view' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And translations in \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>...\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{ \"invoice\": { \"actions\": { \"view\": \"View\" } } }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>...and \u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{ \"invoice\": { \"actions\": { \"view\": \"Voir\" } } }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Finally, run a quick test by starting the app with \u003Ccode>npm start\u003C\u002Fcode>, then open \u003Ccode>\u002Finvoice\u002F:id\u002Fview\u003C\u002Fcode> to verify that the page displays and prints cleanly. From the dashboard, click \u003Cstrong>View\u003C\u002Fstrong>. It should open the same screen where the \u003Cstrong>Print \u002F Save as PDF\u003C\u002Fstrong> action is working properly.\u003C\u002Fp>\u003Cp>Our last step today will be adding \u003Cstrong>Search \u002F Filter \u002F Sort\u003C\u002Fstrong> to the dashboard.\u003C\u002Fp>\u003Ch2 id=\"%F0%9F%94%8E-search-filter-and-sort-feature\">🔎 Search, filter and sort feature\u003C\u002Fh2>\u003Ch3 id=\"1-dashboard-component\">1. Dashboard component\u003C\u002Fh3>\u003Cp>In your \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fstrong>\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular\u002Fcore';\nimport { RouterModule, Router } from '@angular\u002Frouter';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Inject \u003Cstrong>Router\u003C\u002Fstrong>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">private readonly router = inject(Router);\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add \u003Cstrong>UI state signals\u003C\u002Fstrong> for search text, status, sort:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">type StatusFilter = 'all' | 'draft' | 'sent' | 'paid';\ntype SortKey = 'dateDesc' | 'dateAsc' | 'amountDesc' | 'amountAsc';\n\nreadonly query  = signal&lt;string&gt;('');\nreadonly status = signal&lt;StatusFilter&gt;('all');\nreadonly sort   = signal&lt;SortKey&gt;('dateDesc');\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Derived view:\u003C\u002Fp>\u003Cpre>\u003Ccode>readonly view = computed(() =&gt; {\n  const q = this.query().trim().toLowerCase();\n  const st = this.status();\n  const sortKey = this.sort();\n\n  let rows = this.invoices();\n\n  if (st !== 'all') rows = rows.filter(r =&gt; r.status === st);\n\n  if (q) {\n    rows = rows.filter(r =&gt;\n      r.number.toLowerCase().includes(q) ||\n      r.clientName.toLowerCase().includes(q) ||\n      (r.clientEmail?.toLowerCase().includes(q) ?? false) ||\n      (r.notes?.toLowerCase().includes(q) ?? false)\n    );\n  }\n\n  const byAmount = (r: Invoice) =&gt; invoiceTotal(r);\n  const byDate   = (r: Invoice) =&gt; r.issueDate; \u002F\u002F ISO sorts lexicographically\n\n  rows = [...rows];\n  switch (sortKey) {\n    case 'amountDesc': rows.sort((a,b) =&gt; byAmount(b) - byAmount(a)); break;\n    case 'amountAsc':  rows.sort((a,b) =&gt; byAmount(a) - byAmount(b)); break;\n    case 'dateAsc':    rows.sort((a,b) =&gt; byDate(a).localeCompare(byDate(b))); break;\n    case 'dateDesc':\n    default:           rows.sort((a,b) =&gt; byDate(b).localeCompare(byDate(a))); break;\n  }\n\n  return rows;\n});\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add row action handlers:\u003C\u002Fp>\u003Cpre>\u003Ccode>viewInvoice(inv: Invoice) { this.router.navigate(['\u002Finvoice', inv.id, 'view']); }\nedit(inv: Invoice)        { this.router.navigate(['\u002Finvoice', inv.id]); }\nmarkSent(inv: Invoice)    { if (inv.status !== 'sent') this.store.setStatus(inv.id, 'sent'); }\nmarkPaid(inv: Invoice)    { if (inv.status !== 'paid') this.store.setStatus(inv.id, 'paid'); }\nremove(inv: Invoice)      { if (confirm(`Delete ${inv.number}? This cannot be undone.`)) this.store.remove(inv.id); }\n\n\u002F\u002F top-bar inputs → signals\nonSearch(v: string)       { this.query.set(v); }\nonStatusChange(v: string) { this.status.set((v as StatusFilter) || 'all'); }\nonSortChange(v: string)   { this.sort.set((v as SortKey) || 'dateDesc'); }\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then update the dashboard template on \u003Ccode>\u003Cstrong>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.html\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode>&lt;header class=\"flex flex-col gap-3 md:flex-row md:items-center\"&gt;\n  &lt;h2 class=\"text-2xl font-semibold\"&gt;{{ 'dashboard.title' | translate }}&lt;\u002Fh2&gt;\n\n  &lt;div class=\"md:ml-auto grid grid-cols-1 md:grid-cols-3 gap-2 items-center\"&gt;\n    &lt;!-- Search --&gt;\n    &lt;input\n      #q type=\"search\"\n      class=\"rounded-lg border p-2\"\n      [value]=\"query()\"\n      (input)=\"onSearch(q.value)\"\n      [placeholder]=\"'dashboard.filters.searchPlaceholder' | translate\" \u002F&gt;\n\n    &lt;!-- Status --&gt;\n    &lt;select\n      #statusSel class=\"rounded-lg border p-2\"\n      [value]=\"status()\"\n      (change)=\"onStatusChange(statusSel.value)\"&gt;\n      &lt;option value=\"all\"&gt;{{ 'status.all' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"draft\"&gt;{{ 'status.draft' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"sent\"&gt;{{ 'status.sent' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"paid\"&gt;{{ 'status.paid' | translate }}&lt;\u002Foption&gt;\n    &lt;\u002Fselect&gt;\n\n    &lt;!-- Sort --&gt;\n    &lt;select\n      #sortSel class=\"rounded-lg border p-2\"\n      [value]=\"sort()\"\n      (change)=\"onSortChange(sortSel.value)\"&gt;\n      &lt;option value=\"dateDesc\"&gt;{{ 'dashboard.filters.sort.dateDesc' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"dateAsc\"&gt;{{ 'dashboard.filters.sort.dateAsc' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"amountDesc\"&gt;{{ 'dashboard.filters.sort.amountDesc' | translate }}&lt;\u002Foption&gt;\n      &lt;option value=\"amountAsc\"&gt;{{ 'dashboard.filters.sort.amountAsc' | translate }}&lt;\u002Foption&gt;\n    &lt;\u002Fselect&gt;\n  &lt;\u002Fdiv&gt;\n\n  &lt;div class=\"flex items-center gap-2\"&gt;\n    &lt;a routerLink=\"\u002Finvoice\u002Fnew\" class=\"btn\"&gt;{{ 'invoice.actions.create' | translate }}&lt;\u002Fa&gt;\n    &lt;button class=\"btn-secondary\" type=\"button\" (click)=\"createSample()\"&gt;+ Sample&lt;\u002Fbutton&gt;\n  &lt;\u002Fdiv&gt;\n&lt;\u002Fheader&gt;\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Insert the result count + empty states above your list:\u003C\u002Fp>\u003Cpre>\u003Ccode>@if (view().length &gt; 0) {\n  &lt;div class=\"text-sm text-gray-500\"&gt;\n    {{ 'dashboard.resultsPlural' | translate : { count: view().length } }}\n    @if (query().length) { — {{ 'dashboard.searchTerm' | translate }}: “{{ query() }}” }\n  &lt;\u002Fdiv&gt;\n}\n\n@if (invoices().length === 0) {\n  &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-600\"&gt;\n    {{ 'dashboard.empty' | translate }}\n  &lt;\u002Fdiv&gt;\n} @else if (view().length === 0) {\n  &lt;div class=\"rounded-xl border border-dashed p-8 text-gray-700 space-y-1\"&gt;\n    &lt;div class=\"font-semibold\"&gt;{{ 'dashboard.noResultsTitle' | translate }}&lt;\u002Fdiv&gt;\n    &lt;div&gt;{{ 'dashboard.noResultsBody' | translate }}&lt;\u002Fdiv&gt;\n    @if (query().length) {\n      &lt;div class=\"text-sm text-gray-500\"&gt;\n        {{ 'dashboard.searchTerm' | translate }}: “{{ query() }}”\n      &lt;\u002Fdiv&gt;\n    }\n  &lt;\u002Fdiv&gt;\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Use the filtered list (\u003Ccode>view()\u003C\u002Fcode>) and updated actions inside your existing list:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;ul class=\"space-y-3\"&gt;\n  @for (inv of view(); track inv.id) {\n    &lt;!-- … existing row header with number\u002Fclient\u002Fdate\u002Ftotal … --&gt;\n\n    &lt;div class=\"mt-3 flex items-center gap-2\"&gt;\n      &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"viewInvoice(inv)\"&gt;\n        {{ 'invoice.actions.view' | translate }}\n      &lt;\u002Fbutton&gt;\n      &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"edit(inv)\"&gt;\n        {{ 'invoice.actions.edit' | translate }}\n      &lt;\u002Fbutton&gt;\n      @if (inv.status !== 'sent') {\n        &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markSent(inv)\"&gt;\n          {{ 'invoice.actions.markSent' | translate }}\n        &lt;\u002Fbutton&gt;\n      }\n      @if (inv.status !== 'paid') {\n        &lt;button type=\"button\" class=\"btn-secondary\" (click)=\"markPaid(inv)\"&gt;\n          {{ 'invoice.actions.markPaid' | translate }}\n        &lt;\u002Fbutton&gt;\n      }\n      &lt;buttontype=\"button\"\n        class=\"inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-400\u002F40\"\n        (click)=\"remove(inv)\"&gt;\n        {{ 'invoice.actions.delete' | translate }}\n      &lt;\u002Fbutton&gt;\n    &lt;\u002Fdiv&gt;\n  }\n&lt;\u002Ful&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then update your translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"all\": \"All\",\n    \"draft\": \"Draft\",\n    \"sent\": \"Sent\",\n    \"paid\": \"Paid\"\n  },\n  \"dashboard\": {\n    \"resultsPlural\": \"{{count}} result(s)\",\n    \"noResultsTitle\": \"Invoice not found\",\n    \"noResultsBody\": \"No invoices match your search or filters.\",\n    \"searchTerm\": \"Search\",\n    \"filters\": {\n      \"searchPlaceholder\": \"Search by number, client, email, notes…\",\n      \"sort\": {\n        \"dateDesc\": \"Newest first\",\n        \"dateAsc\": \"Oldest first\",\n        \"amountDesc\": \"Amount: high → low\",\n        \"amountAsc\": \"Amount: low → high\"\n      }\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"status\": {\n    \"all\": \"Tous\",\n    \"draft\": \"Brouillon\",\n    \"sent\": \"Envoyée\",\n    \"paid\": \"Payée\"\n  },\n  \"dashboard\": {\n    \"resultsPlural\": \"{{count}} résultat(s)\",\n    \"noResultsTitle\": \"Facture introuvable\",\n    \"noResultsBody\": \"Aucune facture ne correspond à votre recherche ou à vos filtres.\",\n    \"searchTerm\": \"Recherche\",\n    \"filters\": {\n      \"searchPlaceholder\": \"Rechercher par numéro, client, email, notes…\",\n      \"sort\": {\n        \"dateDesc\": \"Plus récentes d'abord\",\n        \"dateAsc\": \"Plus anciennes d'abord\",\n        \"amountDesc\": \"Montant : élevé → faible\",\n        \"amountAsc\": \"Montant : faible → élevé\"\n      }\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Try typing in the search box and observe the list updates instantly! Play with filters or sorting to see invoices reshuffle, then clear everything to get back to your cozy “Newest first” view.\u003C\u002Fp>\u003Ch3 id=\"2-add-an-export-method\">2. Add an export method\u003C\u002Fh3>\u003Cp>Add the code blow inside the \u003Ccode>InvoiceStore\u003C\u002Fcode> class in \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>.\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F** ---- backup\u002Fexport ---- *\u002F\nexportJSON(pretty = true): string {\n  const payload = { version: 2, data: this._invoices() };\n  return JSON.stringify(payload, null, pretty ? 2 : 0);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add this helper in  \u003Ccode>src\u002Fapp\u002Fcore\u002Futils\u002Ffiles.ts\u003C\u002Fcode>: \u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">export function downloadText(\n  filename: string,\n  text: string,\n  mime = 'application\u002Fjson;charset=utf-8'\n) {\n  const blob = new Blob([text], { type: mime });\n  const url = URL.createObjectURL(blob);\n  const a = document.createElement('a');\n  a.href = url;\n  a.download = filename;\n  document.body.appendChild(a);\n  a.click();\n  document.body.removeChild(a);\n  URL.revokeObjectURL(url);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Next, add the import at the top and then the method inside the class in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F\u002F add import\nimport { downloadText } from '..\u002F..\u002Fcore\u002Futils\u002Ffiles';\n\n\u002F\u002F inside DashboardComponent\nexportBackup() {\n  const json = this.store.exportJSON(true);\n  const stamp = new Date().toISOString().slice(0, 19).replace(\u002F[:T]\u002Fg, '-'); \u002F\u002F YYYY-MM-DD-HH-MM-SS\n  downloadText(`invoices-backup-${stamp}.json`, json);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add now the export button in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;button class=\"btn-secondary\" type=\"button\" (click)=\"exportBackup()\"&gt;\n  {{ 'dashboard.actions.export' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"export\": \"Exporter en JSON\"\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"export\": \"Export JSON\"\n    }\n  }\n}\n\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Ch3 id=\"3-add-import-json\">\u003Cbr>3. Add Import JSON\u003C\u002Fh3>\u003Cp>Here, add a simple way for users to \u003Cstrong>restore saved invoices\u003C\u002Fstrong> by importing their JSON backups. They can either replace all data or merge it with existing invoices\u003C\u002Fp>\u003Cp>Add these inside the \u003Ccode>InvoiceStore\u003C\u002Fcode> class in \u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Fstores\u002Finvoice.store.ts\u003C\u002Fstrong>\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">\u002F** ---- restore\u002Fimport helpers ---- *\u002F\nsetAll(list: Invoice[]): void {\n  this._invoices.set([...list]);\n}\n\nmergeAll(list: Invoice[]): void {\n  const map = new Map&lt;string, Invoice&gt;();\n  for (const inv of this._invoices()) map.set(inv.id, inv);\n  for (const inv of list) map.set(inv.id, inv); \u002F\u002F imported wins on id collision\n  this._invoices.set([...map.values()]);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>The \u003Cstrong>\u003Ccode>setAll\u003C\u002Fcode> \u003C\u002Fstrong>replaces everything in one go;\u003Cstrong> \u003Ccode>mergeAll\u003C\u002Fcode>\u003C\u002Fstrong> keeps existing invoices and lets imported ones win when IDs collide.\u003C\u002Fp>\u003Cp>Add these imports at the top alongside your existing ones in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { ViewChild, ElementRef } from '@angular\u002Fcore';\nimport { sanitizeInvoice } from '..\u002F..\u002Fcore\u002Fpersistence\u002Finvoice.serialization';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add a reference to the hidden input inside the class:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">@ViewChild('importInput') importInput?: ElementRef&lt;HTMLInputElement&gt;;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Include these two small methods with your other actions:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">triggerImport(el: HTMLInputElement) {\n  el.value = ''; \u002F\u002F allow re-selecting the same file\n  el.click();\n}\n\nasync handleImport(files: FileList | null) {\n  if (!files || files.length === 0) return;\n  const file = files[0];\n\n  try {\n    const text = await file.text();\n    const parsed: unknown = JSON.parse(text);\n\n    \u002F\u002F Accept v1 (array) or v2 ({version, data: []})\n    const rawList: unknown[] =\n      Array.isArray(parsed)\n        ? parsed\n        : (parsed &amp;&amp; typeof parsed === 'object' &amp;&amp; Array.isArray((parsed as any)['data']))\n          ? (parsed as any)['data']\n          : [];\n\n    const imported = rawList\n      .map(sanitizeInvoice)\n      .filter((x): x is NonNullable&lt;ReturnType&lt;typeof sanitizeInvoice&gt;&gt; =&gt; !!x);\n\n    if (imported.length === 0) {\n      alert('Import failed: no valid invoices found in file.');\n      return;\n    }\n\n    const replace = confirm(\n      `Found ${imported.length} invoices.\\\\n\\\\nOK = Replace ALL existing invoices\\\\nCancel = Merge (imported overwrite by id)`\n    );\n\n    if (replace) {\n      this.store.setAll(imported);\n      alert('Restore complete: replaced all invoices.');\n    } else {\n      this.store.mergeAll(imported);\n      alert('Restore complete: merged invoices.');\n    }\n  } catch (e) {\n    console.error(e);\n    alert('Import failed: invalid JSON or unreadable file.');\n  } finally {\n    if (this.importInput?.nativeElement) this.importInput.nativeElement.value = '';\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Let hide file input for import button in the header actions (next to Create \u002F + Sample \u002F Export) of the \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;!-- Hidden file input for import --&gt;\n&lt;input\n  #importInput\n  type=\"file\"\n  accept=\"application\u002Fjson\"\n  class=\"hidden\"\n  (change)=\"handleImport(importInput.files)\" \u002F&gt;\n\n&lt;button class=\"btn-secondary\" type=\"button\" (click)=\"triggerImport(importInput)\"&gt;\n  {{ 'dashboard.actions.import' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Add the import text on \u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"import\": \"Import JSON\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>And on \u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"import\": \"Importer JSON\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Open the app and run a quick test to make sure the import works.\u003C\u002Fp>\u003Cp>Let’s round out backups with a clean \u003Cstrong>CSV export.\u003C\u002Fstrong> No need to use libraries: just two neat files you can open in Excel\u002FSheets. \u003Cstrong>One for invoices, one for line items\u003C\u002Fstrong>.\u003C\u002Fp>\u003Ch3 id=\"4-export-as-csv\">4. Export as CSV\u003C\u002Fh3>\u003Cp>Let’s make it easy for users to work with their data outside the app. \u003C\u002Fp>\u003Cp>With a single click, they’ll be able to \u003Cstrong>export all invoices and line items as CSV files\u003C\u002Fstrong>, perfect for quick reviews, reports, or even Excel and Google Sheets.\u003C\u002Fp>\u003Cp>Create a CSV helper first:\u003C\u002Fp>\u003Cp>\u003Ccode>\u003Cstrong>src\u002Fapp\u002Fcore\u002Futils\u002Fcsv.ts\u003C\u002Fstrong>\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">function csvEscape(value: unknown): string {\n  const s = value === undefined || value === null ? '' : String(value);\n  if (\u002F[\",\\\\r\\\\n]\u002F.test(s)) return `\"${s.replace(\u002F\"\u002Fg, '\"\"')}\"`;\n  return s;\n}\n\n\u002F** Build a CSV string from an array of plain objects using the provided column order. *\u002F\nexport function toCsv(columns: string[], rows: Array&lt;Record&lt;string, unknown&gt;&gt;): string {\n  const header = columns.join(',');\n  const data = rows.map(r =&gt; columns.map(c =&gt; csvEscape(r[c])).join(','));\n  return [header, ...data].join('\\\\r\\\\n');\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This tiny helper \u003Cstrong>takes any list of objects and turns it into a clean, spreadsheet-ready CSV file\u003C\u002Fstrong>. It automatically escapes commas and quotes so everything opens correctly in Excel or Sheets.\u003C\u002Fp>\u003Cp>Add these imports at the top in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.ts\u003C\u002Fcode>:\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-tsx\">import { toCsv } from '..\u002F..\u002Fcore\u002Futils\u002Fcsv';\nimport { lineTotal, invoiceTotal } from '..\u002F..\u002Fcore\u002Futils\u002Fmoney';\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Then, inside your \u003Ccode>DashboardComponent\u003C\u002Fcode>, add this method:\u003C\u002Fp>\u003Cpre>\u003Ccode>\u002F\u002F ---------- export CSV ----------\nexportCsv() {\n  const list = this.invoices();\n\n  \u002F\u002F Invoices CSV (one row per invoice; totals are raw numbers in base currency)\n  const invCols = [\n    'id','number','clientName','clientEmail','issueDate','dueDate',\n    'currency','status','itemsCount','total','createdAt','updatedAt'\n  ];\n  const invRows = list.map(inv =&gt; ({\n    id: inv.id,\n    number: inv.number,\n    clientName: inv.clientName,\n    clientEmail: inv.clientEmail ?? '',\n    issueDate: inv.issueDate,\n    dueDate: inv.dueDate ?? '',\n    currency: inv.currency,\n    status: inv.status,\n    itemsCount: inv.items.length,\n    total: invoiceTotal(inv),        \u002F\u002F numeric, not localized\n    createdAt: inv.createdAt,\n    updatedAt: inv.updatedAt,\n  }));\n  const invoicesCsv = toCsv(invCols, invRows);\n\n  \u002F\u002F Items CSV (one row per line item; includes invoiceId)\n  const itemCols = [\n    'invoiceId','itemId','description','quantity','unitPrice','taxRate','discountRate','lineTotal'\n  ];\n  const itemRows = list.flatMap(inv =&gt;\n    inv.items.map(it =&gt; ({\n      invoiceId: inv.id,\n      itemId: it.id,\n      description: it.description,\n      quantity: it.quantity,\n      unitPrice: it.unitPrice,\n      taxRate: it.taxRate ?? 0,\n      discountRate: it.discountRate ?? 0,\n      lineTotal: lineTotal(it),      \u002F\u002F numeric, not localized\n    }))\n  );\n  const itemsCsv = toCsv(itemCols, itemRows);\n\n  const stamp = new Date().toISOString().slice(0, 19).replace(\u002F[:T]\u002Fg, '-');\n  downloadText(`invoices-${stamp}.csv`, invoicesCsv, 'text\u002Fcsv;charset=utf-8');\n  downloadText(`invoice_items-${stamp}.csv`, itemsCsv, 'text\u002Fcsv;charset=utf-8');\n}\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>This exports \u003Cstrong>two CSV files\u003C\u002Fstrong>: one summarizing each invoice and another listing all individual line items. Keeping the numbers raw makes them easier to analyze or chart later in any spreadsheet.\u003C\u002Fp>\u003Cp>Let add the UI button in \u003Ccode>src\u002Fapp\u002Ffeatures\u002Fdashboard\u002Fdashboard.component.html\u003C\u002Fcode>  in the header actions (next to Create \u002F + Sample \u002F Export JSON \u002F Import JSON):\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-html\">&lt;button class=\"btn-secondary\" type=\"button\" (click)=\"exportCsv()\"&gt;\n  {{ 'dashboard.actions.exportCsv' | translate }}\n&lt;\u002Fbutton&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Update your translations:\u003C\u002Fp>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Fen.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"exportCsv\": \"Export CSV\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>\u003Ccode>public\u002Fassets\u002Fi18n\u002Ffr.json\u003C\u002Fcode>\u003C\u002Fp>\u003Cpre>\u003Ccode class=\"language-json\">{\n  \"dashboard\": {\n    \"actions\": {\n      \"exportCsv\": \"Exporter CSV\"\n    }\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp>Open the app, hit \u003Cstrong>Export CSV\u003C\u002Fstrong>, and voilà! \u003Cstrong>Two neat files land on your computer \u003C\u002Fstrong>(\u003Ccode>invoices-YYYY-MM-DD-HH-MM-SS.csv\u003C\u002Fcode>and \u003Ccode>invoice_items-YYYY-MM-DD-HH-MM-SS.csv\u003C\u002Fcode>). Drop them into Excel or Google Sheets and play around: sort, filter, or build quick reports in seconds.\u003C\u002Fp>\u003Ch2 id=\"%E2%9E%A1%EF%B8%8F-whats-next\">➡️ What's next?\u003C\u002Fh2>\u003Cp>This wraps up the second part of your journey where we built the foundation of the app and set up translation services to make it bilingual and user-friendly. In the next part, \u003Cstrong>we’ll take things further by integrating the Angular app with Localazy\u003C\u002Fstrong>, unlocking the real advantages of localization: faster translations, easier updates, and a smoother multilingual experience for every user.\u003C\u002Fp>","public","none","2025-11-07T20:35:52.000+01:00",[1943],{"id":1860,"name":1861,"slug":1862,"profile_image":1863,"cover_image":8,"bio":1944,"website":1945,"location":1946,"facebook":8,"twitter":8,"meta_title":8,"meta_description":8,"url":1947},"Curious technical writer who loves turning complex tools into clear, helpful documentation for developers.","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fkevine-nzapdi\u002F","Yaoundé, Cameroon","https:\u002F\u002Fghost.localazy.com\u002Fauthor\u002Fkevine-nzapdi\u002F",[1949,1952,1955,1958,1963,1968,1973,1978,1983],{"id":1950,"name":1871,"slug":1871,"description":8,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1951},"60f567ab6f8ebe0001bd80ec","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fi18n\u002F",{"id":1953,"name":562,"slug":561,"description":562,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1954},"60a37fc7cf7b6a0001d1f10b","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fangular\u002F",{"id":1956,"name":1875,"slug":1876,"description":8,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1957},"6556628a0470910001941ffe","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Ftutorial\u002F",{"id":1959,"name":1960,"slug":1961,"description":1883,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1962},"69147f7163350c0001d61910","[DICT] ngx-translate","term-426","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fterm-426\u002F",{"id":1964,"name":1965,"slug":1966,"description":1901,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1967},"60a37fc7cf7b6a0001d1f162","[DICT] Angular","term-69","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fterm-69\u002F",{"id":1969,"name":1970,"slug":1971,"description":1911,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1972},"66e40c7034f1ce00011a32cf","[DICT] i18n ","term-192","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fterm-192\u002F",{"id":1974,"name":1975,"slug":1976,"description":8,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1977},"60fffb486f8ebe0001bd8ddd","[COMSHARE]","comshare","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fcomshare\u002F",{"id":1979,"name":1980,"slug":1981,"description":1933,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1982},"60a37fc7cf7b6a0001d1f11b","[CTA] Why developers love Localazy 💖","cta-13","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fcta-13\u002F",{"id":1984,"name":1985,"slug":1986,"description":8,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1987},"62f35ef7fe956a0001089272","[AD] Zapier","ad-6","https:\u002F\u002Fghost.localazy.com\u002Ftag\u002Fad-6\u002F",{"id":1860,"name":1861,"slug":1862,"profile_image":1863,"cover_image":8,"bio":1944,"website":1945,"location":1946,"facebook":8,"twitter":8,"meta_title":8,"meta_description":8,"url":1947},{"id":1950,"name":1871,"slug":1871,"description":8,"feature_image":8,"visibility":1939,"og_image":8,"og_title":8,"og_description":8,"twitter_image":8,"twitter_title":8,"twitter_description":8,"meta_title":8,"meta_description":8,"codeinjection_head":8,"codeinjection_foot":8,"canonical_url":8,"accent_color":8,"url":1951},"https:\u002F\u002Fghost.localazy.com\u002Fguide-to-translate-angular-apps-with-ngx-translate-app-logic\u002F",[1992,2008,2032],{"id":1993,"owner":1994,"created_by":8,"sort":8,"title":1995,"slug":1996,"modified_on":53,"created_on":1997,"pinned":91,"badge":8,"priority":1824,"main_image":1998,"status":4,"tags":1999,"dictionary":2007,"reading_time":1835,"excerpt":-1,"og_title":53,"og_description":53,"og_image":53},"60a37fc7cf7b6a0001d1f1d1",{"id":1817,"first_name":1818,"last_name":53,"slug":1819,"avatar":1820},"An overview of software localization and i18n tools (2022)","overview-software-localization-i18n-tools-2021","2021-05-02T15:08:51.000+02:00","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2022\u002F04\u002FSoftware-localization-overview22.png",[2000,2002,2005,2006],{"id":387,"created_on":1828,"status":4,"label":1706,"slug":2001,"on_index_page":91},"localazy",{"id":387,"created_on":1828,"status":4,"label":2003,"slug":2004,"on_index_page":91},"Tip","tip",{"id":387,"created_on":1828,"status":4,"label":287,"slug":1833,"on_index_page":93},{"id":387,"created_on":1828,"status":4,"label":1871,"slug":1871,"on_index_page":93},[],{"id":2009,"owner":2010,"created_by":8,"sort":8,"title":2015,"slug":2016,"modified_on":53,"created_on":2017,"pinned":91,"badge":8,"priority":1824,"main_image":2018,"status":4,"tags":2019,"dictionary":2031,"reading_time":1835,"excerpt":-1,"og_title":53,"og_description":53,"og_image":53},"646b7e63047091000193c33b",{"id":2011,"first_name":2012,"last_name":53,"slug":2013,"avatar":2014},"619d2332df4bcb00010bc032","Moyinoluwa Adenuga","moyinoluwa-adenuga","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2023\u002F12\u002Fadenuga.png","How to localize a NestJS application with nestjs-i18n and Localazy","how-to-localize-a-nestjs-application-with-nestjs-i18n-and-localazy","2023-05-24T14:22:32.000+02:00","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2023\u002F05\u002Flocalazy-article-nestj-js-1.png",[2020,2024,2025,2028],{"id":2021,"created_on":1828,"status":4,"label":2022,"slug":2023,"on_index_page":91},646,"NestJS","nestjs",{"id":387,"created_on":1828,"status":4,"label":1871,"slug":1871,"on_index_page":93},{"id":387,"created_on":1828,"status":4,"label":2026,"slug":2027,"on_index_page":91},"HowTo","howto",{"id":387,"created_on":1828,"status":4,"label":2029,"slug":2030,"on_index_page":91},"Automated Localization","automated-localization",[],{"id":2033,"owner":2034,"created_by":8,"sort":8,"title":2039,"slug":2040,"modified_on":53,"created_on":2041,"pinned":91,"badge":8,"priority":1824,"main_image":2042,"status":4,"tags":2043,"dictionary":2050,"reading_time":1835,"excerpt":-1,"og_title":53,"og_description":53,"og_image":53},"610e58ca6f8ebe0001bd9286",{"id":2035,"first_name":2036,"last_name":53,"slug":2037,"avatar":2038},"6092822ecf7b6a0001d1dab3","David Václavek","david-vaclavek","\u002F\u002Fwww.gravatar.com\u002Favatar\u002F90788a3f3dc9558a151a87d7fe096592?s=250&d=mm&r=x","How to localize Nette app using contributte\u002Ftranslation and Localazy","localize-php-nette-app-using-contributte-translation-localazy","2021-09-07T10:11:37.000+02:00","https:\u002F\u002Fghost.localazy.com\u002Fcontent\u002Fimages\u002F2021\u002F09\u002FHow-to-lokalize-Nette.png",[2044,2048,2049],{"id":2045,"created_on":1828,"status":4,"label":2046,"slug":2047,"on_index_page":91},613703526,"PHP","php",{"id":387,"created_on":1828,"status":4,"label":2026,"slug":2027,"on_index_page":91},{"id":387,"created_on":1828,"status":4,"label":1871,"slug":1871,"on_index_page":93},[],{"id":57,"sort":8,"created_on":2052,"name":2053,"cta_link":2054,"banner_image":2055},"2022-08-10T07:30:18.000Z","Zapier","https:\u002F\u002Flocalazy.com\u002Ffeatures\u002Fzapier-integration",{"id":2056},"b2f25845-b34a-4a0f-980c-f14db1fe6d6a"]